diff --git a/.factory/prompts/docs-automation/phase2-explore.md b/.factory/prompts/docs-automation/phase2-explore.md new file mode 100644 index 0000000000000000000000000000000000000000..e8f0b1861912f9665d70e42dd02e1bb8a398b01e --- /dev/null +++ b/.factory/prompts/docs-automation/phase2-explore.md @@ -0,0 +1,55 @@ +# Phase 2: Explore Repository + +You are analyzing a codebase to understand its structure before reviewing documentation impact. + +## Objective +Produce a structured overview of the repository to inform subsequent documentation analysis. + +## Instructions + +1. **Identify Primary Languages and Frameworks** + - Scan for Cargo.toml, package.json, or other manifest files + - Note the primary language(s) and key dependencies + +2. **Map Documentation Structure** + - This project uses **mdBook** (https://rust-lang.github.io/mdBook/) + - Documentation is in `docs/src/` + - Table of contents: `docs/src/SUMMARY.md` (mdBook format: https://rust-lang.github.io/mdBook/format/summary.html) + - Style guide: `docs/.rules` + - Agent guidelines: `docs/AGENTS.md` + - Formatting: Prettier (config in `docs/.prettierrc`) + +3. **Identify Build and Tooling** + - Note build systems (cargo, npm, etc.) + - Identify documentation tooling (mdbook, etc.) + +4. **Output Format** +Produce a JSON summary: + +```json +{ + "primary_language": "Rust", + "frameworks": ["GPUI"], + "documentation": { + "system": "mdBook", + "location": "docs/src/", + "toc_file": "docs/src/SUMMARY.md", + "toc_format": "https://rust-lang.github.io/mdBook/format/summary.html", + "style_guide": "docs/.rules", + "agent_guidelines": "docs/AGENTS.md", + "formatter": "prettier", + "formatter_config": "docs/.prettierrc", + "custom_preprocessor": "docs_preprocessor (handles {#kb action::Name} syntax)" + }, + "key_directories": { + "source": "crates/", + "docs": "docs/src/", + "extensions": "extensions/" + } +} +``` + +## Constraints +- Read-only: Do not modify any files +- Focus on structure, not content details +- Complete within 2 minutes diff --git a/.factory/prompts/docs-automation/phase3-analyze.md b/.factory/prompts/docs-automation/phase3-analyze.md new file mode 100644 index 0000000000000000000000000000000000000000..8fc8622434d3be2e6be7997e9773c1b2435202c6 --- /dev/null +++ b/.factory/prompts/docs-automation/phase3-analyze.md @@ -0,0 +1,57 @@ +# Phase 3: Analyze Changes + +You are analyzing code changes to understand their nature and scope. + +## Objective +Produce a clear, neutral summary of what changed in the codebase. + +## Input +You will receive: +- List of changed files from the triggering commit/PR +- Repository structure from Phase 2 + +## Instructions + +1. **Categorize Changed Files** + - Source code (which crates/modules) + - Configuration + - Tests + - Documentation (already existing) + - Other + +2. **Analyze Each Change** + - Review diffs for files likely to impact documentation + - Focus on: public APIs, settings, keybindings, commands, user-visible behavior + +3. **Identify What Did NOT Change** + - Note stable interfaces or behaviors + - Important for avoiding unnecessary documentation updates + +4. **Output Format** +Produce a markdown summary: + +```markdown +## Change Analysis + +### Changed Files Summary +| Category | Files | Impact Level | +| --- | --- | --- | +| Source - [crate] | file1.rs, file2.rs | High/Medium/Low | +| Settings | settings.json | Medium | +| Tests | test_*.rs | None | + +### Behavioral Changes +- **[Feature/Area]**: Description of what changed from user perspective +- **[Feature/Area]**: Description... + +### Unchanged Areas +- [Area]: Confirmed no changes to [specific behavior] + +### Files Requiring Deeper Review +- `path/to/file.rs`: Reason for deeper review +``` + +## Constraints +- Read-only: Do not modify any files +- Neutral tone: Describe what changed, not whether it's good/bad +- Do not propose documentation changes yet diff --git a/.factory/prompts/docs-automation/phase4-plan.md b/.factory/prompts/docs-automation/phase4-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..9e6a15814e7813cbf27afb0413987afa539feaf6 --- /dev/null +++ b/.factory/prompts/docs-automation/phase4-plan.md @@ -0,0 +1,76 @@ +# Phase 4: Plan Documentation Impact + +You are determining whether and how documentation should be updated based on code changes. + +## Objective +Produce a structured documentation plan that will guide Phase 5 execution. + +## Documentation System +This is an **mdBook** site (https://rust-lang.github.io/mdBook/): +- `docs/src/SUMMARY.md` defines book structure per https://rust-lang.github.io/mdBook/format/summary.html +- If adding new pages, they MUST be added to SUMMARY.md +- Use `{#kb action::ActionName}` syntax for keybindings (custom preprocessor expands these) +- Prettier formatting (80 char width) will be applied automatically + +## Input +You will receive: +- Change analysis from Phase 3 +- Repository structure from Phase 2 +- Documentation guidelines from `docs/AGENTS.md` + +## Instructions + +1. **Review AGENTS.md** + - Load and apply all rules from `docs/AGENTS.md` + - Respect scope boundaries (in-scope vs out-of-scope) + +2. **Evaluate Documentation Impact** + For each behavioral change from Phase 3: + - Does existing documentation cover this area? + - Is the documentation now inaccurate or incomplete? + - Classify per AGENTS.md "Change Classification" section + +3. **Identify Specific Updates** + For each required update: + - Exact file path + - Specific section or heading + - Type of change (update existing, add new, deprecate) + - Description of the change + +4. **Flag Uncertainty** + Explicitly mark: + - Assumptions you're making + - Areas where human confirmation is needed + - Ambiguous requirements + +5. **Output Format** +Use the exact format specified in `docs/AGENTS.md` Phase 4 section: + +```markdown +## Documentation Impact Assessment + +### Summary +Brief description of code changes analyzed. + +### Documentation Updates Required: [Yes/No] + +### Planned Changes + +#### 1. [File Path] +- **Section**: [Section name or "New section"] +- **Change Type**: [Update/Add/Deprecate] +- **Reason**: Why this change is needed +- **Description**: What will be added/modified + +### Uncertainty Flags +- [ ] [Description of any assumptions or areas needing confirmation] + +### No Changes Needed +- [List files reviewed but not requiring updates, with brief reason] +``` + +## Constraints +- Read-only: Do not modify any files +- Conservative: When uncertain, flag for human review rather than planning changes +- Scoped: Only plan changes that trace directly to code changes from Phase 3 +- No scope expansion: Do not plan "improvements" unrelated to triggering changes diff --git a/.factory/prompts/docs-automation/phase5-apply.md b/.factory/prompts/docs-automation/phase5-apply.md new file mode 100644 index 0000000000000000000000000000000000000000..9cc63071fccf880443b729baa06f0ddbd769276b --- /dev/null +++ b/.factory/prompts/docs-automation/phase5-apply.md @@ -0,0 +1,67 @@ +# Phase 5: Apply Documentation Plan + +You are executing a pre-approved documentation plan for an **mdBook** documentation site. + +## Objective +Implement exactly the changes specified in the documentation plan from Phase 4. + +## Documentation System +- **mdBook**: https://rust-lang.github.io/mdBook/ +- **SUMMARY.md**: Follows mdBook format (https://rust-lang.github.io/mdBook/format/summary.html) +- **Prettier**: Will be run automatically after this phase (80 char line width) +- **Custom preprocessor**: Use `{#kb action::ActionName}` for keybindings instead of hardcoding + +## Input +You will receive: +- Documentation plan from Phase 4 +- Documentation guidelines from `docs/AGENTS.md` +- Style rules from `docs/.rules` + +## Instructions + +1. **Validate Plan** + - Confirm all planned files are within scope per AGENTS.md + - Verify no out-of-scope files are targeted + +2. **Execute Each Planned Change** + For each item in "Planned Changes": + - Navigate to the specified file + - Locate the specified section + - Apply the described change + - Follow style rules from `docs/.rules` + +3. **Style Compliance** + Every edit must follow `docs/.rules`: + - Second person, present tense + - No hedging words ("simply", "just", "easily") + - Proper keybinding format (`Cmd+Shift+P`) + - Settings Editor first, JSON second + - Correct terminology (folder not directory, etc.) + +4. **Preserve Context** + - Maintain surrounding content structure + - Keep consistent heading levels + - Preserve existing cross-references + +## Constraints +- Execute ONLY changes listed in the plan +- Do not discover new documentation targets +- Do not make stylistic improvements outside planned sections +- Do not expand scope beyond what Phase 4 specified +- If a planned change cannot be applied (file missing, section not found), skip and note it + +## Output +After applying changes, output a summary: + +```markdown +## Applied Changes + +### Successfully Applied +- `path/to/file.md`: [Brief description of change] + +### Skipped (Could Not Apply) +- `path/to/file.md`: [Reason - e.g., "Section not found"] + +### Warnings +- [Any issues encountered during application] +``` diff --git a/.factory/prompts/docs-automation/phase6-summarize.md b/.factory/prompts/docs-automation/phase6-summarize.md new file mode 100644 index 0000000000000000000000000000000000000000..b1480ac9431702539fa2a570c2b456bcdfae46af --- /dev/null +++ b/.factory/prompts/docs-automation/phase6-summarize.md @@ -0,0 +1,54 @@ +# Phase 6: Summarize Changes + +You are generating a summary of documentation updates for PR review. + +## Objective +Create a clear, reviewable summary of all documentation changes made. + +## Input +You will receive: +- Applied changes report from Phase 5 +- Original change analysis from Phase 3 +- Git diff of documentation changes + +## Instructions + +1. **Gather Change Information** + - List all modified documentation files + - Identify the corresponding code changes that triggered each update + +2. **Generate Summary** + Use the format specified in `docs/AGENTS.md` Phase 6 section: + +```markdown +## Documentation Update Summary + +### Changes Made +| File | Change | Related Code | +| --- | --- | --- | +| docs/src/path.md | Brief description | PR #123 or commit SHA | + +### Rationale +Brief explanation of why these updates were made, linking back to the triggering code changes. + +### Review Notes +- Items reviewers should pay special attention to +- Any uncertainty flags from Phase 4 that were addressed +- Assumptions made during documentation +``` + +3. **Add Context for Reviewers** + - Highlight any changes that might be controversial + - Note if any planned changes were skipped and why + - Flag areas where reviewer expertise is especially needed + +## Output Format +The summary should be suitable for: +- PR description body +- Commit message (condensed version) +- Team communication + +## Constraints +- Read-only (documentation changes already applied in Phase 5) +- Factual: Describe what was done, not justify why it's good +- Complete: Account for all changes, including skipped items diff --git a/.factory/prompts/docs-automation/phase7-commit.md b/.factory/prompts/docs-automation/phase7-commit.md new file mode 100644 index 0000000000000000000000000000000000000000..adfd92eec7d3058af3917f2663228b4f6ee5c445 --- /dev/null +++ b/.factory/prompts/docs-automation/phase7-commit.md @@ -0,0 +1,67 @@ +# Phase 7: Commit and Open PR + +You are creating a git branch, committing documentation changes, and opening a PR. + +## Objective +Package documentation updates into a reviewable pull request. + +## Input +You will receive: +- Summary from Phase 6 +- List of modified files + +## Instructions + +1. **Create Branch** + ```sh + git checkout -b docs/auto-update-{date} + ``` + Use format: `docs/auto-update-YYYY-MM-DD` or `docs/auto-update-{short-sha}` + +2. **Stage and Commit** + - Stage only documentation files in `docs/src/` + - Do not stage any other files + + Commit message format: + ``` + docs: auto-update documentation for [brief description] + + [Summary from Phase 6, condensed] + + Triggered by: [commit SHA or PR reference] + + Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> + ``` + +3. **Push Branch** + ```sh + git push -u origin docs/auto-update-{date} + ``` + +4. **Create Pull Request** + Use the Phase 6 summary as the PR body. + + PR Title: `docs: [Brief description of documentation updates]` + + Labels (if available): `documentation`, `automated` + + Base branch: `main` + +## Constraints +- Do NOT auto-merge +- Do NOT request specific reviewers (let CODEOWNERS handle it) +- Do NOT modify files outside `docs/src/` +- If no changes to commit, exit gracefully with message "No documentation changes to commit" + +## Output +```markdown +## PR Created + +- **Branch**: docs/auto-update-{date} +- **PR URL**: https://github.com/zed-industries/zed/pull/XXXX +- **Status**: Ready for review + +### Commit +- SHA: {commit-sha} +- Files: {count} documentation files modified +``` diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yml b/.github/ISSUE_TEMPLATE/10_bug_report.yml index 711ad6de245d2a194984bc493217c9fbf859aa27..cae10f02ec3b1bcb024f0d1f1bce0691a39054b4 100644 --- a/.github/ISSUE_TEMPLATE/10_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yml @@ -75,6 +75,22 @@ body: validations: required: false + - type: textarea + attributes: + label: Relevant Keymap + description: | + Open the command palette in Zed, then type “zed: open keymap file” and copy/paste the file's contents. + value: | +
keymap.json + + + ```json + + ``` + +
+ validations: + required: false - type: textarea attributes: label: (for AI issues) Model provider details diff --git a/.github/actionlint.yml b/.github/actionlint.yml index 6d8e0107e9b42e71bb7266c0629393b9057e05bc..1c931883da4222c92f72a562d3cd58303ee35a06 100644 --- a/.github/actionlint.yml +++ b/.github/actionlint.yml @@ -25,6 +25,7 @@ self-hosted-runner: - namespace-profile-32x64-ubuntu-2204 # Namespace Ubuntu 24.04 (like ubuntu-latest) - namespace-profile-2x4-ubuntu-2404 + - namespace-profile-8x32-ubuntu-2404 # Namespace Limited Preview - namespace-profile-8x16-ubuntu-2004-arm-m4 - namespace-profile-8x32-ubuntu-2004-arm-m4 diff --git a/.github/actions/build_docs/action.yml b/.github/actions/build_docs/action.yml index d2e62d5b22ee49c7dcb9b42085a648098fbdb6bb..1ff271f73ff6b800ec3a94615f31c35a7729bb47 100644 --- a/.github/actions/build_docs/action.yml +++ b/.github/actions/build_docs/action.yml @@ -19,6 +19,18 @@ runs: shell: bash -euxo pipefail {0} run: ./script/linux + - name: Install mold linker + shell: bash -euxo pipefail {0} + run: ./script/install-mold + + - name: Download WASI SDK + shell: bash -euxo pipefail {0} + run: ./script/download-wasi-sdk + + - name: Generate action metadata + shell: bash -euxo pipefail {0} + run: ./script/generate-action-metadata + - name: Check for broken links (in MD) uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1 with: diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml index 2e75659de0bdf51f4586ef57770fd54dc4eeb074..21b9a8fe0e184773b35752565da6530bb666c6ec 100644 --- a/.github/workflows/after_release.yml +++ b/.github/workflows/after_release.yml @@ -5,13 +5,27 @@ on: release: types: - published + workflow_dispatch: + inputs: + tag_name: + description: tag_name + required: true + type: string + prerelease: + description: prerelease + required: true + type: boolean + body: + description: body + type: string + default: '' jobs: rebuild_releases_page: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: after_release::rebuild_releases_page::refresh_cloud_releases - run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name }} + run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name || inputs.tag_name }} shell: bash -euxo pipefail {0} - name: after_release::rebuild_releases_page::redeploy_zed_dev run: npm exec --yes -- vercel@37 --token="$VERCEL_TOKEN" --scope zed-industries redeploy https://zed.dev @@ -27,7 +41,7 @@ jobs: - id: get-release-url name: after_release::post_to_discord::get_release_url run: | - if [ "${{ github.event.release.prerelease }}" == "true" ]; then + if [ "${{ github.event.release.prerelease || inputs.prerelease }}" == "true" ]; then URL="https://zed.dev/releases/preview" else URL="https://zed.dev/releases/stable" @@ -40,9 +54,9 @@ jobs: uses: 2428392/gh-truncate-string-action@b3ff790d21cf42af3ca7579146eedb93c8fb0757 with: stringToTruncate: | - 📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! + 📣 Zed [${{ github.event.release.tag_name || inputs.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! - ${{ github.event.release.body }} + ${{ github.event.release.body || inputs.body }} maxLength: 2000 truncationSymbol: '...' - name: after_release::post_to_discord::discord_webhook_action @@ -56,7 +70,7 @@ jobs: - id: set-package-name name: after_release::publish_winget::set_package_name run: | - if ("${{ github.event.release.prerelease }}" -eq "true") { + if ("${{ github.event.release.prerelease || inputs.prerelease }}" -eq "true") { $PACKAGE_NAME = "ZedIndustries.Zed.Preview" } else { $PACKAGE_NAME = "ZedIndustries.Zed" @@ -68,6 +82,7 @@ jobs: uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f with: identifier: ${{ steps.set-package-name.outputs.PACKAGE_NAME }} + release-tag: ${{ github.event.release.tag_name || inputs.tag_name }} max-versions-to-keep: 5 token: ${{ secrets.WINGET_TOKEN }} create_sentry_release: diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml new file mode 100644 index 0000000000000000000000000000000000000000..33da35cb2aec86e19c9c65c399542cf1a1b78943 --- /dev/null +++ b/.github/workflows/autofix_pr.yml @@ -0,0 +1,132 @@ +# Generated from xtask::workflows::autofix_pr +# Rebuild with `cargo xtask workflows`. +name: autofix_pr +run-name: 'autofix PR #${{ inputs.pr_number }}' +on: + workflow_dispatch: + inputs: + pr_number: + description: pr_number + required: true + type: string + run_clippy: + description: run_clippy + type: boolean + default: 'true' +jobs: + run_autofix: + runs-on: namespace-profile-16x32-ubuntu-2204 + steps: + - name: steps::checkout_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + - name: autofix_pr::run_autofix::checkout_pr + run: gh pr checkout ${{ inputs.pr_number }} + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: steps::setup_cargo_config + run: | + mkdir -p ./../.cargo + cp ./.cargo/ci-config.toml ./../.cargo/config.toml + shell: bash -euxo pipefail {0} + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + - name: steps::setup_linux + run: ./script/linux + shell: bash -euxo pipefail {0} + - name: steps::install_mold + run: ./script/install-mold + shell: bash -euxo pipefail {0} + - name: steps::download_wasi_sdk + run: ./script/download-wasi-sdk + shell: bash -euxo pipefail {0} + - name: steps::setup_pnpm + uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 + with: + version: '9' + - name: autofix_pr::run_autofix::run_prettier_fix + run: ./script/prettier --write + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_cargo_fmt + run: cargo fmt --all + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_cargo_fix + if: ${{ inputs.run_clippy }} + run: cargo fix --workspace --release --all-targets --all-features --allow-dirty --allow-staged + shell: bash -euxo pipefail {0} + - name: autofix_pr::run_autofix::run_clippy_fix + if: ${{ inputs.run_clippy }} + run: cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged + shell: bash -euxo pipefail {0} + - id: create-patch + name: autofix_pr::run_autofix::create_patch + run: | + if git diff --quiet; then + echo "No changes to commit" + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + git diff > autofix.patch + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + shell: bash -euxo pipefail {0} + - name: upload artifact autofix-patch + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: autofix-patch + path: autofix.patch + if-no-files-found: ignore + retention-days: '1' + - name: steps::cleanup_cargo_config + if: always() + run: | + rm -rf ./../.cargo + shell: bash -euxo pipefail {0} + outputs: + has_changes: ${{ steps.create-patch.outputs.has_changes }} + commit_changes: + needs: + - run_autofix + if: needs.run_autofix.outputs.has_changes == 'true' + runs-on: namespace-profile-2x4-ubuntu-2404 + steps: + - id: get-app-token + name: steps::authenticate_as_zippy + uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + - name: steps::checkout_repo_with_token + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + token: ${{ steps.get-app-token.outputs.token }} + - name: autofix_pr::commit_changes::checkout_pr + run: gh pr checkout ${{ inputs.pr_number }} + shell: bash -euxo pipefail {0} + env: + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + - name: autofix_pr::download_patch_artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + with: + name: autofix-patch + - name: autofix_pr::commit_changes::apply_patch + run: git apply autofix.patch + shell: bash -euxo pipefail {0} + - name: autofix_pr::commit_changes::commit_and_push + run: | + git commit -am "Autofix" + git push + shell: bash -euxo pipefail {0} + env: + GIT_COMMITTER_NAME: Zed Zippy + GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: Zed Zippy + GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} +concurrency: + group: ${{ github.workflow }}-${{ inputs.pr_number }} + cancel-in-progress: true diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index bc01aae17e7141a2359b162c3de94c1aec7b765c..d4dee5154f2209521f3e9d183c05c118e8861521 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -30,7 +30,7 @@ jobs: with: clean: false - id: get-app-token - name: cherry_pick::run_cherry_pick::authenticate_as_zippy + name: steps::authenticate_as_zippy uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml index 93f1d5602331bb76fe5d678098ab8c087b1f3d52..d73b38320731e0a2f9a52ff863de5095eddb7b6a 100644 --- a/.github/workflows/community_champion_auto_labeler.yml +++ b/.github/workflows/community_champion_auto_labeler.yml @@ -34,6 +34,7 @@ jobs: CharlesChen0823 chbk cppcoffee + davidbarsky davewa ddoemonn djsauble diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index 14c1a0a08338ee513a8269094b41ee404beef726..6347b713257f49c02f981774faa0d0359e05e4d3 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -1,29 +1,40 @@ name: "Close Stale Issues" on: schedule: - - cron: "0 8 31 DEC *" + - cron: "0 2 * * 5" workflow_dispatch: + inputs: + debug-only: + description: "Run in dry-run mode (no changes made)" + type: boolean + default: false + operations-per-run: + description: "Max number of issues to process (default: 1000)" + type: number + default: 1000 jobs: stale: if: github.repository_owner == 'zed-industries' runs-on: ubuntu-latest steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9 + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: > - Hi there! 👋 - - We're working to clean up our issue tracker by closing older bugs that might not be relevant anymore. If you are able to reproduce this issue in the latest version of Zed, please let us know by commenting on this issue, and it will be kept open. If you can't reproduce it, feel free to close the issue yourself. Otherwise, it will close automatically in 14 days. + Hi there! + Zed development moves fast and a significant number of bugs become outdated. + If you can reproduce this bug on the latest stable Zed, please let us know by leaving a comment with the Zed version. + If the bug doesn't appear for you anymore, feel free to close the issue yourself; otherwise, the bot will close it in a couple of weeks. Thanks for your help! - close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please open a new issue with a link to this issue." + close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please leave a comment with your Zed version so that we can reopen the issue." days-before-stale: 60 days-before-close: 14 only-issue-types: "Bug,Crash" - operations-per-run: 1000 + operations-per-run: ${{ inputs.operations-per-run || 1000 }} ascending: true enable-statistics: true + debug-only: ${{ inputs.debug-only }} stale-issue-label: "stale" exempt-issue-labels: "never stale" diff --git a/.github/workflows/docs_automation.yml b/.github/workflows/docs_automation.yml new file mode 100644 index 0000000000000000000000000000000000000000..46dcfa56616c24a78f61e075d3e1a7909655b8aa --- /dev/null +++ b/.github/workflows/docs_automation.yml @@ -0,0 +1,264 @@ +name: Documentation Automation + +on: + # push: + # branches: [main] + # paths: + # - 'crates/**' + # - 'extensions/**' + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to analyze (gets full PR diff)' + required: false + type: string + trigger_sha: + description: 'Commit SHA to analyze (ignored if pr_number is set)' + required: false + type: string + +permissions: + contents: write + pull-requests: write + +env: + FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} + DROID_MODEL: claude-opus-4-5-20251101 + +jobs: + docs-automation: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Droid CLI + id: install-droid + run: | + curl -fsSL https://app.factory.ai/cli | sh + echo "${HOME}/.local/bin" >> "$GITHUB_PATH" + echo "DROID_BIN=${HOME}/.local/bin/droid" >> "$GITHUB_ENV" + # Verify installation + "${HOME}/.local/bin/droid" --version + + - name: Setup Node.js (for Prettier) + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Prettier + run: npm install -g prettier + + - name: Get changed files + id: changed + run: | + if [ -n "${{ inputs.pr_number }}" ]; then + # Get full PR diff + echo "Analyzing PR #${{ inputs.pr_number }}" + echo "source=pr" >> "$GITHUB_OUTPUT" + echo "ref=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT" + gh pr diff "${{ inputs.pr_number }}" --name-only > /tmp/changed_files.txt + elif [ -n "${{ inputs.trigger_sha }}" ]; then + # Get single commit diff + SHA="${{ inputs.trigger_sha }}" + echo "Analyzing commit $SHA" + echo "source=commit" >> "$GITHUB_OUTPUT" + echo "ref=$SHA" >> "$GITHUB_OUTPUT" + git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt + else + # Default to current commit + SHA="${{ github.sha }}" + echo "Analyzing commit $SHA" + echo "source=commit" >> "$GITHUB_OUTPUT" + echo "ref=$SHA" >> "$GITHUB_OUTPUT" + git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt || git diff --name-only HEAD~1 HEAD > /tmp/changed_files.txt + fi + + echo "Changed files:" + cat /tmp/changed_files.txt + env: + GH_TOKEN: ${{ github.token }} + + # Phase 0: Guardrails are loaded via AGENTS.md in each phase + + # Phase 2: Explore Repository (Read-Only - default) + - name: "Phase 2: Explore Repository" + id: phase2 + run: | + "$DROID_BIN" exec \ + -m "$DROID_MODEL" \ + -f .factory/prompts/docs-automation/phase2-explore.md \ + > /tmp/phase2-output.txt 2>&1 || true + echo "Repository exploration complete" + cat /tmp/phase2-output.txt + + # Phase 3: Analyze Changes (Read-Only - default) + - name: "Phase 3: Analyze Changes" + id: phase3 + run: | + CHANGED_FILES=$(tr '\n' ' ' < /tmp/changed_files.txt) + echo "Analyzing changes in: $CHANGED_FILES" + + # Build prompt with context + cat > /tmp/phase3-prompt.md << 'EOF' + $(cat .factory/prompts/docs-automation/phase3-analyze.md) + + ## Context + + ### Changed Files + $CHANGED_FILES + + ### Phase 2 Output + $(cat /tmp/phase2-output.txt) + EOF + + "$DROID_BIN" exec \ + -m "$DROID_MODEL" \ + "$(cat .factory/prompts/docs-automation/phase3-analyze.md) + + Changed files: $CHANGED_FILES" \ + > /tmp/phase3-output.md 2>&1 || true + echo "Change analysis complete" + cat /tmp/phase3-output.md + + # Phase 4: Plan Documentation Impact (Read-Only - default) + - name: "Phase 4: Plan Documentation Impact" + id: phase4 + run: | + "$DROID_BIN" exec \ + -m "$DROID_MODEL" \ + -f .factory/prompts/docs-automation/phase4-plan.md \ + > /tmp/phase4-plan.md 2>&1 || true + echo "Documentation plan complete" + cat /tmp/phase4-plan.md + + # Check if updates are required + if grep -q "NO_UPDATES_REQUIRED" /tmp/phase4-plan.md; then + echo "updates_required=false" >> "$GITHUB_OUTPUT" + else + echo "updates_required=true" >> "$GITHUB_OUTPUT" + fi + + # Phase 5: Apply Plan (Write-Enabled with --auto medium) + - name: "Phase 5: Apply Documentation Plan" + id: phase5 + if: steps.phase4.outputs.updates_required == 'true' + run: | + "$DROID_BIN" exec \ + -m "$DROID_MODEL" \ + --auto medium \ + -f .factory/prompts/docs-automation/phase5-apply.md \ + > /tmp/phase5-report.md 2>&1 || true + echo "Documentation updates applied" + cat /tmp/phase5-report.md + + # Phase 5b: Format with Prettier + - name: "Phase 5b: Format with Prettier" + id: phase5b + if: steps.phase4.outputs.updates_required == 'true' + run: | + echo "Formatting documentation with Prettier..." + cd docs && prettier --write src/ + + echo "Verifying Prettier formatting passes..." + cd docs && prettier --check src/ + + echo "Prettier formatting complete" + + # Phase 6: Summarize Changes (Read-Only - default) + - name: "Phase 6: Summarize Changes" + id: phase6 + if: steps.phase4.outputs.updates_required == 'true' + run: | + # Get git diff of docs + git diff docs/src/ > /tmp/docs-diff.txt || true + + "$DROID_BIN" exec \ + -m "$DROID_MODEL" \ + -f .factory/prompts/docs-automation/phase6-summarize.md \ + > /tmp/phase6-summary.md 2>&1 || true + echo "Summary generated" + cat /tmp/phase6-summary.md + + # Phase 7: Commit and Open PR + - name: "Phase 7: Create PR" + id: phase7 + if: steps.phase4.outputs.updates_required == 'true' + run: | + # Check if there are actual changes + if git diff --quiet docs/src/; then + echo "No documentation changes detected" + exit 0 + fi + + # Configure git + git config user.name "factory-droid[bot]" + git config user.email "138933559+factory-droid[bot]@users.noreply.github.com" + + # Daily batch branch - one branch per day, multiple commits accumulate + BRANCH_NAME="docs/auto-update-$(date +%Y-%m-%d)" + + # Stash local changes from phase 5 + git stash push -m "docs-automation-changes" -- docs/src/ + + # Check if branch already exists on remote + if git ls-remote --exit-code --heads origin "$BRANCH_NAME" > /dev/null 2>&1; then + echo "Branch $BRANCH_NAME exists, checking out and updating..." + git fetch origin "$BRANCH_NAME" + git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME" + else + echo "Creating new branch $BRANCH_NAME..." + git checkout -b "$BRANCH_NAME" + fi + + # Apply stashed changes + git stash pop || true + + # Stage and commit + git add docs/src/ + SUMMARY=$(head -50 < /tmp/phase6-summary.md) + git commit -m "docs: auto-update documentation + + ${SUMMARY} + + Triggered by: ${{ steps.changed.outputs.source }} ${{ steps.changed.outputs.ref }} + + Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>" + + # Push + git push -u origin "$BRANCH_NAME" + + # Check if PR already exists for this branch + EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number' || echo "") + + if [ -n "$EXISTING_PR" ]; then + echo "PR #$EXISTING_PR already exists for branch $BRANCH_NAME, updated with new commit" + else + # Create new PR + gh pr create \ + --title "docs: automated documentation update ($(date +%Y-%m-%d))" \ + --body-file /tmp/phase6-summary.md \ + --base main || true + echo "PR created on branch: $BRANCH_NAME" + fi + env: + GH_TOKEN: ${{ github.token }} + + # Summary output + - name: "Summary" + if: always() + run: | + echo "## Documentation Automation Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [ "${{ steps.phase4.outputs.updates_required }}" == "false" ]; then + echo "No documentation updates required for this change." >> "$GITHUB_STEP_SUMMARY" + elif [ -f /tmp/phase6-summary.md ]; then + cat /tmp/phase6-summary.md >> "$GITHUB_STEP_SUMMARY" + else + echo "Workflow completed. Check individual phase outputs for details." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 4781014e32f01b473b3358f2a81a3613fe5cdce9..31676e5c914719a34f8b2e61193475ed107cd2db 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -25,33 +25,6 @@ on: description: The app secret for the corresponding app ID required: true jobs: - check_extension: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') - runs-on: namespace-profile-2x4-ubuntu-2404 - steps: - - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - with: - clean: false - - id: cache-zed-extension-cli - name: extension_tests::cache_zed_extension_cli - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 - with: - path: zed-extension - key: zed-extension-${{ env.ZED_EXTENSION_CLI_SHA }} - - name: extension_tests::download_zed_extension_cli - if: steps.cache-zed-extension-cli.outputs.cache-hit != 'true' - run: | - wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" - chmod +x zed-extension - shell: bash -euxo pipefail {0} - - name: extension_tests::check - run: | - mkdir -p /tmp/ext-scratch - mkdir -p /tmp/ext-output - ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output - shell: bash -euxo pipefail {0} - timeout-minutes: 2 check_bump_needed: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-2x4-ubuntu-2404 @@ -89,7 +62,6 @@ jobs: timeout-minutes: 1 bump_extension_version: needs: - - check_extension - check_bump_needed if: |- (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && @@ -141,10 +113,10 @@ jobs: delete-branch: true token: ${{ steps.generate-token.outputs.token }} sign-commits: true + assignees: ${{ github.actor }} timeout-minutes: 1 create_version_label: needs: - - check_extension - check_bump_needed if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check_bump_needed.outputs.needs_bump == 'false' runs-on: namespace-profile-8x16-ubuntu-2204 diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 9f0917e388c74cffed8f342f7504bc111e6f5147..43f26a2be786eeb41161ab5b36cd4ed71a0bd6f8 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -51,7 +51,7 @@ jobs: needs: - orchestrate if: needs.orchestrate.outputs.check_rust == 'true' - runs-on: namespace-profile-16x32-ubuntu-2204 + runs-on: namespace-profile-4x8-ubuntu-2204 steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 @@ -79,7 +79,7 @@ jobs: needs: - orchestrate if: needs.orchestrate.outputs.check_extension == 'true' - runs-on: namespace-profile-2x4-ubuntu-2404 + runs-on: namespace-profile-8x32-ubuntu-2404 steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7afac285b5a34df2aadd04952400809059e12222..ffc2554a55e00a5bdb7bd1ee0bfeebd5667755d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -472,11 +472,17 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre') runs-on: namespace-profile-2x4-ubuntu-2404 steps: + - id: get-app-token + name: steps::authenticate_as_zippy + uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - name: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false shell: bash -euxo pipefail {0} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} notify_on_failure: needs: - upload_release_assets diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index ad228103e33bd17dbe180d1c267c5141f5433080..47a84574e7c33fb8a40a90c67cd4f7dadb356978 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -74,9 +74,12 @@ jobs: uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 with: version: '9' - - name: ./script/prettier + - name: steps::prettier run: ./script/prettier shell: bash -euxo pipefail {0} + - name: steps::cargo_fmt + run: cargo fmt --all -- --check + shell: bash -euxo pipefail {0} - name: ./script/check-todos run: ./script/check-todos shell: bash -euxo pipefail {0} @@ -87,9 +90,6 @@ jobs: uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 with: config: ./typos.toml - - name: steps::cargo_fmt - run: cargo fmt --all -- --check - shell: bash -euxo pipefail {0} timeout-minutes: 60 run_tests_windows: needs: @@ -353,6 +353,9 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk shell: bash -euxo pipefail {0} + - name: ./script/generate-action-metadata + run: ./script/generate-action-metadata + shell: bash -euxo pipefail {0} - name: run_tests::check_docs::install_mdbook uses: peaceiris/actions-mdbook@ee69d230fe19748b7abf22df32acaa93833fad08 with: @@ -497,6 +500,8 @@ jobs: env: GIT_AUTHOR_NAME: Protobuf Action GIT_AUTHOR_EMAIL: ci@zed.dev + GIT_COMMITTER_NAME: Protobuf Action + GIT_COMMITTER_EMAIL: ci@zed.dev steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 diff --git a/.gitignore b/.gitignore index 2a91a65b6eaef906681bf3f6e315de07b094c4b1..c71417c32bff76af9d4c9c67661556e1625c9d15 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .DS_Store .blob_store .build +.claude/settings.local.json .envrc .flatpak-builder .idea @@ -35,7 +36,11 @@ DerivedData/ Packages xcuserdata/ +crates/docs_preprocessor/actions.json # Don't commit any secrets to the repo. .env .env.secret.toml + +# `nix build` output +/result diff --git a/.mailmap b/.mailmap index db4632d6ca34346d3e8fa289222d7f310b7bdfe5..1e956c52cf76589fc016e1410122ccd94e4818ae 100644 --- a/.mailmap +++ b/.mailmap @@ -141,6 +141,9 @@ Uladzislau Kaminski Uladzislau Kaminski Vitaly Slobodin Vitaly Slobodin +Yara +Yara +Yara Will Bradley Will Bradley WindSoilder diff --git a/.rules b/.rules index 82d15eb9e88299ee7c7fe6c717b2da2646e676a7..7c98c65d7e0eaf3ed0d57898dbd8acee28a220ae 100644 --- a/.rules +++ b/.rules @@ -26,6 +26,12 @@ }); ``` +# Timers in tests + +* In GPUI tests, prefer GPUI executor timers over `smol::Timer::after(...)` when you need timeouts, delays, or to drive `run_until_parked()`: + - Use `cx.background_executor().timer(duration).await` (or `cx.background_executor.timer(duration).await` in `TestAppContext`) so the work is scheduled on GPUI's dispatcher. + - Avoid `smol::Timer::after(...)` for test timeouts when you rely on `run_until_parked()`, because it may not be tracked by GPUI's scheduler and can lead to "nothing left to run" when pumping. + # GPUI GPUI is a UI framework which also provides primitives for state and concurrency management. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cbac4af2b57f0350fa9f5665e110e0d6e7f6341..713546387db36765c2fba3d15b59a87918d71198 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,15 +15,16 @@ with the community to improve the product in ways we haven't thought of (or had In particular we love PRs that are: -- Fixes to existing bugs and issues. -- Small enhancements to existing features, particularly to make them work for more people. +- Fixing or extending the docs. +- Fixing bugs. +- Small enhancements to existing features to make them work for more people (making things work on more platforms/modes/whatever). - Small extra features, like keybindings or actions you miss from other editors or extensions. -- Work towards shipping larger features on our roadmap. +- Part of a Community Program like [Let's Git Together](https://github.com/zed-industries/zed/issues/41541). If you're looking for concrete ideas: -- Our [top-ranking issues](https://github.com/zed-industries/zed/issues/5393) based on votes by the community. -- Our [public roadmap](https://zed.dev/roadmap) contains a rough outline of our near-term priorities for Zed. +- [Triaged bugs with confirmed steps to reproduce](https://github.com/zed-industries/zed/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3Astate%3Areproducible). +- [Area labels](https://github.com/zed-industries/zed/labels?q=area%3A*) to browse bugs in a specific part of the product you care about (after clicking on an area label, add type:Bug to the search). ## Sending changes @@ -37,9 +38,17 @@ like, sorry). Although we will take a look, we tend to only merge about half the PRs that are submitted. If you'd like your PR to have the best chance of being merged: -- Include a clear description of what you're solving, and why it's important to you. -- Include tests. -- If it changes the UI, attach screenshots or screen recordings. +- Make sure the change is **desired**: we're always happy to accept bugfixes, + but features should be confirmed with us first if you aim to avoid wasted + effort. If there isn't already a GitHub issue for your feature with staff + confirmation that we want it, start with a GitHub discussion rather than a PR. +- Include a clear description of **what you're solving**, and why it's important. +- Include **tests**. +- If it changes the UI, attach **screenshots** or screen recordings. +- Make the PR about **one thing only**, e.g. if it's a bugfix, don't add two + features and a refactoring on top of that. +- Keep AI assistance under your judgement and responsibility: it's unlikely + we'll merge a vibe-coded PR that the author doesn't understand. The internal advice for reviewers is as follows: @@ -50,10 +59,9 @@ The internal advice for reviewers is as follows: If you need more feedback from us: the best way is to be responsive to Github comments, or to offer up time to pair with us. -If you are making a larger change, or need advice on how to finish the change -you're making, please open the PR early. We would love to help you get -things right, and it's often easier to see how to solve a problem before the -diff gets too big. +If you need help deciding how to fix a bug, or finish implementing a feature +that we've agreed we want, please open a PR early so we can discuss how to make +the change with code in hand. ## Things we will (probably) not merge @@ -61,11 +69,11 @@ Although there are few hard and fast rules, typically we don't merge: - Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions). - New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs. +- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit. - Giant refactorings. - Non-trivial changes with no tests. - Stylistic code changes that do not alter any app logic. Reducing allocations, removing `.unwrap()`s, fixing typos is great; making code "more readable" — maybe not so much. -- Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit. -- Anything that seems completely AI generated. +- Anything that seems AI-generated without understanding the output. ## Bird's-eye view of Zed diff --git a/Cargo.lock b/Cargo.lock index 6e558cbf395866ce6b75ff5764ba98a5ec81607a..d73200e3ced437f1e61e8c5b0da06f306358eb14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,7 @@ dependencies = [ "terminal", "ui", "url", + "urlencoding", "util", "uuid", "watch", @@ -110,6 +111,15 @@ dependencies = [ "workspace", ] +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli 0.31.1", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -159,6 +169,7 @@ dependencies = [ "derive_more 0.99.20", "editor", "env_logger 0.11.8", + "eval_utils", "fs", "futures 0.3.31", "git", @@ -210,14 +221,14 @@ dependencies = [ "worktree", "zed_env_vars", "zlog", - "zstd 0.11.2+zstd.1.5.2", + "zstd", ] [[package]] name = "agent-client-protocol" -version = "0.7.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525705e39c11cd73f7bc784e3681a9386aa30c8d0630808d3dc2237eb4f9cb1b" +checksum = "d3e527d7dfe0f334313d42d1d9318f0a79665f6f21c440d0798f230a77a7ed6c" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -226,22 +237,22 @@ dependencies = [ "derive_more 2.0.1", "futures 0.3.31", "log", - "parking_lot", "serde", "serde_json", ] [[package]] name = "agent-client-protocol-schema" -version = "0.6.2" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecf16c18fea41282d6bbadd1549a06be6836bddb1893f44a6235f340fa24e2af" +checksum = "6903a00e8ac822f9bacac59a1932754d7387c72ebb7c9c7439ad021505591da4" dependencies = [ "anyhow", "derive_more 2.0.1", "schemars", "serde", "serde_json", + "strum 0.27.2", ] [[package]] @@ -290,6 +301,7 @@ dependencies = [ name = "agent_settings" version = "0.1.0" dependencies = [ + "agent-client-protocol", "anyhow", "cloud_llm_client", "collections", @@ -328,6 +340,7 @@ dependencies = [ "buffer_diff", "chrono", "client", + "clock", "cloud_llm_client", "collections", "command_palette_hooks", @@ -335,6 +348,7 @@ dependencies = [ "context_server", "db", "editor", + "eval_utils", "extension", "extension_host", "feature_flags", @@ -343,6 +357,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", + "gpui_tokio", "html_to_markdown", "http_client", "image", @@ -370,6 +385,7 @@ dependencies = [ "proto", "rand 0.9.2", "release_channel", + "reqwest_client", "rope", "rules_library", "schemars", @@ -383,7 +399,6 @@ dependencies = [ "streaming_diff", "task", "telemetry", - "telemetry_events", "terminal", "terminal_view", "text", @@ -396,11 +411,43 @@ dependencies = [ "unindent", "url", "util", + "uuid", "watch", "workspace", "zed_actions", ] +[[package]] +name = "agent_ui_v2" +version = "0.1.0" +dependencies = [ + "agent", + "agent_servers", + "agent_settings", + "agent_ui", + "anyhow", + "assistant_text_thread", + "chrono", + "db", + "editor", + "feature_flags", + "fs", + "fuzzy", + "gpui", + "menu", + "project", + "prompt_store", + "serde", + "serde_json", + "settings", + "text", + "time", + "time_format", + "ui", + "util", + "workspace", +] + [[package]] name = "ahash" version = "0.7.8" @@ -676,21 +723,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "argminmax" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f13d10a41ac8d2ec79ee34178d61e6f47a29c2edfe7ef1721c7383b0359e65" -dependencies = [ - "num-traits", -] - -[[package]] -name = "array-init-cursor" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3" - [[package]] name = "arraydeque" version = "0.5.1" @@ -761,7 +793,7 @@ dependencies = [ "url", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.9", + "wayland-protocols", "zbus", ] @@ -844,7 +876,6 @@ dependencies = [ "fs", "futures 0.3.31", "fuzzy", - "globset", "gpui", "html_to_markdown", "http_client", @@ -903,7 +934,7 @@ dependencies = [ "settings", "smallvec", "smol", - "telemetry_events", + "telemetry", "text", "ui", "unindent", @@ -1274,15 +1305,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atoi_simd" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a49e05797ca52e312a0c658938b7d00693ef037799ef7187678f212d7684cf" -dependencies = [ - "debug_unsafe", -] - [[package]] name = "atomic" version = "0.5.3" @@ -1419,9 +1441,9 @@ dependencies = [ [[package]] name = "aws-config" -version = "1.8.8" +version = "1.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37cf2b6af2a95a20e266782b4f76f1a5e12bf412a9db2de9c1e9123b9d8c0ad8" +checksum = "1856b1b48b65f71a4dd940b1c0931f9a7b646d4a924b9828ffefc1454714668a" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1485,9 +1507,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.5.12" +version = "1.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa006bb32360ed90ac51203feafb9d02e3d21046e1fd3a450a404b90ea73e5d" +checksum = "9f2402da1a5e16868ba98725e5d73f26b8116eaa892e56f2cd0bf5eec7985f70" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -1510,9 +1532,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.109.0" +version = "1.112.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfdfd941dcb253c17bf70baddbf1e5b22f19e29d313d2e049bad4b1dadb2011" +checksum = "c06c037e6823696d752702ec2bad758d3cf95d1b92b712c8ac7e93824b5e2391" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1592,9 +1614,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.86.0" +version = "1.88.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0abbfab841446cce6e87af853a3ba2cc1bc9afcd3f3550dd556c43d434c86d" +checksum = "d05b276777560aa9a196dbba2e3aada4d8006d3d7eeb3ba7fe0c317227d933c4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1614,9 +1636,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.88.0" +version = "1.90.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a68d675582afea0e94d38b6ca9c5aaae4ca14f1d36faa6edb19b42e687e70d7" +checksum = "f9be14d6d9cd761fac3fd234a0f47f7ed6c0df62d83c0eeb7012750e4732879b" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1636,9 +1658,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.88.0" +version = "1.90.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d30990923f4f675523c51eb1c0dec9b752fb267b36a61e83cbc219c9d86da715" +checksum = "98a862d704c817d865c8740b62d8bbeb5adcb30965e93b471df8a5bcefa20a80" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1659,9 +1681,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.3.5" +version = "1.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bffc03068fbb9c8dd5ce1c6fb240678a5cffb86fb2b7b1985c999c4b83c8df68" +checksum = "c35452ec3f001e1f2f6db107b6373f1f48f05ec63ba2c5c9fa91f07dad32af11" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -1718,9 +1740,9 @@ dependencies = [ [[package]] name = "aws-smithy-eventstream" -version = "0.60.12" +version = "0.60.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9656b85088f8d9dc7ad40f9a6c7228e1e8447cdf4b046c87e152e0805dea02fa" +checksum = "e29a304f8319781a39808847efb39561351b1bb76e933da7aa90232673638658" dependencies = [ "aws-smithy-types", "bytes 1.10.1", @@ -1729,9 +1751,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.62.4" +version = "0.62.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3feafd437c763db26aa04e0cc7591185d0961e64c61885bece0fb9d50ceac671" +checksum = "445d5d720c99eed0b4aa674ed00d835d9b1427dd73e04adaf2f94c6b2d6f9fca" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -1739,6 +1761,7 @@ dependencies = [ "bytes 1.10.1", "bytes-utils", "futures-core", + "futures-util", "http 0.2.12", "http 1.3.1", "http-body 0.4.6", @@ -1750,9 +1773,9 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1053b5e587e6fa40ce5a79ea27957b04ba660baa02b28b7436f64850152234f1" +checksum = "623254723e8dfd535f566ee7b2381645f8981da086b5c4aa26c0c41582bb1d2c" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -1780,9 +1803,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.61.6" +version = "0.61.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff418fc8ec5cadf8173b10125f05c2e7e1d46771406187b2c878557d4503390" +checksum = "2db31f727935fc63c6eeae8b37b438847639ec330a9161ece694efba257e0c54" dependencies = [ "aws-smithy-types", ] @@ -1808,9 +1831,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.9.3" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ab99739082da5347660c556689256438defae3bcefd66c52b095905730e404" +checksum = "0bbe9d018d646b96c7be063dd07987849862b0e6d07c778aad7d93d1be6c1ef0" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -1832,9 +1855,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3683c5b152d2ad753607179ed71988e8cfd52964443b4f74fd8e552d0bbfeb46" +checksum = "ec7204f9fd94749a7c53b26da1b961b4ac36bf070ef1e0b94bb09f79d4f6c193" dependencies = [ "aws-smithy-async", "aws-smithy-types", @@ -1849,9 +1872,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.3.3" +version = "1.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f5b3a7486f6690ba25952cabf1e7d75e34d69eaff5081904a47bc79074d6457" +checksum = "25f535879a207fce0db74b679cfc3e91a3159c8144d717d55f5832aea9eef46e" dependencies = [ "base64-simd", "bytes 1.10.1", @@ -1875,18 +1898,18 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.11" +version = "0.60.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c34127e8c624bc2999f3b657e749c1393bedc9cd97b92a804db8ced4d2e163" +checksum = "eab77cdd036b11056d2a30a7af7b775789fb024bf216acc13884c6c97752ae56" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.9" +version = "1.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2fd329bf0e901ff3f60425691410c69094dc2a1f34b331f37bfc4e9ac1565a1" +checksum = "d79fb68e3d7fe5d4833ea34dc87d2e97d26d3086cb3da660bb6b1f76d98680b6" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -1985,7 +2008,7 @@ version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ - "addr2line", + "addr2line 0.25.1", "cfg-if", "libc", "miniz_oxide", @@ -2066,26 +2089,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "serde", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] - [[package]] name = "bindgen" version = "0.71.1" @@ -2126,30 +2129,15 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec 0.8.0", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bit-vec" version = "0.8.0" @@ -2253,19 +2241,6 @@ dependencies = [ "profiling", ] -[[package]] -name = "blake3" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq 0.3.1", -] - [[package]] name = "block" version = "0.1.6" @@ -2328,9 +2303,9 @@ dependencies = [ [[package]] name = "borrow-or-share" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "borsh" @@ -2355,12 +2330,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "boxcar" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" - [[package]] name = "breadcrumbs" version = "0.1.0" @@ -2527,9 +2496,6 @@ name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" -dependencies = [ - "serde", -] [[package]] name = "bytes-utils" @@ -2816,15 +2782,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -2854,9 +2811,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -2939,6 +2896,17 @@ dependencies = [ "util", ] +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + [[package]] name = "chrono" version = "0.4.42" @@ -2953,16 +2921,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "chrono-tz" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" -dependencies = [ - "chrono", - "phf 0.12.1", -] - [[package]] name = "chunked_transfer" version = "1.5.0" @@ -3205,26 +3163,11 @@ dependencies = [ "uuid", ] -[[package]] -name = "cloud_zeta2_prompt" -version = "0.1.0" -dependencies = [ - "anyhow", - "cloud_llm_client", - "indoc", - "ordered-float 2.10.1", - "rustc-hash 2.1.1", - "schemars", - "serde", - "serde_json", - "strum 0.27.2", -] - [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" dependencies = [ "cc", ] @@ -3325,8 +3268,8 @@ name = "codestral" version = "0.1.0" dependencies = [ "anyhow", - "edit_prediction", "edit_prediction_context", + "edit_prediction_types", "futures 0.3.31", "gpui", "http_client", @@ -3516,17 +3459,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "comfy-table" -version = "7.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" -dependencies = [ - "crossterm", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "command-fds" version = "0.3.2" @@ -3580,21 +3512,6 @@ dependencies = [ "workspace", ] -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "serde", - "static_assertions", -] - [[package]] name = "component" version = "0.1.0" @@ -3608,6 +3525,33 @@ dependencies = [ "theme", ] +[[package]] +name = "component_preview" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "collections", + "component", + "db", + "fs", + "gpui", + "language", + "log", + "node_runtime", + "notifications", + "project", + "release_channel", + "reqwest_client", + "session", + "settings", + "theme", + "ui", + "ui_input", + "uuid", + "workspace", +] + [[package]] name = "compression-codecs" version = "0.4.31" @@ -3700,12 +3644,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - [[package]] name = "context_server" version = "0.1.0" @@ -3724,8 +3662,10 @@ dependencies = [ "serde", "serde_json", "settings", + "slotmap", "smol", "tempfile", + "terminal", "url", "util", ] @@ -3758,7 +3698,7 @@ dependencies = [ "command_palette_hooks", "ctor", "dirs 4.0.0", - "edit_prediction", + "edit_prediction_types", "editor", "fs", "futures 0.3.31", @@ -3783,6 +3723,7 @@ dependencies = [ "task", "theme", "ui", + "url", "util", "workspace", "zlog", @@ -4033,20 +3974,38 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.120.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5023e06632d8f351c2891793ccccfe4aef957954904392434038745fb6f1f68" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.120.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c4012b4c8c1f6eb05c0a0a540e3e1ee992631af51aa2bbb3e712903ce4fd65" +dependencies = [ + "cranelift-srcgen", +] + [[package]] name = "cranelift-bforest" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4" +checksum = "4d6d883b4942ef3a7104096b8bc6f2d1a41393f159ac8de12aed27b25d67f895" dependencies = [ "cranelift-entity", ] [[package]] name = "cranelift-bitset" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34" +checksum = "db7b2ee9eec6ca8a716d900d5264d678fb2c290c58c46c8da7f94ee268175d17" dependencies = [ "serde", "serde_derive", @@ -4054,11 +4013,12 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e" +checksum = "aeda0892577afdce1ac2e9a983a55f8c5b87a59334e1f79d8f735a2d7ba4f4b4" dependencies = [ "bumpalo", + "cranelift-assembler-x64", "cranelift-bforest", "cranelift-bitset", "cranelift-codegen-meta", @@ -4067,9 +4027,10 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli 0.31.1", - "hashbrown 0.14.5", + "hashbrown 0.15.5", "log", "postcard", + "pulley-interpreter", "regalloc2", "rustc-hash 2.1.1", "serde", @@ -4081,33 +4042,36 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8" +checksum = "e461480d87f920c2787422463313326f67664e68108c14788ba1676f5edfcd15" dependencies = [ + "cranelift-assembler-x64-meta", "cranelift-codegen-shared", + "cranelift-srcgen", + "pulley-interpreter", ] [[package]] name = "cranelift-codegen-shared" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb" +checksum = "976584d09f200c6c84c4b9ff7af64fc9ad0cb64dffa5780991edd3fe143a30a1" [[package]] name = "cranelift-control" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef" +checksum = "46d43d70f4e17c545aa88dbf4c84d4200755d27c6e3272ebe4de65802fa6a955" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323" +checksum = "d75418674520cb400c8772bfd6e11a62736c78fc1b6e418195696841d1bf91f1" dependencies = [ "cranelift-bitset", "serde", @@ -4116,9 +4080,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57" +checksum = "3c8b1a91c86687a344f3c52dd6dfb6e50db0dfa7f2e9c7711b060b3623e1fdeb" dependencies = [ "cranelift-codegen", "log", @@ -4128,21 +4092,27 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d" +checksum = "711baa4e3432d4129295b39ec2b4040cc1b558874ba0a37d08e832e857db7285" [[package]] name = "cranelift-native" -version = "0.116.1" +version = "0.120.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7" +checksum = "41c83e8666e3bcc5ffeaf6f01f356f0e1f9dcd69ce5511a1efd7ca5722001a3f" dependencies = [ "cranelift-codegen", "libc", "target-lexicon 0.13.3", ] +[[package]] +name = "cranelift-srcgen" +version = "0.120.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e3f4d783a55c64266d17dc67d2708852235732a100fc40dd9f1051adc64d7b" + [[package]] name = "crash-context" version = "0.6.3" @@ -4171,7 +4141,7 @@ dependencies = [ name = "crashes" version = "0.1.0" dependencies = [ - "bincode 1.3.3", + "bincode", "cfg-if", "crash-handler", "extension_host", @@ -4184,7 +4154,8 @@ dependencies = [ "serde_json", "smol", "system_specs", - "zstd 0.11.2+zstd.1.5.2", + "windows 0.61.3", + "zstd", ] [[package]] @@ -4329,29 +4300,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.9.4", - "crossterm_winapi", - "document-features", - "parking_lot", - "rustix 1.1.2", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crunchy" version = "0.2.4" @@ -4706,12 +4654,6 @@ dependencies = [ "util", ] -[[package]] -name = "debug_unsafe" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85d3cef41d236720ed453e102153a53e4cc3d2fde848c0078a50cf249e8e3e5b" - [[package]] name = "debugger_tools" version = "0.1.0" @@ -4744,6 +4686,7 @@ dependencies = [ "db", "debugger_tools", "editor", + "feature_flags", "file_icons", "futures 0.3.31", "fuzzy", @@ -5105,8 +5048,6 @@ name = "docs_preprocessor" version = "0.1.0" dependencies = [ "anyhow", - "command_palette", - "gpui", "mdbook", "regex", "serde", @@ -5115,19 +5056,9 @@ dependencies = [ "task", "theme", "util", - "zed", "zlog", ] -[[package]] -name = "document-features" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" -dependencies = [ - "litrs", -] - [[package]] name = "documented" version = "0.9.2" @@ -5277,44 +5208,108 @@ dependencies = [ name = "edit_prediction" version = "0.1.0" dependencies = [ + "ai_onboarding", + "anyhow", + "arrayvec", + "brotli", + "buffer_diff", "client", + "clock", + "cloud_api_types", + "cloud_llm_client", + "collections", + "copilot", + "ctor", + "db", + "edit_prediction_context", + "edit_prediction_types", + "feature_flags", + "fs", + "futures 0.3.31", "gpui", + "indoc", + "itertools 0.14.0", "language", + "language_model", + "log", + "lsp", + "menu", + "open_ai", + "parking_lot", + "postage", + "pretty_assertions", + "project", + "pulldown-cmark 0.12.2", + "rand 0.9.2", + "regex", + "release_channel", + "semver", + "serde", + "serde_json", + "settings", + "strum 0.27.2", + "telemetry", + "telemetry_events", + "text", + "thiserror 2.0.17", + "time", + "ui", + "util", + "uuid", + "workspace", + "worktree", + "zed_actions", + "zeta_prompt", + "zlog", ] [[package]] -name = "edit_prediction_button" +name = "edit_prediction_cli" version = "0.1.0" dependencies = [ + "anthropic", "anyhow", + "chrono", + "clap", "client", "cloud_llm_client", - "codestral", - "copilot", + "collections", + "debug_adapter_extension", + "dirs 4.0.0", "edit_prediction", - "editor", - "feature_flags", + "extension", "fs", "futures 0.3.31", "gpui", + "gpui_tokio", + "http_client", "indoc", "language", - "lsp", - "menu", + "language_extension", + "language_model", + "language_models", + "languages", + "libc", + "log", + "node_runtime", "paths", + "pretty_assertions", "project", - "regex", + "prompt_store", + "release_channel", + "reqwest_client", + "serde", "serde_json", "settings", - "supermaven", - "telemetry", - "theme", - "ui", - "ui_input", + "shellexpand 2.1.2", + "smol", + "sqlez", + "sqlez_macros", + "terminal_view", "util", - "workspace", - "zed_actions", - "zeta", + "wasmtime", + "watch", + "zeta_prompt", ] [[package]] @@ -5322,33 +5317,84 @@ name = "edit_prediction_context" version = "0.1.0" dependencies = [ "anyhow", - "arrayvec", - "clap", "cloud_llm_client", "collections", + "env_logger 0.11.8", "futures 0.3.31", "gpui", - "hashbrown 0.15.5", "indoc", - "itertools 0.14.0", "language", "log", - "ordered-float 2.10.1", - "postage", + "lsp", + "parking_lot", "pretty_assertions", "project", - "regex", "serde", "serde_json", "settings", - "slotmap", - "strum 0.27.2", + "smallvec", "text", "tree-sitter", - "tree-sitter-c", - "tree-sitter-cpp", - "tree-sitter-go", "util", + "zeta_prompt", + "zlog", +] + +[[package]] +name = "edit_prediction_types" +version = "0.1.0" +dependencies = [ + "client", + "gpui", + "language", + "text", +] + +[[package]] +name = "edit_prediction_ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "buffer_diff", + "client", + "clock", + "cloud_llm_client", + "codestral", + "collections", + "command_palette_hooks", + "copilot", + "edit_prediction", + "edit_prediction_types", + "editor", + "feature_flags", + "fs", + "futures 0.3.31", + "gpui", + "indoc", + "language", + "language_model", + "lsp", + "markdown", + "menu", + "multi_buffer", + "paths", + "pretty_assertions", + "project", + "regex", + "release_channel", + "semver", + "serde_json", + "settings", + "supermaven", + "telemetry", + "text", + "theme", + "time", + "ui", + "util", + "workspace", + "zed_actions", + "zeta_prompt", "zlog", ] @@ -5368,7 +5414,7 @@ dependencies = [ "ctor", "dap", "db", - "edit_prediction", + "edit_prediction_types", "emojis", "feature_flags", "file_icons", @@ -5412,9 +5458,11 @@ dependencies = [ "text", "theme", "time", + "tracing", "tree-sitter-bash", "tree-sitter-c", "tree-sitter-html", + "tree-sitter-md", "tree-sitter-python", "tree-sitter-rust", "tree-sitter-typescript", @@ -5430,6 +5478,7 @@ dependencies = [ "workspace", "zed_actions", "zlog", + "ztracing", ] [[package]] @@ -5705,12 +5754,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "ethnum" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" - [[package]] name = "euclid" version = "0.22.11" @@ -5775,6 +5818,15 @@ dependencies = [ "watch", ] +[[package]] +name = "eval_utils" +version = "0.1.0" +dependencies = [ + "gpui", + "serde", + "smol", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -5855,6 +5907,7 @@ dependencies = [ "gpui", "heck 0.5.0", "http_client", + "indoc", "language", "log", "lsp", @@ -5865,6 +5918,7 @@ dependencies = [ "serde", "serde_json", "task", + "tempfile", "toml 0.8.23", "url", "util", @@ -5986,40 +6040,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fancy-regex" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" -dependencies = [ - "bit-set 0.5.3", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fancy-regex" -version = "0.14.0" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set 0.8.0", + "bit-set", "regex-automata", "regex-syntax", ] -[[package]] -name = "fast-float2" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" - [[package]] name = "fast-srgb8" version = "1.0.0" @@ -6178,9 +6209,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fixedbitset" @@ -6195,7 +6226,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" dependencies = [ "crc32fast", - "libz-rs-sys", "miniz_oxide", ] @@ -6231,9 +6261,9 @@ checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" [[package]] name = "fluent-uri" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" dependencies = [ "borrow-or-share", "ref-cast", @@ -6452,16 +6482,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "fs4" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" -dependencies = [ - "rustix 1.1.2", - "windows-sys 0.59.0", -] - [[package]] name = "fs_benchmarks" version = "0.1.0" @@ -6922,6 +6942,20 @@ dependencies = [ "seq-macro", ] +[[package]] +name = "generator" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.61.3", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -6972,7 +7006,7 @@ dependencies = [ [[package]] name = "gh-workflow" version = "0.8.0" -source = "git+https://github.com/zed-industries/gh-workflow?rev=e5f883040530b4df36437f140084ee5cc7c1c9be#e5f883040530b4df36437f140084ee5cc7c1c9be" +source = "git+https://github.com/zed-industries/gh-workflow?rev=09acfdf2bd5c1d6254abefd609c808ff73547b2c#09acfdf2bd5c1d6254abefd609c808ff73547b2c" dependencies = [ "async-trait", "derive_more 2.0.1", @@ -6989,7 +7023,7 @@ dependencies = [ [[package]] name = "gh-workflow-macros" version = "0.8.0" -source = "git+https://github.com/zed-industries/gh-workflow?rev=e5f883040530b4df36437f140084ee5cc7c1c9be#e5f883040530b4df36437f140084ee5cc7c1c9be" +source = "git+https://github.com/zed-industries/gh-workflow?rev=09acfdf2bd5c1d6254abefd609c808ff73547b2c#09acfdf2bd5c1d6254abefd609c808ff73547b2c" dependencies = [ "heck 0.5.0", "quote", @@ -7083,6 +7117,7 @@ dependencies = [ "gpui", "http_client", "indoc", + "itertools 0.14.0", "pretty_assertions", "regex", "serde", @@ -7128,6 +7163,8 @@ dependencies = [ "picker", "pretty_assertions", "project", + "prompt_store", + "rand 0.9.2", "recent_projects", "remote", "schemars", @@ -7140,6 +7177,7 @@ dependencies = [ "theme", "time", "time_format", + "tracing", "ui", "unindent", "util", @@ -7149,6 +7187,7 @@ dependencies = [ "zed_actions", "zeroize", "zlog", + "ztracing", ] [[package]] @@ -7318,6 +7357,7 @@ dependencies = [ "libc", "log", "lyon", + "mach2 0.5.0", "media", "metal", "naga", @@ -7362,7 +7402,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-cursor", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", "windows 0.61.3", @@ -7525,10 +7565,20 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", - "rayon", "serde", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.8.4" @@ -7626,7 +7676,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c255bdf46e07fb840d120a36dcc81f385140d7191c76a7391672675c01a55d" dependencies = [ - "bincode 1.3.3", + "bincode", "byteorder", "heed-traits", "serde", @@ -7816,7 +7866,6 @@ dependencies = [ "tempfile", "url", "util", - "zed-reqwest", ] [[package]] @@ -8216,7 +8265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -8386,7 +8435,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fb8251fb7bcd9ccd3725ed8deae9fe7db8e586495c9eb5b0c52e6233e5e75ea" dependencies = [ - "bincode 1.3.3", + "bincode", "crossbeam-channel", "fnv", "lazy_static", @@ -8604,6 +8653,7 @@ dependencies = [ "extension", "gpui", "language", + "lsp", "paths", "project", "schemars", @@ -8618,21 +8668,21 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.30.0" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" +checksum = "73c9ffb2b5c56d58030e1b532d8e8389da94590515f118cf35b5cb68e4764a7e" dependencies = [ "ahash 0.8.12", - "base64 0.22.1", "bytecount", + "data-encoding", "email_address", - "fancy-regex 0.14.0", + "fancy-regex", "fraction", + "getrandom 0.3.4", "idna", "itoa", "num-cmp", "num-traits", - "once_cell", "percent-encoding", "referencing", "regex", @@ -8640,6 +8690,7 @@ dependencies = [ "reqwest 0.12.24", "serde", "serde_json", + "unicode-general-category", "uuid-simd", ] @@ -8790,6 +8841,7 @@ dependencies = [ "ctor", "diffy", "ec4rs", + "encoding_rs", "fs", "futures 0.3.31", "fuzzy", @@ -8808,6 +8860,7 @@ dependencies = [ "regex", "rpc", "schemars", + "semver", "serde", "serde_json", "settings", @@ -8870,6 +8923,7 @@ dependencies = [ "cloud_api_types", "cloud_llm_client", "collections", + "credentials_provider", "futures 0.3.31", "gpui", "http_client", @@ -8885,9 +8939,9 @@ dependencies = [ "serde_json", "settings", "smol", - "telemetry_events", "thiserror 2.0.17", "util", + "zed_env_vars", ] [[package]] @@ -8911,6 +8965,8 @@ dependencies = [ "credentials_provider", "deepseek", "editor", + "extension", + "extension_host", "fs", "futures 0.3.31", "google_ai", @@ -8944,7 +9000,6 @@ dependencies = [ "util", "vercel", "x_ai", - "zed_env_vars", ] [[package]] @@ -9042,6 +9097,7 @@ dependencies = [ "regex", "rope", "rust-embed", + "semver", "serde", "serde_json", "serde_json_lenient", @@ -9229,15 +9285,6 @@ dependencies = [ "webrtc-sys", ] -[[package]] -name = "libz-rs-sys" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" -dependencies = [ - "zlib-rs", -] - [[package]] name = "libz-sys" version = "1.1.22" @@ -9300,12 +9347,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" -[[package]] -name = "litrs" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" - [[package]] name = "livekit" version = "0.7.8" @@ -9475,6 +9516,19 @@ dependencies = [ "value-bag", ] +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "loop9" version = "0.1.5" @@ -9597,25 +9651,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "lz4" -version = "1.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" -dependencies = [ - "lz4-sys", -] - -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "mac" version = "0.1.1" @@ -10164,9 +10199,11 @@ dependencies = [ "sum_tree", "text", "theme", + "tracing", "tree-sitter", "util", "zlog", + "ztracing", ] [[package]] @@ -10188,7 +10225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", - "bit-set 0.8.0", + "bit-set", "bitflags 2.9.4", "cfg_aliases 0.2.1", "codespan-reporting 0.12.0", @@ -10478,15 +10515,6 @@ name = "notify-types" version = "2.0.0" source = "git+https://github.com/zed-industries/notify.git?rev=b4588b2e5aee68f4c0e100f140e808cbce7b1419#b4588b2e5aee68f4c0e100f140e808cbce7b1419" -[[package]] -name = "now" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89e9874397a1f0a52fc1f197a8effd9735223cb2390e9dcc83ac6cd02923d0" -dependencies = [ - "chrono", -] - [[package]] name = "ntapi" version = "0.4.1" @@ -10882,41 +10910,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "object_store" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c1be0c6c22ec0817cdc77d3842f721a17fd30ab6965001415b5402a74e6b740" -dependencies = [ - "async-trait", - "base64 0.22.1", - "bytes 1.10.1", - "chrono", - "form_urlencoded", - "futures 0.3.31", - "http 1.3.1", - "http-body-util", - "humantime", - "hyper 1.7.0", - "itertools 0.14.0", - "parking_lot", - "percent-encoding", - "quick-xml 0.38.3", - "rand 0.9.2", - "reqwest 0.12.24", - "ring", - "serde", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.17", - "tokio", - "tracing", - "url", - "walkdir", - "wasm-bindgen-futures", - "web-time", -] - [[package]] name = "ollama" version = "0.1.0" @@ -10941,7 +10934,6 @@ dependencies = [ "documented", "fs", "fuzzy", - "git", "gpui", "menu", "notifications", @@ -12157,16 +12149,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "planus" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3daf8e3d4b712abe1d690838f6e29fb76b76ea19589c4afa39ec30e12f62af71" -dependencies = [ - "array-init-cursor", - "hashbrown 0.15.5", -] - [[package]] name = "plist" version = "1.8.0" @@ -12235,640 +12217,126 @@ dependencies = [ ] [[package]] -name = "polars" -version = "0.51.0" +name = "polling" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5f7feb5d56b954e691dff22a8b2d78d77433dcc93c35fe21c3777fdc121b697" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "getrandom 0.2.16", - "getrandom 0.3.4", - "polars-arrow", - "polars-core", - "polars-error", - "polars-io", - "polars-lazy", - "polars-ops", - "polars-parquet", - "polars-sql", - "polars-time", - "polars-utils", - "version_check", + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] -name = "polars-arrow" -version = "0.51.0" +name = "pollster" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b4fed2343961b3eea3db2cee165540c3e1ad9d5782350cc55a9e76cf440148" -dependencies = [ - "atoi_simd", - "bitflags 2.9.4", - "bytemuck", - "chrono", - "chrono-tz", - "dyn-clone", - "either", - "ethnum", - "getrandom 0.2.16", - "getrandom 0.3.4", - "hashbrown 0.15.5", - "itoa", - "lz4", - "num-traits", - "polars-arrow-format", - "polars-error", - "polars-schema", - "polars-utils", - "serde", - "simdutf8", - "streaming-iterator", - "strum_macros 0.27.2", - "version_check", - "zstd 0.13.3", -] +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" [[package]] -name = "polars-arrow-format" -version = "0.2.1" +name = "pori" +version = "0.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a556ac0ee744e61e167f34c1eb0013ce740e0ee6cd8c158b2ec0b518f10e6675" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" dependencies = [ - "planus", - "serde", + "nom 7.1.3", ] [[package]] -name = "polars-compute" -version = "0.51.0" +name = "portable-atomic" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138785beda4e4a90a025219f09d0d15a671b2be9091513ede58e05db6ad4413f" -dependencies = [ - "atoi_simd", - "bytemuck", - "chrono", - "either", - "fast-float2", - "hashbrown 0.15.5", - "itoa", - "num-traits", - "polars-arrow", - "polars-error", - "polars-utils", - "rand 0.9.2", - "ryu", - "serde", - "skiplist", - "strength_reduce", - "strum_macros 0.27.2", - "version_check", -] +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] -name = "polars-core" -version = "0.51.0" +name = "portable-atomic-util" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e77b1f08ef6dbb032bb1d0d3365464be950df9905f6827a95b24c4ca5518901d" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" dependencies = [ - "bitflags 2.9.4", - "boxcar", - "bytemuck", - "chrono", - "chrono-tz", - "comfy-table", - "either", - "hashbrown 0.15.5", - "indexmap", - "itoa", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-dtype", - "polars-error", - "polars-row", - "polars-schema", - "polars-utils", - "rand 0.9.2", - "rand_distr", - "rayon", - "regex", - "serde", - "serde_json", - "strum_macros 0.27.2", - "uuid", - "version_check", - "xxhash-rust", + "portable-atomic", ] [[package]] -name = "polars-dtype" -version = "0.51.0" +name = "portable-pty" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89c43d0ea57168be4546c4d8064479ed8b29a9c79c31a0c7c367ee734b9b7158" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" dependencies = [ - "boxcar", - "hashbrown 0.15.5", - "polars-arrow", - "polars-error", - "polars-utils", - "serde", - "uuid", + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", ] [[package]] -name = "polars-error" -version = "0.51.0" +name = "postage" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cb5d98f59f8b94673ee391840440ad9f0d2170afced95fc98aa86f895563c0" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" dependencies = [ - "object_store", + "atomic", + "crossbeam-queue", + "futures 0.3.31", + "log", "parking_lot", - "polars-arrow-format", - "regex", - "signal-hook", - "simdutf8", + "pin-project", + "pollster", + "static_assertions", + "thiserror 1.0.69", ] [[package]] -name = "polars-expr" -version = "0.51.0" +name = "postcard" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343931b818cf136349135ba11dbc18c27683b52c3477b1ba8ca606cf5ab1965c" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" dependencies = [ - "bitflags 2.9.4", - "hashbrown 0.15.5", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-io", - "polars-ops", - "polars-plan", - "polars-row", - "polars-time", - "polars-utils", - "rand 0.9.2", - "rayon", - "recursive", + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", ] [[package]] -name = "polars-io" -version = "0.51.0" +name = "potential_utf" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10388c64b8155122488229a881d1c6f4fdc393bc988e764ab51b182fcb2307e4" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ - "async-trait", - "atoi_simd", - "blake3", - "bytes 1.10.1", - "chrono", - "fast-float2", - "fs4", - "futures 0.3.31", - "glob", - "hashbrown 0.15.5", - "home", - "itoa", - "memchr", - "memmap2", - "num-traits", - "object_store", - "percent-encoding", - "polars-arrow", - "polars-core", - "polars-error", - "polars-parquet", - "polars-schema", - "polars-time", - "polars-utils", - "rayon", - "regex", - "reqwest 0.12.24", - "ryu", - "serde", - "serde_json", - "simdutf8", - "tokio", - "tokio-util", - "url", + "zerovec", ] [[package]] -name = "polars-lazy" -version = "0.51.0" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb6e2c6c2fa4ea0c660df1c06cf56960c81e7c2683877995bae3d4e3d408147" -dependencies = [ - "bitflags 2.9.4", - "chrono", - "either", - "memchr", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-expr", - "polars-io", - "polars-mem-engine", - "polars-ops", - "polars-plan", - "polars-stream", - "polars-time", - "polars-utils", - "rayon", - "version_check", -] +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "polars-mem-engine" -version = "0.51.0" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a856e98e253587c28d8132a5e7e5a75cb2c44731ca090f1481d45f1d123771" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "futures 0.3.31", - "memmap2", - "polars-arrow", - "polars-core", - "polars-error", - "polars-expr", - "polars-io", - "polars-ops", - "polars-plan", - "polars-time", - "polars-utils", - "rayon", - "recursive", - "tokio", + "zerocopy", ] [[package]] -name = "polars-ops" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acf6062173fdc9ba05775548beb66e76643a148d9aeadc9984ed712bc4babd76" -dependencies = [ - "argminmax", - "base64 0.22.1", - "bytemuck", - "chrono", - "chrono-tz", - "either", - "hashbrown 0.15.5", - "hex", - "indexmap", - "libm", - "memchr", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-error", - "polars-schema", - "polars-utils", - "rayon", - "regex", - "regex-syntax", - "strum_macros 0.27.2", - "unicode-normalization", - "unicode-reverse", - "version_check", -] - -[[package]] -name = "polars-parquet" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1d769180dec070df0dc4b89299b364bf2cfe32b218ecc4ddd8f1a49ae60669" -dependencies = [ - "async-stream", - "base64 0.22.1", - "brotli", - "bytemuck", - "ethnum", - "flate2", - "futures 0.3.31", - "hashbrown 0.15.5", - "lz4", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-error", - "polars-parquet-format", - "polars-utils", - "serde", - "simdutf8", - "snap", - "streaming-decompression", - "zstd 0.13.3", -] - -[[package]] -name = "polars-parquet-format" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c025243dcfe8dbc57e94d9f82eb3bef10b565ab180d5b99bed87fd8aea319ce1" -dependencies = [ - "async-trait", - "futures 0.3.31", -] - -[[package]] -name = "polars-plan" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd3a2e33ae4484fe407ab2d2ba5684f0889d1ccf3ad6b844103c03638e6d0a0" -dependencies = [ - "bitflags 2.9.4", - "bytemuck", - "bytes 1.10.1", - "chrono", - "chrono-tz", - "either", - "futures 0.3.31", - "hashbrown 0.15.5", - "memmap2", - "num-traits", - "percent-encoding", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-error", - "polars-io", - "polars-ops", - "polars-parquet", - "polars-time", - "polars-utils", - "rayon", - "recursive", - "regex", - "sha2", - "strum_macros 0.27.2", - "version_check", -] - -[[package]] -name = "polars-row" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18734f17e0e348724df3ae65f3ee744c681117c04b041cac969dfceb05edabc0" -dependencies = [ - "bitflags 2.9.4", - "bytemuck", - "polars-arrow", - "polars-compute", - "polars-dtype", - "polars-error", - "polars-utils", -] - -[[package]] -name = "polars-schema" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6c1ab13e04d5167661a9854ed1ea0482b2ed9b8a0f1118dabed7cd994a85e3" -dependencies = [ - "indexmap", - "polars-error", - "polars-utils", - "serde", - "version_check", -] - -[[package]] -name = "polars-sql" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e7766da02cc1d464994404d3e88a7a0ccd4933df3627c325480fbd9bbc0a11" -dependencies = [ - "bitflags 2.9.4", - "hex", - "polars-core", - "polars-error", - "polars-lazy", - "polars-ops", - "polars-plan", - "polars-time", - "polars-utils", - "rand 0.9.2", - "regex", - "serde", - "sqlparser", -] - -[[package]] -name = "polars-stream" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f6c6ca1ea01f9dea424d167e4f33f5ec44cd67fbfac9efd40575ed20521f14" -dependencies = [ - "async-channel 2.5.0", - "async-trait", - "atomic-waker", - "bitflags 2.9.4", - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-queue", - "crossbeam-utils", - "futures 0.3.31", - "memmap2", - "parking_lot", - "percent-encoding", - "pin-project-lite", - "polars-arrow", - "polars-core", - "polars-error", - "polars-expr", - "polars-io", - "polars-mem-engine", - "polars-ops", - "polars-parquet", - "polars-plan", - "polars-utils", - "rand 0.9.2", - "rayon", - "recursive", - "slotmap", - "tokio", - "tokio-util", - "version_check", -] - -[[package]] -name = "polars-time" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6a3a6e279a7a984a0b83715660f9e880590c6129ec2104396bfa710bcd76dee" -dependencies = [ - "atoi_simd", - "bytemuck", - "chrono", - "chrono-tz", - "now", - "num-traits", - "polars-arrow", - "polars-compute", - "polars-core", - "polars-error", - "polars-ops", - "polars-utils", - "rayon", - "regex", - "strum_macros 0.27.2", -] - -[[package]] -name = "polars-utils" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57b267021b0e5422d7fbc70fd79e51b9f9a8466c585779373a18b0199e973f29" -dependencies = [ - "bincode 2.0.1", - "bytemuck", - "bytes 1.10.1", - "compact_str", - "either", - "flate2", - "foldhash 0.1.5", - "hashbrown 0.15.5", - "indexmap", - "libc", - "memmap2", - "num-traits", - "polars-error", - "rand 0.9.2", - "raw-cpuid 11.6.0", - "rayon", - "regex", - "rmp-serde", - "serde", - "serde_json", - "serde_stacker", - "slotmap", - "stacker", - "uuid", - "version_check", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.2", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" - -[[package]] -name = "pori" -version = "0.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" -dependencies = [ - "nom 7.1.3", -] - -[[package]] -name = "portable-atomic" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix 0.28.0", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg 0.10.1", -] - -[[package]] -name = "postage" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" -dependencies = [ - "atomic", - "crossbeam-queue", - "futures 0.3.31", - "log", - "parking_lot", - "pin-project", - "pollster", - "static_assertions", - "thiserror 1.0.69", -] - -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "serde", -] - -[[package]] -name = "potential_utf" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" +name = "precomputed-hash" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" @@ -13043,8 +12511,10 @@ dependencies = [ "context_server", "dap", "dap_adapters", + "db", + "encoding_rs", "extension", - "fancy-regex 0.14.0", + "fancy-regex", "fs", "futures 0.3.31", "fuzzy", @@ -13089,6 +12559,7 @@ dependencies = [ "terminal", "text", "toml 0.8.23", + "tracing", "unindent", "url", "util", @@ -13098,6 +12569,7 @@ dependencies = [ "worktree", "zeroize", "zlog", + "ztracing", ] [[package]] @@ -13134,6 +12606,7 @@ dependencies = [ "gpui", "language", "menu", + "notifications", "pretty_assertions", "project", "rayon", @@ -13211,6 +12684,8 @@ dependencies = [ "paths", "rope", "serde", + "strum 0.27.2", + "tempfile", "text", "util", "uuid", @@ -13414,13 +12889,12 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" [[package]] name = "pulley-interpreter" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d" +checksum = "986beaef947a51d17b42b0ea18ceaa88450d35b6994737065ed505c39172db71" dependencies = [ "cranelift-bitset", "log", - "sptr", "wasmtime-math", ] @@ -13499,7 +12973,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" dependencies = [ "memchr", - "serde", ] [[package]] @@ -13801,6 +13274,7 @@ dependencies = [ "askpass", "auto_update", "dap", + "db", "editor", "extension_host", "file_finder", @@ -13812,6 +13286,7 @@ dependencies = [ "log", "markdown", "menu", + "node_runtime", "ordered-float 2.10.1", "paths", "picker", @@ -13830,29 +13305,10 @@ dependencies = [ "util", "windows-registry 0.6.1", "workspace", + "worktree", "zed_actions", ] -[[package]] -name = "recursive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" -dependencies = [ - "recursive-proc-macro-impl", - "stacker", -] - -[[package]] -name = "recursive-proc-macro-impl" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" -dependencies = [ - "quote", - "syn 2.0.106", -] - [[package]] name = "redox_syscall" version = "0.2.16" @@ -13915,13 +13371,14 @@ dependencies = [ [[package]] name = "referencing" -version = "0.30.0" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" +checksum = "4283168a506f0dcbdce31c9f9cce3129c924da4c6bca46e46707fcb746d2d70c" dependencies = [ "ahash 0.8.12", "fluent-uri", - "once_cell", + "getrandom 0.3.4", + "hashbrown 0.16.1", "parking_lot", "percent-encoding", "serde_json", @@ -13936,9 +13393,9 @@ dependencies = [ [[package]] name = "regalloc2" -version = "0.11.2" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" +checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" dependencies = [ "allocator-api2", "bumpalo", @@ -14208,35 +13665,26 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.4.12", "http 1.3.1", "http-body 1.0.1", "http-body-util", "hyper 1.7.0", - "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", - "quinn", - "rustls 0.23.33", - "rustls-native-certs 0.8.2", - "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tokio-rustls 0.26.2", - "tokio-util", "tower 0.5.2", "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", ] @@ -14359,17 +13807,6 @@ dependencies = [ "paste", ] -[[package]] -name = "rmp-serde" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" -dependencies = [ - "byteorder", - "rmp", - "serde", -] - [[package]] name = "rmpv" version = "1.3.0" @@ -14406,9 +13843,11 @@ dependencies = [ "rand 0.9.2", "rayon", "sum_tree", + "tracing", "unicode-segmentation", "util", "zlog", + "ztracing", ] [[package]] @@ -14439,7 +13878,7 @@ dependencies = [ "tracing", "util", "zlog", - "zstd 0.11.2+zstd.1.5.2", + "zstd", ] [[package]] @@ -14904,6 +14343,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "settings", "theme", ] @@ -15134,12 +14574,14 @@ dependencies = [ "settings", "smol", "theme", + "tracing", "ui", "unindent", "util", "util_macros", "workspace", "zed_actions", + "ztracing", ] [[package]] @@ -15331,17 +14773,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_stacker" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" -dependencies = [ - "serde", - "serde_core", - "stacker", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -15475,6 +14906,8 @@ dependencies = [ "assets", "bm25", "client", + "copilot", + "edit_prediction", "editor", "feature_flags", "fs", @@ -15483,6 +14916,7 @@ dependencies = [ "gpui", "heck 0.5.0", "language", + "language_models", "log", "menu", "node_runtime", @@ -15683,16 +15117,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" -[[package]] -name = "skiplist" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354fd282d3177c2951004953e2fdc4cb342fa159bbee8b829852b6a081c8ea1" -dependencies = [ - "rand 0.9.2", - "thiserror 2.0.17", -] - [[package]] name = "skrifa" version = "0.37.0" @@ -15768,12 +15192,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -[[package]] -name = "snap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" - [[package]] name = "snippet" version = "0.1.0" @@ -15820,26 +15238,6 @@ dependencies = [ "workspace", ] -[[package]] -name = "soa-rs" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75ae4668062b095fda87ba54118697bed601f07f6c68bf50289a25ca0c8c935" -dependencies = [ - "soa-rs-derive", -] - -[[package]] -name = "soa-rs-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c09121507da587d3434e5929ce3321162f36bd3eff403873cb163c06b176913" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "socket2" version = "0.5.10" @@ -15959,15 +15357,6 @@ dependencies = [ "unicode_categories", ] -[[package]] -name = "sqlparser" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05a528114c392209b3264855ad491fcce534b94a38771b0a0b97a79379275ce8" -dependencies = [ - "log", -] - [[package]] name = "sqlx" version = "0.8.6" @@ -16269,15 +15658,6 @@ dependencies = [ "ui", ] -[[package]] -name = "streaming-decompression" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf6cc3b19bfb128a8ad11026086e31d3ce9ad23f8ea37354b31383a187c44cf3" -dependencies = [ - "fallible-streaming-iterator", -] - [[package]] name = "streaming-iterator" version = "0.1.9" @@ -16409,7 +15789,9 @@ dependencies = [ "log", "rand 0.9.2", "rayon", + "tracing", "zlog", + "ztracing", ] [[package]] @@ -16419,7 +15801,7 @@ dependencies = [ "anyhow", "client", "collections", - "edit_prediction", + "edit_prediction_types", "editor", "env_logger 0.11.8", "futures 0.3.31", @@ -17115,13 +16497,13 @@ dependencies = [ "alacritty_terminal", "anyhow", "collections", - "fancy-regex 0.14.0", "futures 0.3.31", "gpui", "itertools 0.14.0", "libc", "log", "rand 0.9.2", + "regex", "release_channel", "schemars", "serde", @@ -17349,12 +16731,12 @@ dependencies = [ [[package]] name = "tiktoken-rs" version = "0.9.1" -source = "git+https://github.com/zed-industries/tiktoken-rs?rev=7249f999c5fdf9bf3cc5c288c964454e4dac0c00#7249f999c5fdf9bf3cc5c288c964454e4dac0c00" +source = "git+https://github.com/zed-industries/tiktoken-rs?rev=2570c4387a8505fb8f1d3f3557454b474f1e8271#2570c4387a8505fb8f1d3f3557454b474f1e8271" dependencies = [ "anyhow", "base64 0.22.1", "bstr", - "fancy-regex 0.13.0", + "fancy-regex", "lazy_static", "regex", "rustc-hash 1.1.0", @@ -17663,7 +17045,6 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", - "futures-util", "pin-project-lite", "tokio", ] @@ -17895,9 +17276,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", @@ -17907,9 +17288,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -17918,9 +17299,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -17949,9 +17330,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -17968,6 +17349,38 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-tracy" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eaa1852afa96e0fe9e44caa53dc0bd2d9d05e0f2611ce09f97f8677af56e4ba" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracy-client", +] + +[[package]] +name = "tracy-client" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d722a05fe49b31fef971c4732a7d4aa6a18283d9ba46abddab35f484872947" +dependencies = [ + "loom", + "once_cell", + "tracy-client-sys", +] + +[[package]] +name = "tracy-client-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f" +dependencies = [ + "cc", + "windows-targets 0.52.6", +] + [[package]] name = "trait-variant" version = "0.1.2" @@ -17991,9 +17404,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.25.10" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +checksum = "974d205cc395652cfa8b37daa053fe56eebd429acf8dc055503fee648dae981e" dependencies = [ "cc", "regex", @@ -18006,9 +17419,9 @@ dependencies = [ [[package]] name = "tree-sitter-bash" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "871b0606e667e98a1237ebdc1b0d7056e0aebfdc3141d12b399865d4cb6ed8a6" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" dependencies = [ "cc", "tree-sitter-language", @@ -18486,6 +17899,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -18513,15 +17932,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" -[[package]] -name = "unicode-reverse" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6f4888ebc23094adfb574fdca9fdc891826287a6397d2cd28802ffd6f20c76" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "unicode-script" version = "0.5.7" @@ -18582,12 +17992,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "url" version = "2.5.7" @@ -18720,7 +18124,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" dependencies = [ "outref", - "uuid", "vsimd", ] @@ -18828,9 +18231,11 @@ dependencies = [ "language", "log", "lsp", + "markdown_preview", "menu", "multi_buffer", "nvim-rs", + "outline_panel", "parking_lot", "perf", "picker", @@ -18864,12 +18269,6 @@ dependencies = [ "settings", ] -[[package]] -name = "virtue" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" - [[package]] name = "vscode_theme" version = "0.2.0" @@ -19094,6 +18493,16 @@ dependencies = [ "wasmparser 0.227.1", ] +[[package]] +name = "wasm-encoder" +version = "0.229.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ba1d491ecacb085a2552025c10a675a6fddcbd03b1fc9b36c536010ce265d2" +dependencies = [ + "leb128fmt", + "wasmparser 0.229.0", +] + [[package]] name = "wasm-metadata" version = "0.201.0" @@ -19178,23 +18587,37 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.229.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3b1f053f5d41aa55640a1fa9b6d1b8a9e4418d118ce308d20e24ff3575a8c" +dependencies = [ + "bitflags 2.9.4", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", +] + [[package]] name = "wasmprinter" -version = "0.221.3" +version = "0.229.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283" +checksum = "d25dac01892684a99b8fbfaf670eb6b56edea8a096438c75392daeb83156ae2e" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.221.3", + "wasmparser 0.229.0", ] [[package]] name = "wasmtime" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69" +checksum = "57373e1d8699662fb791270ac5dfac9da5c14f618ecf940cdb29dc3ad9472a3c" dependencies = [ + "addr2line 0.24.2", "anyhow", "async-trait", "bitflags 2.9.4", @@ -19202,7 +18625,7 @@ dependencies = [ "cc", "cfg-if", "encoding_rs", - "hashbrown 0.14.5", + "hashbrown 0.15.5", "indexmap", "libc", "log", @@ -19210,12 +18633,11 @@ dependencies = [ "memfd", "object 0.36.7", "once_cell", - "paste", "postcard", "psm", "pulley-interpreter", "rayon", - "rustix 0.38.44", + "rustix 1.1.2", "semver", "serde", "serde_derive", @@ -19223,7 +18645,7 @@ dependencies = [ "sptr", "target-lexicon 0.13.3", "trait-variant", - "wasmparser 0.221.3", + "wasmparser 0.229.0", "wasmtime-asm-macros", "wasmtime-component-macro", "wasmtime-component-util", @@ -19240,18 +18662,18 @@ dependencies = [ [[package]] name = "wasmtime-asm-macros" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2" +checksum = "bd0fc91372865167a695dc98d0d6771799a388a7541d3f34e939d0539d6583de" dependencies = [ "cfg-if", ] [[package]] name = "wasmtime-c-api-impl" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea30cef3608f2de5797c7bbb94c1ba4f3676d9a7f81ae86ced1b512e2766ed0c" +checksum = "46db556f1dccdd88e0672bd407162ab0036b72e5eccb0f4398d8251cba32dba1" dependencies = [ "anyhow", "log", @@ -19262,9 +18684,9 @@ dependencies = [ [[package]] name = "wasmtime-c-api-macros" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022a79ebe1124d5d384d82463d7e61c6b4dd857d81f15cb8078974eeb86db65b" +checksum = "315cc6bc8cdc66f296accb26d7625ae64c1c7b6da6f189e8a72ce6594bf7bd36" dependencies = [ "proc-macro2", "quote", @@ -19272,9 +18694,9 @@ dependencies = [ [[package]] name = "wasmtime-component-macro" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf" +checksum = "25c9c7526675ff9a9794b115023c4af5128e3eb21389bfc3dc1fd344d549258f" dependencies = [ "anyhow", "proc-macro2", @@ -19282,20 +18704,20 @@ dependencies = [ "syn 2.0.106", "wasmtime-component-util", "wasmtime-wit-bindgen", - "wit-parser 0.221.3", + "wit-parser 0.229.0", ] [[package]] name = "wasmtime-component-util" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e" +checksum = "cc42ec8b078875804908d797cb4950fec781d9add9684c9026487fd8eb3f6291" [[package]] name = "wasmtime-cranelift" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87" +checksum = "b2bd72f0a6a0ffcc6a184ec86ac35c174e48ea0e97bbae277c8f15f8bf77a566" dependencies = [ "anyhow", "cfg-if", @@ -19305,22 +18727,23 @@ dependencies = [ "cranelift-frontend", "cranelift-native", "gimli 0.31.1", - "itertools 0.12.1", + "itertools 0.14.0", "log", "object 0.36.7", + "pulley-interpreter", "smallvec", "target-lexicon 0.13.3", - "thiserror 1.0.69", - "wasmparser 0.221.3", + "thiserror 2.0.17", + "wasmparser 0.229.0", "wasmtime-environ", "wasmtime-versioned-export-macros", ] [[package]] name = "wasmtime-environ" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad" +checksum = "e6187bb108a23eb25d2a92aa65d6c89fb5ed53433a319038a2558567f3011ff2" dependencies = [ "anyhow", "cpp_demangle", @@ -19337,22 +18760,22 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon 0.13.3", - "wasm-encoder 0.221.3", - "wasmparser 0.221.3", + "wasm-encoder 0.229.0", + "wasmparser 0.229.0", "wasmprinter", "wasmtime-component-util", ] [[package]] name = "wasmtime-fiber" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117" +checksum = "dc8965d2128c012329f390e24b8b2758dd93d01bf67e1a1a0dd3d8fd72f56873" dependencies = [ "anyhow", "cc", "cfg-if", - "rustix 0.38.44", + "rustix 1.1.2", "wasmtime-asm-macros", "wasmtime-versioned-export-macros", "windows-sys 0.59.0", @@ -19360,9 +18783,9 @@ dependencies = [ [[package]] name = "wasmtime-jit-icache-coherence" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1" +checksum = "7af0e940cb062a45c0b3f01a926f77da5947149e99beb4e3dd9846d5b8f11619" dependencies = [ "anyhow", "cfg-if", @@ -19372,24 +18795,24 @@ dependencies = [ [[package]] name = "wasmtime-math" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17" +checksum = "acfca360e719dda9a27e26944f2754ff2fd5bad88e21919c42c5a5f38ddd93cb" dependencies = [ "libm", ] [[package]] name = "wasmtime-slab" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf" +checksum = "48e240559cada55c4b24af979d5f6c95e0029f5772f32027ec3c62b258aaff65" [[package]] name = "wasmtime-versioned-export-macros" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b" +checksum = "d0963c1438357a3d8c0efe152b4ef5259846c1cf8b864340270744fe5b3bae5e" dependencies = [ "proc-macro2", "quote", @@ -19398,9 +18821,9 @@ dependencies = [ [[package]] name = "wasmtime-wasi" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d1be69bfcab1bdac74daa7a1f9695ab992b9c8e21b9b061e7d66434097e0ca4" +checksum = "4ae951b72c7c6749a1c15dcdfb6d940a2614c932b4a54f474636e78e2c744b4c" dependencies = [ "anyhow", "async-trait", @@ -19415,30 +18838,43 @@ dependencies = [ "futures 0.3.31", "io-extras", "io-lifetimes", - "rustix 0.38.44", + "rustix 1.1.2", "system-interface", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tracing", - "trait-variant", "url", "wasmtime", + "wasmtime-wasi-io", "wiggle", "windows-sys 0.59.0", ] +[[package]] +name = "wasmtime-wasi-io" +version = "33.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a835790dcecc3d7051ec67da52ba9e04af25e1bc204275b9391e3f0042b10797" +dependencies = [ + "anyhow", + "async-trait", + "bytes 1.10.1", + "futures 0.3.31", + "wasmtime", +] + [[package]] name = "wasmtime-winch" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdbabfb8f20502d5e1d81092b9ead3682ae59988487aafcd7567387b7a43cf8f" +checksum = "cbc3b117d03d6eeabfa005a880c5c22c06503bb8820f3aa2e30f0e8d87b6752f" dependencies = [ "anyhow", "cranelift-codegen", "gimli 0.31.1", "object 0.36.7", "target-lexicon 0.13.3", - "wasmparser 0.221.3", + "wasmparser 0.229.0", "wasmtime-cranelift", "wasmtime-environ", "winch-codegen", @@ -19446,14 +18882,14 @@ dependencies = [ [[package]] name = "wasmtime-wit-bindgen" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6" +checksum = "1382f4f09390eab0d75d4994d0c3b0f6279f86a571807ec67a8253c87cf6a145" dependencies = [ "anyhow", "heck 0.5.0", "indexmap", - "wit-parser 0.221.3", + "wit-parser 0.229.0", ] [[package]] @@ -19529,18 +18965,6 @@ dependencies = [ "xcursor", ] -[[package]] -name = "wayland-protocols" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" -dependencies = [ - "bitflags 2.9.4", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - [[package]] name = "wayland-protocols" version = "0.32.9" @@ -19555,14 +18979,14 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.2.0" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ "bitflags 2.9.4", "wayland-backend", "wayland-client", - "wayland-protocols 0.31.2", + "wayland-protocols", "wayland-scanner", ] @@ -19575,7 +18999,7 @@ dependencies = [ "bitflags 2.9.4", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.9", + "wayland-protocols", "wayland-scanner", ] @@ -19735,6 +19159,20 @@ dependencies = [ "winsafe", ] +[[package]] +name = "which_key" +version = "0.1.0" +dependencies = [ + "command_palette", + "gpui", + "serde", + "settings", + "theme", + "ui", + "util", + "workspace", +] + [[package]] name = "whoami" version = "1.6.1" @@ -19747,14 +19185,14 @@ dependencies = [ [[package]] name = "wiggle" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9af35bc9629c52c261465320a9a07959164928b4241980ba1cf923b9e6751d" +checksum = "649c1aca13ef9e9dccf2d5efbbebf12025bc5521c3fb7754355ef60f5eb810be" dependencies = [ "anyhow", "async-trait", "bitflags 2.9.4", - "thiserror 1.0.69", + "thiserror 2.0.17", "tracing", "wasmtime", "wiggle-macro", @@ -19762,24 +19200,23 @@ dependencies = [ [[package]] name = "wiggle-generate" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cf267dd05673912c8138f4b54acabe6bd53407d9d1536f0fadb6520dd16e101" +checksum = "164870fc34214ee42bd81b8ce9e7c179800fa1a7d4046d17a84e7f7bf422c8ad" dependencies = [ "anyhow", "heck 0.5.0", "proc-macro2", "quote", - "shellexpand 2.1.2", "syn 2.0.106", "witx", ] [[package]] name = "wiggle-macro" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c5c473d4198e6c2d377f3809f713ff0c110cab88a0805ae099a82119ee250c" +checksum = "d873bb5b59ca703b5e41562e96a4796d1af61bf4cf80bf8a7abda755a380ec1c" dependencies = [ "proc-macro2", "quote", @@ -19820,18 +19257,19 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "29.0.1" +version = "33.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f849ef2c5f46cb0a20af4b4487aaa239846e52e2c03f13fa3c784684552859c" +checksum = "7914c296fbcef59d1b89a15e82384d34dc9669bc09763f2ef068a28dd3a64ebf" dependencies = [ "anyhow", + "cranelift-assembler-x64", "cranelift-codegen", "gimli 0.31.1", "regalloc2", "smallvec", "target-lexicon 0.13.3", - "thiserror 1.0.69", - "wasmparser 0.221.3", + "thiserror 2.0.17", + "wasmparser 0.229.0", "wasmtime-cranelift", "wasmtime-environ", ] @@ -20729,9 +20167,9 @@ dependencies = [ [[package]] name = "wit-parser" -version = "0.221.3" +version = "0.227.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" +checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" dependencies = [ "anyhow", "id-arena", @@ -20742,14 +20180,14 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.221.3", + "wasmparser 0.227.1", ] [[package]] name = "wit-parser" -version = "0.227.1" +version = "0.229.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" +checksum = "459c6ba62bf511d6b5f2a845a2a736822e38059c1cfa0b644b467bbbfae4efa6" dependencies = [ "anyhow", "id-arena", @@ -20760,7 +20198,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.227.1", + "wasmparser 0.229.0", ] [[package]] @@ -20789,13 +20227,16 @@ dependencies = [ "component", "dap", "db", + "feature_flags", "fs", "futures 0.3.31", + "git", "gpui", "http_client", "itertools 0.14.0", "language", "log", + "markdown", "menu", "node_runtime", "parking_lot", @@ -20829,8 +20270,10 @@ version = "0.1.0" dependencies = [ "anyhow", "async-lock 2.8.0", + "chardetng", "clock", "collections", + "encoding_rs", "fs", "futures 0.3.31", "fuzzy", @@ -20858,6 +20301,16 @@ dependencies = [ "zlog", ] +[[package]] +name = "worktree_benchmarks" +version = "0.1.0" +dependencies = [ + "fs", + "gpui", + "settings", + "worktree", +] + [[package]] name = "writeable" version = "0.6.1" @@ -21025,12 +20478,6 @@ dependencies = [ "toml_edit 0.22.27", ] -[[package]] -name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - [[package]] name = "yaml-rust2" version = "0.8.1" @@ -21205,12 +20652,13 @@ dependencies = [ [[package]] name = "zed" -version = "0.216.0" +version = "0.219.0" dependencies = [ "acp_tools", "activity_indicator", "agent_settings", "agent_ui", + "agent_ui_v2", "anyhow", "ashpd 0.11.0", "askpass", @@ -21218,7 +20666,7 @@ dependencies = [ "audio", "auto_update", "auto_update_ui", - "bincode 1.3.3", + "bincode", "breadcrumbs", "call", "channel", @@ -21231,6 +20679,7 @@ dependencies = [ "collections", "command_palette", "component", + "component_preview", "copilot", "crashes", "dap", @@ -21240,7 +20689,8 @@ dependencies = [ "debugger_tools", "debugger_ui", "diagnostics", - "edit_prediction_button", + "edit_prediction", + "edit_prediction_ui", "editor", "env_logger 0.11.8", "extension", @@ -21331,10 +20781,10 @@ dependencies = [ "time", "title_bar", "toolchain_selector", + "tracing", "tree-sitter-md", "tree-sitter-rust", "ui", - "ui_input", "ui_prompt", "url", "urlencoding", @@ -21345,16 +20795,16 @@ dependencies = [ "watch", "web_search", "web_search_providers", + "which_key", "windows 0.61.3", "winresource", "workspace", "zed-reqwest", "zed_actions", "zed_env_vars", - "zeta", - "zeta2_tools", "zlog", "zlog_settings", + "ztracing", ] [[package]] @@ -21495,6 +20945,8 @@ dependencies = [ [[package]] name = "zed_extension_api" version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" dependencies = [ "serde", "serde_json", @@ -21503,9 +20955,7 @@ dependencies = [ [[package]] name = "zed_extension_api" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" +version = "0.8.0" dependencies = [ "serde", "serde_json", @@ -21521,23 +20971,23 @@ dependencies = [ [[package]] name = "zed_html" -version = "0.2.3" +version = "0.3.0" dependencies = [ - "zed_extension_api 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "zed_extension_api 0.7.0", ] [[package]] name = "zed_proto" -version = "0.2.3" +version = "0.3.1" dependencies = [ - "zed_extension_api 0.1.0", + "zed_extension_api 0.7.0", ] [[package]] name = "zed_test_extension" version = "0.1.0" dependencies = [ - "zed_extension_api 0.7.0", + "zed_extension_api 0.8.0", ] [[package]] @@ -21665,148 +21115,10 @@ dependencies = [ ] [[package]] -name = "zeta" -version = "0.1.0" -dependencies = [ - "ai_onboarding", - "anyhow", - "arrayvec", - "brotli", - "buffer_diff", - "client", - "clock", - "cloud_api_types", - "cloud_llm_client", - "cloud_zeta2_prompt", - "collections", - "command_palette_hooks", - "copilot", - "credentials_provider", - "ctor", - "db", - "edit_prediction", - "edit_prediction_context", - "editor", - "feature_flags", - "fs", - "futures 0.3.31", - "gpui", - "indoc", - "itertools 0.14.0", - "language", - "language_model", - "log", - "lsp", - "markdown", - "menu", - "open_ai", - "parking_lot", - "postage", - "pretty_assertions", - "project", - "rand 0.9.2", - "regex", - "release_channel", - "semver", - "serde", - "serde_json", - "settings", - "smol", - "strsim", - "strum 0.27.2", - "telemetry", - "telemetry_events", - "theme", - "thiserror 2.0.17", - "ui", - "util", - "uuid", - "workspace", - "worktree", - "zed_actions", - "zlog", -] - -[[package]] -name = "zeta2_tools" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "client", - "cloud_llm_client", - "cloud_zeta2_prompt", - "collections", - "edit_prediction_context", - "editor", - "feature_flags", - "futures 0.3.31", - "gpui", - "indoc", - "language", - "multi_buffer", - "pretty_assertions", - "project", - "serde", - "serde_json", - "settings", - "telemetry", - "text", - "ui", - "ui_input", - "util", - "workspace", - "zeta", - "zlog", -] - -[[package]] -name = "zeta_cli" +name = "zeta_prompt" version = "0.1.0" dependencies = [ - "anyhow", - "chrono", - "clap", - "client", - "cloud_llm_client", - "cloud_zeta2_prompt", - "collections", - "debug_adapter_extension", - "edit_prediction_context", - "extension", - "fs", - "futures 0.3.31", - "gpui", - "gpui_tokio", - "indoc", - "language", - "language_extension", - "language_model", - "language_models", - "languages", - "log", - "node_runtime", - "ordered-float 2.10.1", - "paths", - "polars", - "pretty_assertions", - "project", - "prompt_store", - "pulldown-cmark 0.12.2", - "release_channel", - "reqwest_client", "serde", - "serde_json", - "settings", - "shellexpand 2.1.2", - "smol", - "soa-rs", - "terminal_view", - "toml 0.8.23", - "util", - "watch", - "zeta", - "zlog", ] [[package]] @@ -21818,7 +21130,7 @@ dependencies = [ "aes", "byteorder", "bzip2", - "constant_time_eq 0.1.5", + "constant_time_eq", "crc32fast", "crossbeam-utils", "flate2", @@ -21826,7 +21138,7 @@ dependencies = [ "pbkdf2 0.11.0", "sha1", "time", - "zstd 0.11.2+zstd.1.5.2", + "zstd", ] [[package]] @@ -21844,12 +21156,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "zlib-rs" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" - [[package]] name = "zlog" version = "0.1.0" @@ -21877,16 +21183,7 @@ version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe 7.2.4", + "zstd-safe", ] [[package]] @@ -21899,15 +21196,6 @@ dependencies = [ "zstd-sys", ] -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - [[package]] name = "zstd-sys" version = "2.0.16+zstd.1.5.7" @@ -21918,6 +21206,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "ztracing" +version = "0.1.0" +dependencies = [ + "tracing", + "tracing-subscriber", + "tracing-tracy", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index b3e77414fe511445a73d3341b53ab8f8f589d884..54f256abac03c50d5aa0ca0d1c5dd7d433319ddf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/agent_servers", "crates/agent_settings", "crates/agent_ui", + "crates/agent_ui_v2", "crates/ai_onboarding", "crates/anthropic", "crates/askpass", @@ -32,13 +33,13 @@ members = [ "crates/cloud_api_client", "crates/cloud_api_types", "crates/cloud_llm_client", - "crates/cloud_zeta2_prompt", "crates/collab", "crates/collab_ui", "crates/collections", "crates/command_palette", "crates/command_palette_hooks", "crates/component", + "crates/component_preview", "crates/context_server", "crates/copilot", "crates/crashes", @@ -54,11 +55,12 @@ members = [ "crates/diagnostics", "crates/docs_preprocessor", "crates/edit_prediction", - "crates/edit_prediction_button", + "crates/edit_prediction_types", + "crates/edit_prediction_ui", "crates/edit_prediction_context", - "crates/zeta2_tools", "crates/editor", "crates/eval", + "crates/eval_utils", "crates/explorer_command_injector", "crates/extension", "crates/extension_api", @@ -191,19 +193,23 @@ members = [ "crates/vercel", "crates/vim", "crates/vim_mode_setting", + "crates/which_key", "crates/watch", "crates/web_search", "crates/web_search_providers", "crates/workspace", "crates/worktree", + "crates/worktree_benchmarks", "crates/x_ai", "crates/zed", "crates/zed_actions", "crates/zed_env_vars", - "crates/zeta", - "crates/zeta_cli", + "crates/edit_prediction_cli", + "crates/zeta_prompt", "crates/zlog", "crates/zlog_settings", + "crates/ztracing", + "crates/ztracing_macro", # # Extensions @@ -240,9 +246,9 @@ action_log = { path = "crates/action_log" } agent = { path = "crates/agent" } activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } +agent_ui_v2 = { path = "crates/agent_ui_v2" } agent_settings = { path = "crates/agent_settings" } agent_servers = { path = "crates/agent_servers" } -ai = { path = "crates/ai" } ai_onboarding = { path = "crates/ai_onboarding" } anthropic = { path = "crates/anthropic" } askpass = { path = "crates/askpass" } @@ -252,7 +258,6 @@ assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_slash_commands = { path = "crates/assistant_slash_commands" } audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } -auto_update_helper = { path = "crates/auto_update_helper" } auto_update_ui = { path = "crates/auto_update_ui" } aws_http_client = { path = "crates/aws_http_client" } bedrock = { path = "crates/bedrock" } @@ -266,13 +271,12 @@ clock = { path = "crates/clock" } cloud_api_client = { path = "crates/cloud_api_client" } cloud_api_types = { path = "crates/cloud_api_types" } cloud_llm_client = { path = "crates/cloud_llm_client" } -cloud_zeta2_prompt = { path = "crates/cloud_zeta2_prompt" } -collab = { path = "crates/collab" } collab_ui = { path = "crates/collab_ui" } collections = { path = "crates/collections", version = "0.1.0" } command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } component = { path = "crates/component" } +component_preview = { path = "crates/component_preview" } context_server = { path = "crates/context_server" } copilot = { path = "crates/copilot" } crashes = { path = "crates/crashes" } @@ -288,6 +292,7 @@ deepseek = { path = "crates/deepseek" } derive_refineable = { path = "crates/refineable/derive_refineable" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } +eval_utils = { path = "crates/eval_utils" } extension = { path = "crates/extension" } extension_host = { path = "crates/extension_host" } extensions_ui = { path = "crates/extensions_ui" } @@ -311,10 +316,9 @@ http_client = { path = "crates/http_client" } http_client_tls = { path = "crates/http_client_tls" } icons = { path = "crates/icons" } image_viewer = { path = "crates/image_viewer" } -edit_prediction = { path = "crates/edit_prediction" } -edit_prediction_button = { path = "crates/edit_prediction_button" } +edit_prediction_types = { path = "crates/edit_prediction_types" } +edit_prediction_ui = { path = "crates/edit_prediction_ui" } edit_prediction_context = { path = "crates/edit_prediction_context" } -zeta2_tools = { path = "crates/zeta2_tools" } inspector_ui = { path = "crates/inspector_ui" } install_cli = { path = "crates/install_cli" } journal = { path = "crates/journal" } @@ -356,8 +360,6 @@ panel = { path = "crates/panel" } paths = { path = "crates/paths" } perf = { path = "tooling/perf" } picker = { path = "crates/picker" } -plugin = { path = "crates/plugin" } -plugin_macros = { path = "crates/plugin_macros" } prettier = { path = "crates/prettier" } settings_profile_selector = { path = "crates/settings_profile_selector" } project = { path = "crates/project" } @@ -368,12 +370,10 @@ proto = { path = "crates/proto" } recent_projects = { path = "crates/recent_projects" } refineable = { path = "crates/refineable" } release_channel = { path = "crates/release_channel" } -scheduler = { path = "crates/scheduler" } remote = { path = "crates/remote" } remote_server = { path = "crates/remote_server" } repl = { path = "crates/repl" } reqwest_client = { path = "crates/reqwest_client" } -rich_text = { path = "crates/rich_text" } rodio = { git = "https://github.com/RustAudio/rodio", rev ="e2074c6c2acf07b57cf717e076bdda7a9ac6e70b", features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } @@ -390,7 +390,6 @@ snippets_ui = { path = "crates/snippets_ui" } sqlez = { path = "crates/sqlez" } sqlez_macros = { path = "crates/sqlez_macros" } story = { path = "crates/story" } -storybook = { path = "crates/storybook" } streaming_diff = { path = "crates/streaming_diff" } sum_tree = { path = "crates/sum_tree" } supermaven = { path = "crates/supermaven" } @@ -407,7 +406,6 @@ terminal_view = { path = "crates/terminal_view" } text = { path = "crates/text" } theme = { path = "crates/theme" } theme_extension = { path = "crates/theme_extension" } -theme_importer = { path = "crates/theme_importer" } theme_selector = { path = "crates/theme_selector" } time_format = { path = "crates/time_format" } title_bar = { path = "crates/title_bar" } @@ -421,6 +419,7 @@ util_macros = { path = "crates/util_macros" } vercel = { path = "crates/vercel" } vim = { path = "crates/vim" } vim_mode_setting = { path = "crates/vim_mode_setting" } +which_key = { path = "crates/which_key" } watch = { path = "crates/watch" } web_search = { path = "crates/web_search" } @@ -431,15 +430,18 @@ x_ai = { path = "crates/x_ai" } zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } zed_env_vars = { path = "crates/zed_env_vars" } -zeta = { path = "crates/zeta" } +edit_prediction = { path = "crates/edit_prediction" } +zeta_prompt = { path = "crates/zeta_prompt" } zlog = { path = "crates/zlog" } zlog_settings = { path = "crates/zlog_settings" } +ztracing = { path = "crates/ztracing" } +ztracing_macro = { path = "crates/ztracing_macro" } # # External crates # -agent-client-protocol = { version = "0.7.0", features = ["unstable"] } +agent-client-protocol = { version = "=0.9.2", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = "0.25.1-rc1" any_vec = "0.14" @@ -458,15 +460,15 @@ async-task = "4.7" async-trait = "0.1" async-tungstenite = "0.31.0" async_zip = { version = "0.0.18", features = ["deflate", "deflate64"] } -aws-config = { version = "1.6.1", features = ["behavior-version-latest"] } -aws-credential-types = { version = "1.2.2", features = [ +aws-config = { version = "1.8.10", features = ["behavior-version-latest"] } +aws-credential-types = { version = "1.2.8", features = [ "hardcoded-credentials", ] } -aws-sdk-bedrockruntime = { version = "1.80.0", features = [ +aws-sdk-bedrockruntime = { version = "1.112.0", features = [ "behavior-version-latest", ] } -aws-smithy-runtime-api = { version = "1.7.4", features = ["http-1x", "client"] } -aws-smithy-types = { version = "1.3.0", features = ["http-body-1-x"] } +aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } +aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" bincode = "1.2.1" @@ -479,6 +481,7 @@ bytes = "1.0" cargo_metadata = "0.19" cargo_toml = "0.21" cfg-if = "1.0.3" +chardetng = "0.1" chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" circular-buffer = "1.0" @@ -502,17 +505,16 @@ dotenvy = "0.15.0" ec4rs = "1.1" emojis = "0.6.1" env_logger = "0.11" +encoding_rs = "0.8" exec = "0.3.1" -fancy-regex = "0.14.0" +fancy-regex = "0.16.0" fork = "0.4.0" futures = "0.3" -futures-batch = "0.6.1" futures-lite = "1.13" -gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "e5f883040530b4df36437f140084ee5cc7c1c9be" } +gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "09acfdf2bd5c1d6254abefd609c808ff73547b2c" } git2 = { version = "0.20.1", default-features = false } globset = "0.4" handlebars = "4.3" -hashbrown = "0.15.3" heck = "0.5" heed = { version = "0.21.0", features = ["read-txn-no-tls"] } hex = "0.4.3" @@ -529,7 +531,7 @@ indoc = "2" inventory = "0.3.19" itertools = "0.14.0" json_dotpath = "1.1" -jsonschema = "0.30.0" +jsonschema = "0.37.0" jsonwebtoken = "9.3" jupyter-protocol = "0.10.0" jupyter-websocket-client = "0.15.0" @@ -548,7 +550,6 @@ nanoid = "0.4" nbformat = "0.15.0" nix = "0.29" num-format = "0.4.4" -num-traits = "0.2" objc = "0.2" objc2-foundation = { version = "=0.3.1", default-features = false, features = [ "NSArray", @@ -587,7 +588,6 @@ pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } -pet-pixi = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "1e86914c3ce2f3a08c0cedbcb0615a7f9fa7a5da" } @@ -627,7 +627,6 @@ scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197 schemars = { version = "1.0", features = ["indexmap2"] } semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0.221", features = ["derive", "rc"] } -serde_derive = "1.0.221" serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] } serde_json_lenient = { version = "0.2", features = [ "preserve_order", @@ -639,10 +638,9 @@ serde_urlencoded = "0.7" sha2 = "0.10" shellexpand = "2.1.0" shlex = "1.3.0" -similar = "2.6" simplelog = "0.12.2" slotmap = "1.0.6" -smallvec = { version = "1.6", features = ["union"] } +smallvec = { version = "1.6", features = ["union", "const_new"] } smol = "2.0" sqlformat = "0.2" stacksafe = "0.1" @@ -656,7 +654,7 @@ sysinfo = "0.37.0" take-until = "0.2.0" tempfile = "3.20.0" thiserror = "2.0.12" -tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "7249f999c5fdf9bf3cc5c288c964454e4dac0c00" } +tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "2570c4387a8505fb8f1d3f3557454b474f1e8271" } time = { version = "0.3", features = [ "macros", "parsing", @@ -668,11 +666,12 @@ time = { version = "0.3", features = [ tiny_http = "0.8" tokio = { version = "1" } tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } +tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io", "tokio"] } toml = "0.8" toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } tower-http = "0.4.4" -tree-sitter = { version = "0.25.10", features = ["wasm"] } -tree-sitter-bash = "0.25.0" +tree-sitter = { version = "0.26", features = ["wasm"] } +tree-sitter-bash = "0.25.1" tree-sitter-c = "0.23" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } tree-sitter-css = "0.23" @@ -694,6 +693,7 @@ tree-sitter-ruby = "0.23" tree-sitter-rust = "0.24" tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter-typescript", rev = "e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" } # https://github.com/tree-sitter/tree-sitter-typescript/pull/347 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } +tracing = "0.1.40" unicase = "2.6" unicode-script = "0.5.7" unicode-segmentation = "1.10" @@ -704,7 +704,7 @@ uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } walkdir = "2.5" wasm-encoder = "0.221" wasmparser = "0.221" -wasmtime = { version = "29", default-features = false, features = [ +wasmtime = { version = "33", default-features = false, features = [ "async", "demangle", "runtime", @@ -713,11 +713,10 @@ wasmtime = { version = "29", default-features = false, features = [ "incremental-cache", "parallel-compilation", ] } -wasmtime-wasi = "29" +wasmtime-wasi = "33" wax = "0.6" which = "6.0.0" windows-core = "0.61" -wit-component = "0.221" yawc = "0.2.5" zeroize = "1.8" zstd = "0.11" @@ -799,20 +798,13 @@ settings_macros = { opt-level = 3 } sqlez_macros = { opt-level = 3, codegen-units = 1 } ui_macros = { opt-level = 3 } util_macros = { opt-level = 3 } -serde_derive = { opt-level = 3 } quote = { opt-level = 3 } syn = { opt-level = 3 } proc-macro2 = { opt-level = 3 } # proc-macros end taffy = { opt-level = 3 } -cranelift-codegen = { opt-level = 3 } -cranelift-codegen-meta = { opt-level = 3 } -cranelift-codegen-shared = { opt-level = 3 } resvg = { opt-level = 3 } -rustybuzz = { opt-level = 3 } -ttf-parser = { opt-level = 3 } -wasmtime-cranelift = { opt-level = 3 } wasmtime = { opt-level = 3 } # Build single-source-file crates with cg=1 as it helps make `cargo build` of a whole workspace a bit faster activity_indicator = { codegen-units = 1 } @@ -821,12 +813,11 @@ breadcrumbs = { codegen-units = 1 } collections = { codegen-units = 1 } command_palette = { codegen-units = 1 } command_palette_hooks = { codegen-units = 1 } -extension_cli = { codegen-units = 1 } feature_flags = { codegen-units = 1 } file_icons = { codegen-units = 1 } fsevent = { codegen-units = 1 } image_viewer = { codegen-units = 1 } -edit_prediction_button = { codegen-units = 1 } +edit_prediction_ui = { codegen-units = 1 } install_cli = { codegen-units = 1 } journal = { codegen-units = 1 } json_schema_store = { codegen-units = 1 } @@ -841,7 +832,6 @@ project_symbols = { codegen-units = 1 } refineable = { codegen-units = 1 } release_channel = { codegen-units = 1 } reqwest_client = { codegen-units = 1 } -rich_text = { codegen-units = 1 } session = { codegen-units = 1 } snippet = { codegen-units = 1 } snippets_ui = { codegen-units = 1 } @@ -874,8 +864,6 @@ unexpected_cfgs = { level = "allow" } dbg_macro = "deny" todo = "deny" -# This is not a style lint, see https://github.com/rust-lang/rust-clippy/pull/15454 -# Remove when the lint gets promoted to `suspicious`. declare_interior_mutable_const = "deny" redundant_clone = "deny" diff --git a/Dockerfile-collab b/Dockerfile-collab index 4bc369140e79219b9719b570659e9edd27453260..188e7daddfb471c41b237ca75469355cfc866ae3 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.91.1-bookworm as builder +FROM rust:1.92-bookworm as builder WORKDIR app COPY . . @@ -34,8 +34,4 @@ RUN apt-get update; \ linux-perf binutils WORKDIR app COPY --from=builder /app/collab /app/collab -COPY --from=builder /app/crates/collab/migrations /app/migrations -COPY --from=builder /app/crates/collab/migrations_llm /app/migrations_llm -ENV MIGRATIONS_PATH=/app/migrations -ENV LLM_DATABASE_MIGRATIONS_PATH=/app/migrations_llm ENTRYPOINT ["/app/collab"] diff --git a/README.md b/README.md index d1e2a75beccc9b115bd3b2e09bcc812aebc98329..866762c8c9139666993c2e29d9682966106c516b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Welcome to Zed, a high-performance, multiplayer code editor from the creators of ### Installation -On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or [install Zed via your local package manager](https://zed.dev/docs/linux#installing-via-a-package-manager). +On macOS, Linux, and Windows you can [download Zed directly](https://zed.dev/download) or install Zed via your local package manager ([macOS](https://zed.dev/docs/installation#macos)/[Linux](https://zed.dev/docs/linux#installing-via-a-package-manager)/[Windows](https://zed.dev/docs/windows#package-managers)). Other platforms are not yet available: @@ -20,7 +20,6 @@ Other platforms are not yet available: - [Building Zed for macOS](./docs/src/development/macos.md) - [Building Zed for Linux](./docs/src/development/linux.md) - [Building Zed for Windows](./docs/src/development/windows.md) -- [Running Collaboration Locally](./docs/src/development/local-collaboration.md) ### Contributing diff --git a/REVIEWERS.conl b/REVIEWERS.conl index 86658189d0ebc3861648c1dd410a3b0b8c199706..bca694d7a06fe1112f7f8bab158dad63a365ea74 100644 --- a/REVIEWERS.conl +++ b/REVIEWERS.conl @@ -28,7 +28,7 @@ ai = @rtfeldman audio - = @dvdsk + = @yara-blue crashes = @p1n3appl3 @@ -53,6 +53,10 @@ extension git = @cole-miller = @danilo-leal + = @yara-blue + = @kubkon + = @Anthony-Eid + = @cameron1024 gpui = @Anthony-Eid @@ -72,7 +76,7 @@ languages linux = @cole-miller - = @dvdsk + = @yara-blue = @p1n3appl3 = @probably-neb = @smitbarmase @@ -88,7 +92,7 @@ multi_buffer = @SomeoneToIgnore pickers - = @dvdsk + = @yara-blue = @p1n3appl3 = @SomeoneToIgnore diff --git a/assets/icons/box.svg b/assets/icons/box.svg new file mode 100644 index 0000000000000000000000000000000000000000..7e1276c629fb8bdc5a7ed48d9e2de6369d4c2bb0 --- /dev/null +++ b/assets/icons/box.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/debug_step_back.svg b/assets/icons/debug_step_back.svg deleted file mode 100644 index 61d45866f61cbabbd9a7ae9975809d342cb76ed5..0000000000000000000000000000000000000000 --- a/assets/icons/debug_step_back.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/debug_step_into.svg b/assets/icons/debug_step_into.svg index 9a517fc7ca0762b17446a75cd90f39a91e1b51cf..0a5882354380b659425fecca2b4c6000516e422f 100644 --- a/assets/icons/debug_step_into.svg +++ b/assets/icons/debug_step_into.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/debug_step_out.svg b/assets/icons/debug_step_out.svg index 147a44f930f34f6c3ddce94693a178a932129cb5..c128f56111f2b68d7229f9d2f61b6b2496f99bba 100644 --- a/assets/icons/debug_step_out.svg +++ b/assets/icons/debug_step_out.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/debug_step_over.svg b/assets/icons/debug_step_over.svg index 336abc11deb866a128e8418dab47af01b6e4d3f6..5d8ccd5b7a20b2f8a108ab4c2e03694db4f6f8a8 100644 --- a/assets/icons/debug_step_over.svg +++ b/assets/icons/debug_step_over.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/git_branch_plus.svg b/assets/icons/git_branch_plus.svg new file mode 100644 index 0000000000000000000000000000000000000000..cf60ce66b4086ba57ef4c2e56f3554d548e863fc --- /dev/null +++ b/assets/icons/git_branch_plus.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/inception.svg b/assets/icons/inception.svg new file mode 100644 index 0000000000000000000000000000000000000000..77a96c0b390ab9f2fe89143c2a89ba916000fabc --- /dev/null +++ b/assets/icons/inception.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/zed_agent_two.svg b/assets/icons/zed_agent_two.svg new file mode 100644 index 0000000000000000000000000000000000000000..c352be84d2f1bea6da1f6a5be70b9420f019b6d6 --- /dev/null +++ b/assets/icons/zed_agent_two.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 5de5b9daae27113807cb6e97eda335a419f18ac9..e2a0bd89b54110209777857e8690ab123d92e3bb 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -25,7 +25,8 @@ "ctrl-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", "open": "workspace::Open", - "ctrl-o": "workspace::Open", + "ctrl-o": "workspace::OpenFiles", + "ctrl-k ctrl-o": "workspace::Open", "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], @@ -41,17 +42,18 @@ "ctrl-f11": "debugger::StepInto", "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", - "ctrl-alt-z": "edit_prediction::RateCompletions", + "ctrl-alt-z": "edit_prediction::RatePredictions", "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", - "ctrl-alt-l": "lsp_tool::ToggleMenu" - } + "ctrl-alt-l": "lsp_tool::ToggleMenu", + "ctrl-alt-shift-s": "workspace::ToggleWorktreeSecurity", + }, }, { "context": "Picker || menu", "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -62,7 +64,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -124,8 +125,8 @@ "shift-f10": "editor::OpenContextMenu", "ctrl-alt-shift-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -144,44 +145,44 @@ "ctrl-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "bindings": { "copy": "markdown::Copy", "ctrl-insert": "markdown::Copy", - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -189,8 +190,8 @@ "ctrl-k ctrl-r": "git::Restore", "ctrl-alt-y": "git::ToggleStaged", "alt-y": "git::StageAndNext", - "alt-shift-y": "git::UnstageAndNext" - } + "alt-shift-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -199,8 +200,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -208,8 +209,8 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", @@ -225,8 +226,9 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "AgentPanel", @@ -239,6 +241,7 @@ "ctrl-alt-l": "agent::OpenRulesLibrary", "ctrl-i": "agent::ToggleProfileSelector", "ctrl-alt-/": "agent::ToggleModelSelector", + "alt-tab": "agent::CycleFavoriteModels", "ctrl-shift-j": "agent::ToggleNavigationMenu", "ctrl-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", @@ -250,108 +253,86 @@ "alt-enter": "agent::ContinueWithBurnMode", "ctrl-y": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "ctrl-alt-z": "agent::RejectOnce" - } + "ctrl-alt-z": "agent::RejectOnce", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "bindings": { "copy": "markdown::CopyAsMarkdown", "ctrl-insert": "markdown::CopyAsMarkdown", - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } - }, - { - "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", - "bindings": { - "enter": "agent::Chat", - "ctrl-enter": "agent::ChatWithFollow", - "ctrl-i": "agent::ToggleProfileSelector", - "shift-ctrl-r": "agent::OpenAgentDiff", - "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { - "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", - "bindings": { - "ctrl-enter": "agent::Chat", - "enter": "editor::Newline", - "ctrl-i": "agent::ToggleProfileSelector", - "shift-ctrl-r": "agent::OpenAgentDiff", - "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } - }, - { - "context": "EditMessageEditor > Editor", + "context": "AgentFeedbackMessageEditor > Editor", "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { - "context": "AgentFeedbackMessageEditor > Editor", + "context": "AcpThread > ModeSelector", "bindings": { - "escape": "menu::Cancel", - "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "ctrl-enter": "menu::Confirm", + }, }, { - "context": "AcpThread > ModeSelector", + "context": "AcpThread > Editor", + "use_key_equivalents": true, "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "agent::ChatWithFollow", + "ctrl-i": "agent::ToggleProfileSelector", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-v": "agent::PasteRaw", + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", "use_key_equivalents": true, "bindings": { "enter": "agent::Chat", - "shift-ctrl-r": "agent::OpenAgentDiff", - "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", "use_key_equivalents": true, "bindings": { "ctrl-enter": "agent::Chat", - "shift-ctrl-r": "agent::OpenAgentDiff", - "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "enter": "editor::Newline", + }, }, { "context": "ThreadHistory", "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -359,8 +340,8 @@ "new": "rules_library::NewRule", "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -373,22 +354,22 @@ "find": "search::FocusSearch", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -399,22 +380,22 @@ "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -422,8 +403,8 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -472,8 +453,8 @@ "ctrl-alt-shift-r": "search::ToggleRegex", "ctrl-alt-shift-x": "search::ToggleRegex", "alt-r": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -500,6 +481,7 @@ "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -536,31 +518,31 @@ "ctrl-\\": "pane::SplitRight", "ctrl-alt-shift-c": "editor::DisplayCursorNames", "alt-.": "editor::GoToHunk", - "alt-,": "editor::GoToPreviousHunk" - } + "alt-,": "editor::GoToPreviousHunk", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -616,8 +598,8 @@ "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", - "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-tab": "tab_switcher::Toggle", "ctrl-e": "file_finder::Toggle", "f1": "command_palette::Toggle", "ctrl-shift-p": "command_palette::Toggle", @@ -654,28 +636,28 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -693,8 +675,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -703,37 +685,37 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -745,22 +727,24 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -770,29 +754,29 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { "bindings": { "ctrl-alt-shift-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "ctrl-alt-i": "dev::ToggleInspector" - } + "ctrl-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -804,15 +788,17 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { - "context": "PromptEditor", + "context": "InlineAssistant", "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -820,14 +806,14 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -844,8 +830,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -883,20 +869,22 @@ "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", "bindings": { - "up": "menu::SelectPrevious", - "down": "menu::SelectNext", + "left": "git_panel::CollapseSelectedEntry", + "right": "git_panel::ExpandSelectedEntry", + "up": "git_panel::PreviousEntry", + "down": "git_panel::NextEntry", "enter": "menu::Confirm", "alt-y": "git::StageFile", "alt-shift-y": "git::UnstageFile", @@ -911,15 +899,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -928,8 +916,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -945,8 +933,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -954,14 +942,14 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -973,16 +961,16 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -994,8 +982,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -1003,35 +991,35 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1040,29 +1028,29 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1071,8 +1059,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1080,15 +1068,15 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1133,65 +1121,69 @@ "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", - "ctrl-shift-5": "pane::SplitRight" - } + "ctrl-shift-5": "pane::SplitRight", + }, }, { "context": "ZedPredictModal", "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1205,8 +1197,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1214,24 +1206,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1243,8 +1235,8 @@ "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1253,23 +1245,28 @@ "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], - "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + "ctrl-1": ["welcome::OpenRecentProject", 0], + "ctrl-2": ["welcome::OpenRecentProject", 1], + "ctrl-3": ["welcome::OpenRecentProject", 2], + "ctrl-4": ["welcome::OpenRecentProject", 3], + "ctrl-5": ["welcome::OpenRecentProject", 4], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1294,16 +1291,16 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1318,29 +1315,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { - "context": "Zeta2Feedback > Editor", + "context": "EditPredictionContext > Editor", "bindings": { - "enter": "editor::Newline", - "ctrl-enter up": "dev::Zeta2RatePredictionPositive", - "ctrl-enter down": "dev::Zeta2RatePredictionNegative" - } - }, - { - "context": "Zeta2Context > Editor", - "bindings": { - "alt-left": "dev::Zeta2ContextGoBack", - "alt-right": "dev::Zeta2ContextGoForward" - } + "alt-left": "dev::EditPredictionContextGoBack", + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { - "ctrl-shift-backspace": "branch_picker::DeleteBranch" - } - } + "ctrl-shift-backspace": "branch_picker::DeleteBranch", + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 2fadafb6ca95f81de28165b23e4063dc7a0c38d8..2524d98e3778684080775f00a82d0764bfd0361b 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -47,11 +47,12 @@ "cmd-m": "zed::Minimize", "fn-f": "zed::ToggleFullScreen", "ctrl-cmd-f": "zed::ToggleFullScreen", - "ctrl-cmd-z": "edit_prediction::RateCompletions", + "ctrl-cmd-z": "edit_prediction::RatePredictions", "ctrl-cmd-i": "edit_prediction::ToggleMenu", "ctrl-cmd-l": "lsp_tool::ToggleMenu", - "ctrl-cmd-c": "editor::DisplayCursorNames" - } + "ctrl-cmd-c": "editor::DisplayCursorNames", + "ctrl-cmd-s": "workspace::ToggleWorktreeSecurity", + }, }, { "context": "Editor", @@ -148,8 +149,8 @@ "shift-f9": "editor::EditLogBreakpoint", "ctrl-f12": "editor::GoToDeclaration", "alt-ctrl-f12": "editor::GoToDeclarationSplit", - "ctrl-cmd-e": "editor::ToggleEditPrediction" - } + "ctrl-cmd-e": "editor::ToggleEditPrediction", + }, }, { "context": "Editor && mode == full", @@ -167,8 +168,8 @@ "cmd->": "agent::AddSelectionToThread", "cmd-<": "assistant::InsertIntoEditor", "cmd-alt-e": "editor::SelectEnclosingSymbol", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && multibuffer", @@ -177,23 +178,23 @@ "cmd-up": "editor::MoveToStartOfExcerpt", "cmd-down": "editor::MoveToStartOfNextExcerpt", "cmd-shift-up": "editor::SelectToStartOfExcerpt", - "cmd-shift-down": "editor::SelectToStartOfNextExcerpt" - } + "cmd-shift-down": "editor::SelectToStartOfNextExcerpt", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::NextEditPrediction", - "alt-shift-tab": "editor::PreviousEditPrediction" - } + "alt-shift-tab": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-tab": "editor::ShowEditPrediction" - } + "alt-tab": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -201,23 +202,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::Copy" - } + "cmd-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff && !AgentPanel", @@ -226,8 +227,8 @@ "cmd-alt-z": "git::Restore", "cmd-alt-y": "git::ToggleStaged", "cmd-y": "git::StageAndNext", - "cmd-shift-y": "git::UnstageAndNext" - } + "cmd-shift-y": "git::UnstageAndNext", + }, }, { "context": "AgentDiff", @@ -236,8 +237,8 @@ "cmd-y": "agent::Keep", "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + }, }, { "context": "Editor && editor_agent_diff", @@ -247,8 +248,8 @@ "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "ContextEditor > Editor", @@ -264,8 +265,10 @@ "cmd-k c": "assistant::CopyCode", "cmd-g": "search::SelectNextMatch", "cmd-shift-g": "search::SelectPreviousMatch", - "cmd-k l": "agent::OpenRulesLibrary" - } + "cmd-k l": "agent::OpenRulesLibrary", + "alt-tab": "agent::CycleFavoriteModels", + "cmd-shift-v": "agent::PasteRaw", + }, }, { "context": "AgentPanel", @@ -279,6 +282,7 @@ "cmd-alt-p": "agent::ManageProfiles", "cmd-i": "agent::ToggleProfileSelector", "cmd-alt-/": "agent::ToggleModelSelector", + "alt-tab": "agent::CycleFavoriteModels", "cmd-shift-j": "agent::ToggleNavigationMenu", "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", @@ -290,70 +294,37 @@ "alt-enter": "agent::ContinueWithBurnMode", "cmd-y": "agent::AllowOnce", "cmd-alt-y": "agent::AllowAlways", - "cmd-alt-z": "agent::RejectOnce" - } + "cmd-alt-z": "agent::RejectOnce", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::CopyAsMarkdown" - } + "cmd-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewTextThread", - "cmd-alt-n": "agent::NewExternalAgentThread" - } + "cmd-alt-n": "agent::NewExternalAgentThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewExternalAgentThread", - "cmd-alt-t": "agent::NewThread" - } - }, - { - "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", - "use_key_equivalents": true, - "bindings": { - "enter": "agent::Chat", - "cmd-enter": "agent::ChatWithFollow", - "cmd-i": "agent::ToggleProfileSelector", - "shift-ctrl-r": "agent::OpenAgentDiff", - "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } - }, - { - "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", - "use_key_equivalents": true, - "bindings": { - "cmd-enter": "agent::Chat", - "enter": "editor::Newline", - "cmd-i": "agent::ToggleProfileSelector", - "shift-ctrl-r": "agent::OpenAgentDiff", - "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } - }, - { - "context": "EditMessageEditor > Editor", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "cmd-alt-t": "agent::NewThread", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -361,54 +332,61 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentConfiguration", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { - "context": "AcpThread > Editor && !use_modifier_to_send", + "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { - "enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "cmd-enter": "agent::ChatWithFollow", + "cmd-shift-v": "agent::PasteRaw", + "cmd-i": "agent::ToggleProfileSelector", + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, + }, + { + "context": "AcpThread > Editor && !use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", "use_key_equivalents": true, "bindings": { "cmd-enter": "agent::Chat", - "shift-ctrl-r": "agent::OpenAgentDiff", - "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "enter": "editor::Newline", + }, }, { "context": "ThreadHistory", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "ThreadHistory > Editor", "bindings": { - "shift-backspace": "agent::RemoveSelectedThread" - } + "shift-backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -416,8 +394,8 @@ "bindings": { "cmd-n": "rules_library::NewRule", "cmd-shift-s": "rules_library::ToggleDefaultRule", - "cmd-w": "workspace::CloseWindow" - } + "cmd-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -431,24 +409,24 @@ "cmd-f": "search::FocusSearch", "cmd-alt-f": "search::ToggleReplace", "cmd-alt-l": "search::ToggleSelection", - "cmd-shift-o": "outline::Toggle" - } + "cmd-shift-o": "outline::Toggle", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -460,24 +438,24 @@ "cmd-shift-f": "search::FocusSearch", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -488,8 +466,8 @@ "shift-enter": "project_search::ToggleAllSearchResults", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -519,8 +497,8 @@ "alt-cmd-w": "search::ToggleWholeWord", "alt-cmd-f": "project_search::ToggleFilters", "alt-cmd-x": "search::ToggleRegex", - "cmd-k shift-enter": "pane::TogglePinTab" - } + "cmd-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -590,24 +568,24 @@ "cmd-.": "editor::ToggleCodeActions", "cmd-k r": "editor::RevealInFileManager", "cmd-k p": "editor::CopyPath", - "cmd-\\": "pane::SplitRight" - } + "cmd-\\": "pane::SplitRight", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "cmd-k v": "markdown::OpenPreviewToTheSide", - "cmd-shift-v": "markdown::OpenPreview" - } + "cmd-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "cmd-k v": "svg::OpenPreviewToTheSide", - "cmd-shift-v": "svg::OpenPreview" - } + "cmd-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", @@ -616,8 +594,8 @@ "cmd-shift-o": "outline::Toggle", "ctrl-g": "go_to_line::Toggle", "cmd-shift-backspace": "editor::GoToPreviousChange", - "cmd-shift-alt-backspace": "editor::GoToNextChange" - } + "cmd-shift-alt-backspace": "editor::GoToNextChange", + }, }, { "context": "Pane", @@ -635,8 +613,8 @@ "ctrl-0": "pane::ActivateLastItem", "ctrl--": "pane::GoBack", "ctrl-_": "pane::GoForward", - "cmd-shift-f": "pane::DeploySearch" - } + "cmd-shift-f": "pane::DeploySearch", + }, }, { "context": "Workspace", @@ -684,8 +662,8 @@ "ctrl-alt-cmd-p": "settings_profile_selector::Toggle", "cmd-t": "project_symbols::Toggle", "cmd-p": "file_finder::Toggle", - "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-tab": "tab_switcher::Toggle", "cmd-shift-p": "command_palette::Toggle", "cmd-shift-m": "diagnostics::Deploy", "cmd-shift-e": "project_panel::ToggleFocus", @@ -707,8 +685,8 @@ "cmd-k shift-down": "workspace::SwapPaneDown", "cmd-shift-x": "zed::Extensions", "f5": "debugger::Rerun", - "cmd-w": "workspace::CloseActiveDock" - } + "cmd-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && !Terminal", @@ -719,27 +697,27 @@ // All task parameters are captured and unchanged between reruns by default. // Use the `"reevaluate_context"` parameter to control this. "cmd-alt-r": ["task::Rerun", { "reevaluate_context": false }], - "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }] + "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }], // also possible to spawn tasks by name: // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] // or by tag: // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], - } + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { "f5": "zed::NoAction", - "f11": "debugger::StepInto" - } + "f11": "debugger::StepInto", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, // Bindings from Sublime Text { @@ -760,8 +738,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -771,16 +749,16 @@ "cmd-k up": "pane::SplitUp", "cmd-k down": "pane::SplitDown", "cmd-k left": "pane::SplitLeft", - "cmd-k right": "pane::SplitRight" - } + "cmd-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -788,45 +766,47 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, { "context": "Editor && edit_prediction", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptNextWordEditPrediction", + "ctrl-cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -837,15 +817,15 @@ "down": "editor::ContextMenuNext", "ctrl-n": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -855,8 +835,8 @@ // TODO: Move this to a dock open action "cmd-shift-c": "collab_panel::ToggleFocus", // Only available in debug builds: opens an element inspector for development. - "cmd-alt-i": "dev::ToggleInspector" - } + "cmd-alt-i": "dev::ToggleInspector", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -869,17 +849,20 @@ "cmd-f8": "editor::GoToHunk", "cmd-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { - "context": "PromptEditor", + "context": "InlineAssistant > Editor", "use_key_equivalents": true, "bindings": { "cmd-alt-/": "agent::ToggleModelSelector", + "alt-tab": "agent::CycleFavoriteModels", "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "cmd-shift-enter": "inline_assistant::ThumbsUpResult", + "cmd-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -888,15 +871,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "cmd-enter": "project_search::SearchInNew" - } + "cmd-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -912,8 +895,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "cmd-alt-enter": "editor::OpenExcerptsSplit" - } + "cmd-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -943,15 +926,15 @@ "cmd-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "VariableList", @@ -964,17 +947,19 @@ "cmd-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "GitPanel && ChangesList", "use_key_equivalents": true, "bindings": { - "up": "menu::SelectPrevious", - "down": "menu::SelectNext", - "cmd-up": "menu::SelectFirst", - "cmd-down": "menu::SelectLast", + "up": "git_panel::PreviousEntry", + "down": "git_panel::NextEntry", + "cmd-up": "git_panel::FirstEntry", + "cmd-down": "git_panel::LastEntry", + "left": "git_panel::CollapseSelectedEntry", + "right": "git_panel::ExpandSelectedEntry", "enter": "menu::Confirm", "cmd-alt-y": "git::ToggleStaged", "space": "git::ToggleStaged", @@ -988,15 +973,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], - "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }] - } + "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitDiff > Editor", @@ -1005,8 +990,8 @@ "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", "cmd-ctrl-y": "git::StageAll", - "cmd-ctrl-shift-y": "git::UnstageAll" - } + "cmd-ctrl-shift-y": "git::UnstageAll", + }, }, { "context": "CommitEditor > Editor", @@ -1019,8 +1004,8 @@ "shift-tab": "git_panel::FocusChanges", "alt-up": "git_panel::FocusChanges", "shift-escape": "git::ExpandCommitEditor", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -1037,8 +1022,8 @@ "cmd-ctrl-y": "git::StageAll", "cmd-ctrl-shift-y": "git::UnstageAll", "cmd-enter": "git::Commit", - "cmd-shift-enter": "git::Amend" - } + "cmd-shift-enter": "git::Amend", + }, }, { "context": "GitCommit > Editor", @@ -1048,16 +1033,16 @@ "escape": "menu::Cancel", "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "cmd-t": "debugger::ToggleThreadPicker", "cmd-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "BreakpointList", @@ -1065,16 +1050,16 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", @@ -1082,22 +1067,22 @@ "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1108,30 +1093,30 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], - "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }] - } + "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "toolchain::AddToolchain" - } + "cmd-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, "bindings": { "cmd-shift-a": "file_finder::ToggleSplitMenu", - "cmd-shift-i": "file_finder::ToggleFilterMenu" - } + "cmd-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1141,8 +1126,8 @@ "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", - "cmd-l": "pane::SplitRight" - } + "cmd-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1151,16 +1136,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1215,8 +1200,8 @@ "ctrl-alt-left": "pane::SplitLeft", "ctrl-alt-right": "pane::SplitRight", "cmd-d": "pane::SplitRight", - "cmd-alt-r": "terminal::RerunTask" - } + "cmd-alt-r": "terminal::RerunTask", + }, }, { "context": "RatePredictionsModal", @@ -1226,8 +1211,8 @@ "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", "shift-down": "zeta::NextEdit", "shift-up": "zeta::PreviousEdit", - "right": "zeta::PreviewPrediction" - } + "right": "zeta::PreviewPrediction", + }, }, { "context": "RatePredictionsModal > Editor", @@ -1235,15 +1220,15 @@ "bindings": { "escape": "zeta::FocusPredictions", "cmd-shift-enter": "zeta::ThumbsUpActivePrediction", - "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction" - } + "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1251,52 +1236,56 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1309,8 +1298,8 @@ "alt-enter": "keymap_editor::CreateBinding", "cmd-c": "keymap_editor::CopyAction", "cmd-shift-c": "keymap_editor::CopyContext", - "cmd-t": "keymap_editor::ShowMatchingKeybinds" - } + "cmd-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1318,24 +1307,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "cmd-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1347,8 +1336,8 @@ "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], "cmd-enter": "onboarding::Finish", "alt-tab": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1357,23 +1346,28 @@ "cmd-=": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd-+": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd--": ["zed::DecreaseUiFontSize", { "persist": false }], - "cmd-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], + "cmd-1": ["welcome::OpenRecentProject", 0], + "cmd-2": ["welcome::OpenRecentProject", 1], + "cmd-3": ["welcome::OpenRecentProject", 2], + "cmd-4": ["welcome::OpenRecentProject", 3], + "cmd-5": ["welcome::OpenRecentProject", 4], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1398,8 +1392,8 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "cmd-{": "settings_editor::FocusPreviousFile", - "cmd-}": "settings_editor::FocusNextFile" - } + "cmd-}": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", @@ -1407,8 +1401,8 @@ "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1423,29 +1417,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { - "context": "Zeta2Feedback > Editor", + "context": "EditPredictionContext > Editor", "bindings": { - "enter": "editor::Newline", - "cmd-enter up": "dev::Zeta2RatePredictionPositive", - "cmd-enter down": "dev::Zeta2RatePredictionNegative" - } - }, - { - "context": "Zeta2Context > Editor", - "bindings": { - "alt-left": "dev::Zeta2ContextGoBack", - "alt-right": "dev::Zeta2ContextGoForward" - } + "alt-left": "dev::EditPredictionContextGoBack", + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { - "cmd-shift-backspace": "branch_picker::DeleteBranch" - } - } + "cmd-shift-backspace": "branch_picker::DeleteBranch", + "cmd-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 8cf77f65813701fd42e3a6948b660368a24fd4e4..039ae408219909006e5d84bb12822444330acd83 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -24,7 +24,8 @@ "ctrl-alt-enter": ["picker::ConfirmInput", { "secondary": true }], "ctrl-shift-w": "workspace::CloseWindow", "shift-escape": "workspace::ToggleZoom", - "ctrl-o": "workspace::Open", + "ctrl-o": "workspace::OpenFiles", + "ctrl-k ctrl-o": "workspace::Open", "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl-shift-=": ["zed::IncreaseBufferFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], @@ -41,16 +42,17 @@ "f11": "zed::ToggleFullScreen", "ctrl-shift-i": "edit_prediction::ToggleMenu", "shift-alt-l": "lsp_tool::ToggleMenu", - "ctrl-shift-alt-c": "editor::DisplayCursorNames" - } + "ctrl-shift-alt-c": "editor::DisplayCursorNames", + "ctrl-shift-alt-s": "workspace::ToggleWorktreeSecurity", + }, }, { "context": "Picker || menu", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -62,7 +64,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -119,8 +120,8 @@ "shift-f10": "editor::OpenContextMenu", "ctrl-alt-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -139,23 +140,23 @@ "shift-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -163,23 +164,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -187,8 +188,8 @@ "bindings": { "ctrl-k ctrl-r": "git::Restore", "alt-y": "git::StageAndNext", - "shift-alt-y": "git::UnstageAndNext" - } + "shift-alt-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -198,8 +199,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "ctrl-shift-r": "agent::OpenAgentDiff" - } + "ctrl-shift-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -208,8 +209,8 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", @@ -225,8 +226,9 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + "ctrl-shift-v": "agent::PasteRaw", + }, }, { "context": "AgentPanel", @@ -239,6 +241,7 @@ "shift-alt-l": "agent::OpenRulesLibrary", "shift-alt-p": "agent::ManageProfiles", "ctrl-i": "agent::ToggleProfileSelector", + "alt-tab": "agent::CycleFavoriteModels", "shift-alt-/": "agent::ToggleModelSelector", "shift-alt-j": "agent::ToggleNavigationMenu", "shift-alt-i": "agent::ToggleOptionsMenu", @@ -251,71 +254,38 @@ "alt-enter": "agent::ContinueWithBurnMode", "shift-alt-a": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "shift-alt-z": "agent::RejectOnce" - } + "shift-alt-z": "agent::RejectOnce", + }, }, { "context": "AgentPanel > NavigationMenu", "use_key_equivalents": true, "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } - }, - { - "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", - "use_key_equivalents": true, - "bindings": { - "enter": "agent::Chat", - "ctrl-enter": "agent::ChatWithFollow", - "ctrl-i": "agent::ToggleProfileSelector", - "ctrl-shift-r": "agent::OpenAgentDiff", - "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } - }, - { - "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", - "use_key_equivalents": true, - "bindings": { - "ctrl-enter": "agent::Chat", - "enter": "editor::Newline", - "ctrl-i": "agent::ToggleProfileSelector", - "ctrl-shift-r": "agent::OpenAgentDiff", - "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } - }, - { - "context": "EditMessageEditor > Editor", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -323,43 +293,50 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { - "context": "AcpThread > Editor && !use_modifier_to_send", + "context": "AcpThread > Editor", "use_key_equivalents": true, "bindings": { - "enter": "agent::Chat", + "ctrl-enter": "agent::ChatWithFollow", + "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "ctrl-shift-v": "agent::PasteRaw", + "shift-tab": "agent::CycleModeSelector", + "alt-tab": "agent::CycleFavoriteModels", + }, + }, + { + "context": "AcpThread > Editor && !use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", "use_key_equivalents": true, "bindings": { "ctrl-enter": "agent::Chat", - "ctrl-shift-r": "agent::OpenAgentDiff", - "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "enter": "editor::Newline", + }, }, { "context": "ThreadHistory", "use_key_equivalents": true, "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -367,8 +344,8 @@ "bindings": { "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -381,24 +358,24 @@ "alt-enter": "search::SelectAllMatches", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -407,24 +384,24 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -432,8 +409,8 @@ "bindings": { "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "Pane", @@ -464,8 +441,10 @@ "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes", "back": "pane::GoBack", "alt--": "pane::GoBack", + "alt-left": "pane::GoBack", "forward": "pane::GoForward", "alt-=": "pane::GoForward", + "alt-right": "pane::GoForward", "f3": "search::SelectNextMatch", "shift-f3": "search::SelectPreviousMatch", "ctrl-shift-f": "project_search::ToggleFocus", @@ -478,8 +457,8 @@ "shift-enter": "project_search::ToggleAllSearchResults", "alt-r": "search::ToggleRegex", // "ctrl-shift-alt-x": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -488,8 +467,8 @@ "bindings": { "ctrl-[": "editor::Outdent", "ctrl-]": "editor::Indent", - "ctrl-shift-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above - "ctrl-shift-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below + "ctrl-alt-up": ["editor::AddSelectionAbove", { "skip_soft_wrap": true }], // Insert Cursor Above + "ctrl-alt-down": ["editor::AddSelectionBelow", { "skip_soft_wrap": true }], // Insert Cursor Below "ctrl-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", @@ -500,10 +479,14 @@ "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand + "ctrl-f3": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip + "ctrl-shift-f3": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", + "ctrl-k ctrl-f": "editor::FormatSelections", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", @@ -535,32 +518,32 @@ "ctrl-k p": "editor::CopyPath", "ctrl-\\": "pane::SplitRight", "alt-.": "editor::GoToHunk", - "alt-,": "editor::GoToPreviousHunk" - } + "alt-,": "editor::GoToPreviousHunk", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "use_key_equivalents": true, "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -608,8 +591,8 @@ "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", - "ctrl-tab": "tab_switcher::Toggle", "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-tab": "tab_switcher::Toggle", "ctrl-e": "file_finder::Toggle", "f1": "command_palette::Toggle", "ctrl-shift-p": "command_palette::Toggle", @@ -644,22 +627,22 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", @@ -667,8 +650,8 @@ "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -685,8 +668,8 @@ "ctrl-alt-left": "editor::MoveToPreviousSubwordStart", "ctrl-alt-right": "editor::MoveToNextSubwordEnd", "ctrl-shift-alt-left": "editor::SelectToPreviousSubwordStart", - "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd" - } + "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -696,16 +679,16 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -713,22 +696,22 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -741,8 +724,9 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", @@ -750,15 +734,16 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptNextWordEditPrediction", + "alt-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -769,16 +754,16 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "use_key_equivalents": true, "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -786,15 +771,15 @@ "bindings": { "ctrl-shift-alt-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "shift-alt-i": "dev::ToggleInspector" - } + "shift-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -807,16 +792,18 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-shift-;": "editor::ToggleInlayHints" - } + "ctrl-shift-;": "editor::ToggleInlayHints", + }, }, { - "context": "PromptEditor", + "context": "InlineAssistant", "use_key_equivalents": true, "bindings": { "ctrl-[": "agent::CyclePreviousInlineAssist", - "ctrl-]": "agent::CycleNextInlineAssist" - } + "ctrl-]": "agent::CycleNextInlineAssist", + "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", + "ctrl-shift-delete": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -825,15 +812,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -848,8 +835,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -880,22 +867,24 @@ "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", "use_key_equivalents": true, "bindings": { - "up": "menu::SelectPrevious", - "down": "menu::SelectNext", + "up": "git_panel::PreviousEntry", + "down": "git_panel::NextEntry", + "left": "git_panel::CollapseSelectedEntry", + "right": "git_panel::ExpandSelectedEntry", "enter": "menu::Confirm", "alt-y": "git::StageFile", "shift-alt-y": "git::UnstageFile", @@ -909,15 +898,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -927,8 +916,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -945,8 +934,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -955,15 +944,15 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "use_key_equivalents": true, "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -976,8 +965,8 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", @@ -985,8 +974,8 @@ "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -999,8 +988,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -1009,16 +998,16 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", @@ -1026,22 +1015,22 @@ "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1051,22 +1040,22 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", @@ -1074,8 +1063,8 @@ "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1085,8 +1074,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1095,16 +1084,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1128,6 +1117,8 @@ "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"], "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"], "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-q": ["terminal::SendKeystroke", "ctrl-q"], + "ctrl-r": ["terminal::SendKeystroke", "ctrl-r"], "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"], "ctrl-shift-a": "editor::SelectAll", "ctrl-shift-f": "buffer_search::Deploy", @@ -1149,21 +1140,21 @@ "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", - "ctrl-shift-5": "pane::SplitRight" - } + "ctrl-shift-5": "pane::SplitRight", + }, }, { "context": "Terminal && selection", "bindings": { - "ctrl-c": "terminal::Copy" - } + "ctrl-c": "terminal::Copy", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1171,53 +1162,57 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", "use_key_equivalents": true, "bindings": { - "pageup": "markdown::MovePageUp", - "pagedown": "markdown::MovePageDown" - } + "pageup": "markdown::ScrollPageUp", + "pagedown": "markdown::ScrollPageDown", + "up": "markdown::ScrollUp", + "down": "markdown::ScrollDown", + "alt-up": "markdown::ScrollUpByItem", + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1230,8 +1225,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1239,24 +1234,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1268,8 +1263,8 @@ "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "shift-alt-a": "onboarding::OpenAccount" - } + "shift-alt-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1278,16 +1273,21 @@ "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], - "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + "ctrl-1": ["welcome::OpenRecentProject", 0], + "ctrl-2": ["welcome::OpenRecentProject", 1], + "ctrl-3": ["welcome::OpenRecentProject", 2], + "ctrl-4": ["welcome::OpenRecentProject", 3], + "ctrl-5": ["welcome::OpenRecentProject", 4], + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1312,8 +1312,8 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", @@ -1321,8 +1321,8 @@ "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1337,29 +1337,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } - }, - { - "context": "Zeta2Feedback > Editor", - "bindings": { - "enter": "editor::Newline", - "ctrl-enter up": "dev::Zeta2RatePredictionPositive", - "ctrl-enter down": "dev::Zeta2RatePredictionNegative" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { - "context": "Zeta2Context > Editor", + "context": "EditPredictionContext > Editor", "bindings": { - "alt-left": "dev::Zeta2ContextGoBack", - "alt-right": "dev::Zeta2ContextGoForward" - } + "alt-left": "dev::EditPredictionContextGoBack", + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { - "ctrl-shift-backspace": "branch_picker::DeleteBranch" - } - } + "ctrl-shift-backspace": "branch_picker::DeleteBranch", + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/initial.json b/assets/keymaps/initial.json index 8e4fe59f44ea7346a51e1c064ffa0553315da3b9..3a8d7f382aa57b39efc22845a17a4ef1bfd240ef 100644 --- a/assets/keymaps/initial.json +++ b/assets/keymaps/initial.json @@ -10,12 +10,12 @@ "context": "Workspace", "bindings": { // "shift shift": "file_finder::Toggle" - } + }, }, { "context": "Editor && vim_mode == insert", "bindings": { // "j k": "vim::NormalBefore" - } - } + }, + }, ] diff --git a/assets/keymaps/linux/atom.json b/assets/keymaps/linux/atom.json index 98992b19fac72055807063edae8b7b23652062d3..a15d4877aab79ac2e570697137ba89e3572d074e 100644 --- a/assets/keymaps/linux/atom.json +++ b/assets/keymaps/linux/atom.json @@ -4,15 +4,15 @@ "bindings": { "ctrl-shift-f5": "workspace::Reload", // window:reload "ctrl-k ctrl-n": "workspace::ActivatePreviousPane", // window:focus-next-pane - "ctrl-k ctrl-p": "workspace::ActivateNextPane" // window:focus-previous-pane - } + "ctrl-k ctrl-p": "workspace::ActivateNextPane", // window:focus-previous-pane + }, }, { "context": "Editor", "bindings": { "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case - "ctrl-k ctrl-l": "editor::ConvertToLowerCase" // editor:lower-case - } + "ctrl-k ctrl-l": "editor::ConvertToLowerCase", // editor:lower-case + }, }, { "context": "Editor && mode == full", @@ -32,8 +32,8 @@ "ctrl-down": "editor::MoveLineDown", // editor:move-line-down "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-shift-m": "markdown::OpenPreviewToTheSide", // markdown-preview:toggle - "ctrl-r": "outline::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "outline::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "BufferSearchBar", @@ -41,8 +41,8 @@ "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous "ctrl-f3": "search::SelectNextMatch", // find-and-replace:find-next-selected - "ctrl-shift-f3": "search::SelectPreviousMatch" // find-and-replace:find-previous-selected - } + "ctrl-shift-f3": "search::SelectPreviousMatch", // find-and-replace:find-previous-selected + }, }, { "context": "Workspace", @@ -50,8 +50,8 @@ "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-t": "file_finder::Toggle", // fuzzy-finder:toggle-file-finder - "ctrl-r": "project_symbols::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "project_symbols::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "Pane", @@ -65,8 +65,8 @@ "ctrl-6": ["pane::ActivateItem", 5], // tree-view:open-selected-entry-in-pane-6 "ctrl-7": ["pane::ActivateItem", 6], // tree-view:open-selected-entry-in-pane-7 "ctrl-8": ["pane::ActivateItem", 7], // tree-view:open-selected-entry-in-pane-8 - "ctrl-9": ["pane::ActivateItem", 8] // tree-view:open-selected-entry-in-pane-9 - } + "ctrl-9": ["pane::ActivateItem", 8], // tree-view:open-selected-entry-in-pane-9 + }, }, { "context": "ProjectPanel", @@ -75,8 +75,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "ctrl-x": "project_panel::Cut", // tree-view:cut "ctrl-c": "project_panel::Copy", // tree-view:copy - "ctrl-v": "project_panel::Paste" // tree-view:paste - } + "ctrl-v": "project_panel::Paste", // tree-view:paste + }, }, { "context": "ProjectPanel && not_editing", @@ -90,7 +90,7 @@ "d": "project_panel::Duplicate", // tree-view:duplicate "home": "menu::SelectFirst", // core:move-to-top "end": "menu::SelectLast", // core:move-to-bottom - "shift-a": "project_panel::NewDirectory" // tree-view:add-folder - } - } + "shift-a": "project_panel::NewDirectory", // tree-view:add-folder + }, + }, ] diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 4d2d13a90d96c31f72b1bb0ccc74608f81004eda..e1eeade9db16d178fb2ce0ec4b2ec03f0ac2c221 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -8,8 +8,8 @@ "ctrl-shift-i": "agent::ToggleFocus", "ctrl-l": "agent::ToggleFocus", "ctrl-shift-l": "agent::ToggleFocus", - "ctrl-shift-j": "agent::OpenSettings" - } + "ctrl-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -20,18 +20,18 @@ "ctrl-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "ctrl-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "ctrl-k": "assistant::InlineAssist", - "ctrl-shift-k": "assistant::InsertIntoEditor" - } + "ctrl-shift-k": "assistant::InsertIntoEditor", + }, }, { - "context": "InlineAssistEditor", + "context": "InlineAssistant > Editor", "use_key_equivalents": true, "bindings": { - "ctrl-shift-backspace": "editor::Cancel" + "ctrl-shift-backspace": "editor::Cancel", // "alt-enter": // Quick Question // "ctrl-shift-enter": // Full File Context // "ctrl-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -47,7 +47,7 @@ "ctrl-shift-backspace": "editor::Cancel", "ctrl-r": "agent::NewThread", "ctrl-shift-v": "editor::Paste", - "ctrl-shift-k": "assistant::InsertIntoEditor" + "ctrl-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "ctrl-t": // new thread tab @@ -56,28 +56,29 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "ctrl-enter": "agent::KeepAll", - "ctrl-backspace": "agent::RejectAll" - } + "ctrl-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "ctrl-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-right": "editor::AcceptNextWordEditPrediction", + "ctrl-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-k": "assistant::InlineAssist" - } - } + "ctrl-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index c5cf22c81220bf286187252394f8fde26bdd6509..5b6f841de07ac2f9bd45c73e032dea0ede409007 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -5,8 +5,8 @@ [ { "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, }, { // Workaround to avoid falling back to default bindings. @@ -18,8 +18,8 @@ "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel "ctrl-x": null, // currently activates `editor::Cut` if no following key is pressed for 1 second "ctrl-p": null, // currently activates `file_finder::Toggle` when the cursor is on the first character of the buffer - "ctrl-n": null // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer - } + "ctrl-n": null, // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer + }, }, { "context": "Editor", @@ -82,8 +82,8 @@ "ctrl-s": "buffer_search::Deploy", // isearch-forward "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -119,22 +119,22 @@ "alt->": "editor::SelectToEnd", "ctrl-home": "editor::SelectToBeginning", "ctrl-end": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, // Example setting for using emacs-style tab // (i.e. indent the current line / selection or perform symbol completion depending on context) @@ -164,8 +164,8 @@ "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { // Workaround to enable using native emacs from the Zed terminal. @@ -185,22 +185,22 @@ "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index a0314c5bc1dd59c17f3f132db804891ef1df0d4e..d3bf53a0d3694943252e0fccb2ac821cc6c2a6d3 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -13,8 +13,8 @@ "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", "shift-f9": "debugger::Start", - "alt-shift-f9": "debugger::Start" - } + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -62,28 +62,30 @@ "ctrl-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "ctrl-shift-u": "editor::ToggleCase" - } + "ctrl-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", "bindings": { "ctrl-f12": "outline::Toggle", "ctrl-r": ["buffer_search::Deploy", { "replace_enabled": true }], + "ctrl-e": "file_finder::Toggle", "ctrl-shift-n": "file_finder::Toggle", + "ctrl-alt-n": "file_finder::Toggle", "ctrl-g": "go_to_line::Toggle", "alt-enter": "editor::ToggleCodeActions", "ctrl-space": "editor::ShowCompletions", "ctrl-q": "editor::Hover", "ctrl-p": "editor::ShowSignatureHelp", - "ctrl-\\": "assistant::InlineAssist" - } + "ctrl-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -91,8 +93,8 @@ "alt-c": "search::ToggleCaseSensitive", "alt-e": "search::ToggleSelection", "alt-x": "search::ToggleRegex", - "alt-w": "search::ToggleWholeWord" - } + "alt-w": "search::ToggleWholeWord", + }, }, { "context": "Workspace", @@ -105,8 +107,8 @@ "ctrl-e": "file_finder::Toggle", "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "ctrl-shift-n": "file_finder::Toggle", - "ctrl-n": "project_symbols::Toggle", "ctrl-alt-n": "file_finder::Toggle", + "ctrl-n": "project_symbols::Toggle", "ctrl-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle", "ctrl-alt-shift-n": "project_symbols::Toggle", @@ -114,8 +116,8 @@ "alt-1": "project_panel::ToggleFocus", "alt-5": "debug_panel::ToggleFocus", "alt-6": "diagnostics::Deploy", - "alt-7": "outline_panel::ToggleFocus" - } + "alt-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -129,15 +131,15 @@ "alt-7": "outline_panel::ToggleFocus", "alt-8": null, // Services (bottom dock) "alt-9": null, // Git History (bottom dock) - "alt-0": "git_panel::ToggleFocus" - } + "alt-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "ctrl-shift-k": "git::Push" - } + "ctrl-shift-k": "git::Push", + }, }, { "context": "Pane", @@ -145,8 +147,8 @@ "ctrl-alt-left": "pane::GoBack", "ctrl-alt-right": "pane::GoForward", "alt-left": "pane::ActivatePreviousItem", - "alt-right": "pane::ActivateNextItem" - } + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -156,8 +158,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", @@ -167,8 +169,8 @@ "ctrl-up": "terminal::ScrollLineUp", "ctrl-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } }, @@ -179,7 +181,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/linux/sublime_text.json b/assets/keymaps/linux/sublime_text.json index eefd59e5bd1aa48125d0c6e3d662f3cb4e270be7..1d689a6f5841a011768113257afbed2c447669ed 100644 --- a/assets/keymaps/linux/sublime_text.json +++ b/assets/keymaps/linux/sublime_text.json @@ -22,8 +22,8 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", @@ -55,20 +55,20 @@ "alt-right": "editor::MoveToNextSubwordEnd", "alt-left": "editor::MoveToPreviousSubwordStart", "alt-shift-right": "editor::SelectToNextSubwordEnd", - "alt-shift-left": "editor::SelectToPreviousSubwordStart" - } + "alt-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "ctrl-r": "outline::Toggle" - } + "ctrl-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "ctrl-k ctrl-z": "git::Restore" - } + "ctrl-k ctrl-z": "git::Restore", + }, }, { "context": "Pane", @@ -83,15 +83,15 @@ "alt-6": ["pane::ActivateItem", 5], "alt-7": ["pane::ActivateItem", 6], "alt-8": ["pane::ActivateItem", 7], - "alt-9": "pane::ActivateLastItem" - } + "alt-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", "bindings": { "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // "ctrl-0": "project_panel::ToggleFocus", // normally resets zoom - "shift-ctrl-r": "project_symbols::Toggle" - } - } + "shift-ctrl-r": "project_symbols::Toggle", + }, + }, ] diff --git a/assets/keymaps/macos/atom.json b/assets/keymaps/macos/atom.json index ca015b667faa05db53d8fdc3bd82352d9bcc62aa..bf049fd3cb3eca8fe8049fa4e0810f82b10a5bbc 100644 --- a/assets/keymaps/macos/atom.json +++ b/assets/keymaps/macos/atom.json @@ -4,16 +4,16 @@ "bindings": { "ctrl-alt-cmd-l": "workspace::Reload", "cmd-k cmd-p": "workspace::ActivatePreviousPane", - "cmd-k cmd-n": "workspace::ActivateNextPane" - } + "cmd-k cmd-n": "workspace::ActivateNextPane", + }, }, { "context": "Editor", "bindings": { "cmd-shift-backspace": "editor::DeleteToBeginningOfLine", "cmd-k cmd-u": "editor::ConvertToUpperCase", - "cmd-k cmd-l": "editor::ConvertToLowerCase" - } + "cmd-k cmd-l": "editor::ConvertToLowerCase", + }, }, { "context": "Editor && mode == full", @@ -33,8 +33,8 @@ "ctrl-cmd-down": "editor::MoveLineDown", "cmd-\\": "workspace::ToggleLeftDock", "ctrl-shift-m": "markdown::OpenPreviewToTheSide", - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "BufferSearchBar", @@ -42,8 +42,8 @@ "cmd-g": ["editor::SelectNext", { "replace_newest": true }], "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], "cmd-f3": "search::SelectNextMatch", - "cmd-shift-f3": "search::SelectPreviousMatch" - } + "cmd-shift-f3": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", @@ -51,8 +51,8 @@ "cmd-\\": "workspace::ToggleLeftDock", "cmd-k cmd-b": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-r": "project_symbols::Toggle" - } + "cmd-shift-r": "project_symbols::Toggle", + }, }, { "context": "Pane", @@ -67,8 +67,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "ProjectPanel", @@ -77,8 +77,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "cmd-x": "project_panel::Cut", "cmd-c": "project_panel::Copy", - "cmd-v": "project_panel::Paste" - } + "cmd-v": "project_panel::Paste", + }, }, { "context": "ProjectPanel && not_editing", @@ -92,7 +92,7 @@ "d": "project_panel::Duplicate", "home": "menu::SelectFirst", "end": "menu::SelectLast", - "shift-a": "project_panel::NewDirectory" - } - } + "shift-a": "project_panel::NewDirectory", + }, + }, ] diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index 97abc7dd819485850107eca6762fc1ed60ec0515..2824575a445ad0c870a59cb516441dc6f1421f31 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -8,8 +8,8 @@ "cmd-shift-i": "agent::ToggleFocus", "cmd-l": "agent::ToggleFocus", "cmd-shift-l": "agent::ToggleFocus", - "cmd-shift-j": "agent::OpenSettings" - } + "cmd-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -20,19 +20,19 @@ "cmd-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "cmd-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "cmd-k": "assistant::InlineAssist", - "cmd-shift-k": "assistant::InsertIntoEditor" - } + "cmd-shift-k": "assistant::InsertIntoEditor", + }, }, { - "context": "InlineAssistEditor", + "context": "InlineAssistant > Editor", "use_key_equivalents": true, "bindings": { "cmd-shift-backspace": "editor::Cancel", - "cmd-enter": "menu::Confirm" + "cmd-enter": "menu::Confirm", // "alt-enter": // Quick Question // "cmd-shift-enter": // Full File Context // "cmd-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -48,7 +48,7 @@ "cmd-shift-backspace": "editor::Cancel", "cmd-r": "agent::NewThread", "cmd-shift-v": "editor::Paste", - "cmd-shift-k": "assistant::InsertIntoEditor" + "cmd-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "cmd-t": // new thread tab @@ -57,28 +57,29 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "cmd-enter": "agent::KeepAll", - "cmd-backspace": "agent::RejectAll" - } + "cmd-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "cmd-right": "editor::AcceptPartialEditPrediction" - } + "cmd-right": "editor::AcceptNextWordEditPrediction", + "cmd-down": "editor::AcceptNextLineEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "cmd-k": "assistant::InlineAssist" - } - } + "cmd-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index ea831c0c059ea082d002f3af01b8d97be9e86616..2f11e2ce00e8b60a0f1c85b5aeb204e866491a45 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -6,8 +6,8 @@ { "context": "!GitPanel", "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, }, { // Workaround to avoid falling back to default bindings. @@ -15,8 +15,8 @@ // NOTE: must be declared before the `Editor` override. "context": "Editor", "bindings": { - "ctrl-g": null // currently activates `go_to_line::Toggle` when there is nothing to cancel - } + "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel + }, }, { "context": "Editor", @@ -79,8 +79,8 @@ "ctrl-s": "buffer_search::Deploy", // isearch-forward "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -116,22 +116,22 @@ "alt->": "editor::SelectToEnd", "ctrl-home": "editor::SelectToBeginning", "ctrl-end": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, // Example setting for using emacs-style tab // (i.e. indent the current line / selection or perform symbol completion depending on context) @@ -161,8 +161,8 @@ "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { // Workaround to enable using native emacs from the Zed terminal. @@ -182,22 +182,22 @@ "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 364f489167f5abb9af21d9f005586bde08439850..9946d8b124957349181db659259174d906d08d3a 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -13,8 +13,8 @@ "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", "shift-f9": "debugger::Start", - "alt-shift-f9": "debugger::Start" - } + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -60,28 +60,30 @@ "cmd-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "cmd-shift-u": "editor::ToggleCase" - } + "cmd-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", "bindings": { "cmd-f12": "outline::Toggle", "cmd-r": ["buffer_search::Deploy", { "replace_enabled": true }], - "cmd-shift-o": "file_finder::Toggle", "cmd-l": "go_to_line::Toggle", + "cmd-e": "file_finder::Toggle", + "cmd-shift-o": "file_finder::Toggle", + "cmd-shift-n": "file_finder::Toggle", "alt-enter": "editor::ToggleCodeActions", "ctrl-space": "editor::ShowCompletions", "cmd-j": "editor::Hover", "cmd-p": "editor::ShowSignatureHelp", - "cmd-\\": "assistant::InlineAssist" - } + "cmd-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -93,8 +95,8 @@ "ctrl-alt-c": "search::ToggleCaseSensitive", "ctrl-alt-e": "search::ToggleSelection", "ctrl-alt-w": "search::ToggleWholeWord", - "ctrl-alt-x": "search::ToggleRegex" - } + "ctrl-alt-x": "search::ToggleRegex", + }, }, { "context": "Workspace", @@ -116,8 +118,8 @@ "cmd-1": "project_panel::ToggleFocus", "cmd-5": "debug_panel::ToggleFocus", "cmd-6": "diagnostics::Deploy", - "cmd-7": "outline_panel::ToggleFocus" - } + "cmd-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -131,15 +133,15 @@ "cmd-7": "outline_panel::ToggleFocus", "cmd-8": null, // Services (bottom dock) "cmd-9": null, // Git History (bottom dock) - "cmd-0": "git_panel::ToggleFocus" - } + "cmd-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "cmd-shift-k": "git::Push" - } + "cmd-shift-k": "git::Push", + }, }, { "context": "Pane", @@ -147,8 +149,8 @@ "cmd-alt-left": "pane::GoBack", "cmd-alt-right": "pane::GoForward", "alt-left": "pane::ActivatePreviousItem", - "alt-right": "pane::ActivateNextItem" - } + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -159,8 +161,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", @@ -170,8 +172,8 @@ "cmd-up": "terminal::ScrollLineUp", "cmd-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "cmd-1": "workspace::CloseActiveDock" } }, @@ -182,7 +184,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/macos/sublime_text.json b/assets/keymaps/macos/sublime_text.json index d1bffca755b611d9046d4b7e794d2303835227a2..f4ae1ce5dda4e2c0dd21e97bd3a411dd4a4f3663 100644 --- a/assets/keymaps/macos/sublime_text.json +++ b/assets/keymaps/macos/sublime_text.json @@ -22,8 +22,8 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", @@ -57,20 +57,20 @@ "ctrl-right": "editor::MoveToNextSubwordEnd", "ctrl-left": "editor::MoveToPreviousSubwordStart", "ctrl-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-shift-left": "editor::SelectToPreviousSubwordStart" - } + "ctrl-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "cmd-k cmd-z": "git::Restore" - } + "cmd-k cmd-z": "git::Restore", + }, }, { "context": "Pane", @@ -85,8 +85,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", @@ -95,7 +95,7 @@ "cmd-t": "file_finder::Toggle", "shift-cmd-r": "project_symbols::Toggle", // Currently busted: https://github.com/zed-industries/feedback/issues/898 - "ctrl-0": "project_panel::ToggleFocus" - } - } + "ctrl-0": "project_panel::ToggleFocus", + }, + }, ] diff --git a/assets/keymaps/macos/textmate.json b/assets/keymaps/macos/textmate.json index f91f39b7f5c079f81b5fcf8e28e2092a33ff1aa4..90450e60af7147f1394eb6cb4c1efc389edad2d0 100644 --- a/assets/keymaps/macos/textmate.json +++ b/assets/keymaps/macos/textmate.json @@ -2,8 +2,8 @@ { "bindings": { "cmd-shift-o": "projects::OpenRecent", - "cmd-alt-tab": "project_panel::ToggleFocus" - } + "cmd-alt-tab": "project_panel::ToggleFocus", + }, }, { "context": "Editor && mode == full", @@ -15,8 +15,8 @@ "cmd-enter": "editor::NewlineBelow", "cmd-alt-enter": "editor::NewlineAbove", "cmd-shift-l": "editor::SelectLine", - "cmd-shift-t": "outline::Toggle" - } + "cmd-shift-t": "outline::Toggle", + }, }, { "context": "Editor", @@ -41,30 +41,30 @@ "ctrl-u": "editor::ConvertToUpperCase", "ctrl-shift-u": "editor::ConvertToLowerCase", "ctrl-alt-u": "editor::ConvertToUpperCamelCase", - "ctrl-_": "editor::ConvertToSnakeCase" - } + "ctrl-_": "editor::ConvertToSnakeCase", + }, }, { "context": "BufferSearchBar", "bindings": { "ctrl-s": "search::SelectNextMatch", - "ctrl-shift-s": "search::SelectPreviousMatch" - } + "ctrl-shift-s": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", "bindings": { "cmd-alt-ctrl-d": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-t": "project_symbols::Toggle" - } + "cmd-shift-t": "project_symbols::Toggle", + }, }, { "context": "Pane", "bindings": { "alt-cmd-r": "search::ToggleRegex", - "ctrl-tab": "project_panel::ToggleFocus" - } + "ctrl-tab": "project_panel::ToggleFocus", + }, }, { "context": "ProjectPanel", @@ -75,11 +75,11 @@ "return": "project_panel::Rename", "cmd-c": "project_panel::Copy", "cmd-v": "project_panel::Paste", - "cmd-alt-c": "project_panel::CopyPath" - } + "cmd-alt-c": "project_panel::CopyPath", + }, }, { "context": "Dock", - "bindings": {} - } + "bindings": {}, + }, ] diff --git a/assets/keymaps/storybook.json b/assets/keymaps/storybook.json index 9b92fbe1a3844043e379647d1dd6c57e082fdf77..432bdc7004a4c66b52e20282aba924611b204aa1 100644 --- a/assets/keymaps/storybook.json +++ b/assets/keymaps/storybook.json @@ -27,7 +27,7 @@ "backspace": "editor::Backspace", "delete": "editor::Delete", "left": "editor::MoveLeft", - "right": "editor::MoveRight" - } - } + "right": "editor::MoveRight", + }, + }, ] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 5ff1dc196a82d0c3226253c4b8d892058598b4e3..6e5d3423872a7dd83234b28e67c5082b36bd858f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -180,10 +180,9 @@ "ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit", "ctrl-w space": "editor::OpenExcerptsSplit", "ctrl-w g space": "editor::OpenExcerptsSplit", - "ctrl-6": "pane::AlternateFile", "ctrl-^": "pane::AlternateFile", - ".": "vim::Repeat" - } + ".": "vim::Repeat", + }, }, { "context": "vim_mode == normal || vim_mode == visual || vim_mode == operator", @@ -224,8 +223,8 @@ "] r": "vim::GoToNextReference", // tree-sitter related commands "[ x": "vim::SelectLargerSyntaxNode", - "] x": "vim::SelectSmallerSyntaxNode" - } + "] x": "vim::SelectSmallerSyntaxNode", + }, }, { "context": "vim_mode == normal", @@ -262,16 +261,16 @@ "[ d": "editor::GoToPreviousDiagnostic", "] c": "editor::GoToHunk", "[ c": "editor::GoToPreviousHunk", - "g c": "vim::PushToggleComments" - } + "g c": "vim::PushToggleComments", + }, }, { "context": "VimControl && VimCount", "bindings": { "0": ["vim::Number", 0], ":": "vim::CountCommand", - "%": "vim::GoToPercentage" - } + "%": "vim::GoToPercentage", + }, }, { "context": "vim_mode == visual", @@ -323,8 +322,8 @@ "g w": "vim::Rewrap", "g ?": "vim::ConvertToRot13", // "g ?": "vim::ConvertToRot47", - "\"": "vim::PushRegister" - } + "\"": "vim::PushRegister", + }, }, { "context": "vim_mode == helix_select", @@ -344,8 +343,8 @@ "ctrl-pageup": "pane::ActivatePreviousItem", "ctrl-pagedown": "pane::ActivateNextItem", ".": "vim::Repeat", - "alt-.": "vim::RepeatFind" - } + "alt-.": "vim::RepeatFind", + }, }, { "context": "vim_mode == insert", @@ -375,8 +374,8 @@ "ctrl-r": "vim::PushRegister", "insert": "vim::ToggleReplace", "ctrl-o": "vim::TemporaryNormal", - "ctrl-s": "editor::ShowSignatureHelp" - } + "ctrl-s": "editor::ShowSignatureHelp", + }, }, { "context": "showing_completions", @@ -384,8 +383,8 @@ "ctrl-d": "vim::ScrollDown", "ctrl-u": "vim::ScrollUp", "ctrl-e": "vim::LineDown", - "ctrl-y": "vim::LineUp" - } + "ctrl-y": "vim::LineUp", + }, }, { "context": "(vim_mode == normal || vim_mode == helix_normal) && !menu", @@ -410,23 +409,31 @@ "shift-s": "vim::SubstituteLine", "\"": "vim::PushRegister", "ctrl-pagedown": "pane::ActivateNextItem", - "ctrl-pageup": "pane::ActivatePreviousItem" - } + "ctrl-pageup": "pane::ActivatePreviousItem", + }, }, { "context": "VimControl && vim_mode == helix_normal && !menu", "bindings": { + "j": ["vim::Down", { "display_lines": true }], + "down": ["vim::Down", { "display_lines": true }], + "k": ["vim::Up", { "display_lines": true }], + "up": ["vim::Up", { "display_lines": true }], + "g j": "vim::Down", + "g down": "vim::Down", + "g k": "vim::Up", + "g up": "vim::Up", "escape": "vim::SwitchToHelixNormalMode", "i": "vim::HelixInsert", "a": "vim::HelixAppend", - "ctrl-[": "editor::Cancel" - } + "ctrl-[": "editor::Cancel", + }, }, { "context": "vim_mode == helix_select && !menu", "bindings": { - "escape": "vim::SwitchToHelixNormalMode" - } + "escape": "vim::SwitchToHelixNormalMode", + }, }, { "context": "(vim_mode == helix_normal || vim_mode == helix_select) && !menu", @@ -446,9 +453,9 @@ "shift-r": "editor::Paste", "`": "vim::ConvertToLowerCase", "alt-`": "vim::ConvertToUpperCase", - "insert": "vim::InsertBefore", + "insert": "vim::InsertBefore", // not a helix default "shift-u": "editor::Redo", - "ctrl-r": "vim::Redo", + "ctrl-r": "vim::Redo", // not a helix default "y": "vim::HelixYank", "p": "vim::HelixPaste", "shift-p": ["vim::HelixPaste", { "before": true }], @@ -477,6 +484,7 @@ "alt-p": "editor::SelectPreviousSyntaxNode", "alt-n": "editor::SelectNextSyntaxNode", + // Search "n": "vim::HelixSelectNext", "shift-n": "vim::HelixSelectPrevious", @@ -484,27 +492,32 @@ "g e": "vim::EndOfDocument", "g h": "vim::StartOfLine", "g l": "vim::EndOfLine", - "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" + "g s": "vim::FirstNonWhitespace", "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", "g b": "vim::WindowBottom", - "g r": "editor::FindAllReferences", // zed specific + "g r": "editor::FindAllReferences", "g n": "pane::ActivateNextItem", - "shift-l": "pane::ActivateNextItem", + "shift-l": "pane::ActivateNextItem", // not a helix default "g p": "pane::ActivatePreviousItem", - "shift-h": "pane::ActivatePreviousItem", - "g .": "vim::HelixGotoLastModification", // go to last modification + "shift-h": "pane::ActivatePreviousItem", // not a helix default + "g .": "vim::HelixGotoLastModification", + "g o": "editor::ToggleSelectedDiffHunks", // Zed specific + "g shift-o": "git::ToggleStaged", // Zed specific + "g shift-r": "git::Restore", // Zed specific + "g u": "git::StageAndNext", // Zed specific + "g shift-u": "git::UnstageAndNext", // Zed specific // Window mode + "space w v": "pane::SplitDown", + "space w s": "pane::SplitRight", "space w h": "workspace::ActivatePaneLeft", - "space w l": "workspace::ActivatePaneRight", - "space w k": "workspace::ActivatePaneUp", "space w j": "workspace::ActivatePaneDown", + "space w k": "workspace::ActivatePaneUp", + "space w l": "workspace::ActivatePaneRight", "space w q": "pane::CloseActiveItem", - "space w s": "pane::SplitRight", - "space w r": "pane::SplitRight", - "space w v": "pane::SplitDown", - "space w d": "pane::SplitDown", + "space w r": "pane::SplitRight", // not a helix default + "space w d": "pane::SplitDown", // not a helix default // Space mode "space f": "file_finder::Toggle", @@ -518,6 +531,7 @@ "space c": "editor::ToggleComments", "space p": "editor::Paste", "space y": "editor::Copy", + "space /": "pane::DeploySearch", // Other ":": "command_palette::Toggle", @@ -525,24 +539,22 @@ "]": ["vim::PushHelixNext", { "around": true }], "[": ["vim::PushHelixPrevious", { "around": true }], "g q": "vim::PushRewrap", - "g w": "vim::PushRewrap" - // "tab": "pane::ActivateNextItem", - // "shift-tab": "pane::ActivatePrevItem", - } + "g w": "vim::PushRewrap", // not a helix default & clashes with helix `goto_word` + }, }, { "context": "vim_mode == insert && !(showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ShowWordCompletions", - "ctrl-n": "editor::ShowWordCompletions" - } + "ctrl-n": "editor::ShowWordCompletions", + }, }, { "context": "(vim_mode == insert || vim_mode == normal) && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, { "context": "vim_mode == replace", @@ -558,8 +570,8 @@ "backspace": "vim::UndoReplace", "tab": "vim::Tab", "enter": "vim::Enter", - "insert": "vim::InsertBefore" - } + "insert": "vim::InsertBefore", + }, }, { "context": "vim_mode == waiting", @@ -571,14 +583,14 @@ "escape": "vim::ClearOperators", "ctrl-k": ["vim::PushDigraph", {}], "ctrl-v": ["vim::PushLiteral", {}], - "ctrl-q": ["vim::PushLiteral", {}] - } + "ctrl-q": ["vim::PushLiteral", {}], + }, }, { "context": "Editor && vim_mode == waiting && (vim_operator == ys || vim_operator == cs)", "bindings": { - "escape": "vim::SwitchToNormalMode" - } + "escape": "vim::SwitchToNormalMode", + }, }, { "context": "vim_mode == operator", @@ -586,8 +598,8 @@ "ctrl-c": "vim::ClearOperators", "ctrl-[": "vim::ClearOperators", "escape": "vim::ClearOperators", - "g c": "vim::Comment" - } + "g c": "vim::Comment", + }, }, { "context": "vim_operator == a || vim_operator == i || vim_operator == cs || vim_operator == helix_next || vim_operator == helix_previous", @@ -624,14 +636,14 @@ "shift-i": ["vim::IndentObj", { "include_below": true }], "f": "vim::Method", "c": "vim::Class", - "e": "vim::EntireFile" - } + "e": "vim::EntireFile", + }, }, { "context": "vim_operator == helix_m", "bindings": { - "m": "vim::Matching" - } + "m": "vim::Matching", + }, }, { "context": "vim_operator == helix_next", @@ -648,8 +660,8 @@ "x": "editor::SelectSmallerSyntaxNode", "d": "editor::GoToDiagnostic", "c": "editor::GoToHunk", - "space": "vim::InsertEmptyLineBelow" - } + "space": "vim::InsertEmptyLineBelow", + }, }, { "context": "vim_operator == helix_previous", @@ -666,8 +678,8 @@ "x": "editor::SelectLargerSyntaxNode", "d": "editor::GoToPreviousDiagnostic", "c": "editor::GoToPreviousHunk", - "space": "vim::InsertEmptyLineAbove" - } + "space": "vim::InsertEmptyLineAbove", + }, }, { "context": "vim_operator == c", @@ -675,8 +687,8 @@ "c": "vim::CurrentLine", "x": "vim::Exchange", "d": "editor::Rename", // zed specific - "s": ["vim::PushChangeSurrounds", {}] - } + "s": ["vim::PushChangeSurrounds", {}], + }, }, { "context": "vim_operator == d", @@ -688,36 +700,36 @@ "shift-o": "git::ToggleStaged", "p": "git::Restore", // "d p" "u": "git::StageAndNext", // "d u" - "shift-u": "git::UnstageAndNext" // "d shift-u" - } + "shift-u": "git::UnstageAndNext", // "d shift-u" + }, }, { "context": "vim_operator == gu", "bindings": { "g u": "vim::CurrentLine", - "u": "vim::CurrentLine" - } + "u": "vim::CurrentLine", + }, }, { "context": "vim_operator == gU", "bindings": { "g shift-u": "vim::CurrentLine", - "shift-u": "vim::CurrentLine" - } + "shift-u": "vim::CurrentLine", + }, }, { "context": "vim_operator == g~", "bindings": { "g ~": "vim::CurrentLine", - "~": "vim::CurrentLine" - } + "~": "vim::CurrentLine", + }, }, { "context": "vim_operator == g?", "bindings": { "g ?": "vim::CurrentLine", - "?": "vim::CurrentLine" - } + "?": "vim::CurrentLine", + }, }, { "context": "vim_operator == gq", @@ -725,66 +737,66 @@ "g q": "vim::CurrentLine", "q": "vim::CurrentLine", "g w": "vim::CurrentLine", - "w": "vim::CurrentLine" - } + "w": "vim::CurrentLine", + }, }, { "context": "vim_operator == y", "bindings": { "y": "vim::CurrentLine", "v": "vim::PushForcedMotion", - "s": ["vim::PushAddSurrounds", {}] - } + "s": ["vim::PushAddSurrounds", {}], + }, }, { "context": "vim_operator == ys", "bindings": { - "s": "vim::CurrentLine" - } + "s": "vim::CurrentLine", + }, }, { "context": "vim_operator == >", "bindings": { - ">": "vim::CurrentLine" - } + ">": "vim::CurrentLine", + }, }, { "context": "vim_operator == <", "bindings": { - "<": "vim::CurrentLine" - } + "<": "vim::CurrentLine", + }, }, { "context": "vim_operator == eq", "bindings": { - "=": "vim::CurrentLine" - } + "=": "vim::CurrentLine", + }, }, { "context": "vim_operator == sh", "bindings": { - "!": "vim::CurrentLine" - } + "!": "vim::CurrentLine", + }, }, { "context": "vim_operator == gc", "bindings": { - "c": "vim::CurrentLine" - } + "c": "vim::CurrentLine", + }, }, { "context": "vim_operator == gR", "bindings": { "r": "vim::CurrentLine", - "shift-r": "vim::CurrentLine" - } + "shift-r": "vim::CurrentLine", + }, }, { "context": "vim_operator == cx", "bindings": { "x": "vim::CurrentLine", - "c": "vim::ClearExchange" - } + "c": "vim::ClearExchange", + }, }, { "context": "vim_mode == literal", @@ -826,15 +838,15 @@ "tab": ["vim::Literal", ["tab", "\u0009"]], // zed extensions: "backspace": ["vim::Literal", ["backspace", "\u0008"]], - "delete": ["vim::Literal", ["delete", "\u007F"]] - } + "delete": ["vim::Literal", ["delete", "\u007F"]], + }, }, { "context": "BufferSearchBar && !in_replace", "bindings": { "enter": "vim::SearchSubmit", - "escape": "buffer_search::Dismiss" - } + "escape": "buffer_search::Dismiss", + }, }, { "context": "VimControl && !menu || !Editor && !Terminal", @@ -895,15 +907,19 @@ "ctrl-w ctrl-n": "workspace::NewFileSplitHorizontal", "ctrl-w n": "workspace::NewFileSplitHorizontal", "g t": "vim::GoToTab", - "g shift-t": "vim::GoToPreviousTab" - } + "g shift-t": "vim::GoToPreviousTab", + }, }, { "context": "!Editor && !Terminal", "bindings": { ":": "command_palette::Toggle", - "g /": "pane::DeploySearch" - } + "g /": "pane::DeploySearch", + "] b": "pane::ActivateNextItem", + "[ b": "pane::ActivatePreviousItem", + "] shift-b": "pane::ActivateLastItem", + "[ shift-b": ["pane::ActivateItem", 0], + }, }, { // netrw compatibility @@ -953,17 +969,45 @@ "6": ["vim::Number", 6], "7": ["vim::Number", 7], "8": ["vim::Number", 8], - "9": ["vim::Number", 9] - } + "9": ["vim::Number", 9], + }, }, { "context": "OutlinePanel && not_editing", "bindings": { - "j": "menu::SelectNext", - "k": "menu::SelectPrevious", + "h": "outline_panel::CollapseSelectedEntry", + "j": "vim::MenuSelectNext", + "k": "vim::MenuSelectPrevious", + "down": "vim::MenuSelectNext", + "up": "vim::MenuSelectPrevious", + "l": "outline_panel::ExpandSelectedEntry", "shift-g": "menu::SelectLast", - "g g": "menu::SelectFirst" - } + "g g": "menu::SelectFirst", + "-": "outline_panel::SelectParent", + "enter": "editor::ToggleFocus", + "/": "menu::Cancel", + "ctrl-u": "outline_panel::ScrollUp", + "ctrl-d": "outline_panel::ScrollDown", + "z t": "outline_panel::ScrollCursorTop", + "z z": "outline_panel::ScrollCursorCenter", + "z b": "outline_panel::ScrollCursorBottom", + "0": ["vim::Number", 0], + "1": ["vim::Number", 1], + "2": ["vim::Number", 2], + "3": ["vim::Number", 3], + "4": ["vim::Number", 4], + "5": ["vim::Number", 5], + "6": ["vim::Number", 6], + "7": ["vim::Number", 7], + "8": ["vim::Number", 8], + "9": ["vim::Number", 9], + }, + }, + { + "context": "OutlinePanel && editing", + "bindings": { + "enter": "menu::Cancel", + }, }, { "context": "GitPanel && ChangesList", @@ -978,8 +1022,8 @@ "x": "git::ToggleStaged", "shift-x": "git::StageAll", "g x": "git::StageRange", - "shift-u": "git::UnstageAll" - } + "shift-u": "git::UnstageAll", + }, }, { "context": "Editor && mode == auto_height && VimControl", @@ -990,8 +1034,8 @@ "#": null, "*": null, "n": null, - "shift-n": null - } + "shift-n": null, + }, }, { "context": "Picker > Editor", @@ -1000,29 +1044,29 @@ "ctrl-u": "editor::DeleteToBeginningOfLine", "ctrl-w": "editor::DeleteToPreviousWordStart", "ctrl-p": "menu::SelectPrevious", - "ctrl-n": "menu::SelectNext" - } + "ctrl-n": "menu::SelectNext", + }, }, { "context": "GitCommit > Editor && VimControl && vim_mode == normal", "bindings": { "ctrl-c": "menu::Cancel", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Editor && edit_prediction", "bindings": { // This is identical to the binding in the base keymap, but the vim bindings above to // "vim::Tab" shadow it, so it needs to be bound again. - "tab": "editor::AcceptEditPrediction" - } + "tab": "editor::AcceptEditPrediction", + }, }, { "context": "MessageEditor > Editor && VimControl", "bindings": { - "enter": "agent::Chat" - } + "enter": "agent::Chat", + }, }, { "context": "os != macos && Editor && edit_prediction_conflict", @@ -1030,8 +1074,8 @@ // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This // is because alt-tab may not be available, as it is often used for window switching on Linux // and Windows. - "alt-l": "editor::AcceptEditPrediction" - } + "alt-l": "editor::AcceptEditPrediction", + }, }, { "context": "SettingsWindow > NavigationMenu && !search", @@ -1041,7 +1085,16 @@ "k": "settings_editor::FocusPreviousNavEntry", "j": "settings_editor::FocusNextNavEntry", "g g": "settings_editor::FocusFirstNavEntry", - "shift-g": "settings_editor::FocusLastNavEntry" - } - } + "shift-g": "settings_editor::FocusLastNavEntry", + }, + }, + { + "context": "MarkdownPreview", + "bindings": { + "ctrl-u": "markdown::ScrollPageUp", + "ctrl-d": "markdown::ScrollPageDown", + "ctrl-y": "markdown::ScrollUp", + "ctrl-e": "markdown::ScrollDown", + }, + }, ] diff --git a/assets/prompts/content_prompt_v2.hbs b/assets/prompts/content_prompt_v2.hbs new file mode 100644 index 0000000000000000000000000000000000000000..826aada8c04863c21d756cf99beb64e582ed4906 --- /dev/null +++ b/assets/prompts/content_prompt_v2.hbs @@ -0,0 +1,40 @@ +{{#if language_name}} +Here's a file of {{language_name}} that the user is going to ask you to make an edit to. +{{else}} +Here's a file of text that the user is going to ask you to make an edit to. +{{/if}} + +The section you'll need to rewrite is marked with tags. + + +{{{document_content}}} + + +{{#if is_truncated}} +The context around the relevant section has been truncated (possibly in the middle of a line) for brevity. +{{/if}} + +And here's the section to rewrite based on that prompt again for reference: + + +{{{rewrite_section}}} + + +{{#if diagnostic_errors}} +Below are the diagnostic errors visible to the user. If the user requests problems to be fixed, use this information, but do not try to fix these errors if the user hasn't asked you to. + +{{#each diagnostic_errors}} + + {{line_number}} + {{error_message}} + {{code_content}} + +{{/each}} +{{/if}} + +Only make changes that are necessary to fulfill the prompt, leave everything else as-is. All surrounding {{content_type}} will be preserved. + +Start at the indentation level in the original file in the rewritten {{content_type}}. + +IMPORTANT: You MUST use one of the provided tools to make the rewrite or to provide an explanation as to why the user's request cannot be fulfilled. You MUST NOT send back unstructured text. If you need to make a statement or ask a question you MUST use one of the tools to do so. +It is an error if you try to make a change that cannot be made simply by editing the rewrite_section. diff --git a/assets/settings/default.json b/assets/settings/default.json index f53019744e72daa253e3ddfa96f48a0541186b61..746ccb5986d0fd1d5ef11df525303e344a7393d2 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -12,7 +12,7 @@ "theme": { "mode": "system", "light": "One Light", - "dark": "One Dark" + "dark": "One Dark", }, "icon_theme": "Zed (Default)", // The name of a base set of key bindings to use. @@ -29,7 +29,7 @@ // Features that can be globally enabled or disabled "features": { // Which edit prediction provider to use. - "edit_prediction_provider": "zed" + "edit_prediction_provider": "zed", }, // The name of a font to use for rendering text in the editor // ".ZedMono" currently aliases to Lilex @@ -69,7 +69,7 @@ // The OpenType features to enable for text in the UI "ui_font_features": { // Disable ligatures: - "calt": false + "calt": false, }, // The weight of the UI font in standard CSS units from 100 to 900. "ui_font_weight": 400, @@ -87,7 +87,7 @@ "border_size": 0.0, // Opacity of the inactive panes. 0 means transparent, 1 means opaque. // Values are clamped to the [0.0, 1.0] range. - "inactive_opacity": 1.0 + "inactive_opacity": 1.0, }, // Layout mode of the bottom dock. Defaults to "contained" // choices: contained, full, left_aligned, right_aligned @@ -103,12 +103,12 @@ "left_padding": 0.2, // The relative width of the right padding of the central pane from the // workspace when the centered layout is used. - "right_padding": 0.2 + "right_padding": 0.2, }, // Image viewer settings "image_viewer": { // The unit for image file sizes: "binary" (KiB, MiB) or decimal (KB, MB) - "unit": "binary" + "unit": "binary", }, // Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier. // @@ -296,7 +296,7 @@ // When true, enables drag and drop text selection in buffer. "enabled": true, // The delay in milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. - "delay": 300 + "delay": 300, }, // What to do when go to definition yields no results. // @@ -400,14 +400,14 @@ // Visible characters used to render whitespace when show_whitespaces is enabled. "whitespace_map": { "space": "•", - "tab": "→" + "tab": "→", }, // Settings related to calls in Zed "calls": { // Join calls with the microphone live by default "mute_on_join": false, // Share your project when you are the first to join a channel - "share_on_join": false + "share_on_join": false, }, // Toolbar related settings "toolbar": { @@ -420,7 +420,7 @@ // Whether to show agent review buttons in the editor toolbar. "agent_review": true, // Whether to show code action buttons in the editor toolbar. - "code_actions": false + "code_actions": false, }, // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only). "use_system_window_tabs": false, @@ -436,10 +436,12 @@ "show_onboarding_banner": true, // Whether to show user picture in the titlebar. "show_user_picture": true, + // Whether to show the user menu in the titlebar. + "show_user_menu": true, // Whether to show the sign in button in the titlebar. "show_sign_in": true, // Whether to show the menus in the titlebar. - "show_menus": false + "show_menus": false, }, "audio": { // Opt into the new audio system. @@ -472,7 +474,7 @@ // the future we will migrate by setting this to false // // You need to rejoin a call for this setting to apply - "experimental.legacy_audio_compatible": true + "experimental.legacy_audio_compatible": true, }, // Scrollbar related settings "scrollbar": { @@ -511,8 +513,8 @@ // When false, forcefully disables the horizontal scrollbar. Otherwise, obey other settings. "horizontal": true, // When false, forcefully disables the vertical scrollbar. Otherwise, obey other settings. - "vertical": true - } + "vertical": true, + }, }, // Minimap related settings "minimap": { @@ -560,7 +562,7 @@ // 3. "gutter" or "none" to not highlight the current line in the minimap. "current_line_highlight": null, // Maximum number of columns to display in the minimap. - "max_width_columns": 80 + "max_width_columns": 80, }, // Enable middle-click paste on Linux. "middle_click_paste": true, @@ -583,7 +585,7 @@ // Whether to show fold buttons in the gutter. "folds": true, // Minimum number of characters to reserve space for in the gutter. - "min_line_number_digits": 4 + "min_line_number_digits": 4, }, "indent_guides": { // Whether to show indent guides in the editor. @@ -604,7 +606,7 @@ // // 1. "disabled" // 2. "indent_aware" - "background_coloring": "disabled" + "background_coloring": "disabled", }, // Whether the editor will scroll beyond the last line. "scroll_beyond_last_line": "one_page", @@ -623,7 +625,7 @@ "fast_scroll_sensitivity": 4.0, "sticky_scroll": { // Whether to stick scopes to the top of the editor. - "enabled": false + "enabled": false, }, "relative_line_numbers": "disabled", // If 'search_wrap' is disabled, search result do not wrap around the end of the file. @@ -641,7 +643,7 @@ // Whether to interpret the search query as a regular expression. "regex": false, // Whether to center the cursor on each search match when navigating. - "center_on_match": false + "center_on_match": false, }, // When to populate a new search's query based on the text under the cursor. // This setting can take the following three values: @@ -684,8 +686,8 @@ "shift": false, "alt": false, "platform": false, - "function": false - } + "function": false, + }, }, // Whether to resize all the panels in a dock when resizing the dock. // Can be a combination of "left", "right" and "bottom". @@ -733,7 +735,7 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Which files containing diagnostic errors/warnings to mark in the project panel. // This setting can take the following three values: @@ -756,7 +758,7 @@ // "always" // 2. Never show indent guides: // "never" - "show": "always" + "show": "always", }, // Sort order for entries in the project panel. // This setting can take three values: @@ -781,8 +783,8 @@ // Whether to automatically open files after pasting or duplicating them. "on_paste": true, // Whether to automatically open files dropped from external sources. - "on_drop": true - } + "on_drop": true, + }, }, "outline_panel": { // Whether to show the outline panel button in the status bar @@ -815,7 +817,7 @@ // "always" // 2. Never show indent guides: // "never" - "show": "always" + "show": "always", }, // Scrollbar-related settings "scrollbar": { @@ -832,11 +834,11 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Default depth to expand outline items in the current file. // Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper. - "expand_outlines_with_depth": 100 + "expand_outlines_with_depth": 100, }, "collaboration_panel": { // Whether to show the collaboration panel button in the status bar. @@ -844,7 +846,7 @@ // Where to dock the collaboration panel. Can be 'left' or 'right'. "dock": "left", // Default width of the collaboration panel. - "default_width": 240 + "default_width": 240, }, "git_panel": { // Whether to show the git panel button in the status bar. @@ -870,18 +872,22 @@ // // Default: false "collapse_untracked_diff": false, + /// Whether to show entries with tree or flat view in the panel + /// + /// Default: false + "tree_view": false, "scrollbar": { // When to show the scrollbar in the git panel. // // Choices: always, auto, never, system // Default: inherits editor scrollbar settings // "show": null - } + }, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. // For example: typing `:wave:` gets replaced with `👋`. - "auto_replace_emoji_shortcode": true + "auto_replace_emoji_shortcode": true, }, "notification_panel": { // Whether to show the notification panel button in the status bar. @@ -889,9 +895,11 @@ // Where to dock the notification panel. Can be 'left' or 'right'. "dock": "right", // Default width of the notification panel. - "default_width": 380 + "default_width": 380, }, "agent": { + // Whether the inline assistant should use streaming tools, when available + "inline_assistant_use_streaming_tools": true, // Whether the agent is enabled. "enabled": true, // What completion mode to start new threads in, if available. Can be 'normal' or 'burn'. @@ -900,6 +908,8 @@ "button": true, // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'. "dock": "right", + // Where to dock the agents panel. Can be 'left' or 'right'. + "agents_panel_dock": "left", // Default width when the agent panel is docked to the left or right. "default_width": 640, // Default height when the agent panel is docked to the bottom. @@ -911,7 +921,7 @@ // The provider to use. "provider": "zed.dev", // The model to use. - "model": "claude-sonnet-4" + "model": "claude-sonnet-4", }, // Additional parameters for language model requests. When making a request to a model, parameters will be taken // from the last entry in this list that matches the model's provider and name. In each entry, both provider @@ -962,12 +972,14 @@ "now": true, "find_path": true, "read_file": true, + "restore_file_from_disk": true, + "save_file": true, "open": true, "grep": true, "terminal": true, "thinking": true, - "web_search": true - } + "web_search": true, + }, }, "ask": { "name": "Ask", @@ -984,14 +996,14 @@ "open": true, "grep": true, "thinking": true, - "web_search": true - } + "web_search": true, + }, }, "minimal": { "name": "Minimal", "enable_all_context_servers": false, - "tools": {} - } + "tools": {}, + }, }, // Where to show notifications when the agent has either completed // its response, or else needs confirmation before it can run a @@ -1020,7 +1032,7 @@ // Minimum number of lines to display in the agent message editor. // // Default: 4 - "message_editor_min_lines": 4 + "message_editor_min_lines": 4, }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, @@ -1055,7 +1067,7 @@ // Whether or not to show the navigation history buttons. "show_nav_history_buttons": true, // Whether or not to show the tab bar buttons. - "show_tab_bar_buttons": true + "show_tab_bar_buttons": true, }, // Settings related to the editor's tabs "tabs": { @@ -1094,19 +1106,28 @@ // "errors" // 3. Mark files with errors and warnings: // "all" - "show_diagnostics": "off" + "show_diagnostics": "off", }, // Settings related to preview tabs. "preview_tabs": { // Whether preview tabs should be enabled. // Preview tabs allow you to open files in preview mode, where they close automatically - // when you switch to another file unless you explicitly pin them. + // when you open another preview tab. // This is useful for quickly viewing files without cluttering your workspace. "enabled": true, + // Whether to open tabs in preview mode when opened from the project panel with a single click. + "enable_preview_from_project_panel": true, // Whether to open tabs in preview mode when selected from the file finder. "enable_preview_from_file_finder": false, - // Whether a preview tab gets replaced when code navigation is used to navigate away from the tab. - "enable_preview_from_code_navigation": false + // Whether to open tabs in preview mode when opened from a multibuffer. + "enable_preview_from_multibuffer": true, + // Whether to open tabs in preview mode when code navigation is used to open a multibuffer. + "enable_preview_multibuffer_from_code_navigation": false, + // Whether to open tabs in preview mode when code navigation is used to open a single file. + "enable_preview_file_from_code_navigation": true, + // Whether to keep tabs in preview mode when code navigation is used to navigate away from them. + // If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. + "enable_keep_preview_on_code_navigation": false, }, // Settings related to the file finder. "file_finder": { @@ -1150,13 +1171,17 @@ // * "all": Use all gitignored files // * "indexed": Use only the files Zed had indexed // * "smart": Be smart and search for ignored when called from a gitignored worktree - "include_ignored": "smart" + "include_ignored": "smart", }, // Whether or not to remove any trailing whitespace from lines of a buffer // before saving it. "remove_trailing_whitespace_on_save": true, // Whether to start a new line with a comment when a previous line is a comment as well. "extend_comment_on_newline": true, + // Whether to continue markdown lists when pressing enter. + "extend_list_on_newline": true, + // Whether to indent list items when pressing tab after a list marker. + "indent_list_on_tab": true, // Removes any lines containing only whitespace at the end of the file and // ensures just one newline at the end. "ensure_final_newline_on_save": true, @@ -1221,7 +1246,7 @@ // Send debug info like crash reports. "diagnostics": true, // Send anonymized usage data like what languages you're using Zed with. - "metrics": true + "metrics": true, }, // Whether to disable all AI features in Zed. // @@ -1255,7 +1280,7 @@ "enabled": true, // Minimum time to wait before pulling diagnostics from the language server(s). // 0 turns the debounce off. - "debounce_ms": 50 + "debounce_ms": 50, }, // Settings for inline diagnostics "inline": { @@ -1273,8 +1298,8 @@ "min_column": 0, // The minimum severity of the diagnostics to show inline. // Inherits editor's diagnostics' max severity settings when `null`. - "max_severity": null - } + "max_severity": null, + }, }, // Files or globs of files that will be excluded by Zed entirely. They will be skipped during file // scans, file searches, and not be displayed in the project file tree. Takes precedence over `file_scan_inclusions`. @@ -1288,7 +1313,7 @@ "**/.DS_Store", "**/Thumbs.db", "**/.classpath", - "**/.settings" + "**/.settings", ], // Files or globs of files that will be included by Zed, even when ignored by git. This is useful // for files that are not tracked by git, but are still important to your project. Note that globs @@ -1300,6 +1325,14 @@ "hidden_files": ["**/.*"], // Git gutter behavior configuration. "git": { + // Global switch to enable or disable all git integration features. + // If set to true, disables all git integration features. + // If set to false, individual git integration features below will be independently enabled or disabled. + "disable_git": false, + // Whether to enable git status tracking. + "enable_status": true, + // Whether to enable git diff display. + "enable_diff": true, // Control whether the git gutter is shown. May take 2 values: // 1. Show the gutter // "git_gutter": "tracked_files" @@ -1323,14 +1356,14 @@ // Whether or not to display the git commit summary on the same line. "show_commit_summary": false, // The minimum column number to show the inline blame information at - "min_column": 0 + "min_column": 0, }, "blame": { - "show_avatar": true + "show_avatar": true, }, // Control which information is shown in the branch picker. "branch_picker": { - "show_author_name": true + "show_author_name": true, }, // How git hunks are displayed visually in the editor. // This setting can take two values: @@ -1342,7 +1375,7 @@ "hunk_style": "staged_hollow", // Should the name or path be displayed first in the git view. // "path_style": "file_name_first" or "file_path_first" - "path_style": "file_name_first" + "path_style": "file_name_first", }, // The list of custom Git hosting providers. "git_hosting_providers": [ @@ -1376,7 +1409,7 @@ "**/secrets.yml", "**/.zed/settings.json", // zed project settings "/**/zed/settings.json", // zed user settings - "/**/zed/keymap.json" + "/**/zed/keymap.json", ], // When to show edit predictions previews in buffer. // This setting takes two possible values: @@ -1394,15 +1427,16 @@ "copilot": { "enterprise_uri": null, "proxy": null, - "proxy_no_verify": null + "proxy_no_verify": null, }, "codestral": { - "model": null, - "max_tokens": null + "api_url": "https://codestral.mistral.ai", + "model": "codestral-latest", + "max_tokens": 150, }, // Whether edit predictions are enabled when editing text threads in the agent panel. // This setting has no effect if globally disabled. - "enabled_in_text_threads": true + "enabled_in_text_threads": true, }, // Settings specific to journaling "journal": { @@ -1412,7 +1446,7 @@ // May take 2 values: // 1. hour12 // 2. hour24 - "hour_format": "hour12" + "hour_format": "hour12", }, // Status bar-related settings. "status_bar": { @@ -1423,7 +1457,7 @@ // Whether to show the cursor position button in the status bar. "cursor_position_button": true, // Whether to show active line endings button in the status bar. - "line_endings_button": false + "line_endings_button": false, }, // Settings specific to the terminal "terminal": { @@ -1544,8 +1578,8 @@ // Preferred Conda manager to use when activating Conda environments. // Values: "auto", "conda", "mamba", "micromamba" // Default: "auto" - "conda_manager": "auto" - } + "conda_manager": "auto", + }, }, "toolbar": { // Whether to display the terminal title in its toolbar's breadcrumbs. @@ -1553,7 +1587,7 @@ // // The shell running in the terminal needs to be configured to emit the title. // Example: `echo -e "\e]2;New Title\007";` - "breadcrumbs": false + "breadcrumbs": false, }, // Scrollbar-related settings "scrollbar": { @@ -1570,7 +1604,7 @@ // "always" // 5. Never show the scrollbar: // "never" - "show": null + "show": null, }, // Set the terminal's font size. If this option is not included, // the terminal will default to matching the buffer's font size. @@ -1633,30 +1667,26 @@ // surrounding symbols or quotes [ "(?x)", - "# optionally starts with 0-2 opening prefix symbols", - "[({\\[<]{0,2}", - "# which may be followed by an opening quote", - "(?[\"'`])?", - "# `path` is the shortest sequence of any non-space character", - "(?(?[^ ]+?", - " # which may end with a line and optionally a column,", - " (?:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:][0-9]+)?\\))?", - "))", - "# which must be followed by a matching quote", - "(?()\\k)", - "# and optionally a single closing symbol", - "[)}\\]>]?", - "# if line/column matched, may be followed by a description", - "(?():[^ 0-9][^ ]*)?", - "# which may be followed by trailing punctuation", - "[.,:)}\\]>]*", - "# and always includes trailing whitespace or end of line", - "([ ]+|$)" - ] + "(?", + " (", + " # multi-char path: first char (not opening delimiter or space)", + " [^({\\[<\"'`\\ ]", + " # middle chars: non-space, and colon/paren only if not followed by digit/paren", + " ([^\\ :(]|[:(][^0-9()])*", + " # last char: not closing delimiter or colon", + " [^()}\\]>\"'`.,;:\\ ]", + " |", + " # single-char path: not delimiter, punctuation, or space", + " [^(){}\\[\\]<>\"'`.,;:\\ ]", + " )", + " # optional line/column suffix (included in path for PathWithPosition::parse_str)", + " (:+[0-9]+(:[0-9]+)?|:?\\([0-9]+([,:]?[0-9]+)?\\))?", + ")", + ], ], // Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. Specifying a // timeout of `0` will disable path hyperlinking in terminal. - "path_hyperlink_timeout_ms": 1 + "path_hyperlink_timeout_ms": 1, }, "code_actions_on_format": {}, // Settings related to running tasks. @@ -1672,7 +1702,7 @@ // * Zed task from history (e.g. one-off task was spawned before) // // Default: true - "prefer_lsp": true + "prefer_lsp": true, }, // An object whose keys are language names, and whose values // are arrays of filenames or extensions of files that should @@ -1687,9 +1717,14 @@ // } // "file_types": { - "JSONC": ["**/.zed/**/*.json", "**/zed/**/*.json", "**/Zed/**/*.json", "**/.vscode/**/*.json", "tsconfig*.json"], + "JSONC": [ + "**/.zed/*.json", + "**/.vscode/**/*.json", + "**/{zed,Zed}/{settings,keymap,tasks,debug}.json", + "tsconfig*.json", + ], "Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"], - "Shell Script": [".env.*"] + "Shell Script": [".env.*"], }, // Settings for which version of Node.js and NPM to use when installing // language servers and Copilot. @@ -1705,14 +1740,14 @@ // `path`, but not `npm_path`, Zed will assume that `npm` is located at // `${path}/../npm`. "path": null, - "npm_path": null + "npm_path": null, }, // The extensions that Zed should automatically install on startup. // // If you don't want any of these extensions, add this field to your settings // and change the value to `false`. "auto_install_extensions": { - "html": true + "html": true, }, // The capabilities granted to extensions. // @@ -1720,7 +1755,7 @@ "granted_extension_capabilities": [ { "kind": "process:exec", "command": "*", "args": ["**"] }, { "kind": "download_file", "host": "*", "path": ["**"] }, - { "kind": "npm:install", "package": "*" } + { "kind": "npm:install", "package": "*" }, ], // Controls how completions are processed for this language. "completions": { @@ -1771,7 +1806,7 @@ // 4. "replace_suffix" // Behaves like `"replace"` if the text after the cursor is a suffix of the completion, and like // `"insert"` otherwise. - "lsp_insert_mode": "replace_suffix" + "lsp_insert_mode": "replace_suffix", }, // Different settings for specific languages. "languages": { @@ -1779,113 +1814,116 @@ "language_servers": ["astro-language-server", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-astro"] - } + "plugins": ["prettier-plugin-astro"], + }, }, "Blade": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "C": { "format_on_save": "off", "use_on_type_format": false, "prettier": { - "allowed": false - } + "allowed": false, + }, }, "C++": { "format_on_save": "off", "use_on_type_format": false, "prettier": { - "allowed": false - } + "allowed": false, + }, + }, + "CSharp": { + "language_servers": ["roslyn", "!omnisharp", "..."], }, "CSS": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Dart": { - "tab_size": 2 + "tab_size": 2, }, "Diff": { "show_edit_predictions": false, "remove_trailing_whitespace_on_save": false, - "ensure_final_newline_on_save": false + "ensure_final_newline_on_save": false, }, "Elixir": { - "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, "Elm": { - "tab_size": 4 + "tab_size": 4, }, "Erlang": { - "language_servers": ["erlang-ls", "!elp", "..."] + "language_servers": ["erlang-ls", "!elp", "..."], }, "Git Commit": { "allow_rewrap": "anywhere", "soft_wrap": "editor_width", - "preferred_line_length": 72 + "preferred_line_length": 72, }, "Go": { "hard_tabs": true, "code_actions_on_format": { - "source.organizeImports": true + "source.organizeImports": true, }, - "debuggers": ["Delve"] + "debuggers": ["Delve"], }, "GraphQL": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "HEEX": { - "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."] + "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."], }, "HTML": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "HTML+ERB": { - "language_servers": ["herb", "!ruby-lsp", "..."] + "language_servers": ["herb", "!ruby-lsp", "..."], }, "Java": { "prettier": { "allowed": true, - "plugins": ["prettier-plugin-java"] - } + "plugins": ["prettier-plugin-java"], + }, }, "JavaScript": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JSON": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JSONC": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "JS+ERB": { - "language_servers": ["!ruby-lsp", "..."] + "language_servers": ["!ruby-lsp", "..."], }, "Kotlin": { - "language_servers": ["!kotlin-language-server", "kotlin-lsp", "..."] + "language_servers": ["!kotlin-language-server", "kotlin-lsp", "..."], }, "LaTeX": { "formatter": "language_server", "language_servers": ["texlab", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-latex"] - } + "plugins": ["prettier-plugin-latex"], + }, }, "Markdown": { "format_on_save": "off", @@ -1893,136 +1931,145 @@ "remove_trailing_whitespace_on_save": false, "allow_rewrap": "anywhere", "soft_wrap": "editor_width", + "completions": { + "words": "disabled", + }, "prettier": { - "allowed": true - } + "allowed": true, + }, }, "PHP": { "language_servers": ["phpactor", "!intelephense", "!phptools", "..."], "prettier": { "allowed": true, "plugins": ["@prettier/plugin-php"], - "parser": "php" - } + "parser": "php", + }, }, "Plain Text": { "allow_rewrap": "anywhere", - "soft_wrap": "editor_width" + "soft_wrap": "editor_width", + "completions": { + "words": "disabled", + }, + }, + "Proto": { + "language_servers": ["buf", "!protols", "!protobuf-language-server", "..."], }, "Python": { "code_actions_on_format": { - "source.organizeImports.ruff": true + "source.organizeImports.ruff": true, }, "formatter": { "language_server": { - "name": "ruff" - } + "name": "ruff", + }, }, "debuggers": ["Debugpy"], - "language_servers": ["basedpyright", "ruff", "!ty", "!pyrefly", "!pyright", "!pylsp", "..."] + "language_servers": ["basedpyright", "ruff", "!ty", "!pyrefly", "!pyright", "!pylsp", "..."], }, "Ruby": { - "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."] + "language_servers": ["solargraph", "!ruby-lsp", "!rubocop", "!sorbet", "!steep", "..."], }, "Rust": { - "debuggers": ["CodeLLDB"] + "debuggers": ["CodeLLDB"], }, "SCSS": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Starlark": { - "language_servers": ["starpls", "!buck2-lsp", "..."] + "language_servers": ["starpls", "!buck2-lsp", "..."], }, "Svelte": { "language_servers": ["svelte-language-server", "..."], "prettier": { "allowed": true, - "plugins": ["prettier-plugin-svelte"] - } + "plugins": ["prettier-plugin-svelte"], + }, }, "TSX": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "Twig": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "TypeScript": { "language_servers": ["!typescript-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "SystemVerilog": { "format_on_save": "off", "language_servers": ["!slang", "..."], - "use_on_type_format": false + "use_on_type_format": false, }, "Vue.js": { "language_servers": ["vue-language-server", "vtsls", "..."], "prettier": { - "allowed": true - } + "allowed": true, + }, }, "XML": { "prettier": { "allowed": true, - "plugins": ["@prettier/plugin-xml"] - } + "plugins": ["@prettier/plugin-xml"], + }, }, "YAML": { "prettier": { - "allowed": true - } + "allowed": true, + }, }, "YAML+ERB": { - "language_servers": ["!ruby-lsp", "..."] + "language_servers": ["!ruby-lsp", "..."], }, "Zig": { - "language_servers": ["zls", "..."] - } + "language_servers": ["zls", "..."], + }, }, // Different settings for specific language models. "language_models": { "anthropic": { - "api_url": "https://api.anthropic.com" + "api_url": "https://api.anthropic.com", }, "bedrock": {}, "google": { - "api_url": "https://generativelanguage.googleapis.com" + "api_url": "https://generativelanguage.googleapis.com", }, "ollama": { - "api_url": "http://localhost:11434" + "api_url": "http://localhost:11434", }, "openai": { - "api_url": "https://api.openai.com/v1" + "api_url": "https://api.openai.com/v1", }, "openai_compatible": {}, "open_router": { - "api_url": "https://openrouter.ai/api/v1" + "api_url": "https://openrouter.ai/api/v1", }, "lmstudio": { - "api_url": "http://localhost:1234/api/v0" + "api_url": "http://localhost:1234/api/v0", }, "deepseek": { - "api_url": "https://api.deepseek.com/v1" + "api_url": "https://api.deepseek.com/v1", }, "mistral": { - "api_url": "https://api.mistral.ai/v1" + "api_url": "https://api.mistral.ai/v1", }, "vercel": { - "api_url": "https://api.v0.dev/v1" + "api_url": "https://api.v0.dev/v1", }, "x_ai": { - "api_url": "https://api.x.ai/v1" + "api_url": "https://api.x.ai/v1", }, - "zed.dev": {} + "zed.dev": {}, }, "session": { // Whether or not to restore unsaved buffers on restart. @@ -2031,7 +2078,13 @@ // dirty files when closing the application. // // Default: true - "restore_unsaved_buffers": true + "restore_unsaved_buffers": true, + // Whether or not to skip worktree trust checks. + // When trusted, project settings are synchronized automatically, + // language and MCP servers are downloaded and started automatically. + // + // Default: false + "trust_all_worktrees": false, }, // Zed's Prettier integration settings. // Allows to enable/disable formatting with Prettier @@ -2049,11 +2102,11 @@ // "singleQuote": true // Forces Prettier integration to use a specific parser name when formatting files with the language // when set to a non-empty string. - "parser": "" + "parser": "", }, // Settings for auto-closing of JSX tags. "jsx_tag_auto_close": { - "enabled": true + "enabled": true, }, // LSP Specific settings. "lsp": { @@ -2074,19 +2127,19 @@ // Specify the DAP name as a key here. "CodeLLDB": { "env": { - "RUST_LOG": "info" - } - } + "RUST_LOG": "info", + }, + }, }, // Common language server settings. "global_lsp_settings": { // Whether to show the LSP servers button in the status bar. - "button": true + "button": true, }, // Jupyter settings "jupyter": { "enabled": true, - "kernel_selections": {} + "kernel_selections": {}, // Specify the language name as the key and the kernel name as the value. // "kernel_selections": { // "python": "conda-base" @@ -2100,7 +2153,7 @@ "max_columns": 128, // Maximum number of lines to keep in REPL's scrollback buffer. // Clamped with [4, 256] range. - "max_lines": 32 + "max_lines": 32, }, // Vim settings "vim": { @@ -2114,7 +2167,14 @@ // Specify the mode as the key and the shape as the value. // The mode can be one of the following: "normal", "replace", "insert", "visual". // The shape can be one of the following: "block", "bar", "underline", "hollow". - "cursor_shape": {} + "cursor_shape": {}, + }, + // Which-key popup settings + "which_key": { + // Whether to show the which-key popup when holding down key combinations. + "enabled": false, + // Delay in milliseconds before showing the which-key popup. + "delay_ms": 1000, }, // The server to connect to. If the environment variable // ZED_SERVER_URL is set, it will override this setting. @@ -2147,9 +2207,9 @@ "windows": { "languages": { "PHP": { - "language_servers": ["intelephense", "!phpactor", "!phptools", "..."] - } - } + "language_servers": ["intelephense", "!phpactor", "!phptools", "..."], + }, + }, }, // Whether to show full labels in line indicator or short ones // @@ -2208,7 +2268,7 @@ "dock": "bottom", "log_dap_communications": true, "format_dap_log_messages": true, - "button": true + "button": true, }, // Configures any number of settings profiles that are temporarily applied on // top of your existing user settings when selected from @@ -2235,5 +2295,5 @@ // Useful for filtering out noisy logs or enabling more verbose logging. // // Example: {"log": {"client": "warn"}} - "log": {} + "log": {}, } diff --git a/assets/settings/initial_debug_tasks.json b/assets/settings/initial_debug_tasks.json index af4512bd51aa82d57ce62e605b45ee61e8f98030..851289392a65aecfca17e00d4c123823ac9e21cb 100644 --- a/assets/settings/initial_debug_tasks.json +++ b/assets/settings/initial_debug_tasks.json @@ -8,7 +8,7 @@ "adapter": "Debugpy", "program": "$ZED_FILE", "request": "launch", - "cwd": "$ZED_WORKTREE_ROOT" + "cwd": "$ZED_WORKTREE_ROOT", }, { "label": "Debug active JavaScript file", @@ -16,7 +16,7 @@ "program": "$ZED_FILE", "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", - "type": "pwa-node" + "type": "pwa-node", }, { "label": "JavaScript debug terminal", @@ -24,6 +24,6 @@ "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", "console": "integratedTerminal", - "type": "pwa-node" - } + "type": "pwa-node", + }, ] diff --git a/assets/settings/initial_server_settings.json b/assets/settings/initial_server_settings.json index d6ec33e60128380378610a273a1bbdff1ecdbaa8..29aa569b105157df7ec48164e2066fdac72c7b41 100644 --- a/assets/settings/initial_server_settings.json +++ b/assets/settings/initial_server_settings.json @@ -3,5 +3,5 @@ // For a full list of overridable settings, and general information on settings, // see the documentation: https://zed.dev/docs/configuring-zed#settings-files { - "lsp": {} + "lsp": {}, } diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index a79e98063237ca297a89b0d151bd48149061b7bb..5bedafbd3a1e75a755598e37cd673742e146fdcc 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -47,8 +47,8 @@ // Whether to show the task line in the output of the spawned task, defaults to `true`. "show_summary": true, // Whether to show the command line in the output of the spawned task, defaults to `true`. - "show_command": true + "show_command": true, // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] - } + }, ] diff --git a/assets/settings/initial_user_settings.json b/assets/settings/initial_user_settings.json index 5ac2063bdb481e057a2d124c1e72f998390b066b..8b573854895a03243803a71a91a35af647f45ca2 100644 --- a/assets/settings/initial_user_settings.json +++ b/assets/settings/initial_user_settings.json @@ -12,6 +12,6 @@ "theme": { "mode": "system", "light": "One Light", - "dark": "One Dark" - } + "dark": "One Dark", + }, } diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index 7c84c603bda7fd7590067ec9f566f3582ba6aefd..e2b7c3c91fca46ab0e4064719bea5c8793faaccc 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -45,6 +45,7 @@ "tab.inactive_background": "#1f2127ff", "tab.active_background": "#0d1016ff", "search.match_background": "#5ac2fe66", + "search.active_match_background": "#ea570166", "panel.background": "#1f2127ff", "panel.focused_border": "#5ac1feff", "pane.focused_border": null, @@ -436,6 +437,7 @@ "tab.inactive_background": "#ececedff", "tab.active_background": "#fcfcfcff", "search.match_background": "#3b9ee566", + "search.active_match_background": "#f88b3666", "panel.background": "#ececedff", "panel.focused_border": "#3b9ee5ff", "pane.focused_border": null, @@ -827,6 +829,7 @@ "tab.inactive_background": "#353944ff", "tab.active_background": "#242835ff", "search.match_background": "#73cffe66", + "search.active_match_background": "#fd722b66", "panel.background": "#353944ff", "panel.focused_border": null, "pane.focused_border": null, diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index a0f0a3ad637a4d212c8bf38f95f2e8424919d6bf..16ae188712f7a800ab4fb8a81a2d24cac99da56b 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -46,6 +46,7 @@ "tab.inactive_background": "#3a3735ff", "tab.active_background": "#282828ff", "search.match_background": "#83a59866", + "search.active_match_background": "#c09f3f66", "panel.background": "#3a3735ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, @@ -70,33 +71,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#282828ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#282828ff", + "terminal.dim_foreground": "#766b5dff", "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -452,6 +453,7 @@ "tab.inactive_background": "#393634ff", "tab.active_background": "#1d2021ff", "search.match_background": "#83a59866", + "search.active_match_background": "#c9653666", "panel.background": "#393634ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, @@ -476,33 +478,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#1d2021ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#1d2021ff", - "terminal.ansi.black": "#1d2021ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -858,6 +860,7 @@ "tab.inactive_background": "#3b3735ff", "tab.active_background": "#32302fff", "search.match_background": "#83a59866", + "search.active_match_background": "#aea85166", "panel.background": "#3b3735ff", "panel.focused_border": null, "pane.focused_border": null, @@ -882,33 +885,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#32302fff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#32302fff", - "terminal.ansi.black": "#32302fff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -1264,6 +1267,7 @@ "tab.inactive_background": "#ecddb4ff", "tab.active_background": "#fbf1c7ff", "search.match_background": "#0b667866", + "search.active_match_background": "#ba2d1166", "panel.background": "#ecddb4ff", "panel.focused_border": null, "pane.focused_border": null, @@ -1291,30 +1295,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#fbf1c7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#0b6678ff", - "terminal.ansi.dim_black": "#5f5650ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -1670,6 +1674,7 @@ "tab.inactive_background": "#ecddb5ff", "tab.active_background": "#f9f5d7ff", "search.match_background": "#0b667866", + "search.active_match_background": "#dc351466", "panel.background": "#ecddb5ff", "panel.focused_border": null, "pane.focused_border": null, @@ -1697,30 +1702,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f9f5d7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f9f5d7ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f9f5d7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -2076,6 +2081,7 @@ "tab.inactive_background": "#ecdcb3ff", "tab.active_background": "#f2e5bcff", "search.match_background": "#0b667866", + "search.active_match_background": "#d7331466", "panel.background": "#ecdcb3ff", "panel.focused_border": null, "pane.focused_border": null, @@ -2103,30 +2109,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f2e5bcff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f2e5bcff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f2e5bcff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index d9d7a37e996053d6f7c6cb28ec7f0d3f92e3b394..13f94991ad44fc997144a3d44527dcbce5231504 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -45,6 +45,7 @@ "tab.inactive_background": "#2f343eff", "tab.active_background": "#282c33ff", "search.match_background": "#74ade866", + "search.active_match_background": "#e8af7466", "panel.background": "#2f343eff", "panel.focused_border": null, "pane.focused_border": null, @@ -67,34 +68,34 @@ "editor.active_wrap_guide": "#c8ccd41a", "editor.document_highlight.read_background": "#74ade81a", "editor.document_highlight.write_background": "#555a6366", - "terminal.background": "#282c33ff", - "terminal.foreground": "#dce0e5ff", + "terminal.background": "#282c34ff", + "terminal.foreground": "#abb2bfff", "terminal.bright_foreground": "#dce0e5ff", - "terminal.dim_foreground": "#282c33ff", - "terminal.ansi.black": "#282c33ff", - "terminal.ansi.bright_black": "#525561ff", - "terminal.ansi.dim_black": "#dce0e5ff", - "terminal.ansi.red": "#d07277ff", - "terminal.ansi.bright_red": "#673a3cff", - "terminal.ansi.dim_red": "#eab7b9ff", - "terminal.ansi.green": "#a1c181ff", - "terminal.ansi.bright_green": "#4d6140ff", - "terminal.ansi.dim_green": "#d1e0bfff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#e5c07bff", - "terminal.ansi.dim_yellow": "#f1dfc1ff", - "terminal.ansi.blue": "#74ade8ff", - "terminal.ansi.bright_blue": "#385378ff", - "terminal.ansi.dim_blue": "#bed5f4ff", - "terminal.ansi.magenta": "#b477cfff", - "terminal.ansi.bright_magenta": "#d6b4e4ff", - "terminal.ansi.dim_magenta": "#612a79ff", - "terminal.ansi.cyan": "#6eb4bfff", - "terminal.ansi.bright_cyan": "#3a565bff", - "terminal.ansi.dim_cyan": "#b9d9dfff", - "terminal.ansi.white": "#dce0e5ff", + "terminal.dim_foreground": "#636d83ff", + "terminal.ansi.black": "#282c34ff", + "terminal.ansi.bright_black": "#636d83ff", + "terminal.ansi.dim_black": "#3b3f4aff", + "terminal.ansi.red": "#e06c75ff", + "terminal.ansi.bright_red": "#EA858Bff", + "terminal.ansi.dim_red": "#a7545aff", + "terminal.ansi.green": "#98c379ff", + "terminal.ansi.bright_green": "#AAD581ff", + "terminal.ansi.dim_green": "#6d8f59ff", + "terminal.ansi.yellow": "#e5c07bff", + "terminal.ansi.bright_yellow": "#FFD885ff", + "terminal.ansi.dim_yellow": "#b8985bff", + "terminal.ansi.blue": "#61afefff", + "terminal.ansi.bright_blue": "#85C1FFff", + "terminal.ansi.dim_blue": "#457cadff", + "terminal.ansi.magenta": "#c678ddff", + "terminal.ansi.bright_magenta": "#D398EBff", + "terminal.ansi.dim_magenta": "#8d54a0ff", + "terminal.ansi.cyan": "#56b6c2ff", + "terminal.ansi.bright_cyan": "#6ED5DEff", + "terminal.ansi.dim_cyan": "#3c818aff", + "terminal.ansi.white": "#abb2bfff", "terminal.ansi.bright_white": "#fafafaff", - "terminal.ansi.dim_white": "#575d65ff", + "terminal.ansi.dim_white": "#8f969bff", "link_text.hover": "#74ade8ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", @@ -448,6 +449,7 @@ "tab.inactive_background": "#ebebecff", "tab.active_background": "#fafafaff", "search.match_background": "#5c79e266", + "search.active_match_background": "#d0a92366", "panel.background": "#ebebecff", "panel.focused_border": null, "pane.focused_border": null, @@ -471,33 +473,33 @@ "editor.document_highlight.read_background": "#5c78e225", "editor.document_highlight.write_background": "#a3a3a466", "terminal.background": "#fafafaff", - "terminal.foreground": "#242529ff", - "terminal.bright_foreground": "#242529ff", - "terminal.dim_foreground": "#fafafaff", - "terminal.ansi.black": "#242529ff", - "terminal.ansi.bright_black": "#747579ff", - "terminal.ansi.dim_black": "#97979aff", - "terminal.ansi.red": "#d36151ff", - "terminal.ansi.bright_red": "#f0b0a4ff", - "terminal.ansi.dim_red": "#6f312aff", - "terminal.ansi.green": "#669f59ff", - "terminal.ansi.bright_green": "#b2cfa9ff", - "terminal.ansi.dim_green": "#354d2eff", - "terminal.ansi.yellow": "#dec184ff", - "terminal.ansi.bright_yellow": "#826221ff", - "terminal.ansi.dim_yellow": "#786441ff", - "terminal.ansi.blue": "#5c78e2ff", - "terminal.ansi.bright_blue": "#b5baf2ff", - "terminal.ansi.dim_blue": "#2d3d75ff", - "terminal.ansi.magenta": "#984ea5ff", - "terminal.ansi.bright_magenta": "#cea6d3ff", - "terminal.ansi.dim_magenta": "#4b2a50ff", - "terminal.ansi.cyan": "#3a82b7ff", - "terminal.ansi.bright_cyan": "#a3bedaff", - "terminal.ansi.dim_cyan": "#254058ff", - "terminal.ansi.white": "#fafafaff", + "terminal.foreground": "#2a2c33ff", + "terminal.bright_foreground": "#2a2c33ff", + "terminal.dim_foreground": "#bbbbbbff", + "terminal.ansi.black": "#000000ff", + "terminal.ansi.bright_black": "#000000ff", + "terminal.ansi.dim_black": "#555555ff", + "terminal.ansi.red": "#de3e35ff", + "terminal.ansi.bright_red": "#de3e35ff", + "terminal.ansi.dim_red": "#9c2b26ff", + "terminal.ansi.green": "#3f953aff", + "terminal.ansi.bright_green": "#3f953aff", + "terminal.ansi.dim_green": "#2b6927ff", + "terminal.ansi.yellow": "#d2b67cff", + "terminal.ansi.bright_yellow": "#d2b67cff", + "terminal.ansi.dim_yellow": "#a48c5aff", + "terminal.ansi.blue": "#2f5af3ff", + "terminal.ansi.bright_blue": "#2f5af3ff", + "terminal.ansi.dim_blue": "#2140abff", + "terminal.ansi.magenta": "#950095ff", + "terminal.ansi.bright_magenta": "#a00095ff", + "terminal.ansi.dim_magenta": "#6a006aff", + "terminal.ansi.cyan": "#3f953aff", + "terminal.ansi.bright_cyan": "#3f953aff", + "terminal.ansi.dim_cyan": "#2b6927ff", + "terminal.ansi.white": "#bbbbbbff", "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#aaaaaaff", + "terminal.ansi.dim_white": "#888888ff", "link_text.hover": "#5c78e2ff", "version_control.added": "#27a657ff", "version_control.modified": "#d3b020ff", diff --git a/clippy.toml b/clippy.toml index 0ce7a6cd68d4e8210788eb7a67aa06c742cc8274..9dd246074a06c4db7b66eff7a83ef68e3612c378 100644 --- a/clippy.toml +++ b/clippy.toml @@ -14,6 +14,7 @@ disallowed-methods = [ { path = "std::process::Command::stderr", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stderr" }, { path = "serde_json::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892. Use `serde_json::from_slice` instead." }, { path = "serde_json_lenient::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892, Use `serde_json_lenient::from_slice` instead." }, + { path = "cocoa::foundation::NSString::alloc", reason = "NSString must be autoreleased to avoid memory leaks. Use `ns_string()` helper instead." }, ] disallowed-types = [ # { path = "std::collections::HashMap", replacement = "collections::HashMap" }, diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 8ef6f1a52c8b207658d59a1e6b877964df9e42ce..70f2e4d259f1611fb42ebc0b064d278c8b3b9c4d 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -46,6 +46,7 @@ url.workspace = true util.workspace = true uuid.workspace = true watch.workspace = true +urlencoding.workspace = true [dev-dependencies] env_logger.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index a42eaa491f7f98e9965cd3aba801690ed996a39a..80b69b7e010f22fb311d1924ed4d6946ef6a0484 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -11,6 +11,7 @@ use language::language_settings::FormatOnSave; pub use mention::*; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use serde::{Deserialize, Serialize}; +use serde_json::to_string_pretty; use settings::Settings as _; use task::{Shell, ShellBuilder}; pub use terminal::*; @@ -43,6 +44,7 @@ pub struct UserMessage { pub content: ContentBlock, pub chunks: Vec, pub checkpoint: Option, + pub indented: bool, } #[derive(Debug)] @@ -73,6 +75,7 @@ impl UserMessage { #[derive(Debug, PartialEq)] pub struct AssistantMessage { pub chunks: Vec, + pub indented: bool, } impl AssistantMessage { @@ -123,6 +126,14 @@ pub enum AgentThreadEntry { } impl AgentThreadEntry { + pub fn is_indented(&self) -> bool { + match self { + Self::UserMessage(message) => message.indented, + Self::AssistantMessage(message) => message.indented, + Self::ToolCall(_) => false, + } + } + pub fn to_markdown(&self, cx: &App) -> String { match self { Self::UserMessage(message) => message.to_markdown(cx), @@ -182,6 +193,7 @@ pub struct ToolCall { pub locations: Vec, pub resolved_locations: Vec>, pub raw_input: Option, + pub raw_input_markdown: Option>, pub raw_output: Option, } @@ -201,17 +213,24 @@ impl ToolCall { }; let mut content = Vec::with_capacity(tool_call.content.len()); for item in tool_call.content { - content.push(ToolCallContent::from_acp( + if let Some(item) = ToolCallContent::from_acp( item, language_registry.clone(), path_style, terminals, cx, - )?); + )? { + content.push(item); + } } + let raw_input_markdown = tool_call + .raw_input + .as_ref() + .and_then(|input| markdown_for_raw_output(input, &language_registry, cx)); + let result = Self { - id: tool_call.id, + id: tool_call.tool_call_id, label: cx .new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)), kind: tool_call.kind, @@ -220,6 +239,7 @@ impl ToolCall { resolved_locations: Vec::default(), status, raw_input: tool_call.raw_input, + raw_input_markdown, raw_output: tool_call.raw_output, }; Ok(result) @@ -241,6 +261,7 @@ impl ToolCall { locations, raw_input, raw_output, + .. } = fields; if let Some(kind) = kind { @@ -262,21 +283,29 @@ impl ToolCall { } if let Some(content) = content { - let new_content_len = content.len(); + let mut new_content_len = content.len(); let mut content = content.into_iter(); // Reuse existing content if we can for (old, new) in self.content.iter_mut().zip(content.by_ref()) { - old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?; + let valid_content = + old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?; + if !valid_content { + new_content_len -= 1; + } } for new in content { - self.content.push(ToolCallContent::from_acp( + if let Some(new) = ToolCallContent::from_acp( new, language_registry.clone(), path_style, terminals, cx, - )?) + )? { + self.content.push(new); + } else { + new_content_len -= 1; + } } self.content.truncate(new_content_len); } @@ -286,6 +315,7 @@ impl ToolCall { } if let Some(raw_input) = raw_input { + self.raw_input_markdown = markdown_for_raw_output(&raw_input, &language_registry, cx); self.raw_input = Some(raw_input); } @@ -425,6 +455,7 @@ impl From for ToolCallStatus { acp::ToolCallStatus::InProgress => Self::InProgress, acp::ToolCallStatus::Completed => Self::Completed, acp::ToolCallStatus::Failed => Self::Failed, + _ => Self::Pending, } } } @@ -537,7 +568,7 @@ impl ContentBlock { .. }) => Self::resource_link_md(&uri, path_style), acp::ContentBlock::Image(image) => Self::image_md(&image), - acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(), + _ => String::new(), } } @@ -591,15 +622,17 @@ impl ToolCallContent { path_style: PathStyle, terminals: &HashMap>, cx: &mut App, - ) -> Result { + ) -> Result> { match content { - acp::ToolCallContent::Content { content } => Ok(Self::ContentBlock(ContentBlock::new( - content, - &language_registry, - path_style, - cx, - ))), - acp::ToolCallContent::Diff { diff } => Ok(Self::Diff(cx.new(|cx| { + acp::ToolCallContent::Content(acp::Content { content, .. }) => { + Ok(Some(Self::ContentBlock(ContentBlock::new( + content, + &language_registry, + path_style, + cx, + )))) + } + acp::ToolCallContent::Diff(diff) => Ok(Some(Self::Diff(cx.new(|cx| { Diff::finalized( diff.path.to_string_lossy().into_owned(), diff.old_text, @@ -607,12 +640,13 @@ impl ToolCallContent { language_registry, cx, ) - }))), - acp::ToolCallContent::Terminal { terminal_id } => terminals + })))), + acp::ToolCallContent::Terminal(acp::Terminal { terminal_id, .. }) => terminals .get(&terminal_id) .cloned() - .map(Self::Terminal) + .map(|terminal| Some(Self::Terminal(terminal))) .ok_or_else(|| anyhow::anyhow!("Terminal with id `{}` not found", terminal_id)), + _ => Ok(None), } } @@ -623,9 +657,9 @@ impl ToolCallContent { path_style: PathStyle, terminals: &HashMap>, cx: &mut App, - ) -> Result<()> { + ) -> Result { let needs_update = match (&self, &new) { - (Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => { + (Self::Diff(old_diff), acp::ToolCallContent::Diff(new_diff)) => { old_diff.read(cx).needs_update( new_diff.old_text.as_deref().unwrap_or(""), &new_diff.new_text, @@ -635,10 +669,14 @@ impl ToolCallContent { _ => true, }; - if needs_update { - *self = Self::from_acp(new, language_registry, path_style, terminals, cx)?; + if let Some(update) = Self::from_acp(new, language_registry, path_style, terminals, cx)? { + if needs_update { + *self = update; + } + Ok(true) + } else { + Ok(false) } - Ok(()) } pub fn to_markdown(&self, cx: &App) -> String { @@ -660,7 +698,7 @@ pub enum ToolCallUpdate { impl ToolCallUpdate { fn id(&self) -> &acp::ToolCallId { match self { - Self::UpdateFields(update) => &update.id, + Self::UpdateFields(update) => &update.tool_call_id, Self::UpdateDiff(diff) => &diff.id, Self::UpdateTerminal(terminal) => &terminal.id, } @@ -732,6 +770,7 @@ impl Plan { acp::PlanEntryStatus::Completed => { stats.completed += 1; } + _ => {} } } @@ -1154,6 +1193,7 @@ impl AcpThread { current_mode_id, .. }) => cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)), + _ => {} } Ok(()) } @@ -1163,6 +1203,16 @@ impl AcpThread { message_id: Option, chunk: acp::ContentBlock, cx: &mut Context, + ) { + self.push_user_content_block_with_indent(message_id, chunk, false, cx) + } + + pub fn push_user_content_block_with_indent( + &mut self, + message_id: Option, + chunk: acp::ContentBlock, + indented: bool, + cx: &mut Context, ) { let language_registry = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); @@ -1173,8 +1223,10 @@ impl AcpThread { id, content, chunks, + indented: existing_indented, .. }) = last_entry + && *existing_indented == indented { *id = message_id.or(id.take()); content.append(chunk.clone(), &language_registry, path_style, cx); @@ -1189,6 +1241,7 @@ impl AcpThread { content, chunks: vec![chunk], checkpoint: None, + indented, }), cx, ); @@ -1200,12 +1253,26 @@ impl AcpThread { chunk: acp::ContentBlock, is_thought: bool, cx: &mut Context, + ) { + self.push_assistant_content_block_with_indent(chunk, is_thought, false, cx) + } + + pub fn push_assistant_content_block_with_indent( + &mut self, + chunk: acp::ContentBlock, + is_thought: bool, + indented: bool, + cx: &mut Context, ) { let language_registry = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() - && let AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) = last_entry + && let AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks, + indented: existing_indented, + }) = last_entry + && *existing_indented == indented { let idx = entries_len - 1; cx.emit(AcpThreadEvent::EntryUpdated(idx)); @@ -1234,6 +1301,7 @@ impl AcpThread { self.push_entry( AgentThreadEntry::AssistantMessage(AssistantMessage { chunks: vec![chunk], + indented, }), cx, ); @@ -1287,11 +1355,7 @@ impl AcpThread { label: cx.new(|cx| Markdown::new("Tool call not found".into(), None, None, cx)), kind: acp::ToolKind::Fetch, content: vec![ToolCallContent::ContentBlock(ContentBlock::new( - acp::ContentBlock::Text(acp::TextContent { - text: "Tool call not found".to_string(), - annotations: None, - meta: None, - }), + "Tool call not found".into(), &languages, path_style, cx, @@ -1300,6 +1364,7 @@ impl AcpThread { locations: Vec::new(), resolved_locations: Vec::new(), raw_input: None, + raw_input_markdown: None, raw_output: None, }; self.push_entry(AgentThreadEntry::ToolCall(failed_tool_call), cx); @@ -1315,7 +1380,7 @@ impl AcpThread { let location_updated = update.fields.locations.is_some(); call.update_fields(update.fields, languages, path_style, &self.terminals, cx)?; if location_updated { - self.resolve_locations(update.id, cx); + self.resolve_locations(update.tool_call_id, cx); } } ToolCallUpdate::UpdateDiff(update) => { @@ -1353,9 +1418,9 @@ impl AcpThread { ) -> Result<(), acp::Error> { let language_registry = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); - let id = update.id.clone(); + let id = update.tool_call_id.clone(); - let agent = self.connection().telemetry_id(); + let agent_telemetry_id = self.connection().telemetry_id(); let session = self.session_id(); if let ToolCallStatus::Completed | ToolCallStatus::Failed = status { let status = if matches!(status, ToolCallStatus::Completed) { @@ -1363,7 +1428,12 @@ impl AcpThread { } else { "failed" }; - telemetry::event!("Agent Tool Call Completed", agent, session, status); + telemetry::event!( + "Agent Tool Call Completed", + agent_telemetry_id, + session, + status + ); } if let Some(ix) = self.index_for_tool_call(&id) { @@ -1518,16 +1588,16 @@ impl AcpThread { // some tools would (incorrectly) continue to auto-accept. if let Some(allow_once_option) = options.iter().find_map(|option| { if matches!(option.kind, acp::PermissionOptionKind::AllowOnce) { - Some(option.id.clone()) + Some(option.option_id.clone()) } else { None } }) { self.upsert_tool_call_inner(tool_call, ToolCallStatus::Pending, cx)?; return Ok(async { - acp::RequestPermissionOutcome::Selected { - option_id: allow_once_option, - } + acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new( + allow_once_option, + )) } .boxed()); } @@ -1543,7 +1613,9 @@ impl AcpThread { let fut = async { match rx.await { - Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, + Ok(option) => acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome::new(option), + ), Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled, } } @@ -1570,6 +1642,7 @@ impl AcpThread { acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => { ToolCallStatus::InProgress } + _ => ToolCallStatus::InProgress, }; let curr_status = mem::replace(&mut call.status, new_status); @@ -1648,14 +1721,7 @@ impl AcpThread { message: &str, cx: &mut Context, ) -> BoxFuture<'static, Result<()>> { - self.send( - vec![acp::ContentBlock::Text(acp::TextContent { - text: message.to_string(), - annotations: None, - meta: None, - })], - cx, - ) + self.send(vec![message.into()], cx) } pub fn send( @@ -1669,11 +1735,7 @@ impl AcpThread { self.project.read(cx).path_style(cx), cx, ); - let request = acp::PromptRequest { - prompt: message.clone(), - session_id: self.session_id.clone(), - meta: None, - }; + let request = acp::PromptRequest::new(self.session_id.clone(), message.clone()); let git_store = self.project.read(cx).git_store().clone(); let message_id = if self.connection.truncate(&self.session_id, cx).is_some() { @@ -1690,6 +1752,7 @@ impl AcpThread { content: block, chunks: message, checkpoint: None, + indented: false, }), cx, ); @@ -1765,7 +1828,7 @@ impl AcpThread { result, Ok(Ok(acp::PromptResponse { stop_reason: acp::StopReason::Cancelled, - meta: None, + .. })) ); @@ -1781,7 +1844,7 @@ impl AcpThread { // Handle refusal - distinguish between user prompt and tool call refusals if let Ok(Ok(acp::PromptResponse { stop_reason: acp::StopReason::Refusal, - meta: _, + .. })) = result { if let Some((user_msg_ix, _)) = this.last_user_message() { @@ -1930,37 +1993,42 @@ impl AcpThread { fn update_last_checkpoint(&mut self, cx: &mut Context) -> Task> { let git_store = self.project.read(cx).git_store().clone(); - let old_checkpoint = if let Some((_, message)) = self.last_user_message() { - if let Some(checkpoint) = message.checkpoint.as_ref() { - checkpoint.git_checkpoint.clone() - } else { - return Task::ready(Ok(())); - } - } else { + let Some((_, message)) = self.last_user_message() else { + return Task::ready(Ok(())); + }; + let Some(user_message_id) = message.id.clone() else { + return Task::ready(Ok(())); + }; + let Some(checkpoint) = message.checkpoint.as_ref() else { return Task::ready(Ok(())); }; + let old_checkpoint = checkpoint.git_checkpoint.clone(); let new_checkpoint = git_store.update(cx, |git, cx| git.checkpoint(cx)); cx.spawn(async move |this, cx| { - let new_checkpoint = new_checkpoint + let Some(new_checkpoint) = new_checkpoint .await .context("failed to get new checkpoint") - .log_err(); - if let Some(new_checkpoint) = new_checkpoint { - let equal = git_store - .update(cx, |git, cx| { - git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx) - })? - .await - .unwrap_or(true); - this.update(cx, |this, cx| { - let (ix, message) = this.last_user_message().context("no user message")?; - let checkpoint = message.checkpoint.as_mut().context("no checkpoint")?; - checkpoint.show = !equal; - cx.emit(AcpThreadEvent::EntryUpdated(ix)); - anyhow::Ok(()) - })??; - } + .log_err() + else { + return Ok(()); + }; + + let equal = git_store + .update(cx, |git, cx| { + git.compare_checkpoints(old_checkpoint.clone(), new_checkpoint, cx) + })? + .await + .unwrap_or(true); + + this.update(cx, |this, cx| { + if let Some((ix, message)) = this.user_message_mut(&user_message_id) { + if let Some(checkpoint) = message.checkpoint.as_mut() { + checkpoint.show = !equal; + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + } + })?; Ok(()) }) @@ -2017,7 +2085,7 @@ impl AcpThread { })?; Ok(project.open_buffer(path, cx)) }) - .map_err(|e| acp::Error::internal_error().with_data(e.to_string())) + .map_err(|e| acp::Error::internal_error().data(e.to_string())) .flatten()?; let buffer = load.await?; @@ -2050,7 +2118,7 @@ impl AcpThread { let start_position = Point::new(line, 0); if start_position > max_point { - return Err(acp::Error::invalid_params().with_data(format!( + return Err(acp::Error::invalid_params().data(format!( "Attempting to read beyond the end of the file, line {}:{}", max_point.row + 1, max_point.column @@ -2202,7 +2270,7 @@ impl AcpThread { let language_registry = project.read(cx).languages().clone(); let is_windows = project.read(cx).path_style(cx).is_windows(); - let terminal_id = acp::TerminalId(Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(Uuid::new_v4().to_string()); let terminal_task = cx.spawn({ let terminal_id = terminal_id.clone(); async move |_this, cx| { @@ -2360,8 +2428,10 @@ fn markdown_for_raw_output( ) })), value => Some(cx.new(|cx| { + let pretty_json = to_string_pretty(value).unwrap_or_else(|_| value.to_string()); + Markdown::new( - format!("```json\n{}\n```", value).into(), + format!("```json\n{}\n```", pretty_json).into(), Some(language_registry.clone()), None, cx, @@ -2412,7 +2482,7 @@ mod tests { .await .unwrap(); - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); // Send Output BEFORE Created - should be buffered by acp_thread thread.update(cx, |thread, cx| { @@ -2474,7 +2544,7 @@ mod tests { .await .unwrap(); - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); // Send Output BEFORE Created thread.update(cx, |thread, cx| { @@ -2492,11 +2562,7 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); @@ -2553,15 +2619,7 @@ mod tests { // Test creating a new user message thread.update(cx, |thread, cx| { - thread.push_user_content_block( - None, - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "Hello, ".to_string(), - meta: None, - }), - cx, - ); + thread.push_user_content_block(None, "Hello, ".into(), cx); }); thread.update(cx, |thread, cx| { @@ -2577,15 +2635,7 @@ mod tests { // Test appending to existing user message let message_1_id = UserMessageId::new(); thread.update(cx, |thread, cx| { - thread.push_user_content_block( - Some(message_1_id.clone()), - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "world!".to_string(), - meta: None, - }), - cx, - ); + thread.push_user_content_block(Some(message_1_id.clone()), "world!".into(), cx); }); thread.update(cx, |thread, cx| { @@ -2600,26 +2650,14 @@ mod tests { // Test creating new user message after assistant message thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "Assistant response".to_string(), - meta: None, - }), - false, - cx, - ); + thread.push_assistant_content_block("Assistant response".into(), false, cx); }); let message_2_id = UserMessageId::new(); thread.update(cx, |thread, cx| { thread.push_user_content_block( Some(message_2_id.clone()), - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "New user message".to_string(), - meta: None, - }), + "New user message".into(), cx, ); }); @@ -2647,27 +2685,22 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { - content: "Thinking ".into(), - meta: None, - }), + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "Thinking ".into(), + )), cx, ) .unwrap(); thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { - content: "hard!".into(), - meta: None, - }), + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "hard!".into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() }, @@ -2735,10 +2768,7 @@ mod tests { .unwrap() .await .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() }, @@ -2960,7 +2990,7 @@ mod tests { .await .unwrap_err(); - assert_eq!(err.code, acp::ErrorCode::RESOURCE_NOT_FOUND.code); + assert_eq!(err.code, acp::ErrorCode::ResourceNotFound); } #[gpui::test] @@ -2969,7 +2999,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let id = acp::ToolCallId("test".into()); + let id = acp::ToolCallId::new("test"); let connection = Rc::new(FakeAgentConnection::new().on_user_message({ let id = id.clone(); @@ -2979,26 +3009,17 @@ mod tests { thread .update(&mut cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: id.clone(), - title: "Label".into(), - kind: acp::ToolKind::Fetch, - status: acp::ToolCallStatus::InProgress, - content: vec![], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new(id.clone(), "Label") + .kind(acp::ToolKind::Fetch) + .status(acp::ToolCallStatus::InProgress), + ), cx, ) }) .unwrap() .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3040,14 +3061,10 @@ mod tests { thread .update(cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate { + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( id, - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - ..Default::default() - }, - meta: None, - }), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), cx, ) }) @@ -3079,33 +3096,21 @@ mod tests { thread .update(&mut cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("test".into()), - title: "Label".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/test/test.txt".into(), - old_text: None, - new_text: "foo".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("test", "Label") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff(acp::Diff::new( + "/test/test.txt", + "foo", + ))]), + ), cx, ) }) .unwrap() .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3158,18 +3163,14 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: content.text.to_uppercase().into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + content.text.to_uppercase().into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3325,34 +3326,22 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool1".into()), - title: "Test Tool".into(), - kind: acp::ToolKind::Fetch, - status: acp::ToolCallStatus::Completed, - content: vec![], - locations: vec![], - raw_input: Some(serde_json::json!({"query": "test"})), - raw_output: Some( - serde_json::json!({"result": "inappropriate content"}), - ), - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool1", "Test Tool") + .kind(acp::ToolKind::Fetch) + .status(acp::ToolCallStatus::Completed) + .raw_input(serde_json::json!({"query": "test"})) + .raw_output(serde_json::json!({"result": "inappropriate content"})), + ), cx, ) .unwrap(); })?; // Now return refusal because of the tool result - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } else { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } } .boxed_local() @@ -3380,16 +3369,7 @@ mod tests { }); // Send a user message - this will trigger tool call and then refusal - let send_task = thread.update(cx, |thread, cx| { - thread.send( - vec![acp::ContentBlock::Text(acp::TextContent { - text: "Hello".into(), - annotations: None, - meta: None, - })], - cx, - ) - }); + let send_task = thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx)); cx.background_executor.spawn(send_task).detach(); cx.run_until_parked(); @@ -3435,21 +3415,11 @@ mod tests { let refuse_next = refuse_next.clone(); move |_request, _thread, _cx| { if refuse_next.load(SeqCst) { - async move { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }) - } - .boxed_local() + async move { Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } + .boxed_local() } else { - async move { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) - } - .boxed_local() + async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } + .boxed_local() } } })); @@ -3506,10 +3476,7 @@ mod tests { let refuse_next = refuse_next.clone(); async move { if refuse_next.load(SeqCst) { - return Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }); + return Ok(acp::PromptResponse::new(acp::StopReason::Refusal)); } let acp::ContentBlock::Text(content) = &request.prompt[0] else { @@ -3518,18 +3485,14 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: content.text.to_uppercase().into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + content.text.to_uppercase().into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3654,8 +3617,8 @@ mod tests { } impl AgentConnection for FakeAgentConnection { - fn telemetry_id(&self) -> &'static str { - "fake" + fn telemetry_id(&self) -> SharedString { + "fake".into() } fn auth_methods(&self) -> &[acp::AuthMethod] { @@ -3668,13 +3631,12 @@ mod tests { _cwd: &Path, cx: &mut App, ) -> Task>> { - let session_id = acp::SessionId( + let session_id = acp::SessionId::new( rand::rng() .sample_iter(&distr::Alphanumeric) .take(7) .map(char::from) - .collect::() - .into(), + .collect::(), ); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { @@ -3684,12 +3646,12 @@ mod tests { project, action_log, session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }); @@ -3718,10 +3680,7 @@ mod tests { let thread = thread.clone(); cx.spawn(async move |cx| handler(params, thread, cx.clone()).await) } else { - Task::ready(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - })) + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) } } @@ -3776,17 +3735,13 @@ mod tests { .unwrap(); // Try to update a tool call that doesn't exist - let nonexistent_id = acp::ToolCallId("nonexistent-tool-call".into()); + let nonexistent_id = acp::ToolCallId::new("nonexistent-tool-call"); thread.update(cx, |thread, cx| { let result = thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate { - id: nonexistent_id.clone(), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - ..Default::default() - }, - meta: None, - }), + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + nonexistent_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), cx, ); @@ -3861,7 +3816,7 @@ mod tests { .unwrap(); // Create 2 terminals BEFORE the checkpoint that have completed running - let terminal_id_1 = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id_1 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let mock_terminal_1 = cx.new(|cx| { let builder = ::terminal::TerminalBuilder::new_display_only( ::terminal::terminal_settings::CursorShape::default(), @@ -3900,17 +3855,13 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id_1.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); }); - let terminal_id_2 = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id_2 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let mock_terminal_2 = cx.new(|cx| { let builder = ::terminal::TerminalBuilder::new_display_only( ::terminal::terminal_settings::CursorShape::default(), @@ -3949,11 +3900,7 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id_2.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); @@ -3973,7 +3920,7 @@ mod tests { // Create a terminal AFTER the checkpoint we'll restore to. // This simulates the AI agent starting a long-running terminal command. - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let mock_terminal = cx.new(|cx| { let builder = ::terminal::TerminalBuilder::new_display_only( ::terminal::terminal_settings::CursorShape::default(), @@ -4015,21 +3962,15 @@ mod tests { thread.update(cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("terminal-tool-1".into()), - title: "Running command".into(), - kind: acp::ToolKind::Execute, - status: acp::ToolCallStatus::InProgress, - content: vec![acp::ToolCallContent::Terminal { - terminal_id: terminal_id.clone(), - }], - locations: vec![], - raw_input: Some( - serde_json::json!({"command": "sleep 1000", "cd": "/test"}), - ), - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("terminal-tool-1", "Running command") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new( + terminal_id.clone(), + ))]) + .raw_input(serde_json::json!({"command": "sleep 1000", "cd": "/test"})), + ), cx, ) .unwrap(); @@ -4133,4 +4074,67 @@ mod tests { "Should have exactly 2 terminals (the completed ones from before checkpoint)" ); } + + /// Tests that update_last_checkpoint correctly updates the original message's checkpoint + /// even when a new user message is added while the async checkpoint comparison is in progress. + /// + /// This is a regression test for a bug where update_last_checkpoint would fail with + /// "no checkpoint" if a new user message (without a checkpoint) was added between when + /// update_last_checkpoint started and when its async closure ran. + #[gpui::test] + async fn test_update_last_checkpoint_with_new_message_added(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; + + let handler_done = Arc::new(AtomicBool::new(false)); + let handler_done_clone = handler_done.clone(); + let connection = Rc::new(FakeAgentConnection::new().on_user_message( + move |_, _thread, _cx| { + handler_done_clone.store(true, SeqCst); + async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }.boxed_local() + }, + )); + + let thread = cx + .update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx)) + .await + .unwrap(); + + let send_future = thread.update(cx, |thread, cx| thread.send_raw("First message", cx)); + let send_task = cx.background_executor.spawn(send_future); + + // Tick until handler completes, then a few more to let update_last_checkpoint start + while !handler_done.load(SeqCst) { + cx.executor().tick(); + } + for _ in 0..5 { + cx.executor().tick(); + } + + thread.update(cx, |thread, cx| { + thread.push_entry( + AgentThreadEntry::UserMessage(UserMessage { + id: Some(UserMessageId::new()), + content: ContentBlock::Empty, + chunks: vec!["Injected message (no checkpoint)".into()], + checkpoint: None, + indented: false, + }), + cx, + ); + }); + + cx.run_until_parked(); + let result = send_task.await; + + assert!( + result.is_ok(), + "send should succeed even when new message added during update_last_checkpoint: {:?}", + result.err() + ); + } } diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 80bec0ee9d351711bdf435cfe63eb99eb1e499e3..fa15a339f7db67a90144b645177f1146c97334b4 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -20,7 +20,7 @@ impl UserMessageId { } pub trait AgentConnection { - fn telemetry_id(&self) -> &'static str; + fn telemetry_id(&self) -> SharedString; fn new_thread( self: Rc, @@ -204,12 +204,21 @@ pub trait AgentModelSelector: 'static { } } +/// Icon for a model in the model selector. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentModelIcon { + /// A built-in icon from Zed's icon set. + Named(IconName), + /// Path to a custom SVG icon file. + Path(SharedString), +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentModelInfo { pub id: acp::ModelId, pub name: SharedString, pub description: Option, - pub icon: Option, + pub icon: Option, } impl From for AgentModelInfo { @@ -239,6 +248,10 @@ impl AgentModelList { AgentModelList::Grouped(groups) => groups.is_empty(), } } + + pub fn is_flat(&self) -> bool { + matches!(self, AgentModelList::Flat(_)) + } } #[cfg(feature = "test-support")] @@ -322,8 +335,8 @@ mod test_support { } impl AgentConnection for StubAgentConnection { - fn telemetry_id(&self) -> &'static str { - "stub" + fn telemetry_id(&self) -> SharedString { + "stub".into() } fn auth_methods(&self) -> &[acp::AuthMethod] { @@ -336,7 +349,7 @@ mod test_support { _cwd: &Path, cx: &mut gpui::App, ) -> Task>> { - let session_id = acp::SessionId(self.sessions.lock().len().to_string().into()); + let session_id = acp::SessionId::new(self.sessions.lock().len().to_string()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( @@ -345,12 +358,12 @@ mod test_support { project, action_log, session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }); @@ -389,10 +402,7 @@ mod test_support { response_tx.replace(tx); cx.spawn(async move |_| { let stop_reason = rx.await?; - Ok(acp::PromptResponse { - stop_reason, - meta: None, - }) + Ok(acp::PromptResponse::new(stop_reason)) }) } else { for update in self.next_prompt_updates.lock().drain(..) { @@ -400,7 +410,7 @@ mod test_support { let update = update.clone(); let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update - && let Some(options) = self.permission_requests.get(&tool_call.id) + && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id) { Some((tool_call.clone(), options.clone())) } else { @@ -429,10 +439,7 @@ mod test_support { cx.spawn(async move |_| { try_join_all(tasks).await?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }) } } diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 54bc7606faa14ca86e7728cd226b79db8189c4da..80e901a4f1e8e02dda7860694782a318eb1323d7 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -158,7 +158,7 @@ impl Diff { } pub fn has_revealed_range(&self, cx: &App) -> bool { - self.multibuffer().read(cx).excerpt_paths().next().is_some() + self.multibuffer().read(cx).paths().next().is_some() } pub fn needs_update(&self, old_text: &str, new_text: &str, cx: &App) -> bool { diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b78eac4903a259a1044892fb2c8233f7e973f025..3e2e53fb7fbdf581b45566bd747cfcbfc1c0a004 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -4,12 +4,14 @@ use file_icons::FileIcons; use prompt_store::{PromptId, UserPromptId}; use serde::{Deserialize, Serialize}; use std::{ + borrow::Cow, fmt, ops::RangeInclusive, path::{Path, PathBuf}, }; use ui::{App, IconName, SharedString}; use url::Url; +use urlencoding::decode; use util::paths::PathStyle; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -74,11 +76,13 @@ impl MentionUri { let path = url.path(); match url.scheme() { "file" => { - let path = if path_style.is_windows() { + let normalized = if path_style.is_windows() { path.trim_start_matches("/") } else { path }; + let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized)); + let path = decoded.as_ref(); if let Some(fragment) = url.fragment() { let line_range = parse_line_range(fragment)?; @@ -108,7 +112,7 @@ impl MentionUri { if let Some(thread_id) = path.strip_prefix("/agent/thread/") { let name = single_query_param(&url, "name")?.context("Missing thread name")?; Ok(Self::Thread { - id: acp::SessionId(thread_id.into()), + id: acp::SessionId::new(thread_id), name, }) } else if let Some(path) = path.strip_prefix("/agent/text-thread/") { @@ -406,6 +410,19 @@ mod tests { assert_eq!(parsed.to_uri().to_string(), selection_uri); } + #[test] + fn test_parse_file_uri_with_non_ascii() { + let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt"); + let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap(); + match &parsed { + MentionUri::File { abs_path } => { + assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt"))); + } + _ => panic!("Expected File variant"), + } + assert_eq!(parsed.to_uri().to_string(), file_uri); + } + #[test] fn test_parse_untitled_selection_uri() { let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10"); diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 8b08868616e19b0d1855558a057af8eebc314e4a..f70e044fbc1b380768dbcd807f1833f6fb5cd48b 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -75,11 +75,9 @@ impl Terminal { let exit_status = exit_status.map(portable_pty::ExitStatus::from); - acp::TerminalExitStatus { - exit_code: exit_status.as_ref().map(|e| e.exit_code()), - signal: exit_status.and_then(|e| e.signal().map(Into::into)), - meta: None, - } + acp::TerminalExitStatus::new() + .exit_code(exit_status.as_ref().map(|e| e.exit_code())) + .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))) }) .shared(), } @@ -103,25 +101,19 @@ impl Terminal { if let Some(output) = self.output.as_ref() { let exit_status = output.exit_status.map(portable_pty::ExitStatus::from); - acp::TerminalOutputResponse { - output: output.content.clone(), - truncated: output.original_content_len > output.content.len(), - exit_status: Some(acp::TerminalExitStatus { - exit_code: exit_status.as_ref().map(|e| e.exit_code()), - signal: exit_status.and_then(|e| e.signal().map(Into::into)), - meta: None, - }), - meta: None, - } + acp::TerminalOutputResponse::new( + output.content.clone(), + output.original_content_len > output.content.len(), + ) + .exit_status( + acp::TerminalExitStatus::new() + .exit_code(exit_status.as_ref().map(|e| e.exit_code())) + .signal(exit_status.and_then(|e| e.signal().map(ToOwned::to_owned))), + ) } else { let (current_content, original_len) = self.truncated_output(cx); - - acp::TerminalOutputResponse { - truncated: current_content.len() < original_len, - output: current_content, - exit_status: None, - meta: None, - } + let truncated = current_content.len() < original_len; + acp::TerminalOutputResponse::new(current_content, truncated) } } @@ -195,8 +187,10 @@ pub async fn create_terminal_entity( Default::default() }; - // Disables paging for `git` and hopefully other commands + // Disable pagers so agent/terminal commands don't hang behind interactive UIs env.insert("PAGER".into(), "".into()); + // Override user core.pager (e.g. delta) which Git prefers over PAGER + env.insert("GIT_PAGER".into(), "cat".into()); env.extend(env_vars); // Use remote shell or default system shell, as appropriate diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 0905effce38d1bfd4fa18e1d00169d6c7ef6c2d7..b0d30367da0634dc82f8db96fc099e268aa4790e 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -371,13 +371,13 @@ impl AcpTools { syntax: cx.theme().syntax().clone(), code_block_overflow_x_scroll: true, code_block: StyleRefinement { - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some( theme_settings.buffer_font.family.clone(), ), font_size: Some((base_size * 0.8).into()), ..Default::default() - }), + }, ..Default::default() }, ..Default::default() diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 35b8da1faf67e211cf2c57440d75e802361964ca..5af44dd3f5d2621c9633d413c2f52fc54e9fdf2a 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -6,7 +6,7 @@ use futures::{FutureExt, StreamExt, channel::mpsc}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, }; -use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint}; +use language::{Anchor, Buffer, BufferEvent, Point, ToPoint}; use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle}; use std::{cmp, ops::Range, sync::Arc}; use text::{Edit, Patch, Rope}; @@ -150,7 +150,7 @@ impl ActionLog { if buffer .read(cx) .file() - .is_some_and(|file| file.disk_state() == DiskState::Deleted) + .is_some_and(|file| file.disk_state().is_deleted()) { // If the buffer had been edited by a tool, but it got // deleted externally, we want to stop tracking it. @@ -162,7 +162,7 @@ impl ActionLog { if buffer .read(cx) .file() - .is_some_and(|file| file.disk_state() != DiskState::Deleted) + .is_some_and(|file| !file.disk_state().is_deleted()) { // If the buffer had been deleted by a tool, but it got // resurrected externally, we want to clear the edits we @@ -768,7 +768,7 @@ impl ActionLog { tracked.version != buffer.version && buffer .file() - .is_some_and(|file| file.disk_state() != DiskState::Deleted) + .is_some_and(|file| !file.disk_state().is_deleted()) }) .map(|(buffer, _)| buffer) } @@ -776,7 +776,7 @@ impl ActionLog { #[derive(Clone)] pub struct ActionLogTelemetry { - pub agent_telemetry_id: &'static str, + pub agent_telemetry_id: SharedString, pub session_id: Arc, } diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index cacbbd6e4e4423e2560fb963ef59daddce2309dc..667033a1bb33ea0372b8a9d8b0bfb00b23f59347 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -83,6 +83,7 @@ ctor.workspace = true db = { workspace = true, "features" = ["test-support"] } editor = { workspace = true, "features" = ["test-support"] } env_logger.workspace = true +eval_utils.workspace = true fs = { workspace = true, "features" = ["test-support"] } git = { workspace = true, "features" = ["test-support"] } gpui = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 404cd6549e5786b92c49379918346b83fcc0e0c1..612360fe887e11859635844c79ee5cf25515c2a1 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -5,12 +5,12 @@ mod legacy_thread; mod native_agent_server; pub mod outline; mod templates; -mod thread; -mod tools; - #[cfg(test)] mod tests; +mod thread; +mod tools; +use context_server::ContextServerId; pub use db::*; pub use history_store::*; pub use native_agent_server::NativeAgentServer; @@ -18,11 +18,11 @@ pub use templates::*; pub use thread::*; pub use tools::*; -use acp_thread::{AcpThread, AgentModelSelector}; +use acp_thread::{AcpThread, AgentModelSelector, UserMessageId}; use agent_client_protocol as acp; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; -use collections::{HashSet, IndexMap}; +use collections::{HashMap, HashSet, IndexMap}; use fs::Fs; use futures::channel::{mpsc, oneshot}; use futures::future::Shared; @@ -30,15 +30,15 @@ use futures::{StreamExt, future}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, }; -use language_model::{LanguageModel, LanguageModelProvider, LanguageModelRegistry}; +use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry}; use project::{Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{ - ProjectContext, PromptStore, RulesFileContext, UserRulesContext, WorktreeContext, + ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, + WorktreeContext, }; use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, update_settings_file}; use std::any::Any; -use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; @@ -51,18 +51,6 @@ pub struct ProjectSnapshot { pub timestamp: DateTime, } -const RULES_FILE_NAMES: [&str; 9] = [ - ".rules", - ".cursorrules", - ".windsurfrules", - ".clinerules", - ".github/copilot-instructions.md", - "CLAUDE.md", - "AGENT.md", - "AGENTS.md", - "GEMINI.md", -]; - pub struct RulesLoadingError { pub message: SharedString, } @@ -105,7 +93,7 @@ impl LanguageModels { fn refresh_list(&mut self, cx: &App) { let providers = LanguageModelRegistry::global(cx) .read(cx) - .providers() + .visible_providers() .into_iter() .filter(|provider| provider.is_authenticated(cx)) .collect::>(); @@ -165,18 +153,21 @@ impl LanguageModels { id: Self::model_id(model), name: model.name().0, description: None, - icon: Some(provider.icon()), + icon: Some(match provider.icon() { + IconOrSvg::Svg(path) => acp_thread::AgentModelIcon::Path(path), + IconOrSvg::Icon(name) => acp_thread::AgentModelIcon::Named(name), + }), } } fn model_id(model: &Arc) -> acp::ModelId { - acp::ModelId(format!("{}/{}", model.provider_id().0, model.id().0).into()) + acp::ModelId::new(format!("{}/{}", model.provider_id().0, model.id().0)) } fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> { let authenticate_all_providers = LanguageModelRegistry::global(cx) .read(cx) - .providers() + .visible_providers() .iter() .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) .collect::>(); @@ -263,12 +254,24 @@ impl NativeAgent { .await; cx.new(|cx| { + let context_server_store = project.read(cx).context_server_store(); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + let mut subscriptions = vec![ cx.subscribe(&project, Self::handle_project_event), cx.subscribe( &LanguageModelRegistry::global(cx), Self::handle_models_updated_event, ), + cx.subscribe( + &context_server_store, + Self::handle_context_server_store_updated, + ), + cx.subscribe( + &context_server_registry, + Self::handle_context_server_registry_event, + ), ]; if let Some(prompt_store) = prompt_store.as_ref() { subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) @@ -277,16 +280,14 @@ impl NativeAgent { let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = watch::channel(()); Self { - sessions: HashMap::new(), + sessions: HashMap::default(), history, project_context: cx.new(|_| project_context), project_context_needs_refresh: project_context_needs_refresh_tx, _maintain_project_context: cx.spawn(async move |this, cx| { Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await }), - context_server_registry: cx.new(|cx| { - ContextServerRegistry::new(project.read(cx).context_server_store(), cx) - }), + context_server_registry, templates, models: LanguageModels::new(cx), project, @@ -355,6 +356,9 @@ impl NativeAgent { pending_save: Task::ready(()), }, ); + + self.update_available_commands(cx); + acp_thread } @@ -425,10 +429,7 @@ impl NativeAgent { .into_iter() .flat_map(|(contents, prompt_metadata)| match contents { Ok(contents) => Some(UserRulesContext { - uuid: match prompt_metadata.id { - prompt_store::PromptId::User { uuid } => uuid, - prompt_store::PromptId::EditWorkflow => return None, - }, + uuid: prompt_metadata.id.as_user()?, title: prompt_metadata.title.map(|title| title.to_string()), contents, }), @@ -622,6 +623,99 @@ impl NativeAgent { } } + fn handle_context_server_store_updated( + &mut self, + _store: Entity, + _event: &project::context_server_store::Event, + cx: &mut Context, + ) { + self.update_available_commands(cx); + } + + fn handle_context_server_registry_event( + &mut self, + _registry: Entity, + event: &ContextServerRegistryEvent, + cx: &mut Context, + ) { + match event { + ContextServerRegistryEvent::ToolsChanged => {} + ContextServerRegistryEvent::PromptsChanged => { + self.update_available_commands(cx); + } + } + } + + fn update_available_commands(&self, cx: &mut Context) { + let available_commands = self.build_available_commands(cx); + for session in self.sessions.values() { + if let Some(acp_thread) = session.acp_thread.upgrade() { + acp_thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::AvailableCommandsUpdate( + acp::AvailableCommandsUpdate::new(available_commands.clone()), + ), + cx, + ) + .log_err(); + }); + } + } + } + + fn build_available_commands(&self, cx: &App) -> Vec { + let registry = self.context_server_registry.read(cx); + + let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default(); + for context_server_prompt in registry.prompts() { + *prompt_name_counts + .entry(context_server_prompt.prompt.name.as_str()) + .or_insert(0) += 1; + } + + registry + .prompts() + .flat_map(|context_server_prompt| { + let prompt = &context_server_prompt.prompt; + + let should_prefix = prompt_name_counts + .get(prompt.name.as_str()) + .copied() + .unwrap_or(0) + > 1; + + let name = if should_prefix { + format!("{}.{}", context_server_prompt.server_id, prompt.name) + } else { + prompt.name.clone() + }; + + let mut command = acp::AvailableCommand::new( + name, + prompt.description.clone().unwrap_or_default(), + ); + + match prompt.arguments.as_deref() { + Some([arg]) => { + let hint = format!("<{}>", arg.name); + + command = command.input(acp::AvailableCommandInput::Unstructured( + acp::UnstructuredCommandInput::new(hint), + )); + } + Some([]) | None => {} + Some(_) => { + // skip >1 argument commands since we don't support them yet + return None; + } + } + + Some(command) + }) + .collect() + } + pub fn load_thread( &mut self, id: acp::SessionId, @@ -720,6 +814,102 @@ impl NativeAgent { history.update(cx, |history, cx| history.reload(cx)).ok(); }); } + + fn send_mcp_prompt( + &self, + message_id: UserMessageId, + session_id: agent_client_protocol::SessionId, + prompt_name: String, + server_id: ContextServerId, + arguments: HashMap, + original_content: Vec, + cx: &mut Context, + ) -> Task> { + let server_store = self.context_server_registry.read(cx).server_store().clone(); + let path_style = self.project.read(cx).path_style(cx); + + cx.spawn(async move |this, cx| { + let prompt = + crate::get_prompt(&server_store, &server_id, &prompt_name, arguments, cx).await?; + + let (acp_thread, thread) = this.update(cx, |this, _cx| { + let session = this + .sessions + .get(&session_id) + .context("Failed to get session")?; + anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) + })??; + + let mut last_is_user = true; + + thread.update(cx, |thread, cx| { + thread.push_acp_user_block( + message_id, + original_content.into_iter().skip(1), + path_style, + cx, + ); + })?; + + for message in prompt.messages { + let context_server::types::PromptMessage { role, content } = message; + let block = mcp_message_content_to_acp_content_block(content); + + match role { + context_server::types::Role::User => { + let id = acp_thread::UserMessageId::new(); + + acp_thread.update(cx, |acp_thread, cx| { + acp_thread.push_user_content_block_with_indent( + Some(id.clone()), + block.clone(), + true, + cx, + ); + anyhow::Ok(()) + })??; + + thread.update(cx, |thread, cx| { + thread.push_acp_user_block(id, [block], path_style, cx); + anyhow::Ok(()) + })??; + } + context_server::types::Role::Assistant => { + acp_thread.update(cx, |acp_thread, cx| { + acp_thread.push_assistant_content_block_with_indent( + block.clone(), + false, + true, + cx, + ); + anyhow::Ok(()) + })??; + + thread.update(cx, |thread, cx| { + thread.push_acp_agent_block(block, cx); + anyhow::Ok(()) + })??; + } + } + + last_is_user = role == context_server::types::Role::User; + } + + let response_stream = thread.update(cx, |thread, cx| { + if last_is_user { + thread.send_existing(cx) + } else { + // Resume if MCP prompt did not end with a user message + thread.resume(cx) + } + })??; + + cx.update(|cx| { + NativeAgentConnection::handle_thread_events(response_stream, acp_thread, cx) + })? + .await + }) + } } /// Wrapper struct that implements the AgentConnection trait @@ -789,28 +979,12 @@ impl NativeAgentConnection { } ThreadEvent::AgentText(text) => { acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - false, - cx, - ) + thread.push_assistant_content_block(text.into(), false, cx) })?; } ThreadEvent::AgentThinking(text) => { acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - true, - cx, - ) + thread.push_assistant_content_block(text.into(), true, cx) })?; } ThreadEvent::ToolCallAuthorization(ToolCallAuthorization { @@ -824,8 +998,9 @@ impl NativeAgentConnection { ) })??; cx.background_spawn(async move { - if let acp::RequestPermissionOutcome::Selected { option_id } = - outcome_task.await + if let acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome { option_id, .. }, + ) = outcome_task.await { response .send(option_id) @@ -852,10 +1027,7 @@ impl NativeAgentConnection { } ThreadEvent::Stop(stop_reason) => { log::debug!("Assistant message complete: {:?}", stop_reason); - return Ok(acp::PromptResponse { - stop_reason, - meta: None, - }); + return Ok(acp::PromptResponse::new(stop_reason)); } } } @@ -867,14 +1039,44 @@ impl NativeAgentConnection { } log::debug!("Response stream completed"); - anyhow::Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + anyhow::Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }) } } +struct Command<'a> { + prompt_name: &'a str, + arg_value: &'a str, + explicit_server_id: Option<&'a str>, +} + +impl<'a> Command<'a> { + fn parse(prompt: &'a [acp::ContentBlock]) -> Option { + let acp::ContentBlock::Text(text_content) = prompt.first()? else { + return None; + }; + let text = text_content.text.trim(); + let command = text.strip_prefix('/')?; + let (command, arg_value) = command + .split_once(char::is_whitespace) + .unwrap_or((command, "")); + + if let Some((server_id, prompt_name)) = command.split_once('.') { + Some(Self { + prompt_name, + arg_value, + explicit_server_id: Some(server_id), + }) + } else { + Some(Self { + prompt_name: command, + arg_value, + explicit_server_id: None, + }) + } + } +} + struct NativeAgentModelSelector { session_id: acp::SessionId, connection: NativeAgentConnection, @@ -968,8 +1170,8 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector { } impl acp_thread::AgentConnection for NativeAgentConnection { - fn telemetry_id(&self) -> &'static str { - "zed" + fn telemetry_id(&self) -> SharedString { + "zed".into() } fn new_thread( @@ -1040,6 +1242,47 @@ impl acp_thread::AgentConnection for NativeAgentConnection { let session_id = params.session_id.clone(); log::info!("Received prompt request for session: {}", session_id); log::debug!("Prompt blocks count: {}", params.prompt.len()); + + if let Some(parsed_command) = Command::parse(¶ms.prompt) { + let registry = self.0.read(cx).context_server_registry.read(cx); + + let explicit_server_id = parsed_command + .explicit_server_id + .map(|server_id| ContextServerId(server_id.into())); + + if let Some(prompt) = + registry.find_prompt(explicit_server_id.as_ref(), parsed_command.prompt_name) + { + let arguments = if !parsed_command.arg_value.is_empty() + && let Some(arg_name) = prompt + .prompt + .arguments + .as_ref() + .and_then(|args| args.first()) + .map(|arg| arg.name.clone()) + { + HashMap::from_iter([(arg_name, parsed_command.arg_value.to_string())]) + } else { + Default::default() + }; + + let prompt_name = prompt.prompt.name.clone(); + let server_id = prompt.server_id.clone(); + + return self.0.update(cx, |agent, cx| { + agent.send_mcp_prompt( + id, + session_id.clone(), + prompt_name, + server_id, + arguments, + params.prompt, + cx, + ) + }); + }; + }; + let path_style = self.0.read(cx).project.read(cx).path_style(cx); self.run_turn(session_id, cx, move |thread, cx| { @@ -1240,6 +1483,15 @@ impl TerminalHandle for AcpTerminalHandle { self.terminal .read_with(cx, |term, cx| term.current_output(cx)) } + + fn kill(&self, cx: &AsyncApp) -> Result<()> { + cx.update(|cx| { + self.terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + })?; + Ok(()) + } } #[cfg(test)] @@ -1374,10 +1626,12 @@ mod internal_tests { IndexMap::from_iter([( AgentModelGroupName("Fake".into()), vec![AgentModelInfo { - id: acp::ModelId("fake/fake".into()), + id: acp::ModelId::new("fake/fake"), name: "Fake".into(), description: None, - icon: Some(ui::IconName::ZedAssistant), + icon: Some(acp_thread::AgentModelIcon::Named( + ui::IconName::ZedAssistant + )), }] )]) ); @@ -1435,7 +1689,7 @@ mod internal_tests { // Select a model let selector = connection.model_selector(&session_id).unwrap(); - let model_id = acp::ModelId("fake/fake".into()); + let model_id = acp::ModelId::new("fake/fake"); cx.update(|cx| selector.select_model(model_id.clone(), cx)) .await .unwrap(); @@ -1521,20 +1775,14 @@ mod internal_tests { thread.send( vec![ "What does ".into(), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: "b.md".into(), - uri: MentionUri::File { + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "b.md", + MentionUri::File { abs_path: path!("/a/b.md").into(), } .to_uri() .to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), + )), " mean?".into(), ], cx, @@ -1633,3 +1881,35 @@ mod internal_tests { }); } } + +fn mcp_message_content_to_acp_content_block( + content: context_server::types::MessageContent, +) -> acp::ContentBlock { + match content { + context_server::types::MessageContent::Text { + text, + annotations: _, + } => text.into(), + context_server::types::MessageContent::Image { + data, + mime_type, + annotations: _, + } => acp::ContentBlock::Image(acp::ImageContent::new(data, mime_type)), + context_server::types::MessageContent::Audio { + data, + mime_type, + annotations: _, + } => acp::ContentBlock::Audio(acp::AudioContent::new(data, mime_type)), + context_server::types::MessageContent::Resource { + resource, + annotations: _, + } => { + let mut link = + acp::ResourceLink::new(resource.uri.to_string(), resource.uri.to_string()); + if let Some(mime_type) = resource.mime_type { + link = link.mime_type(mime_type); + } + acp::ContentBlock::ResourceLink(link) + } + } +} diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index d5166c5df931b6f7fad63769449aaa9784b5263f..7a88c5870574cae424bd1fff50f1d20cdb00fa44 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -366,7 +366,7 @@ impl ThreadsDatabase { for (id, summary, updated_at) in rows { threads.push(DbThreadMetadata { - id: acp::SessionId(id), + id: acp::SessionId::new(id), title: summary.into(), updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), }); diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index 81dce33d0394b5757be4934031f31b6f17233e9c..01c81e0103a2d3624c7e8eb9b9c587726fcc4876 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -4,7 +4,7 @@ use crate::{ }; use Role::*; use client::{Client, UserStore}; -use collections::HashMap; +use eval_utils::{EvalOutput, EvalOutputProcessor, OutcomeKind}; use fs::FakeFs; use futures::{FutureExt, future::LocalBoxFuture}; use gpui::{AppContext, TestAppContext, Timer}; @@ -20,16 +20,62 @@ use rand::prelude::*; use reqwest_client::ReqwestClient; use serde_json::json; use std::{ - cmp::Reverse, fmt::{self, Display}, - io::Write as _, path::Path, str::FromStr, - sync::mpsc, time::Duration, }; use util::path; +#[derive(Default, Clone, Debug)] +struct EditAgentOutputProcessor { + mismatched_tag_threshold: f32, + cumulative_tags: usize, + cumulative_mismatched_tags: usize, + eval_outputs: Vec>, +} + +fn mismatched_tag_threshold(mismatched_tag_threshold: f32) -> EditAgentOutputProcessor { + EditAgentOutputProcessor { + mismatched_tag_threshold, + cumulative_tags: 0, + cumulative_mismatched_tags: 0, + eval_outputs: Vec::new(), + } +} + +#[derive(Clone, Debug)] +struct EditEvalMetadata { + tags: usize, + mismatched_tags: usize, +} + +impl EvalOutputProcessor for EditAgentOutputProcessor { + type Metadata = EditEvalMetadata; + + fn process(&mut self, output: &EvalOutput) { + if matches!(output.outcome, OutcomeKind::Passed | OutcomeKind::Failed) { + self.cumulative_mismatched_tags += output.metadata.mismatched_tags; + self.cumulative_tags += output.metadata.tags; + self.eval_outputs.push(output.clone()); + } + } + + fn assert(&mut self) { + let mismatched_tag_ratio = + self.cumulative_mismatched_tags as f32 / self.cumulative_tags as f32; + if mismatched_tag_ratio > self.mismatched_tag_threshold { + for eval_output in &self.eval_outputs { + println!("{}", eval_output.data); + } + panic!( + "Too many mismatched tags: {:?}", + self.cumulative_mismatched_tags + ); + } + } +} + #[test] #[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_extract_handle_command_output() { @@ -55,22 +101,19 @@ fn eval_extract_handle_command_output() { include_str!("evals/fixtures/extract_handle_command_output/possible-07.diff"), ]; let edit_description = "Extract `handle_command_output` method from `run_git_blame`."; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and extract a method in - the final stanza of `run_git_blame` to deal with command failures, - call it `handle_command_output` and take the std::process::Output as the only parameter. - Do not document the method and do not add any comments. + Read the `{input_file_path}` file and extract a method in + the final stanza of `run_git_blame` to deal with command failures, + call it `handle_command_output` and take the std::process::Output as the only parameter. + Do not document the method and do not add any comments. - Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. - "})], + Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. + "})], ), message( Assistant, @@ -102,9 +145,9 @@ fn eval_extract_handle_command_output() { ), ], Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs), - ), - ); + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); } #[test] @@ -122,18 +165,16 @@ fn eval_delete_run_git_blame() { let input_file_content = include_str!("evals/fixtures/delete_run_git_blame/before.rs"); let output_file_content = include_str!("evals/fixtures/delete_run_git_blame/after.rs"); let edit_description = "Delete the `run_git_blame` function."; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and delete `run_git_blame`. Just that - one function, not its usages. - "})], + Read the `{input_file_path}` file and delete `run_git_blame`. Just that + one function, not its usages. + "})], ), message( Assistant, @@ -166,8 +207,8 @@ fn eval_delete_run_git_blame() { ], Some(input_file_content.into()), EvalAssertion::assert_eq(output_file_content), - ), - ); + )) + }); } #[test] @@ -185,18 +226,16 @@ fn eval_translate_doc_comments() { let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/translate_doc_comments/before.rs"); let edit_description = "Translate all doc comments to Italian"; - eval( - 200, - 1., - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(200, 1., mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the {input_file_path} file and edit it (without overwriting it), - translating all the doc comments to italian. - "})], + Read the {input_file_path} file and edit it (without overwriting it), + translating all the doc comments to italian. + "})], ), message( Assistant, @@ -229,8 +268,8 @@ fn eval_translate_doc_comments() { ], Some(input_file_content.into()), EvalAssertion::judge_diff("Doc comments were translated to Italian"), - ), - ); + )) + }); } #[test] @@ -249,33 +288,31 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { let input_file_content = include_str!("evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs"); let edit_description = "Update compile_parser_to_wasm to use wasi-sdk instead of emscripten"; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. - Use `ureq` to download the SDK for the current platform and architecture. - Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. - Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) - that's inside of the archive. - Don't re-download the SDK if that executable already exists. - - Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} - - Here are the available wasi-sdk assets: - - wasi-sdk-25.0-x86_64-macos.tar.gz - - wasi-sdk-25.0-arm64-macos.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-windows.tar.gz - "})], + Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. + Use `ureq` to download the SDK for the current platform and architecture. + Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. + Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) + that's inside of the archive. + Don't re-download the SDK if that executable already exists. + + Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} + + Here are the available wasi-sdk assets: + - wasi-sdk-25.0-x86_64-macos.tar.gz + - wasi-sdk-25.0-arm64-macos.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-windows.tar.gz + "})], ), message( Assistant, @@ -352,11 +389,11 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { ], Some(input_file_content.into()), EvalAssertion::judge_diff(indoc! {" - - The compile_parser_to_wasm method has been changed to use wasi-sdk - - ureq is used to download the SDK for current platform and architecture - "}), - ), - ); + - The compile_parser_to_wasm method has been changed to use wasi-sdk + - ureq is used to download the SDK for current platform and architecture + "}), + )) + }); } #[test] @@ -380,11 +417,8 @@ fn eval_disable_cursor_blinking() { include_str!("evals/fixtures/disable_cursor_blinking/possible-03.diff"), include_str!("evals/fixtures/disable_cursor_blinking/possible-04.diff"), ]; - eval( - 100, - 0.51, - 0.05, - EvalInput::from_conversation( + eval_utils::eval(100, 0.51, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text("Let's research how to cursor blinking works.")]), message( @@ -421,10 +455,10 @@ fn eval_disable_cursor_blinking() { message( User, [text(indoc! {" - Comment out the lines that interact with the BlinkManager. - Keep the outer `update` blocks, but comments everything that's inside (including if statements). - Don't add additional comments. - "})], + Comment out the lines that interact with the BlinkManager. + Keep the outer `update` blocks, but comments everything that's inside (including if statements). + Don't add additional comments. + "})], ), message( Assistant, @@ -440,9 +474,9 @@ fn eval_disable_cursor_blinking() { ), ], Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs), - ), - ); + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); } #[test] @@ -467,20 +501,16 @@ fn eval_from_pixels_constructor() { let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/from_pixels_constructor/before.rs"); let edit_description = "Implement from_pixels constructor and add tests."; - eval( - 100, - 0.95, - // For whatever reason, this eval produces more mismatched tags. - // Increasing for now, let's see if we can bring this down. - 0.25, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.25), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(indoc! {" - Introduce a new `from_pixels` constructor in Canvas and - also add tests for it in the same file. - "})], + Introduce a new `from_pixels` constructor in Canvas and + also add tests for it in the same file. + "})], ), message( Assistant, @@ -545,92 +575,92 @@ fn eval_from_pixels_constructor() { "tool_4", "grep", indoc! {" - Found 6 matches: + Found 6 matches: - ## Matches in font-kit/src/loaders/core_text.rs + ## Matches in font-kit/src/loaders/core_text.rs - ### mod test › L926-936 - ``` - mod test { - use super::Font; - use crate::properties::{Stretch, Weight}; + ### mod test › L926-936 + ``` + mod test { + use super::Font; + use crate::properties::{Stretch, Weight}; - #[cfg(feature = \"source\")] - use crate::source::SystemSource; + #[cfg(feature = \"source\")] + use crate::source::SystemSource; - static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; + static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; - #[cfg(feature = \"source\")] - #[test] - ``` + #[cfg(feature = \"source\")] + #[test] + ``` - 55 lines remaining in ancestor node. Read the file to see all. + 55 lines remaining in ancestor node. Read the file to see all. - ### mod test › L947-951 - ``` - } + ### mod test › L947-951 + ``` + } - #[test] - fn test_core_text_to_css_font_weight() { - // Exact matches - ``` + #[test] + fn test_core_text_to_css_font_weight() { + // Exact matches + ``` - ### mod test › L959-963 - ``` - } + ### mod test › L959-963 + ``` + } - #[test] - fn test_core_text_to_css_font_stretch() { - // Exact matches - ``` + #[test] + fn test_core_text_to_css_font_stretch() { + // Exact matches + ``` - ## Matches in font-kit/src/loaders/freetype.rs + ## Matches in font-kit/src/loaders/freetype.rs - ### mod test › L1238-1248 - ``` - mod test { - use crate::loaders::freetype::Font; + ### mod test › L1238-1248 + ``` + mod test { + use crate::loaders::freetype::Font; - static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; - static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; + static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; + static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; - #[test] - fn get_pcf_postscript_name() { - let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); - assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); - } - ``` + #[test] + fn get_pcf_postscript_name() { + let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); + assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); + } + ``` - 1 lines remaining in ancestor node. Read the file to see all. + 1 lines remaining in ancestor node. Read the file to see all. - ## Matches in font-kit/src/sources/core_text.rs + ## Matches in font-kit/src/sources/core_text.rs - ### mod test › L265-275 - ``` - mod test { - use crate::properties::{Stretch, Weight}; + ### mod test › L265-275 + ``` + mod test { + use crate::properties::{Stretch, Weight}; - #[test] - fn test_css_to_core_text_font_weight() { - // Exact matches - assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); - assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); - assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); - assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); + #[test] + fn test_css_to_core_text_font_weight() { + // Exact matches + assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); + assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); + assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); + assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); - ``` + ``` - 27 lines remaining in ancestor node. Read the file to see all. + 27 lines remaining in ancestor node. Read the file to see all. - ### mod test › L278-282 - ``` - } + ### mod test › L278-282 + ``` + } - #[test] - fn test_css_to_core_text_font_stretch() { - // Exact matches - ``` - "}, + #[test] + fn test_css_to_core_text_font_stretch() { + // Exact matches + ``` + "}, )], ), message( @@ -648,11 +678,11 @@ fn eval_from_pixels_constructor() { ], Some(input_file_content.into()), EvalAssertion::judge_diff(indoc! {" - - The diff contains a new `from_pixels` constructor - - The diff contains new tests for the `from_pixels` constructor - "}), - ), - ); + - The diff contains a new `from_pixels` constructor + - The diff contains new tests for the `from_pixels` constructor + "}), + )) + }); } #[test] @@ -670,11 +700,9 @@ fn eval_zode() { let input_file_path = "root/zode.py"; let input_content = None; let edit_description = "Create the main Zode CLI script"; - eval( - 50, - 1., - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(50, 1., mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text(include_str!("evals/fixtures/zode/prompt.md"))]), message( @@ -733,7 +761,7 @@ fn eval_zode() { ], ), ], - input_content, + input_content.clone(), EvalAssertion::new(async move |sample, _, _cx| { let invalid_starts = [' ', '`', '\n']; let mut message = String::new(); @@ -758,8 +786,8 @@ fn eval_zode() { }) } }), - ), - ); + )) + }); } #[test] @@ -777,19 +805,17 @@ fn eval_add_overwrite_test() { let input_file_path = "root/action_log.rs"; let input_file_content = include_str!("evals/fixtures/add_overwrite_test/before.rs"); let edit_description = "Add a new test for overwriting a file in action_log.rs"; - eval( - 200, - 0.5, // TODO: make this eval better - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(200, 0.5, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(indoc! {" - Introduce a new test in `action_log.rs` to test overwriting a file. - That is, a file already exists, but we call `buffer_created` as if the file were new. - Take inspiration from all the other tests in the file. - "})], + Introduce a new test in `action_log.rs` to test overwriting a file. + That is, a file already exists, but we call `buffer_created` as if the file were new. + Take inspiration from all the other tests in the file. + "})], ), message( Assistant, @@ -809,81 +835,81 @@ fn eval_add_overwrite_test() { "tool_1", "read_file", indoc! {" - pub struct ActionLog [L13-20] - tracked_buffers [L15] - edited_since_project_diagnostics_check [L17] - project [L19] - impl ActionLog [L22-498] - pub fn new [L24-30] - pub fn project [L32-34] - pub fn checked_project_diagnostics [L37-39] - pub fn has_edited_files_since_project_diagnostics_check [L42-44] - fn track_buffer_internal [L46-101] - fn handle_buffer_event [L103-116] - fn handle_buffer_edited [L118-123] - fn handle_buffer_file_changed [L125-158] - async fn maintain_diff [L160-264] - pub fn buffer_read [L267-269] - pub fn buffer_created [L272-276] - pub fn buffer_edited [L279-287] - pub fn will_delete_buffer [L289-304] - pub fn keep_edits_in_range [L306-364] - pub fn reject_edits_in_ranges [L366-459] - pub fn keep_all_edits [L461-473] - pub fn changed_buffers [L476-482] - pub fn stale_buffers [L485-497] - fn apply_non_conflicting_edits [L500-561] - fn diff_snapshots [L563-585] - fn point_to_row_edit [L587-614] - enum ChangeAuthor [L617-620] - User [L618] - Agent [L619] - enum TrackedBufferStatus [L623-627] - Created [L624] - Modified [L625] - Deleted [L626] - struct TrackedBuffer [L629-641] - buffer [L630] - base_text [L631] - unreviewed_changes [L632] - status [L633] - version [L634] - diff [L635] - snapshot [L636] - diff_update [L637] - _open_lsp_handle [L638] - _maintain_diff [L639] - _subscription [L640] - impl TrackedBuffer [L643-657] - fn has_changes [L644-650] - fn schedule_diff_update [L652-656] - pub struct ChangedBuffer [L659-661] - pub diff [L660] - mod tests [L664-1574] - fn init_logger [L678-682] - fn init_test [L684-691] - async fn test_keep_edits [L694-769] - async fn test_deletions [L772-854] - async fn test_overlapping_user_edits [L857-951] - async fn test_creating_files [L954-1010] - async fn test_deleting_files [L1013-1120] - async fn test_reject_edits [L1123-1255] - async fn test_reject_multiple_edits [L1258-1331] - async fn test_reject_deleted_file [L1334-1388] - async fn test_reject_created_file [L1391-1443] - async fn test_random_diffs [L1446-1535] - fn quiesce [L1510-1534] - struct HunkStatus [L1538-1542] - range [L1539] - diff_status [L1540] - old_text [L1541] - fn unreviewed_hunks [L1544-1573] - - Showing symbols 1-69 (total symbols: 69) - - Using the line numbers in this outline, you can call this tool again while specifying - the start_line and end_line fields to see the implementations of symbols in the outline. - "}, + pub struct ActionLog [L13-20] + tracked_buffers [L15] + edited_since_project_diagnostics_check [L17] + project [L19] + impl ActionLog [L22-498] + pub fn new [L24-30] + pub fn project [L32-34] + pub fn checked_project_diagnostics [L37-39] + pub fn has_edited_files_since_project_diagnostics_check [L42-44] + fn track_buffer_internal [L46-101] + fn handle_buffer_event [L103-116] + fn handle_buffer_edited [L118-123] + fn handle_buffer_file_changed [L125-158] + async fn maintain_diff [L160-264] + pub fn buffer_read [L267-269] + pub fn buffer_created [L272-276] + pub fn buffer_edited [L279-287] + pub fn will_delete_buffer [L289-304] + pub fn keep_edits_in_range [L306-364] + pub fn reject_edits_in_ranges [L366-459] + pub fn keep_all_edits [L461-473] + pub fn changed_buffers [L476-482] + pub fn stale_buffers [L485-497] + fn apply_non_conflicting_edits [L500-561] + fn diff_snapshots [L563-585] + fn point_to_row_edit [L587-614] + enum ChangeAuthor [L617-620] + User [L618] + Agent [L619] + enum TrackedBufferStatus [L623-627] + Created [L624] + Modified [L625] + Deleted [L626] + struct TrackedBuffer [L629-641] + buffer [L630] + base_text [L631] + unreviewed_changes [L632] + status [L633] + version [L634] + diff [L635] + snapshot [L636] + diff_update [L637] + _open_lsp_handle [L638] + _maintain_diff [L639] + _subscription [L640] + impl TrackedBuffer [L643-657] + fn has_changes [L644-650] + fn schedule_diff_update [L652-656] + pub struct ChangedBuffer [L659-661] + pub diff [L660] + mod tests [L664-1574] + fn init_logger [L678-682] + fn init_test [L684-691] + async fn test_keep_edits [L694-769] + async fn test_deletions [L772-854] + async fn test_overlapping_user_edits [L857-951] + async fn test_creating_files [L954-1010] + async fn test_deleting_files [L1013-1120] + async fn test_reject_edits [L1123-1255] + async fn test_reject_multiple_edits [L1258-1331] + async fn test_reject_deleted_file [L1334-1388] + async fn test_reject_created_file [L1391-1443] + async fn test_random_diffs [L1446-1535] + fn quiesce [L1510-1534] + struct HunkStatus [L1538-1542] + range [L1539] + diff_status [L1540] + old_text [L1541] + fn unreviewed_hunks [L1544-1573] + + Showing symbols 1-69 (total symbols: 69) + + Using the line numbers in this outline, you can call this tool again while specifying + the start_line and end_line fields to see the implementations of symbols in the outline. + "}, )], ), message( @@ -963,17 +989,17 @@ fn eval_add_overwrite_test() { Assistant, [ text(indoc! {" - Based on my understanding of the existing tests and code, I'll now create a new test for overwriting a file. In this test, we'll: + Based on my understanding of the existing tests and code, I'll now create a new test for overwriting a file. In this test, we'll: - 1. Create a file - 2. Open a buffer for it - 3. Track it in the action log - 4. Modify the buffer - 5. Call `buffer_created` again as if the file were new - 6. Check that the changes are properly tracked + 1. Create a file + 2. Open a buffer for it + 3. Track it in the action log + 4. Modify the buffer + 5. Call `buffer_created` again as if the file were new + 6. Check that the changes are properly tracked - Let's write the test: - "}), + Let's write the test: + "}), tool_use( "tool_5", "edit_file", @@ -990,8 +1016,8 @@ fn eval_add_overwrite_test() { EvalAssertion::judge_diff( "A new test for overwritten files was created, without changing any previous test", ), - ), - ); + )) + }); } #[test] @@ -1016,20 +1042,18 @@ fn eval_create_empty_file() { let input_file_content = None; let expected_output_content = String::new(); - eval( - 100, - 0.99, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.99, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text("Create a second empty todo file ")]), message( Assistant, [ text(formatdoc! {" - I'll help you create a second empty todo file. - First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. - "}), + I'll help you create a second empty todo file. + First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. + "}), tool_use( "toolu_01GAF8TtsgpjKxCr8fgQLDgR", "list_directory", @@ -1051,8 +1075,8 @@ fn eval_create_empty_file() { Assistant, [ text(formatdoc! {" - I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory: - "}), + I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory: + "}), tool_use( "toolu_01Tb3iQ9griqSYMmVuykQPWU", "edit_file", @@ -1065,12 +1089,12 @@ fn eval_create_empty_file() { ], ), ], - input_file_content, + input_file_content.clone(), // Bad behavior is to write something like // "I'll create an empty TODO3 file as requested." - EvalAssertion::assert_eq(expected_output_content), - ), - ); + EvalAssertion::assert_eq(expected_output_content.clone()), + )) + }); } fn message( @@ -1312,115 +1336,45 @@ impl EvalAssertion { } } -fn eval( - iterations: usize, - expected_pass_ratio: f32, - mismatched_tag_threshold: f32, - mut eval: EvalInput, -) { - let mut evaluated_count = 0; - let mut failed_count = 0; - report_progress(evaluated_count, failed_count, iterations); - - let (tx, rx) = mpsc::channel(); - - // Cache the last message in the conversation, and run one instance of the eval so that - // all the next ones are cached. - eval.conversation.last_mut().unwrap().cache = true; - run_eval(eval.clone(), tx.clone()); - - let executor = gpui::background_executor(); - let semaphore = Arc::new(smol::lock::Semaphore::new(32)); - for _ in 1..iterations { - let eval = eval.clone(); - let tx = tx.clone(); - let semaphore = semaphore.clone(); - executor - .spawn(async move { - let _guard = semaphore.acquire().await; - run_eval(eval, tx) - }) - .detach(); - } - drop(tx); - - let mut failed_evals = HashMap::default(); - let mut errored_evals = HashMap::default(); - let mut eval_outputs = Vec::new(); - let mut cumulative_parser_metrics = EditParserMetrics::default(); - while let Ok(output) = rx.recv() { - match output { - Ok(output) => { - cumulative_parser_metrics += output.sample.edit_output.parser_metrics.clone(); - eval_outputs.push(output.clone()); - if output.assertion.score < 80 { - failed_count += 1; - failed_evals - .entry(output.sample.text_after.clone()) - .or_insert(Vec::new()) - .push(output); - } - } - Err(error) => { - failed_count += 1; - *errored_evals.entry(format!("{:?}", error)).or_insert(0) += 1; - } - } - - evaluated_count += 1; - report_progress(evaluated_count, failed_count, iterations); - } - - let actual_pass_ratio = (iterations - failed_count) as f32 / iterations as f32; - println!("Actual pass ratio: {}\n", actual_pass_ratio); - if actual_pass_ratio < expected_pass_ratio { - let mut errored_evals = errored_evals.into_iter().collect::>(); - errored_evals.sort_by_key(|(_, count)| Reverse(*count)); - for (error, count) in errored_evals { - println!("Eval errored {} times. Error: {}", count, error); - } - - let mut failed_evals = failed_evals.into_iter().collect::>(); - failed_evals.sort_by_key(|(_, evals)| Reverse(evals.len())); - for (_buffer_output, failed_evals) in failed_evals { - let eval_output = failed_evals.first().unwrap(); - println!("Eval failed {} times", failed_evals.len()); - println!("{}", eval_output); - } - - panic!( - "Actual pass ratio: {}\nExpected pass ratio: {}", - actual_pass_ratio, expected_pass_ratio - ); - } - - let mismatched_tag_ratio = - cumulative_parser_metrics.mismatched_tags as f32 / cumulative_parser_metrics.tags as f32; - if mismatched_tag_ratio > mismatched_tag_threshold { - for eval_output in eval_outputs { - println!("{}", eval_output); - } - panic!("Too many mismatched tags: {:?}", cumulative_parser_metrics); - } -} - -fn run_eval(eval: EvalInput, tx: mpsc::Sender>) { +fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput { let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); let mut cx = TestAppContext::build(dispatcher, None); - let output = cx.executor().block_test(async { + let result = cx.executor().block_test(async { let test = EditAgentTest::new(&mut cx).await; test.eval(eval, &mut cx).await }); - tx.send(output).unwrap(); + cx.quit(); + match result { + Ok(output) => eval_utils::EvalOutput { + data: output.to_string(), + outcome: if output.assertion.score < 80 { + eval_utils::OutcomeKind::Failed + } else { + eval_utils::OutcomeKind::Passed + }, + metadata: EditEvalMetadata { + tags: output.sample.edit_output.parser_metrics.tags, + mismatched_tags: output.sample.edit_output.parser_metrics.mismatched_tags, + }, + }, + Err(e) => eval_utils::EvalOutput { + data: format!("{e:?}"), + outcome: eval_utils::OutcomeKind::Error, + metadata: EditEvalMetadata { + tags: 0, + mismatched_tags: 0, + }, + }, + } } #[derive(Clone)] -struct EvalOutput { +struct EditEvalOutput { sample: EvalSample, assertion: EvalAssertionOutcome, } -impl Display for EvalOutput { +impl Display for EditEvalOutput { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { writeln!(f, "Score: {:?}", self.assertion.score)?; if let Some(message) = self.assertion.message.as_ref() { @@ -1439,22 +1393,6 @@ impl Display for EvalOutput { } } -fn report_progress(evaluated_count: usize, failed_count: usize, iterations: usize) { - let passed_count = evaluated_count - failed_count; - let passed_ratio = if evaluated_count == 0 { - 0.0 - } else { - passed_count as f64 / evaluated_count as f64 - }; - print!( - "\r\x1b[KEvaluated {}/{} ({:.2}% passed)", - evaluated_count, - iterations, - passed_ratio * 100.0 - ); - std::io::stdout().flush().unwrap(); -} - struct EditAgentTest { agent: EditAgent, project: Entity, @@ -1550,7 +1488,10 @@ impl EditAgentTest { }) } - async fn eval(&self, eval: EvalInput, cx: &mut TestAppContext) -> Result { + async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result { + // Make sure the last message in the conversation is cached. + eval.conversation.last_mut().unwrap().cache = true; + let path = self .project .read_with(cx, |project, cx| { @@ -1656,7 +1597,7 @@ impl EditAgentTest { .run(&sample, self.judge_model.clone(), cx) .await?; - Ok(EvalOutput { assertion, sample }) + Ok(EditEvalOutput { assertion, sample }) } } diff --git a/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md b/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md index 902e43857c3214cde68372f1c9ff5f8015528ae2..29755d441f7a4f74709c1ac414e2a9a73fe6ac21 100644 --- a/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md +++ b/crates/agent/src/edit_agent/evals/fixtures/zode/prompt.md @@ -2,12 +2,12 @@ - We're starting from a completely blank project - Like Aider/Claude Code you take the user's initial prompt and then call the LLM and perform tool calls in a loop until the ultimate goal is achieved. - Unlike Aider or Claude code, it's not intended to be interactive. Once the initial prompt is passed in, there will be no further input from the user. -- The system you will build must reach the stated goal just by performing too calls and calling the LLM +- The system you will build must reach the stated goal just by performing tool calls and calling the LLM - I want you to build this in python. Use the anthropic python sdk and the model context protocol sdk. Use a virtual env and pip to install dependencies - Follow the anthropic guidance on tool calls: https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview - Use this Anthropic model: `claude-3-7-sonnet-20250219` - Use this Anthropic API Key: `sk-ant-api03-qweeryiofdjsncmxquywefidopsugus` -- One of the most important pieces to this is having good too calls. We will be using the tools provided by the Claude MCP server. You can start this server using `claude mcp serve` and then you will need to write code that acts as an MCP **client** to connect to this mcp server via MCP. Likely you want to start this using a subprocess. The JSON schema showing the tools available via this sdk are available below. Via this MCP server you have access to all the tools that zode needs: Bash, GlobTool, GrepTool, LS, View, Edit, Replace, WebFetchTool +- One of the most important pieces to this is having good tool calls. We will be using the tools provided by the Claude MCP server. You can start this server using `claude mcp serve` and then you will need to write code that acts as an MCP **client** to connect to this mcp server via MCP. Likely you want to start this using a subprocess. The JSON schema showing the tools available via this sdk are available below. Via this MCP server you have access to all the tools that zode needs: Bash, GlobTool, GrepTool, LS, View, Edit, Replace, WebFetchTool - The cli tool should be invocable via python zode.py file.md where file.md is any possible file that contains the users prompt. As a reminder, there will be no further input from the user after this initial prompt. Zode must take it from there and call the LLM and tools until the user goal is accomplished - Try and keep all code in zode.py and make heavy use of the asks I mentioned - Once you’ve implemented this, you must run python zode.py eval/instructions.md to see how well our new agent tool does! diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index efc0e3966d30fbc8bc7857c9da0404ce7dd4201f..c455f73316e3fc7a641fa8a31ac0ad766a2ae584 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -216,14 +216,10 @@ impl HistoryStore { } pub fn reload(&self, cx: &mut Context) { - let database_future = ThreadsDatabase::connect(cx); + let database_connection = ThreadsDatabase::connect(cx); cx.spawn(async move |this, cx| { - let threads = database_future - .await - .map_err(|err| anyhow!(err))? - .list_threads() - .await?; - + let database = database_connection.await; + let threads = database.map_err(|err| anyhow!(err))?.list_threads().await?; this.update(cx, |this, cx| { if this.recently_opened_entries.len() < MAX_RECENTLY_OPENED_ENTRIES { for thread in threads @@ -344,7 +340,8 @@ impl HistoryStore { fn load_recently_opened_entries(cx: &AsyncApp) -> Task>> { cx.background_spawn(async move { if cfg!(any(feature = "test-support", test)) { - anyhow::bail!("history store does not persist in tests"); + log::warn!("history store does not persist in tests"); + return Ok(VecDeque::new()); } let json = KEY_VALUE_STORE .read_kvp(RECENTLY_OPENED_THREADS_KEY)? @@ -354,9 +351,9 @@ impl HistoryStore { .into_iter() .take(MAX_RECENTLY_OPENED_ENTRIES) .flat_map(|entry| match entry { - SerializedRecentOpen::AcpThread(id) => Some(HistoryEntryId::AcpThread( - acp::SessionId(id.as_str().into()), - )), + SerializedRecentOpen::AcpThread(id) => { + Some(HistoryEntryId::AcpThread(acp::SessionId::new(id.as_str()))) + } SerializedRecentOpen::TextThread(file_name) => Some( HistoryEntryId::TextThread(text_threads_dir().join(file_name).into()), ), diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 4c78c5a3f85b6628f9784fe7ecbadc8531b017d0..95312fd32536b99059e2ebc6ebd0a9ea522f94be 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -1,10 +1,14 @@ use std::{any::Any, path::Path, rc::Rc, sync::Arc}; +use agent_client_protocol as acp; use agent_servers::{AgentServer, AgentServerDelegate}; +use agent_settings::AgentSettings; use anyhow::Result; +use collections::HashSet; use fs::Fs; use gpui::{App, Entity, SharedString, Task}; use prompt_store::PromptStore; +use settings::{LanguageModelSelection, Settings as _, update_settings_file}; use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates}; @@ -21,10 +25,6 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { - fn telemetry_id(&self) -> &'static str { - "zed" - } - fn name(&self) -> SharedString { "Zed Agent".into() } @@ -75,6 +75,38 @@ impl AgentServer for NativeAgentServer { fn into_any(self: Rc) -> Rc { self } + + fn favorite_model_ids(&self, cx: &mut App) -> HashSet { + AgentSettings::get_global(cx).favorite_model_ids() + } + + fn toggle_favorite_model( + &self, + model_id: acp::ModelId, + should_be_favorite: bool, + fs: Arc, + cx: &App, + ) { + let selection = model_id_to_selection(&model_id); + update_settings_file(fs, cx, move |settings, _| { + let agent = settings.agent.get_or_insert_default(); + if should_be_favorite { + agent.add_favorite_model(selection.clone()); + } else { + agent.remove_favorite_model(&selection); + } + }); + } +} + +/// Convert a ModelId (e.g. "anthropic/claude-3-5-sonnet") to a LanguageModelSelection. +fn model_id_to_selection(model_id: &acp::ModelId) -> LanguageModelSelection { + let id = model_id.0.as_ref(); + let (provider, model) = id.split_once('/').unwrap_or(("", id)); + LanguageModelSelection { + provider: provider.to_owned().into(), + model: model.to_owned(), + } } #[cfg(test)] diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 4620647135631fdb367b0dc2604e89770a938c07..2477e46a85183813f61bb60d7e3de7f119a4f00c 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -16,7 +16,7 @@ You are a highly skilled software engineer with extensive knowledge in many prog 3. DO NOT use tools to access items that are already available in the context section. 4. Use only the tools that are currently available. 5. DO NOT use a tool that is not available just because it appears in the conversation. This means the user turned it off. -6. NEVER run commands that don't terminate on their own such as web servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers. +6. When running commands that may run indefinitely or for a long time (such as build scripts, tests, servers, or file watchers), specify `timeout_ms` to bound runtime. If the command times out, the user can always ask you to run it again with a longer timeout or no timeout if they're willing to wait or cancel manually. 7. Avoid HTML entity escaping - use plain characters instead. ## Searching and Reading diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index b33080671980eb28c7900aea4bb0942d152a054a..45028902e467fe67945ddf444c9ae417dcaed654 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -9,14 +9,16 @@ use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use fs::{FakeFs, Fs}; use futures::{ - StreamExt, + FutureExt as _, StreamExt, channel::{ mpsc::{self, UnboundedReceiver}, oneshot, }, + future::{Fuse, Shared}, }; use gpui::{ - App, AppContext, Entity, Task, TestAppContext, UpdateGlobal, http_client::FakeHttpClient, + App, AppContext, AsyncApp, Entity, Task, TestAppContext, UpdateGlobal, + http_client::FakeHttpClient, }; use indoc::indoc; use language_model::{ @@ -35,12 +37,109 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; use settings::{Settings, SettingsStore}; -use std::{path::Path, rc::Rc, sync::Arc, time::Duration}; +use std::{ + path::Path, + pin::Pin, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use util::path; mod test_tools; use test_tools::*; +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); +} + +struct FakeTerminalHandle { + killed: Arc, + wait_for_exit: Shared>, + output: acp::TerminalOutputResponse, + id: acp::TerminalId, +} + +impl FakeTerminalHandle { + fn new_never_exits(cx: &mut App) -> Self { + let killed = Arc::new(AtomicBool::new(false)); + + let killed_for_task = killed.clone(); + let wait_for_exit = cx + .spawn(async move |cx| { + loop { + if killed_for_task.load(Ordering::SeqCst) { + return acp::TerminalExitStatus::new(); + } + cx.background_executor() + .timer(Duration::from_millis(1)) + .await; + } + }) + .shared(); + + Self { + killed, + wait_for_exit, + output: acp::TerminalOutputResponse::new("partial output".to_string(), false), + id: acp::TerminalId::new("fake_terminal".to_string()), + } + } + + fn was_killed(&self) -> bool { + self.killed.load(Ordering::SeqCst) + } +} + +impl crate::TerminalHandle for FakeTerminalHandle { + fn id(&self, _cx: &AsyncApp) -> Result { + Ok(self.id.clone()) + } + + fn current_output(&self, _cx: &AsyncApp) -> Result { + Ok(self.output.clone()) + } + + fn wait_for_exit(&self, _cx: &AsyncApp) -> Result>> { + Ok(self.wait_for_exit.clone()) + } + + fn kill(&self, _cx: &AsyncApp) -> Result<()> { + self.killed.store(true, Ordering::SeqCst); + Ok(()) + } +} + +struct FakeThreadEnvironment { + handle: Rc, +} + +impl crate::ThreadEnvironment for FakeThreadEnvironment { + fn create_terminal( + &self, + _command: String, + _cwd: Option, + _output_byte_limit: Option, + _cx: &mut AsyncApp, + ) -> Task>> { + Task::ready(Ok(self.handle.clone() as Rc)) + } +} + +fn always_allow_tools(cx: &mut TestAppContext) { + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.always_allow_tool_actions = true; + agent_settings::AgentSettings::override_global(settings, cx); + }); +} + #[gpui::test] async fn test_echo(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -71,6 +170,120 @@ async fn test_echo(cx: &mut TestAppContext) { assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); } +#[gpui::test] +async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: Some(5), + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + let mut task_future: Pin>>>> = Box::pin(task.fuse()); + + let deadline = std::time::Instant::now() + Duration::from_millis(500); + loop { + if let Some(result) = task_future.as_mut().now_or_never() { + let result = result.expect("terminal tool task should complete"); + + assert!( + handle.was_killed(), + "expected terminal handle to be killed on timeout" + ); + assert!( + result.contains("partial output"), + "expected result to include terminal output, got: {result}" + ); + return; + } + + if std::time::Instant::now() >= deadline { + panic!("timed out waiting for terminal tool task to complete"); + } + + cx.run_until_parked(); + cx.background_executor.timer(Duration::from_millis(1)).await; + } +} + +#[gpui::test] +#[ignore] +async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAppContext) { + init_test(cx); + always_allow_tools(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let handle = Rc::new(cx.update(|cx| FakeTerminalHandle::new_never_exits(cx))); + let environment = Rc::new(FakeThreadEnvironment { + handle: handle.clone(), + }); + + #[allow(clippy::arc_with_non_send_sync)] + let tool = Arc::new(crate::TerminalTool::new(project, environment)); + let (event_stream, mut rx) = crate::ToolCallEventStream::test(); + + let _task = cx.update(|cx| { + tool.run( + crate::TerminalToolInput { + command: "sleep 1000".to_string(), + cd: ".".to_string(), + timeout_ms: None, + }, + event_stream, + cx, + ) + }); + + let update = rx.expect_update_fields().await; + assert!( + update.content.iter().any(|blocks| { + blocks + .iter() + .any(|c| matches!(c, acp::ToolCallContent::Terminal(_))) + }), + "expected tool call update to include terminal content" + ); + + smol::Timer::after(Duration::from_millis(25)).await; + + assert!( + !handle.was_killed(), + "did not expect terminal handle to be killed without a timeout" + ); +} + #[gpui::test] async fn test_thinking(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -493,14 +706,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { // Approve the first tool_call_auth_1 .response - .send(tool_call_auth_1.options[1].id.clone()) + .send(tool_call_auth_1.options[1].option_id.clone()) .unwrap(); cx.run_until_parked(); // Reject the second tool_call_auth_2 .response - .send(tool_call_auth_1.options[2].id.clone()) + .send(tool_call_auth_1.options[2].option_id.clone()) .unwrap(); cx.run_until_parked(); @@ -510,14 +723,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { message.content, vec![ language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_1.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), output: Some("Allowed".into()) }), language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_2.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: true, content: "Permission to run tool denied by user".into(), @@ -543,7 +756,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let tool_call_auth_3 = next_tool_call_authorization(&mut events).await; tool_call_auth_3 .response - .send(tool_call_auth_3.options[0].id.clone()) + .send(tool_call_auth_3.options[0].option_id.clone()) .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -552,7 +765,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { message.content, vec![language_model::MessageContent::ToolResult( LanguageModelToolResult { - tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_3.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), @@ -1353,20 +1566,20 @@ async fn test_cancellation(cx: &mut TestAppContext) { ThreadEvent::ToolCall(tool_call) => { assert_eq!(tool_call.title, expected_tools.remove(0)); if tool_call.title == "Echo" { - echo_id = Some(tool_call.id); + echo_id = Some(tool_call.tool_call_id); } } ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( acp::ToolCallUpdate { - id, + tool_call_id, fields: acp::ToolCallUpdateFields { status: Some(acp::ToolCallStatus::Completed), .. }, - meta: None, + .. }, - )) if Some(&id) == echo_id.as_ref() => { + )) if Some(&tool_call_id) == echo_id.as_ref() => { echo_completed = true; } _ => {} @@ -1995,11 +2208,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { .update(|cx| { connection.prompt( Some(acp_thread::UserMessageId::new()), - acp::PromptRequest { - session_id: session_id.clone(), - prompt: vec!["ghi".into()], - meta: None, - }, + acp::PromptRequest::new(session_id.clone(), vec!["ghi".into()]), cx, ) }) @@ -2056,68 +2265,50 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { let tool_call = expect_tool_call(&mut events).await; assert_eq!( tool_call, - acp::ToolCall { - id: acp::ToolCallId("1".into()), - title: "Thinking".into(), - kind: acp::ToolKind::Think, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(json!({})), - raw_output: None, - meta: Some(json!({ "tool_name": "thinking" })), - } + acp::ToolCall::new("1", "Thinking") + .kind(acp::ToolKind::Think) + .raw_input(json!({})) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + "thinking".into() + )])) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - title: Some("Thinking".into()), - kind: Some(acp::ToolKind::Think), - raw_input: Some(json!({ "content": "Thinking hard!" })), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new() + .title("Thinking") + .kind(acp::ToolKind::Think) + .raw_input(json!({ "content": "Thinking hard!"})) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - content: Some(vec!["Thinking hard!".into()]), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new().content(vec!["Thinking hard!".into()]) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - raw_output: Some("Finished thinking.".into()), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .raw_output("Finished thinking.") + ) ); } @@ -2618,3 +2809,181 @@ fn setup_context_server( cx.run_until_parked(); mcp_tool_calls_rx } + +#[gpui::test] +async fn test_tokens_before_message(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // First message + let message_1_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_1_id.clone(), ["First message"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // Before any response, tokens_before_message should return None for first message + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_1_id), + None, + "First message should have no tokens before it" + ); + }); + + // Complete first message with usage + fake_model.send_last_completion_stream_text_chunk("Response 1"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // First message still has no tokens before it + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_1_id), + None, + "First message should still have no tokens before it after response" + ); + }); + + // Second message + let message_2_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_2_id.clone(), ["Second message"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // Second message should have first message's input tokens before it + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_2_id), + Some(100), + "Second message should have 100 tokens before it (from first request)" + ); + }); + + // Complete second message + fake_model.send_last_completion_stream_text_chunk("Response 2"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 250, // Total for this request (includes previous context) + output_tokens: 75, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // Third message + let message_3_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_3_id.clone(), ["Third message"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // Third message should have second message's input tokens (250) before it + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_3_id), + Some(250), + "Third message should have 250 tokens before it (from second request)" + ); + // Second message should still have 100 + assert_eq!( + thread.tokens_before_message(&message_2_id), + Some(100), + "Second message should still have 100 tokens before it" + ); + // First message still has none + assert_eq!( + thread.tokens_before_message(&message_1_id), + None, + "First message should still have no tokens before it" + ); + }); +} + +#[gpui::test] +async fn test_tokens_before_message_after_truncate(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + // Set up three messages with responses + let message_1_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_1_id.clone(), ["Message 1"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Response 1"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let message_2_id = UserMessageId::new(); + thread + .update(cx, |thread, cx| { + thread.send(message_2_id.clone(), ["Message 2"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Response 2"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 250, + output_tokens: 75, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // Verify initial state + thread.read_with(cx, |thread, _| { + assert_eq!(thread.tokens_before_message(&message_2_id), Some(100)); + }); + + // Truncate at message 2 (removes message 2 and everything after) + thread + .update(cx, |thread, cx| thread.truncate(message_2_id.clone(), cx)) + .unwrap(); + cx.run_until_parked(); + + // After truncation, message_2_id no longer exists, so lookup should return None + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.tokens_before_message(&message_2_id), + None, + "After truncation, message 2 no longer exists" + ); + // Message 1 still exists but has no tokens before it + assert_eq!( + thread.tokens_before_message(&message_1_id), + None, + "First message still has no tokens before it" + ); + }); +} diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 294c96b3ecb7800ab5b5f62749d335682efebd60..ef3ca23c3caf816a28e91e9e75b21f2cc80451e7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2,7 +2,8 @@ use crate::{ ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, - SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, WebSearchTool, + RestoreFileFromDiskTool, SaveFileTool, SystemPromptTemplate, Template, Templates, TerminalTool, + ThinkingTool, WebSearchTool, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; @@ -107,7 +108,13 @@ impl Message { pub fn to_request(&self) -> Vec { match self { - Message::User(message) => vec![message.to_request()], + Message::User(message) => { + if message.content.is_empty() { + vec![] + } else { + vec![message.to_request()] + } + } Message::Agent(message) => message.to_request(), Message::Resume => vec![LanguageModelRequestMessage { role: Role::User, @@ -530,6 +537,7 @@ pub trait TerminalHandle { fn id(&self, cx: &AsyncApp) -> Result; fn current_output(&self, cx: &AsyncApp) -> Result; fn wait_for_exit(&self, cx: &AsyncApp) -> Result>>; + fn kill(&self, cx: &AsyncApp) -> Result<()>; } pub trait ThreadEnvironment { @@ -619,12 +627,9 @@ pub struct Thread { impl Thread { fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities { let image = model.map_or(true, |model| model.supports_images()); - acp::PromptCapabilities { - meta: None, - image, - audio: false, - embedded_context: true, - } + acp::PromptCapabilities::new() + .image(image) + .embedded_context(true) } pub fn new( @@ -640,7 +645,7 @@ impl Thread { let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel(Self::prompt_capabilities(model.as_deref())); Self { - id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()), + id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()), prompt_id: PromptId::new(), updated_at: Utc::now(), title: None, @@ -737,17 +742,11 @@ impl Thread { let Some(tool) = tool else { stream .0 - .unbounded_send(Ok(ThreadEvent::ToolCall(acp::ToolCall { - meta: None, - id: acp::ToolCallId(tool_use.id.to_string().into()), - title: tool_use.name.to_string(), - kind: acp::ToolKind::Other, - status: acp::ToolCallStatus::Failed, - content: Vec::new(), - locations: Vec::new(), - raw_input: Some(tool_use.input.clone()), - raw_output: None, - }))) + .unbounded_send(Ok(ThreadEvent::ToolCall( + acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) + .status(acp::ToolCallStatus::Failed) + .raw_input(tool_use.input.clone()), + ))) .ok(); return; }; @@ -777,8 +776,8 @@ impl Thread { stream.update_tool_call_fields( &tool_use.id, - acp::ToolCallUpdateFields { - status: Some( + acp::ToolCallUpdateFields::new() + .status( tool_result .as_ref() .map_or(acp::ToolCallStatus::Failed, |result| { @@ -788,10 +787,8 @@ impl Thread { acp::ToolCallStatus::Completed } }), - ), - raw_output: output, - ..Default::default() - }, + ) + .raw_output(output), ); } @@ -1012,6 +1009,8 @@ impl Thread { self.project.clone(), self.action_log.clone(), )); + self.add_tool(SaveFileTool::new(self.project.clone())); + self.add_tool(RestoreFileFromDiskTool::new(self.project.clone())); self.add_tool(TerminalTool::new(self.project.clone(), environment)); self.add_tool(ThinkingTool); self.add_tool(WebSearchTool); @@ -1096,6 +1095,28 @@ impl Thread { }) } + /// Get the total input token count as of the message before the given message. + /// + /// Returns `None` if: + /// - `target_id` is the first message (no previous message) + /// - The previous message hasn't received a response yet (no usage data) + /// - `target_id` is not found in the messages + pub fn tokens_before_message(&self, target_id: &UserMessageId) -> Option { + let mut previous_user_message_id: Option<&UserMessageId> = None; + + for message in &self.messages { + if let Message::User(user_msg) = message { + if &user_msg.id == target_id { + let prev_id = previous_user_message_id?; + let usage = self.request_token_usage.get(prev_id)?; + return Some(usage.input_tokens); + } + previous_user_message_id = Some(&user_msg.id); + } + } + None + } + /// Look up the active profile and resolve its preferred model if one is configured. fn resolve_profile_model( profile_id: &AgentProfileId, @@ -1148,20 +1169,64 @@ impl Thread { where T: Into, { + let content = content.into_iter().map(Into::into).collect::>(); + log::debug!("Thread::send content: {:?}", content); + + self.messages + .push(Message::User(UserMessage { id, content })); + cx.notify(); + + self.send_existing(cx) + } + + pub fn send_existing( + &mut self, + cx: &mut Context, + ) -> Result>> { let model = self.model().context("No language model configured")?; log::info!("Thread::send called with model: {}", model.name().0); self.advance_prompt_id(); - let content = content.into_iter().map(Into::into).collect::>(); - log::debug!("Thread::send content: {:?}", content); + log::debug!("Total messages in thread: {}", self.messages.len()); + self.run_turn(cx) + } + pub fn push_acp_user_block( + &mut self, + id: UserMessageId, + blocks: impl IntoIterator, + path_style: PathStyle, + cx: &mut Context, + ) { + let content = blocks + .into_iter() + .map(|block| UserMessageContent::from_content_block(block, path_style)) + .collect::>(); self.messages .push(Message::User(UserMessage { id, content })); cx.notify(); + } - log::debug!("Total messages in thread: {}", self.messages.len()); - self.run_turn(cx) + pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context) { + let text = match block { + acp::ContentBlock::Text(text_content) => text_content.text, + acp::ContentBlock::Image(_) => "[image]".to_string(), + acp::ContentBlock::Audio(_) => "[audio]".to_string(), + acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri, + acp::ContentBlock::Resource(resource) => match resource.resource { + acp::EmbeddedResourceResource::TextResourceContents(resource) => resource.uri, + acp::EmbeddedResourceResource::BlobResourceContents(resource) => resource.uri, + _ => "[resource]".to_string(), + }, + _ => "[unknown]".to_string(), + }; + + self.messages.push(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::Text(text)], + ..Default::default() + })); + cx.notify(); } #[cfg(feature = "eval")] @@ -1274,15 +1339,13 @@ impl Thread { event_stream.update_tool_call_fields( &tool_result.tool_use_id, - acp::ToolCallUpdateFields { - status: Some(if tool_result.is_error { + acp::ToolCallUpdateFields::new() + .status(if tool_result.is_error { acp::ToolCallStatus::Failed } else { acp::ToolCallStatus::Completed - }), - raw_output: tool_result.output.clone(), - ..Default::default() - }, + }) + .raw_output(tool_result.output.clone()), ); this.update(cx, |this, _cx| { this.pending_message() @@ -1560,12 +1623,10 @@ impl Thread { } else { event_stream.update_tool_call_fields( &tool_use.id, - acp::ToolCallUpdateFields { - title: Some(title.into()), - kind: Some(kind), - raw_input: Some(tool_use.input.clone()), - ..Default::default() - }, + acp::ToolCallUpdateFields::new() + .title(title.as_str()) + .kind(kind) + .raw_input(tool_use.input.clone()), ); } @@ -1587,10 +1648,9 @@ impl Thread { let fs = self.project.read(cx).fs().clone(); let tool_event_stream = ToolCallEventStream::new(tool_use.id.clone(), event_stream.clone(), Some(fs)); - tool_event_stream.update_fields(acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }); + tool_event_stream.update_fields( + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), + ); let supports_images = self.model().is_some_and(|model| model.supports_images()); let tool_result = tool.run(tool_use.input, tool_event_stream, cx); log::debug!("Running tool {}", tool_use.name); @@ -1665,6 +1725,10 @@ impl Thread { self.pending_summary_generation.is_some() } + pub fn is_generating_title(&self) -> bool { + self.pending_title_generation.is_some() + } + pub fn summary(&mut self, cx: &mut Context) -> Shared>> { if let Some(summary) = self.summary.as_ref() { return Task::ready(Some(summary.clone())).shared(); @@ -1732,7 +1796,7 @@ impl Thread { task } - fn generate_title(&mut self, cx: &mut Context) { + pub fn generate_title(&mut self, cx: &mut Context) { let Some(model) = self.summarization_model.clone() else { return; }; @@ -1981,6 +2045,12 @@ impl Thread { self.running_turn.as_ref()?.tools.get(name).cloned() } + pub fn has_tool(&self, name: &str) -> bool { + self.running_turn + .as_ref() + .is_some_and(|turn| turn.tools.contains_key(name)) + } + fn build_request_messages( &self, available_tools: Vec, @@ -2381,19 +2451,13 @@ impl ThreadEventStream { kind: acp::ToolKind, input: serde_json::Value, ) -> acp::ToolCall { - acp::ToolCall { - meta: Some(serde_json::json!({ - "tool_name": tool_name - })), - id: acp::ToolCallId(id.to_string().into()), - title, - kind, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(input), - raw_output: None, - } + acp::ToolCall::new(id.to_string(), title) + .kind(kind) + .raw_input(input) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + tool_name.into(), + )])) } fn update_tool_call_fields( @@ -2403,12 +2467,7 @@ impl ThreadEventStream { ) { self.0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( - acp::ToolCallUpdate { - meta: None, - id: acp::ToolCallId(tool_use_id.to_string().into()), - fields, - } - .into(), + acp::ToolCallUpdate::new(tool_use_id.to_string(), fields).into(), ))) .ok(); } @@ -2471,7 +2530,7 @@ impl ToolCallEventStream { .0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp_thread::ToolCallUpdateDiff { - id: acp::ToolCallId(self.tool_use_id.to_string().into()), + id: acp::ToolCallId::new(self.tool_use_id.to_string()), diff, } .into(), @@ -2489,33 +2548,26 @@ impl ToolCallEventStream { .0 .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( ToolCallAuthorization { - tool_call: acp::ToolCallUpdate { - meta: None, - id: acp::ToolCallId(self.tool_use_id.to_string().into()), - fields: acp::ToolCallUpdateFields { - title: Some(title.into()), - ..Default::default() - }, - }, + tool_call: acp::ToolCallUpdate::new( + self.tool_use_id.to_string(), + acp::ToolCallUpdateFields::new().title(title.into()), + ), options: vec![ - acp::PermissionOption { - id: acp::PermissionOptionId("always_allow".into()), - name: "Always Allow".into(), - kind: acp::PermissionOptionKind::AllowAlways, - meta: None, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("allow".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - meta: None, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("deny".into()), - name: "Deny".into(), - kind: acp::PermissionOptionKind::RejectOnce, - meta: None, - }, + acp::PermissionOption::new( + acp::PermissionOptionId::new("always_allow"), + "Always Allow", + acp::PermissionOptionKind::AllowAlways, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("deny"), + "Deny", + acp::PermissionOptionKind::RejectOnce, + ), ], response: response_tx, }, @@ -2660,7 +2712,15 @@ impl UserMessageContent { // TODO Self::Text("[blob]".to_string()) } + other => { + log::warn!("Unexpected content type: {:?}", other); + Self::Text("[unknown]".to_string()) + } }, + other => { + log::warn!("Unexpected content type: {:?}", other); + Self::Text("[unknown]".to_string()) + } } } } @@ -2668,32 +2728,15 @@ impl UserMessageContent { impl From for acp::ContentBlock { fn from(content: UserMessageContent) -> Self { match content { - UserMessageContent::Text(text) => acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - UserMessageContent::Image(image) => acp::ContentBlock::Image(acp::ImageContent { - data: image.source.to_string(), - mime_type: "image/png".to_string(), - meta: None, - annotations: None, - uri: None, - }), - UserMessageContent::Mention { uri, content } => { - acp::ContentBlock::Resource(acp::EmbeddedResource { - meta: None, - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - meta: None, - mime_type: None, - text: content, - uri: uri.to_uri().to_string(), - }, - ), - annotations: None, - }) + UserMessageContent::Text(text) => text.into(), + UserMessageContent::Image(image) => { + acp::ContentBlock::Image(acp::ImageContent::new(image.source, "image/png")) } + UserMessageContent::Mention { uri, content } => acp::ContentBlock::Resource( + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(content, uri.to_uri().to_string()), + )), + ), } } } @@ -2701,7 +2744,6 @@ impl From for acp::ContentBlock { fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage { LanguageModelImage { source: image_content.data.into(), - // TODO: make this optional? - size: gpui::Size::new(0.into(), 0.into()), + size: None, } } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 1d3c0d557716ec3a52f910971547df4ee764cab0..358903a32baa5ead9b073642015e6829501307a2 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -12,6 +12,9 @@ mod move_path_tool; mod now_tool; mod open_tool; mod read_file_tool; +mod restore_file_from_disk_tool; +mod save_file_tool; + mod terminal_tool; mod thinking_tool; mod web_search_tool; @@ -33,6 +36,9 @@ pub use move_path_tool::*; pub use now_tool::*; pub use open_tool::*; pub use read_file_tool::*; +pub use restore_file_from_disk_tool::*; +pub use save_file_tool::*; + pub use terminal_tool::*; pub use thinking_tool::*; pub use web_search_tool::*; @@ -88,6 +94,8 @@ tools! { NowTool, OpenTool, ReadFileTool, + RestoreFileFromDiskTool, + SaveFileTool, TerminalTool, ThinkingTool, WebSearchTool, diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 03a0ef84e73d4cbca83d61077d568ec58cd7ae2b..3b01b2feb7dd36615a8ba7c63d81a81694e0d268 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -2,12 +2,24 @@ use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream}; use agent_client_protocol::ToolKind; use anyhow::{Result, anyhow, bail}; use collections::{BTreeMap, HashMap}; -use context_server::ContextServerId; -use gpui::{App, Context, Entity, SharedString, Task}; +use context_server::{ContextServerId, client::NotificationSubscription}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; use project::context_server_store::{ContextServerStatus, ContextServerStore}; use std::sync::Arc; use util::ResultExt; +pub struct ContextServerPrompt { + pub server_id: ContextServerId, + pub prompt: context_server::types::Prompt, +} + +pub enum ContextServerRegistryEvent { + ToolsChanged, + PromptsChanged, +} + +impl EventEmitter for ContextServerRegistry {} + pub struct ContextServerRegistry { server_store: Entity, registered_servers: HashMap, @@ -16,7 +28,10 @@ pub struct ContextServerRegistry { struct RegisteredContextServer { tools: BTreeMap>, + prompts: BTreeMap, load_tools: Task>, + load_prompts: Task>, + _tools_updated_subscription: Option, } impl ContextServerRegistry { @@ -28,6 +43,7 @@ impl ContextServerRegistry { }; for server in server_store.read(cx).running_servers() { this.reload_tools_for_server(server.id(), cx); + this.reload_prompts_for_server(server.id(), cx); } this } @@ -56,6 +72,88 @@ impl ContextServerRegistry { .map(|(id, server)| (id, &server.tools)) } + pub fn prompts(&self) -> impl Iterator { + self.registered_servers + .values() + .flat_map(|server| server.prompts.values()) + } + + pub fn find_prompt( + &self, + server_id: Option<&ContextServerId>, + name: &str, + ) -> Option<&ContextServerPrompt> { + if let Some(server_id) = server_id { + self.registered_servers + .get(server_id) + .and_then(|server| server.prompts.get(name)) + } else { + self.registered_servers + .values() + .find_map(|server| server.prompts.get(name)) + } + } + + pub fn server_store(&self) -> &Entity { + &self.server_store + } + + fn get_or_register_server( + &mut self, + server_id: &ContextServerId, + cx: &mut Context, + ) -> &mut RegisteredContextServer { + self.registered_servers + .entry(server_id.clone()) + .or_insert_with(|| Self::init_registered_server(server_id, &self.server_store, cx)) + } + + fn init_registered_server( + server_id: &ContextServerId, + server_store: &Entity, + cx: &mut Context, + ) -> RegisteredContextServer { + let tools_updated_subscription = server_store + .read(cx) + .get_running_server(server_id) + .and_then(|server| { + let client = server.client()?; + + if !client.capable(context_server::protocol::ServerCapability::Tools) { + return None; + } + + let server_id = server.id(); + let this = cx.entity().downgrade(); + + Some(client.on_notification( + "notifications/tools/list_changed", + Box::new(move |_params, cx: AsyncApp| { + let server_id = server_id.clone(); + let this = this.clone(); + cx.spawn(async move |cx| { + this.update(cx, |this, cx| { + log::info!( + "Received tools/list_changed notification for server {}", + server_id + ); + this.reload_tools_for_server(server_id, cx); + }) + }) + .detach(); + }), + )) + }); + + RegisteredContextServer { + tools: BTreeMap::default(), + prompts: BTreeMap::default(), + load_tools: Task::ready(Ok(())), + load_prompts: Task::ready(Ok(())), + _tools_updated_subscription: tools_updated_subscription, + } + } + fn reload_tools_for_server(&mut self, server_id: ContextServerId, cx: &mut Context) { let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else { return; @@ -63,17 +161,12 @@ impl ContextServerRegistry { let Some(client) = server.client() else { return; }; + if !client.capable(context_server::protocol::ServerCapability::Tools) { return; } - let registered_server = - self.registered_servers - .entry(server_id.clone()) - .or_insert(RegisteredContextServer { - tools: BTreeMap::default(), - load_tools: Task::ready(Ok(())), - }); + let registered_server = self.get_or_register_server(&server_id, cx); registered_server.load_tools = cx.spawn(async move |this, cx| { let response = client .request::(()) @@ -94,6 +187,49 @@ impl ContextServerRegistry { )); registered_server.tools.insert(tool.name(), tool); } + cx.emit(ContextServerRegistryEvent::ToolsChanged); + cx.notify(); + } + }) + }); + } + + fn reload_prompts_for_server(&mut self, server_id: ContextServerId, cx: &mut Context) { + let Some(server) = self.server_store.read(cx).get_running_server(&server_id) else { + return; + }; + let Some(client) = server.client() else { + return; + }; + if !client.capable(context_server::protocol::ServerCapability::Prompts) { + return; + } + + let registered_server = self.get_or_register_server(&server_id, cx); + + registered_server.load_prompts = cx.spawn(async move |this, cx| { + let response = client + .request::(()) + .await; + + this.update(cx, |this, cx| { + let Some(registered_server) = this.registered_servers.get_mut(&server_id) else { + return; + }; + + registered_server.prompts.clear(); + if let Some(response) = response.log_err() { + for prompt in response.prompts { + let name: SharedString = prompt.name.clone().into(); + registered_server.prompts.insert( + name, + ContextServerPrompt { + server_id: server_id.clone(), + prompt, + }, + ); + } + cx.emit(ContextServerRegistryEvent::PromptsChanged); cx.notify(); } }) @@ -112,9 +248,17 @@ impl ContextServerRegistry { ContextServerStatus::Starting => {} ContextServerStatus::Running => { self.reload_tools_for_server(server_id.clone(), cx); + self.reload_prompts_for_server(server_id.clone(), cx); } ContextServerStatus::Stopped | ContextServerStatus::Error(_) => { - self.registered_servers.remove(server_id); + if let Some(registered_server) = self.registered_servers.remove(server_id) { + if !registered_server.tools.is_empty() { + cx.emit(ContextServerRegistryEvent::ToolsChanged); + } + if !registered_server.prompts.is_empty() { + cx.emit(ContextServerRegistryEvent::PromptsChanged); + } + } cx.notify(); } } @@ -251,3 +395,39 @@ impl AnyAgentTool for ContextServerTool { Ok(()) } } + +pub fn get_prompt( + server_store: &Entity, + server_id: &ContextServerId, + prompt_name: &str, + arguments: HashMap, + cx: &mut AsyncApp, +) -> Task> { + let server = match cx.update(|cx| server_store.read(cx).get_running_server(server_id)) { + Ok(server) => server, + Err(error) => return Task::ready(Err(error)), + }; + let Some(server) = server else { + return Task::ready(Err(anyhow::anyhow!("Context server not found"))); + }; + + let Some(protocol) = server.client() else { + return Task::ready(Err(anyhow::anyhow!("Context server not initialized"))); + }; + + let prompt_name = prompt_name.to_string(); + + cx.background_spawn(async move { + let response = protocol + .request::( + context_server::types::PromptsGetParams { + name: prompt_name, + arguments: (!arguments.is_empty()).then(|| arguments), + meta: None, + }, + ) + .await?; + + Ok(response) + }) +} diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index de2dd384693c8af3e04007895c843743c5ead722..3acb7f5951f3ca4b682dcabc62a0d54c35ab08d6 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -273,14 +273,9 @@ impl AgentTool for EditFileTool { }; let abs_path = project.read(cx).absolute_path(&project_path, cx); if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: abs_path, - line: None, - meta: None, - }]), - ..Default::default() - }); + event_stream.update_fields( + ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]), + ); } let authorize = self.authorize(&input, &event_stream, cx); @@ -311,20 +306,39 @@ impl AgentTool for EditFileTool { // Check if the file has been modified since the agent last read it if let Some(abs_path) = abs_path.as_ref() { - let (last_read_mtime, current_mtime, is_dirty) = self.thread.update(cx, |thread, cx| { + let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| { let last_read = thread.file_read_times.get(abs_path).copied(); let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime()); let dirty = buffer.read(cx).is_dirty(); - (last_read, current, dirty) + let has_save = thread.has_tool("save_file"); + let has_restore = thread.has_tool("restore_file_from_disk"); + (last_read, current, dirty, has_save, has_restore) })?; // Check for unsaved changes first - these indicate modifications we don't know about if is_dirty { - anyhow::bail!( - "This file cannot be written to because it has unsaved changes. \ - Please end the current conversation immediately by telling the user you want to write to this file (mention its path explicitly) but you can't write to it because it has unsaved changes. \ - Ask the user to save that buffer's changes and to inform you when it's ok to proceed." - ); + let message = match (has_save_tool, has_restore_tool) { + (true, true) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." + } + (true, false) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ + If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." + } + (false, true) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." + } + (false, false) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ + then ask them to save or revert the file manually and inform you when it's ok to proceed." + } + }; + anyhow::bail!("{}", message); } // Check if the file was modified on disk since we last read it @@ -389,10 +403,7 @@ impl AgentTool for EditFileTool { range.start.to_point(&buffer.snapshot()).row }).ok(); if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![ToolCallLocation { path: abs_path, line, meta: None }]), - ..Default::default() - }); + event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)])); } emitted_location = true; } @@ -2210,9 +2221,21 @@ mod tests { assert!(result.is_err(), "Edit should fail when buffer is dirty"); let error_msg = result.unwrap_err().to_string(); assert!( - error_msg.contains("cannot be written to because it has unsaved changes"), + error_msg.contains("This file has unsaved changes."), "Error should mention unsaved changes, got: {}", error_msg ); + assert!( + error_msg.contains("keep or discard"), + "Error should ask whether to keep or discard changes, got: {}", + error_msg + ); + // Since save_file and restore_file_from_disk tools aren't added to the thread, + // the error message should ask the user to manually save or revert + assert!( + error_msg.contains("save or revert the file manually"), + "Error should ask user to manually save or revert when tools aren't available, got: {}", + error_msg + ); } } diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 70d7b29f75d4da984c4acda13dcdbfe7bc69fbbc..2a33b14b4c87d87154e2aa1ee25363b397189f89 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -118,33 +118,29 @@ impl AgentTool for FindPathTool { let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len()) ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())]; - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(if paginated_matches.is_empty() { - "No matches".into() - } else if paginated_matches.len() == 1 { - "1 match".into() - } else { - format!("{} matches", paginated_matches.len()) - }), - content: Some( - paginated_matches - .iter() - .map(|path| acp::ToolCallContent::Content { - content: acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: format!("file://{}", path.display()), - name: path.to_string_lossy().into(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), - }) - .collect(), - ), - ..Default::default() - }); + event_stream.update_fields( + acp::ToolCallUpdateFields::new() + .title(if paginated_matches.is_empty() { + "No matches".into() + } else if paginated_matches.len() == 1 { + "1 match".into() + } else { + format!("{} matches", paginated_matches.len()) + }) + .content( + paginated_matches + .iter() + .map(|path| { + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + path.to_string_lossy(), + format!("file://{}", path.display()), + )), + )) + }) + .collect::>(), + ), + ); Ok(FindPathToolOutput { offset: input.offset, diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index ec61b013e87ccb3afc133ee0a264e55a6d8baee9..0caba91564fd1fc9e670909490d4e776b8ad6f11 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -322,7 +322,6 @@ mod tests { use super::*; use gpui::{TestAppContext, UpdateGlobal}; - use language::{Language, LanguageConfig, LanguageMatcher}; use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; @@ -564,7 +563,7 @@ mod tests { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| { - project.languages().add(rust_lang().into()) + project.languages().add(language::rust_lang()) }); project @@ -793,22 +792,6 @@ mod tests { }); } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../../languages/src/rust/outline.scm")) - .unwrap() - } - #[gpui::test] async fn test_grep_security_boundaries(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index fd7b85d5ee4d075f5ab5f3fcdef2d1919e763dd7..acfd4a16746fc1f78fd388f5dacf3e360f070ab5 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -153,14 +153,10 @@ impl AgentTool for ReadFileTool { let file_path = input.path.clone(); - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: input.start_line.map(|line| line.saturating_sub(1)), - meta: None, - }]), - ..Default::default() - }); + event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ + acp::ToolCallLocation::new(&abs_path) + .line(input.start_line.map(|line| line.saturating_sub(1))), + ])); if image_store::is_image_file(&self.project, &project_path, cx) { return cx.spawn(async move |cx| { @@ -289,12 +285,9 @@ impl AgentTool for ReadFileTool { text, } .to_string(); - event_stream.update_fields(ToolCallUpdateFields { - content: Some(vec![acp::ToolCallContent::Content { - content: markdown.into(), - }]), - ..Default::default() - }) + event_stream.update_fields(ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new(markdown)), + ])); } })?; @@ -308,7 +301,6 @@ mod test { use super::*; use crate::{ContextServerRegistry, Templates, Thread}; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; - use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; use language_model::fake_provider::FakeLanguageModel; use project::{FakeFs, Project}; use prompt_store::ProjectContext; @@ -412,7 +404,7 @@ mod test { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(Arc::new(rust_lang())); + language_registry.add(language::rust_lang()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); @@ -602,49 +594,6 @@ mod test { }); } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query( - r#" - (line_comment) @annotation - - (struct_item - "struct" @context - name: (_) @name) @item - (enum_item - "enum" @context - name: (_) @name) @item - (enum_variant - name: (_) @name) @item - (field_declaration - name: (_) @name) @item - (impl_item - "impl" @context - trait: (_)? @name - "for"? @context - type: (_) @name - body: (_ "{" (_)* "}")) @item - (function_item - "fn" @context - name: (_) @name) @item - (mod_item - "mod" @context - name: (_) @name) @item - "#, - ) - .unwrap() - } - #[gpui::test] async fn test_read_file_security(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..f5723f6ee3ee46144152dd3ed2939ab2cfaca9c0 --- /dev/null +++ b/crates/agent/src/tools/restore_file_from_disk_tool.rs @@ -0,0 +1,352 @@ +use agent_client_protocol as acp; +use anyhow::Result; +use collections::FxHashSet; +use gpui::{App, Entity, SharedString, Task}; +use language::Buffer; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::{AgentTool, ToolCallEventStream}; + +/// Discards unsaved changes in open buffers by reloading file contents from disk. +/// +/// Use this tool when: +/// - You attempted to edit files but they have unsaved changes the user does not want to keep. +/// - You want to reset files to the on-disk state before retrying an edit. +/// +/// Only use this tool after asking the user for permission, because it will discard unsaved changes. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct RestoreFileFromDiskToolInput { + /// The paths of the files to restore from disk. + pub paths: Vec, +} + +pub struct RestoreFileFromDiskTool { + project: Entity, +} + +impl RestoreFileFromDiskTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for RestoreFileFromDiskTool { + type Input = RestoreFileFromDiskToolInput; + type Output = String; + + fn name() -> &'static str { + "restore_file_from_disk" + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + match input { + Ok(input) if input.paths.len() == 1 => "Restore file from disk".into(), + Ok(input) => format!("Restore {} files from disk", input.paths.len()).into(), + Err(_) => "Restore files from disk".into(), + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + let input_paths = input.paths; + + cx.spawn(async move |cx| { + let mut buffers_to_reload: FxHashSet> = FxHashSet::default(); + + let mut restored_paths: Vec = Vec::new(); + let mut clean_paths: Vec = Vec::new(); + let mut not_found_paths: Vec = Vec::new(); + let mut open_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut reload_errors: Vec = Vec::new(); + + for path in input_paths { + let project_path = + project.read_with(cx, |project, cx| project.find_project_path(&path, cx)); + + let project_path = match project_path { + Ok(Some(project_path)) => project_path, + Ok(None) => { + not_found_paths.push(path); + continue; + } + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }; + + let open_buffer_task = + project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + + let buffer = match open_buffer_task { + Ok(task) => match task.await { + Ok(buffer) => buffer, + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }, + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }; + + let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) { + Ok(is_dirty) => is_dirty, + Err(error) => { + dirty_check_errors.push((path, error.to_string())); + continue; + } + }; + + if is_dirty { + buffers_to_reload.insert(buffer); + restored_paths.push(path); + } else { + clean_paths.push(path); + } + } + + if !buffers_to_reload.is_empty() { + let reload_task = project.update(cx, |project, cx| { + project.reload_buffers(buffers_to_reload, true, cx) + }); + + match reload_task { + Ok(task) => { + if let Err(error) = task.await { + reload_errors.push(error.to_string()); + } + } + Err(error) => { + reload_errors.push(error.to_string()); + } + } + } + + let mut lines: Vec = Vec::new(); + + if !restored_paths.is_empty() { + lines.push(format!("Restored {} file(s).", restored_paths.len())); + } + if !clean_paths.is_empty() { + lines.push(format!("{} clean.", clean_paths.len())); + } + + if !not_found_paths.is_empty() { + lines.push(format!("Not found ({}):", not_found_paths.len())); + for path in ¬_found_paths { + lines.push(format!("- {}", path.display())); + } + } + if !open_errors.is_empty() { + lines.push(format!("Open failed ({}):", open_errors.len())); + for (path, error) in &open_errors { + lines.push(format!("- {}: {}", path.display(), error)); + } + } + if !dirty_check_errors.is_empty() { + lines.push(format!( + "Dirty check failed ({}):", + dirty_check_errors.len() + )); + for (path, error) in &dirty_check_errors { + lines.push(format!("- {}: {}", path.display(), error)); + } + } + if !reload_errors.is_empty() { + lines.push(format!("Reload failed ({}):", reload_errors.len())); + for error in &reload_errors { + lines.push(format!("- {}", error)); + } + } + + if lines.is_empty() { + Ok("No paths provided.".to_string()) + } else { + Ok(lines.join("\n")) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fs::Fs; + use gpui::TestAppContext; + use language::LineEnding; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + #[gpui::test] + async fn test_restore_file_from_disk_output_and_effects(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dirty.txt": "on disk: dirty\n", + "clean.txt": "on disk: clean\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let tool = Arc::new(RestoreFileFromDiskTool::new(project.clone())); + + // Make dirty.txt dirty in-memory by saving different content into the buffer without saving to disk. + let dirty_project_path = project.read_with(cx, |project, cx| { + project + .find_project_path("root/dirty.txt", cx) + .expect("dirty.txt should exist in project") + }); + + let dirty_buffer = project + .update(cx, |project, cx| { + project.open_buffer(dirty_project_path, cx) + }) + .await + .unwrap(); + dirty_buffer.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx); + }); + assert!( + dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "dirty.txt buffer should be dirty before restore" + ); + + // Ensure clean.txt is opened but remains clean. + let clean_project_path = project.read_with(cx, |project, cx| { + project + .find_project_path("root/clean.txt", cx) + .expect("clean.txt should exist in project") + }); + + let clean_buffer = project + .update(cx, |project, cx| { + project.open_buffer(clean_project_path, cx) + }) + .await + .unwrap(); + assert!( + !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "clean.txt buffer should start clean" + ); + + let output = cx + .update(|cx| { + tool.clone().run( + RestoreFileFromDiskToolInput { + paths: vec![ + PathBuf::from("root/dirty.txt"), + PathBuf::from("root/clean.txt"), + ], + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Output should mention restored + clean. + assert!( + output.contains("Restored 1 file(s)."), + "expected restored count line, got:\n{output}" + ); + assert!( + output.contains("1 clean."), + "expected clean count line, got:\n{output}" + ); + + // Effect: dirty buffer should be restored back to disk content and become clean. + let dirty_text = dirty_buffer.read_with(cx, |buffer, _| buffer.text()); + assert_eq!( + dirty_text, "on disk: dirty\n", + "dirty.txt buffer should be restored to disk contents" + ); + assert!( + !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "dirty.txt buffer should not be dirty after restore" + ); + + // Disk contents should be unchanged (restore-from-disk should not write). + let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap(); + assert_eq!(disk_dirty, "on disk: dirty\n"); + + // Sanity: clean buffer should remain clean and unchanged. + let clean_text = clean_buffer.read_with(cx, |buffer, _| buffer.text()); + assert_eq!(clean_text, "on disk: clean\n"); + assert!( + !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "clean.txt buffer should remain clean" + ); + + // Test empty paths case. + let output = cx + .update(|cx| { + tool.clone().run( + RestoreFileFromDiskToolInput { paths: vec![] }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + assert_eq!(output, "No paths provided."); + + // Test not-found path case (path outside the project root). + let output = cx + .update(|cx| { + tool.clone().run( + RestoreFileFromDiskToolInput { + paths: vec![PathBuf::from("nonexistent/path.txt")], + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + assert!( + output.contains("Not found (1):"), + "expected not-found header line, got:\n{output}" + ); + assert!( + output.contains("- nonexistent/path.txt"), + "expected not-found path bullet, got:\n{output}" + ); + + let _ = LineEnding::Unix; // keep import used if the buffer edit API changes + } +} diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..429352200109c52303c9f6f94a28a49136af1a61 --- /dev/null +++ b/crates/agent/src/tools/save_file_tool.rs @@ -0,0 +1,351 @@ +use agent_client_protocol as acp; +use anyhow::Result; +use collections::FxHashSet; +use gpui::{App, Entity, SharedString, Task}; +use language::Buffer; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::{AgentTool, ToolCallEventStream}; + +/// Saves files that have unsaved changes. +/// +/// Use this tool when you need to edit files but they have unsaved changes that must be saved first. +/// Only use this tool after asking the user for permission to save their unsaved changes. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct SaveFileToolInput { + /// The paths of the files to save. + pub paths: Vec, +} + +pub struct SaveFileTool { + project: Entity, +} + +impl SaveFileTool { + pub fn new(project: Entity) -> Self { + Self { project } + } +} + +impl AgentTool for SaveFileTool { + type Input = SaveFileToolInput; + type Output = String; + + fn name() -> &'static str { + "save_file" + } + + fn kind() -> acp::ToolKind { + acp::ToolKind::Other + } + + fn initial_title( + &self, + input: Result, + _cx: &mut App, + ) -> SharedString { + match input { + Ok(input) if input.paths.len() == 1 => "Save file".into(), + Ok(input) => format!("Save {} files", input.paths.len()).into(), + Err(_) => "Save files".into(), + } + } + + fn run( + self: Arc, + input: Self::Input, + _event_stream: ToolCallEventStream, + cx: &mut App, + ) -> Task> { + let project = self.project.clone(); + let input_paths = input.paths; + + cx.spawn(async move |cx| { + let mut buffers_to_save: FxHashSet> = FxHashSet::default(); + + let mut saved_paths: Vec = Vec::new(); + let mut clean_paths: Vec = Vec::new(); + let mut not_found_paths: Vec = Vec::new(); + let mut open_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut dirty_check_errors: Vec<(PathBuf, String)> = Vec::new(); + let mut save_errors: Vec<(String, String)> = Vec::new(); + + for path in input_paths { + let project_path = + project.read_with(cx, |project, cx| project.find_project_path(&path, cx)); + + let project_path = match project_path { + Ok(Some(project_path)) => project_path, + Ok(None) => { + not_found_paths.push(path); + continue; + } + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }; + + let open_buffer_task = + project.update(cx, |project, cx| project.open_buffer(project_path, cx)); + + let buffer = match open_buffer_task { + Ok(task) => match task.await { + Ok(buffer) => buffer, + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }, + Err(error) => { + open_errors.push((path, error.to_string())); + continue; + } + }; + + let is_dirty = match buffer.read_with(cx, |buffer, _| buffer.is_dirty()) { + Ok(is_dirty) => is_dirty, + Err(error) => { + dirty_check_errors.push((path, error.to_string())); + continue; + } + }; + + if is_dirty { + buffers_to_save.insert(buffer); + saved_paths.push(path); + } else { + clean_paths.push(path); + } + } + + // Save each buffer individually since there's no batch save API. + for buffer in buffers_to_save { + let path_for_buffer = match buffer.read_with(cx, |buffer, _| { + buffer + .file() + .map(|file| file.path().to_rel_path_buf()) + .map(|path| path.as_rel_path().as_unix_str().to_owned()) + }) { + Ok(path) => path.unwrap_or_else(|| "".to_string()), + Err(error) => { + save_errors.push(("".to_string(), error.to_string())); + continue; + } + }; + + let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx)); + + match save_task { + Ok(task) => { + if let Err(error) = task.await { + save_errors.push((path_for_buffer, error.to_string())); + } + } + Err(error) => { + save_errors.push((path_for_buffer, error.to_string())); + } + } + } + + let mut lines: Vec = Vec::new(); + + if !saved_paths.is_empty() { + lines.push(format!("Saved {} file(s).", saved_paths.len())); + } + if !clean_paths.is_empty() { + lines.push(format!("{} clean.", clean_paths.len())); + } + + if !not_found_paths.is_empty() { + lines.push(format!("Not found ({}):", not_found_paths.len())); + for path in ¬_found_paths { + lines.push(format!("- {}", path.display())); + } + } + if !open_errors.is_empty() { + lines.push(format!("Open failed ({}):", open_errors.len())); + for (path, error) in &open_errors { + lines.push(format!("- {}: {}", path.display(), error)); + } + } + if !dirty_check_errors.is_empty() { + lines.push(format!( + "Dirty check failed ({}):", + dirty_check_errors.len() + )); + for (path, error) in &dirty_check_errors { + lines.push(format!("- {}: {}", path.display(), error)); + } + } + if !save_errors.is_empty() { + lines.push(format!("Save failed ({}):", save_errors.len())); + for (path, error) in &save_errors { + lines.push(format!("- {}: {}", path, error)); + } + } + + if lines.is_empty() { + Ok("No paths provided.".to_string()) + } else { + Ok(lines.join("\n")) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fs::Fs; + use gpui::TestAppContext; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + #[gpui::test] + async fn test_save_file_output_and_effects(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dirty.txt": "on disk: dirty\n", + "clean.txt": "on disk: clean\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let tool = Arc::new(SaveFileTool::new(project.clone())); + + // Make dirty.txt dirty in-memory. + let dirty_project_path = project.read_with(cx, |project, cx| { + project + .find_project_path("root/dirty.txt", cx) + .expect("dirty.txt should exist in project") + }); + + let dirty_buffer = project + .update(cx, |project, cx| { + project.open_buffer(dirty_project_path, cx) + }) + .await + .unwrap(); + dirty_buffer.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx); + }); + assert!( + dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "dirty.txt buffer should be dirty before save" + ); + + // Ensure clean.txt is opened but remains clean. + let clean_project_path = project.read_with(cx, |project, cx| { + project + .find_project_path("root/clean.txt", cx) + .expect("clean.txt should exist in project") + }); + + let clean_buffer = project + .update(cx, |project, cx| { + project.open_buffer(clean_project_path, cx) + }) + .await + .unwrap(); + assert!( + !clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "clean.txt buffer should start clean" + ); + + let output = cx + .update(|cx| { + tool.clone().run( + SaveFileToolInput { + paths: vec![ + PathBuf::from("root/dirty.txt"), + PathBuf::from("root/clean.txt"), + ], + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Output should mention saved + clean. + assert!( + output.contains("Saved 1 file(s)."), + "expected saved count line, got:\n{output}" + ); + assert!( + output.contains("1 clean."), + "expected clean count line, got:\n{output}" + ); + + // Effect: dirty buffer should now be clean and disk should have new content. + assert!( + !dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()), + "dirty.txt buffer should not be dirty after save" + ); + + let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap(); + assert_eq!( + disk_dirty, "in memory: dirty\n", + "dirty.txt disk content should be updated" + ); + + // Sanity: clean buffer should remain clean and disk unchanged. + let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap(); + assert_eq!(disk_clean, "on disk: clean\n"); + + // Test empty paths case. + let output = cx + .update(|cx| { + tool.clone().run( + SaveFileToolInput { paths: vec![] }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + assert_eq!(output, "No paths provided."); + + // Test not-found path case. + let output = cx + .update(|cx| { + tool.clone().run( + SaveFileToolInput { + paths: vec![PathBuf::from("nonexistent/path.txt")], + }, + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + assert!( + output.contains("Not found (1):"), + "expected not-found header line, got:\n{output}" + ); + assert!( + output.contains("- nonexistent/path.txt"), + "expected not-found path bullet, got:\n{output}" + ); + } +} diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 6d30c19152001deaef5deeacbdf266e28ac03d08..f3302fb1894612287bf04acfbfa301188bf853fb 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -1,6 +1,7 @@ use agent_client_protocol as acp; use anyhow::Result; -use gpui::{App, Entity, SharedString, Task}; +use futures::FutureExt as _; +use gpui::{App, AppContext, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,6 +9,7 @@ use std::{ path::{Path, PathBuf}, rc::Rc, sync::Arc, + time::Duration, }; use util::markdown::MarkdownInlineCode; @@ -25,13 +27,17 @@ const COMMAND_OUTPUT_LIMIT: u64 = 16 * 1024; /// /// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own. /// +/// For potentially long-running commands, prefer specifying `timeout_ms` to bound runtime and prevent indefinite hangs. +/// /// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct TerminalToolInput { /// The one-liner command to execute. - command: String, + pub command: String, /// Working directory for the command. This must be one of the root directories of the project. - cd: String, + pub cd: String, + /// Optional maximum runtime (in milliseconds). If exceeded, the running terminal task is killed. + pub timeout_ms: Option, } pub struct TerminalTool { @@ -112,12 +118,30 @@ impl AgentTool for TerminalTool { .await?; let terminal_id = terminal.id(cx)?; - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]), - ..Default::default() - }); + event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)), + ])); + + let timeout = input.timeout_ms.map(Duration::from_millis); + + let exit_status = match timeout { + Some(timeout) => { + let wait_for_exit = terminal.wait_for_exit(cx)?; + let timeout_task = cx.background_spawn(async move { + smol::Timer::after(timeout).await; + }); + + futures::select! { + status = wait_for_exit.clone().fuse() => status, + _ = timeout_task.fuse() => { + terminal.kill(cx)?; + wait_for_exit.await + } + } + } + None => terminal.wait_for_exit(cx)?.await, + }; - let exit_status = terminal.wait_for_exit(cx)?.await; let output = terminal.current_output(cx)?; Ok(process_content(output, &input.command, exit_status)) diff --git a/crates/agent/src/tools/thinking_tool.rs b/crates/agent/src/tools/thinking_tool.rs index 0a68f7545f81ce3202c110b1435d33b57adf409c..96024326f6f1610f500972b1a98be45258e3966b 100644 --- a/crates/agent/src/tools/thinking_tool.rs +++ b/crates/agent/src/tools/thinking_tool.rs @@ -43,10 +43,8 @@ impl AgentTool for ThinkingTool { event_stream: ToolCallEventStream, _cx: &mut App, ) -> Task> { - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![input.content.into()]), - ..Default::default() - }); + event_stream + .update_fields(acp::ToolCallUpdateFields::new().content(vec![input.content.into()])); Task::ready(Ok("Finished thinking.".to_string())) } } diff --git a/crates/agent/src/tools/web_search_tool.rs b/crates/agent/src/tools/web_search_tool.rs index 03e9db6601579e082e4d83de50f1999209d9f197..eb4ebacea2a8e48d6efa9032f46b336ca30c39b6 100644 --- a/crates/agent/src/tools/web_search_tool.rs +++ b/crates/agent/src/tools/web_search_tool.rs @@ -76,10 +76,8 @@ impl AgentTool for WebSearchTool { let response = match search_task.await { Ok(response) => response, Err(err) => { - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some("Web Search Failed".to_string()), - ..Default::default() - }); + event_stream + .update_fields(acp::ToolCallUpdateFields::new().title("Web Search Failed")); return Err(err); } }; @@ -107,26 +105,23 @@ fn emit_update(response: &WebSearchResponse, event_stream: &ToolCallEventStream) } else { format!("{} results", response.results.len()) }; - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(format!("Searched the web: {result_text}")), - content: Some( - response - .results - .iter() - .map(|result| acp::ToolCallContent::Content { - content: acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: result.title.clone(), - uri: result.url.clone(), - title: Some(result.title.clone()), - description: Some(result.text.clone()), - mime_type: None, - annotations: None, - size: None, - meta: None, - }), - }) - .collect(), - ), - ..Default::default() - }); + event_stream.update_fields( + acp::ToolCallUpdateFields::new() + .title(format!("Searched the web: {result_text}")) + .content( + response + .results + .iter() + .map(|result| { + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink( + acp::ResourceLink::new(result.title.clone(), result.url.clone()) + .title(result.title.clone()) + .description(result.text.clone()), + ), + )) + }) + .collect::>(), + ), + ); } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index a44bdd1f22478e92ace192c939561f855c2814bd..e99855fe8a7241468e93f01fe6c7b6fee161f600 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,6 +9,8 @@ use futures::io::BufReader; use project::Project; use project::agent_server_store::AgentServerCommand; use serde::Deserialize; +use settings::Settings as _; +use task::ShellBuilder; use util::ResultExt as _; use std::path::PathBuf; @@ -21,7 +23,7 @@ use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task, WeakEntit use acp_thread::{AcpThread, AuthRequired, LoadError, TerminalProviderEvent}; use terminal::TerminalBuilder; -use terminal::terminal_settings::{AlternateScroll, CursorShape}; +use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings}; #[derive(Debug, Error)] #[error("Unsupported version")] @@ -29,7 +31,7 @@ pub struct UnsupportedVersion; pub struct AcpConnection { server_name: SharedString, - telemetry_id: &'static str, + telemetry_id: SharedString, connection: Rc, sessions: Rc>>, auth_methods: Vec, @@ -54,7 +56,6 @@ pub struct AcpSession { pub async fn connect( server_name: SharedString, - telemetry_id: &'static str, command: AgentServerCommand, root_dir: &Path, default_mode: Option, @@ -64,7 +65,6 @@ pub async fn connect( ) -> Result> { let conn = AcpConnection::stdio( server_name, - telemetry_id, command.clone(), root_dir, default_mode, @@ -76,12 +76,11 @@ pub async fn connect( Ok(Rc::new(conn) as _) } -const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; +const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1; impl AcpConnection { pub async fn stdio( server_name: SharedString, - telemetry_id: &'static str, command: AgentServerCommand, root_dir: &Path, default_mode: Option, @@ -89,9 +88,11 @@ impl AcpConnection { is_remote: bool, cx: &mut AsyncApp, ) -> Result { - let mut child = util::command::new_smol_command(&command.path); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let mut child = + builder.build_command(Some(command.path.display().to_string()), &command.args); child - .args(command.args.iter().map(|arg| arg.as_str())) .envs(command.env.iter().flatten()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -174,34 +175,38 @@ impl AcpConnection { })?; let response = connection - .initialize(acp::InitializeRequest { - protocol_version: acp::VERSION, - client_capabilities: acp::ClientCapabilities { - fs: acp::FileSystemCapability { - read_text_file: true, - write_text_file: true, - meta: None, - }, - terminal: true, - meta: Some(serde_json::json!({ - // Experimental: Allow for rendering terminal output from the agents - "terminal_output": true, - "terminal-auth": true, - })), - }, - client_info: Some(acp::Implementation { - name: "zed".to_owned(), - title: release_channel.map(|c| c.to_owned()), - version, - }), - meta: None, - }) + .initialize( + acp::InitializeRequest::new(acp::ProtocolVersion::V1) + .client_capabilities( + acp::ClientCapabilities::new() + .fs(acp::FileSystemCapability::new() + .read_text_file(true) + .write_text_file(true)) + .terminal(true) + // Experimental: Allow for rendering terminal output from the agents + .meta(acp::Meta::from_iter([ + ("terminal_output".into(), true.into()), + ("terminal-auth".into(), true.into()), + ])), + ) + .client_info( + acp::Implementation::new("zed", version) + .title(release_channel.map(ToOwned::to_owned)), + ), + ) .await?; if response.protocol_version < MINIMUM_SUPPORTED_VERSION { return Err(UnsupportedVersion.into()); } + let telemetry_id = response + .agent_info + // Use the one the agent provides if we have one + .map(|info| info.name.into()) + // Otherwise, just use the name + .unwrap_or_else(|| server_name.clone()); + Ok(Self { auth_methods: response.auth_methods, root_dir: root_dir.to_owned(), @@ -236,8 +241,8 @@ impl Drop for AcpConnection { } impl AgentConnection for AcpConnection { - fn telemetry_id(&self) -> &'static str { - self.telemetry_id + fn telemetry_id(&self) -> SharedString { + self.telemetry_id.clone() } fn new_thread( @@ -253,14 +258,13 @@ impl AgentConnection for AcpConnection { let default_model = self.default_model.clone(); let cwd = cwd.to_path_buf(); let context_server_store = project.read(cx).context_server_store().read(cx); - let mcp_servers = - if project.read(cx).is_local() { - context_server_store - .configured_server_ids() - .iter() - .filter_map(|id| { - let configuration = context_server_store.configuration_for_server(id)?; - match &*configuration { + let mcp_servers = if project.read(cx).is_local() { + context_server_store + .configured_server_ids() + .iter() + .filter_map(|id| { + let configuration = context_server_store.configuration_for_server(id)?; + match &*configuration { project::context_server_store::ContextServerConfiguration::Custom { command, .. @@ -268,53 +272,47 @@ impl AgentConnection for AcpConnection { | project::context_server_store::ContextServerConfiguration::Extension { command, .. - } => Some(acp::McpServer::Stdio { - name: id.0.to_string(), - command: command.path.clone(), - args: command.args.clone(), - env: if let Some(env) = command.env.as_ref() { - env.iter() - .map(|(name, value)| acp::EnvVariable { - name: name.clone(), - value: value.clone(), - meta: None, - }) - .collect() - } else { - vec![] - }, - }), + } => Some(acp::McpServer::Stdio( + acp::McpServerStdio::new(id.0.to_string(), &command.path) + .args(command.args.clone()) + .env(if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable::new(name, value)) + .collect() + } else { + vec![] + }), + )), project::context_server_store::ContextServerConfiguration::Http { url, headers, - } => Some(acp::McpServer::Http { - name: id.0.to_string(), - url: url.to_string(), - headers: headers.iter().map(|(name, value)| acp::HttpHeader { - name: name.clone(), - value: value.clone(), - meta: None, - }).collect(), - }), + } => Some(acp::McpServer::Http( + acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers( + headers + .iter() + .map(|(name, value)| acp::HttpHeader::new(name, value)) + .collect(), + ), + )), } - }) - .collect() - } else { - // In SSH projects, the external agent is running on the remote - // machine, and currently we only run MCP servers on the local - // machine. So don't pass any MCP servers to the agent in that case. - Vec::new() - }; + }) + .collect() + } else { + // In SSH projects, the external agent is running on the remote + // machine, and currently we only run MCP servers on the local + // machine. So don't pass any MCP servers to the agent in that case. + Vec::new() + }; cx.spawn(async move |cx| { let response = conn - .new_session(acp::NewSessionRequest { mcp_servers, cwd, meta: None }) + .new_session(acp::NewSessionRequest::new(cwd).mcp_servers(mcp_servers)) .await .map_err(|err| { - if err.code == acp::ErrorCode::AUTH_REQUIRED.code { + if err.code == acp::ErrorCode::AuthRequired { let mut error = AuthRequired::new(); - if err.message != acp::ErrorCode::AUTH_REQUIRED.message { + if err.message != acp::ErrorCode::AuthRequired.to_string() { error = error.with_description(err.message); } @@ -341,11 +339,7 @@ impl AgentConnection for AcpConnection { let modes = modes.clone(); let conn = conn.clone(); async move |_| { - let result = conn.set_session_mode(acp::SetSessionModeRequest { - session_id, - mode_id: default_mode, - meta: None, - }) + let result = conn.set_session_mode(acp::SetSessionModeRequest::new(session_id, default_mode)) .await.log_err(); if result.is_none() { @@ -388,11 +382,7 @@ impl AgentConnection for AcpConnection { let models = models.clone(); let conn = conn.clone(); async move |_| { - let result = conn.set_session_model(acp::SetSessionModelRequest { - session_id, - model_id: default_model, - meta: None, - }) + let result = conn.set_session_model(acp::SetSessionModelRequest::new(session_id, default_model)) .await.log_err(); if result.is_none() { @@ -456,12 +446,8 @@ impl AgentConnection for AcpConnection { fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { let conn = self.connection.clone(); cx.foreground_executor().spawn(async move { - conn.authenticate(acp::AuthenticateRequest { - method_id: method_id.clone(), - meta: None, - }) - .await?; - + conn.authenticate(acp::AuthenticateRequest::new(method_id)) + .await?; Ok(()) }) } @@ -488,11 +474,11 @@ impl AgentConnection for AcpConnection { match result { Ok(response) => Ok(response), Err(err) => { - if err.code == acp::ErrorCode::AUTH_REQUIRED.code { + if err.code == acp::ErrorCode::AuthRequired { return Err(anyhow!(acp::Error::auth_required())); } - if err.code != ErrorCode::INTERNAL_ERROR.code { + if err.code != ErrorCode::InternalError { anyhow::bail!(err) } @@ -515,10 +501,7 @@ impl AgentConnection for AcpConnection { && (details.contains("This operation was aborted") || details.contains("The user aborted a request")) { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Cancelled, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::Cancelled)) } else { Err(anyhow!(details)) } @@ -535,10 +518,7 @@ impl AgentConnection for AcpConnection { session.suppress_abort_err = true; } let conn = self.connection.clone(); - let params = acp::CancelNotification { - session_id: session_id.clone(), - meta: None, - }; + let params = acp::CancelNotification::new(session_id.clone()); cx.foreground_executor() .spawn(async move { conn.cancel(params).await }) .detach(); @@ -619,11 +599,7 @@ impl acp_thread::AgentSessionModes for AcpSessionModes { let state = self.state.clone(); cx.foreground_executor().spawn(async move { let result = connection - .set_session_mode(acp::SetSessionModeRequest { - session_id, - mode_id, - meta: None, - }) + .set_session_mode(acp::SetSessionModeRequest::new(session_id, mode_id)) .await; if result.is_err() { @@ -682,11 +658,7 @@ impl acp_thread::AgentModelSelector for AcpModelSelector { let state = self.state.clone(); cx.foreground_executor().spawn(async move { let result = connection - .set_session_model(acp::SetSessionModelRequest { - session_id, - model_id, - meta: None, - }) + .set_session_model(acp::SetSessionModelRequest::new(session_id, model_id)) .await; if result.is_err() { @@ -748,10 +720,7 @@ impl acp::Client for ClientDelegate { let outcome = task.await; - Ok(acp::RequestPermissionResponse { - outcome, - meta: None, - }) + Ok(acp::RequestPermissionResponse::new(outcome)) } async fn write_text_file( @@ -783,10 +752,7 @@ impl acp::Client for ClientDelegate { let content = task.await?; - Ok(acp::ReadTextFileResponse { - content, - meta: None, - }) + Ok(acp::ReadTextFileResponse::new(content)) } async fn session_notification( @@ -821,7 +787,7 @@ impl acp::Client for ClientDelegate { if let Some(terminal_info) = meta.get("terminal_info") { if let Some(id_str) = terminal_info.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); + let terminal_id = acp::TerminalId::new(id_str); let cwd = terminal_info .get("cwd") .and_then(|v| v.as_str().map(PathBuf::from)); @@ -837,7 +803,7 @@ impl acp::Client for ClientDelegate { let lower = cx.new(|cx| builder.subscribe(cx)); thread.on_terminal_provider_event( TerminalProviderEvent::Created { - terminal_id: terminal_id.clone(), + terminal_id, label: tc.title.clone(), cwd, output_byte_limit: None, @@ -862,15 +828,12 @@ impl acp::Client for ClientDelegate { if let Some(meta) = &tcu.meta { if let Some(term_out) = meta.get("terminal_output") { if let Some(id_str) = term_out.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); + let terminal_id = acp::TerminalId::new(id_str); if let Some(s) = term_out.get("data").and_then(|v| v.as_str()) { let data = s.as_bytes().to_vec(); let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { thread.on_terminal_provider_event( - TerminalProviderEvent::Output { - terminal_id: terminal_id.clone(), - data, - }, + TerminalProviderEvent::Output { terminal_id, data }, cx, ); }); @@ -881,21 +844,24 @@ impl acp::Client for ClientDelegate { // terminal_exit if let Some(term_exit) = meta.get("terminal_exit") { if let Some(id_str) = term_exit.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); - let status = acp::TerminalExitStatus { - exit_code: term_exit - .get("exit_code") - .and_then(|v| v.as_u64()) - .map(|i| i as u32), - signal: term_exit - .get("signal") - .and_then(|v| v.as_str().map(|s| s.to_string())), - meta: None, - }; + let terminal_id = acp::TerminalId::new(id_str); + let status = acp::TerminalExitStatus::new() + .exit_code( + term_exit + .get("exit_code") + .and_then(|v| v.as_u64()) + .map(|i| i as u32), + ) + .signal( + term_exit + .get("signal") + .and_then(|v| v.as_str().map(|s| s.to_string())), + ); + let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { - terminal_id: terminal_id.clone(), + terminal_id, status, }, cx, @@ -932,7 +898,7 @@ impl acp::Client for ClientDelegate { // Register with renderer let terminal_entity = thread.update(&mut self.cx.clone(), |thread, cx| { thread.register_terminal_created( - acp::TerminalId(uuid::Uuid::new_v4().to_string().into()), + acp::TerminalId::new(uuid::Uuid::new_v4().to_string()), format!("{} {}", args.command, args.args.join(" ")), args.cwd.clone(), args.output_byte_limit, @@ -942,10 +908,7 @@ impl acp::Client for ClientDelegate { })?; let terminal_id = terminal_entity.read_with(&self.cx, |terminal, _| terminal.id().clone())?; - Ok(acp::CreateTerminalResponse { - terminal_id, - meta: None, - }) + Ok(acp::CreateTerminalResponse::new(terminal_id)) } async fn kill_terminal_command( @@ -1006,10 +969,7 @@ impl acp::Client for ClientDelegate { })?? .await; - Ok(acp::WaitForTerminalExitResponse { - exit_status, - meta: None, - }) + Ok(acp::WaitForTerminalExitResponse::new(exit_status)) } } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index cf03b71a78b358d7b110c450f769f9645094baaa..c6e66688dd6af6748a97dcd4569827fd7fa32493 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -4,6 +4,8 @@ mod codex; mod custom; mod gemini; +use collections::HashSet; + #[cfg(any(test, feature = "test-support"))] pub mod e2e_tests; @@ -56,10 +58,19 @@ impl AgentServerDelegate { pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; fn name(&self) -> SharedString; - fn telemetry_id(&self) -> &'static str; + fn connect( + &self, + root_dir: Option<&Path>, + delegate: AgentServerDelegate, + cx: &mut App, + ) -> Task, Option)>>; + + fn into_any(self: Rc) -> Rc; + fn default_mode(&self, _cx: &mut App) -> Option { None } + fn set_default_mode( &self, _mode_id: Option, @@ -80,14 +91,18 @@ pub trait AgentServer: Send { ) { } - fn connect( - &self, - root_dir: Option<&Path>, - delegate: AgentServerDelegate, - cx: &mut App, - ) -> Task, Option)>>; + fn favorite_model_ids(&self, _cx: &mut App) -> HashSet { + HashSet::default() + } - fn into_any(self: Rc) -> Rc; + fn toggle_favorite_model( + &self, + _model_id: agent_client_protocol::ModelId, + _should_be_favorite: bool, + _fs: Arc, + _cx: &App, + ) { + } } impl dyn AgentServer { diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index ac79ab7484de90a84ce3d6720f54bcec6addc6b5..30ef39af953e66fc983c3d7f189042b6577e84c0 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -1,4 +1,5 @@ use agent_client_protocol as acp; +use collections::HashSet; use fs::Fs; use settings::{SettingsStore, update_settings_file}; use std::path::Path; @@ -22,10 +23,6 @@ pub struct AgentServerLoginCommand { } impl AgentServer for ClaudeCode { - fn telemetry_id(&self) -> &'static str { - "claude-code" - } - fn name(&self) -> SharedString { "Claude Code".into() } @@ -41,7 +38,7 @@ impl AgentServer for ClaudeCode { settings .as_ref() - .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -62,7 +59,7 @@ impl AgentServer for ClaudeCode { settings .as_ref() - .and_then(|s| s.default_model.clone().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { @@ -76,6 +73,48 @@ impl AgentServer for ClaudeCode { }); } + fn favorite_model_ids(&self, cx: &mut App) -> HashSet { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).claude.clone() + }); + + settings + .as_ref() + .map(|s| { + s.favorite_models + .iter() + .map(|id| acp::ModelId::new(id.clone())) + .collect() + }) + .unwrap_or_default() + } + + fn toggle_favorite_model( + &self, + model_id: acp::ModelId, + should_be_favorite: bool, + fs: Arc, + cx: &App, + ) { + update_settings_file(fs, cx, move |settings, _| { + let favorite_models = &mut settings + .agent_servers + .get_or_insert_default() + .claude + .get_or_insert_default() + .favorite_models; + + let model_id_str = model_id.to_string(); + if should_be_favorite { + if !favorite_models.contains(&model_id_str) { + favorite_models.push(model_id_str); + } + } else { + favorite_models.retain(|id| id != &model_id_str); + } + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -83,7 +122,6 @@ impl AgentServer for ClaudeCode { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -108,7 +146,6 @@ impl AgentServer for ClaudeCode { .await?; let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index ec01cd4e523b5696b2f09b5e51e7137fcfb16c91..15dc4688294da979149f331ea52e8163ae5d3093 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -5,6 +5,7 @@ use std::{any::Any, path::Path}; use acp_thread::AgentConnection; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; +use collections::HashSet; use fs::Fs; use gpui::{App, AppContext as _, SharedString, Task}; use project::agent_server_store::{AllAgentServersSettings, CODEX_NAME}; @@ -23,10 +24,6 @@ pub(crate) mod tests { } impl AgentServer for Codex { - fn telemetry_id(&self) -> &'static str { - "codex" - } - fn name(&self) -> SharedString { "Codex".into() } @@ -42,7 +39,7 @@ impl AgentServer for Codex { settings .as_ref() - .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -63,7 +60,7 @@ impl AgentServer for Codex { settings .as_ref() - .and_then(|s| s.default_model.clone().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { @@ -77,6 +74,48 @@ impl AgentServer for Codex { }); } + fn favorite_model_ids(&self, cx: &mut App) -> HashSet { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings.get::(None).codex.clone() + }); + + settings + .as_ref() + .map(|s| { + s.favorite_models + .iter() + .map(|id| acp::ModelId::new(id.clone())) + .collect() + }) + .unwrap_or_default() + } + + fn toggle_favorite_model( + &self, + model_id: acp::ModelId, + should_be_favorite: bool, + fs: Arc, + cx: &App, + ) { + update_settings_file(fs, cx, move |settings, _| { + let favorite_models = &mut settings + .agent_servers + .get_or_insert_default() + .codex + .get_or_insert_default() + .favorite_models; + + let model_id_str = model_id.to_string(); + if should_be_favorite { + if !favorite_models.contains(&model_id_str) { + favorite_models.push(model_id_str); + } + } else { + favorite_models.retain(|id| id != &model_id_str); + } + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -84,7 +123,6 @@ impl AgentServer for Codex { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -110,7 +148,6 @@ impl AgentServer for Codex { let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index e7625c2cc06095c9a24a2537e4e83bced26d73f3..f58948190266adeb0e5509d2ec2825a48d503f50 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -1,7 +1,8 @@ -use crate::{AgentServerDelegate, load_proxy_env}; +use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; use agent_client_protocol as acp; use anyhow::{Context as _, Result}; +use collections::HashSet; use fs::Fs; use gpui::{App, AppContext as _, SharedString, Task}; use project::agent_server_store::{AllAgentServersSettings, ExternalAgentServerName}; @@ -20,11 +21,7 @@ impl CustomAgentServer { } } -impl crate::AgentServer for CustomAgentServer { - fn telemetry_id(&self) -> &'static str { - "custom" - } - +impl AgentServer for CustomAgentServer { fn name(&self) -> SharedString { self.name.clone() } @@ -44,7 +41,7 @@ impl crate::AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_mode().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -58,6 +55,7 @@ impl crate::AgentServer for CustomAgentServer { .or_insert_with(|| settings::CustomAgentServerSettings::Extension { default_model: None, default_mode: None, + favorite_models: Vec::new(), }); match settings { @@ -80,7 +78,7 @@ impl crate::AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_model().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model().map(acp::ModelId::new)) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { @@ -94,6 +92,7 @@ impl crate::AgentServer for CustomAgentServer { .or_insert_with(|| settings::CustomAgentServerSettings::Extension { default_model: None, default_mode: None, + favorite_models: Vec::new(), }); match settings { @@ -105,6 +104,66 @@ impl crate::AgentServer for CustomAgentServer { }); } + fn favorite_model_ids(&self, cx: &mut App) -> HashSet { + let settings = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .custom + .get(&self.name()) + .cloned() + }); + + settings + .as_ref() + .map(|s| { + s.favorite_models() + .iter() + .map(|id| acp::ModelId::new(id.clone())) + .collect() + }) + .unwrap_or_default() + } + + fn toggle_favorite_model( + &self, + model_id: acp::ModelId, + should_be_favorite: bool, + fs: Arc, + cx: &App, + ) { + let name = self.name(); + update_settings_file(fs, cx, move |settings, _| { + let settings = settings + .agent_servers + .get_or_insert_default() + .custom + .entry(name.clone()) + .or_insert_with(|| settings::CustomAgentServerSettings::Extension { + default_model: None, + default_mode: None, + favorite_models: Vec::new(), + }); + + let favorite_models = match settings { + settings::CustomAgentServerSettings::Custom { + favorite_models, .. + } + | settings::CustomAgentServerSettings::Extension { + favorite_models, .. + } => favorite_models, + }; + + let model_id_str = model_id.to_string(); + if should_be_favorite { + if !favorite_models.contains(&model_id_str) { + favorite_models.push(model_id_str); + } + } else { + favorite_models.retain(|id| id != &model_id_str); + } + }); + } + fn connect( &self, root_dir: Option<&Path>, @@ -112,14 +171,12 @@ impl crate::AgentServer for CustomAgentServer { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); - cx.spawn(async move |cx| { let (command, root_dir, login) = store .update(cx, |store, cx| { @@ -139,7 +196,6 @@ impl crate::AgentServer for CustomAgentServer { .await?; let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 824b999bdaff46cf3ad3a570b62fecd596612563..975bb7e373c5ddd13df6c6bc951096fced668355 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -82,26 +82,9 @@ where .update(cx, |thread, cx| { thread.send( vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "Read the file ".into(), - annotations: None, - meta: None, - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: "foo.rs".into(), - name: "foo.rs".into(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), - acp::ContentBlock::Text(acp::TextContent { - text: " and tell me what the content of the println! is".into(), - annotations: None, - meta: None, - }), + "Read the file ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new("foo.rs", "foo.rs")), + " and tell me what the content of the println! is".into(), ], cx, ) @@ -429,7 +412,7 @@ macro_rules! common_e2e_tests { async fn tool_call_with_permission(cx: &mut ::gpui::TestAppContext) { $crate::e2e_tests::test_tool_call_with_permission( $server, - ::agent_client_protocol::PermissionOptionId($allow_option_id.into()), + ::agent_client_protocol::PermissionOptionId::new($allow_option_id), cx, ) .await; @@ -477,6 +460,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { ignore_system_version: None, default_mode: None, default_model: None, + favorite_models: vec![], }), gemini: Some(crate::gemini::tests::local_command().into()), codex: Some(BuiltinAgentServerSettings { @@ -486,6 +470,7 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc { ignore_system_version: None, default_mode: None, default_model: None, + favorite_models: vec![], }), custom: collections::HashMap::default(), }, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index c1b2efb081551f82752dc15a909eec64ff78d94e..5fea74746aec73f3ea7bb33562244e4a6eea5ba7 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -12,10 +12,6 @@ use project::agent_server_store::GEMINI_NAME; pub struct Gemini; impl AgentServer for Gemini { - fn telemetry_id(&self) -> &'static str { - "gemini-cli" - } - fn name(&self) -> SharedString { "Gemini CLI".into() } @@ -31,7 +27,6 @@ impl AgentServer for Gemini { cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let telemetry_id = self.telemetry_id(); let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); @@ -66,7 +61,6 @@ impl AgentServer for Gemini { let connection = crate::acp::connect( name, - telemetry_id, command, root_dir.as_ref(), default_mode, diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 8ddcac24fe054d1226f2bbac49498fd35d6ed1c3..0d7163549f0a4b172773c9ac95dcbc84b7212667 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -12,6 +12,7 @@ workspace = true path = "src/agent_settings.rs" [dependencies] +agent-client-protocol.workspace = true anyhow.workspace = true cloud_llm_client.workspace = true collections.workspace = true diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 084ac7c3e7a1be4920126f857145e64b65a255dd..b513ec1a70b6f7ab02382dfa312ea2d4d6a47234 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -2,14 +2,15 @@ mod agent_profile; use std::sync::Arc; -use collections::IndexMap; +use agent_client_protocol::ModelId; +use collections::{HashSet, IndexMap}; use gpui::{App, Pixels, px}; use language_model::LanguageModel; use project::DisableAiSettings; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ - DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, + DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, NotifyWhenAgentWaiting, RegisterSetting, Settings, }; @@ -24,13 +25,16 @@ pub struct AgentSettings { pub enabled: bool, pub button: bool, pub dock: DockPosition, + pub agents_panel_dock: DockSide, pub default_width: Pixels, pub default_height: Pixels, pub default_model: Option, pub inline_assistant_model: Option, + pub inline_assistant_use_streaming_tools: bool, pub commit_message_model: Option, pub thread_summary_model: Option, pub inline_alternatives: Vec, + pub favorite_models: Vec, pub default_profile: AgentProfileId, pub default_view: DefaultAgentView, pub profiles: IndexMap, @@ -94,6 +98,13 @@ impl AgentSettings { pub fn set_message_editor_max_lines(&self) -> usize { self.message_editor_min_lines * 2 } + + pub fn favorite_model_ids(&self) -> HashSet { + self.favorite_models + .iter() + .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model))) + .collect() + } } #[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Default)] @@ -151,13 +162,18 @@ impl Settings for AgentSettings { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), + agents_panel_dock: agent.agents_panel_dock.unwrap(), default_width: px(agent.default_width.unwrap()), default_height: px(agent.default_height.unwrap()), default_model: Some(agent.default_model.unwrap()), inline_assistant_model: agent.inline_assistant_model, + inline_assistant_use_streaming_tools: agent + .inline_assistant_use_streaming_tools + .unwrap_or(true), commit_message_model: agent.commit_message_model, thread_summary_model: agent.thread_summary_model, inline_alternatives: agent.inline_alternatives.unwrap_or_default(), + favorite_models: agent.favorite_models, default_profile: AgentProfileId(agent.default_profile.unwrap()), default_view: agent.default_view.unwrap(), profiles: agent diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 0f52c07078f447c9d8a95312ccd96561516907a1..8a9633e578a85323f2a289bd83c169a1f5d7f272 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,7 +13,8 @@ path = "src/agent_ui.rs" doctest = false [features] -test-support = ["gpui/test-support", "language/test-support"] +test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support", "agent/test-support"] +unit-eval = [] [dependencies] acp_thread.workspace = true @@ -39,6 +40,7 @@ component.workspace = true context_server.workspace = true db.workspace = true editor.workspace = true +eval_utils = { workspace = true, optional = true } extension.workspace = true extension_host.workspace = true feature_flags.workspace = true @@ -47,6 +49,7 @@ fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true +gpui_tokio.workspace = true html_to_markdown.workspace = true http_client.workspace = true indoc.workspace = true @@ -69,6 +72,7 @@ postage.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true +rand.workspace = true release_channel.workspace = true rope.workspace = true rules_library.workspace = true @@ -82,7 +86,6 @@ smol.workspace = true streaming_diff.workspace = true task.workspace = true telemetry.workspace = true -telemetry_events.workspace = true terminal.workspace = true terminal_view.workspace = true text.workspace = true @@ -93,19 +96,23 @@ ui.workspace = true ui_input.workspace = true url.workspace = true util.workspace = true +uuid.workspace = true watch.workspace = true workspace.workspace = true zed_actions.workspace = true image.workspace = true async-fs.workspace = true +reqwest_client = { workspace = true, optional = true } [dev-dependencies] acp_thread = { workspace = true, features = ["test-support"] } agent = { workspace = true, features = ["test-support"] } assistant_text_thread = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } +clock.workspace = true db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +eval_utils.workspace = true gpui = { workspace = true, "features" = ["test-support"] } indoc.workspace = true language = { workspace = true, "features" = ["test-support"] } @@ -114,6 +121,6 @@ language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } semver.workspace = true -rand.workspace = true +reqwest_client.workspace = true tree-sitter-md.workspace = true unindent.workspace = true diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 6fb94dfb6b84826d715e9b28163e9968fc2df3b9..feae74a86bc241c5d2e01f0941eafc60210f1bf6 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -22,7 +22,7 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; pub struct EntryViewState { workspace: WeakEntity, - project: Entity, + project: WeakEntity, history_store: Entity, prompt_store: Option>, entries: Vec, @@ -34,7 +34,7 @@ pub struct EntryViewState { impl EntryViewState { pub fn new( workspace: WeakEntity, - project: Entity, + project: WeakEntity, history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, @@ -328,7 +328,7 @@ impl Entry { fn create_terminal( workspace: WeakEntity, - project: Entity, + project: WeakEntity, terminal: Entity, window: &mut Window, cx: &mut App, @@ -336,9 +336,9 @@ fn create_terminal( cx.new(|cx| { let mut view = TerminalView::new( terminal.read(cx).inner().clone(), - workspace.clone(), + workspace, None, - project.downgrade(), + project, window, cx, ); @@ -432,24 +432,11 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let tool_call = acp::ToolCall { - id: acp::ToolCallId("tool".into()), - title: "Tool call".into(), - kind: acp::ToolKind::Other, - status: acp::ToolCallStatus::InProgress, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/hello.txt".into(), - old_text: Some("hi world".into()), - new_text: "hello world".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; + let tool_call = acp::ToolCall::new("tool", "Tool call") + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/hello.txt", "hello world").old_text("hi world"), + )]); let connection = Rc::new(StubAgentConnection::new()); let thread = cx .update(|_, cx| { @@ -471,7 +458,7 @@ mod tests { let view_state = cx.new(|_cx| { EntryViewState::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store, None, Default::default(), diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index facb86f3b87e746d35d8b91f27550e351b10e8b6..d2c9cf9cb430793d788ed8cb61ecaa01e8f989a8 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -21,8 +21,8 @@ use editor::{ }; use futures::{FutureExt as _, future::join_all}; use gpui::{ - AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, KeyContext, - SharedString, Subscription, Task, TextStyle, WeakEntity, + AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, + KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, Language, language_settings::InlayHintKind}; use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; @@ -31,15 +31,14 @@ use rope::Point; use settings::Settings; use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc}; use theme::ThemeSettings; -use ui::prelude::*; +use ui::{ContextMenu, prelude::*}; use util::{ResultExt, debug_panic}; use workspace::{CollaboratorId, Workspace}; -use zed_actions::agent::Chat; +use zed_actions::agent::{Chat, PasteRaw}; pub struct MessageEditor { mention_set: Entity, editor: Entity, - project: Entity, workspace: WeakEntity, prompt_capabilities: Rc>, available_commands: Rc>>, @@ -98,7 +97,7 @@ impl PromptCompletionProviderDelegate for Entity { impl MessageEditor { pub fn new( workspace: WeakEntity, - project: Entity, + project: WeakEntity, history_store: Entity, prompt_store: Option>, prompt_capabilities: Rc>, @@ -124,6 +123,7 @@ impl MessageEditor { let mut editor = Editor::new(mode, buffer, None, window, cx); editor.set_placeholder_text(placeholder, window, cx); editor.set_show_indent_guides(false, cx); + editor.set_show_completions_on_input(Some(true)); editor.set_soft_wrap(); editor.set_use_modal_editing(true); editor.set_context_menu_options(ContextMenuOptions { @@ -132,15 +132,25 @@ impl MessageEditor { placement: Some(ContextMenuPlacement::Above), }); editor.register_addon(MessageEditorAddon::new()); + + editor.set_custom_context_menu(|editor, _point, window, cx| { + let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx)); + + Some(ContextMenu::build(window, cx, |menu, _, _| { + menu.action("Cut", Box::new(editor::actions::Cut)) + .action_disabled_when( + !has_selection, + "Copy", + Box::new(editor::actions::Copy), + ) + .action("Paste", Box::new(editor::actions::Paste)) + })) + }); + editor }); - let mention_set = cx.new(|_cx| { - MentionSet::new( - project.downgrade(), - history_store.clone(), - prompt_store.clone(), - ) - }); + let mention_set = + cx.new(|_cx| MentionSet::new(project, history_store.clone(), prompt_store.clone())); let completion_provider = Rc::new(PromptCompletionProvider::new( cx.entity(), editor.downgrade(), @@ -198,7 +208,6 @@ impl MessageEditor { Self { editor, - project, mention_set, workspace, prompt_capabilities, @@ -225,8 +234,13 @@ impl MessageEditor { .iter() .find(|command| command.name == command_name)?; - let acp::AvailableCommandInput::Unstructured { mut hint } = - available_command.input.clone()?; + let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput { + mut hint, + .. + }) = available_command.input.clone()? + else { + return None; + }; let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize; if hint_pos > snapshot.len() { @@ -403,34 +417,27 @@ impl MessageEditor { } => { all_tracked_buffers.extend(tracked_buffers.iter().cloned()); if supports_embedded_context { - acp::ContentBlock::Resource(acp::EmbeddedResource { - annotations: None, - resource: - acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - mime_type: None, - text: content.clone(), - uri: uri.to_uri().to_string(), - meta: None, - }, + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new( + content.clone(), + uri.to_uri().to_string(), ), - meta: None, - }) + ), + )) } else { - acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: uri.name(), - uri: uri.to_uri().to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }) + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )) } } - Mention::Image(mention_image) => { - let uri = match uri { + Mention::Image(mention_image) => acp::ContentBlock::Image( + acp::ImageContent::new( + mention_image.data.clone(), + mention_image.format.mime_type(), + ) + .uri(match uri { MentionUri::File { .. } => Some(uri.to_uri().to_string()), MentionUri::PastedImage => None, other => { @@ -440,25 +447,11 @@ impl MessageEditor { ); None } - }; - acp::ContentBlock::Image(acp::ImageContent { - annotations: None, - data: mention_image.data.to_string(), - mime_type: mention_image.format.mime_type().into(), - uri, - meta: None, - }) - } - Mention::Link => acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: uri.name(), - uri: uri.to_uri().to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), + }), + ), + Mention::Link => acp::ContentBlock::ResourceLink( + acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()), + ), }; chunks.push(chunk); ix = crease_range.end.0; @@ -565,6 +558,142 @@ impl MessageEditor { } fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let editor_clipboard_selections = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + ClipboardEntry::String(text) => { + text.metadata_json::>() + } + _ => None, + }); + + // Insert creases for pasted clipboard selections that: + // 1. Contain exactly one selection + // 2. Have an associated file path + // 3. Span multiple lines (not single-line selections) + // 4. Belong to a file that exists in the current project + let should_insert_creases = util::maybe!({ + let selections = editor_clipboard_selections.as_ref()?; + if selections.len() > 1 { + return Some(false); + } + let selection = selections.first()?; + let file_path = selection.file_path.as_ref()?; + let line_range = selection.line_range.as_ref()?; + + if line_range.start() == line_range.end() { + return Some(false); + } + + Some( + workspace + .read(cx) + .project() + .read(cx) + .project_path_for_absolute_path(file_path, cx) + .is_some(), + ) + }) + .unwrap_or(false); + + if should_insert_creases && let Some(selections) = editor_clipboard_selections { + cx.stop_propagation(); + let insertion_target = self + .editor + .read(cx) + .selections + .newest_anchor() + .start + .text_anchor; + + let project = workspace.read(cx).project().clone(); + for selection in selections { + if let (Some(file_path), Some(line_range)) = + (selection.file_path, selection.line_range) + { + let crease_text = + acp_thread::selection_name(Some(file_path.as_ref()), &line_range); + + let mention_uri = MentionUri::Selection { + abs_path: Some(file_path.clone()), + line_range: line_range.clone(), + }; + + let mention_text = mention_uri.as_link().to_string(); + let (excerpt_id, text_anchor, content_len) = + self.editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx); + let snapshot = buffer.snapshot(cx); + let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap(); + let text_anchor = insertion_target.bias_left(&buffer_snapshot); + + editor.insert(&mention_text, window, cx); + editor.insert(" ", window, cx); + + (*excerpt_id, text_anchor, mention_text.len()) + }); + + let Some((crease_id, tx)) = insert_crease_for_mention( + excerpt_id, + text_anchor, + content_len, + crease_text.into(), + mention_uri.icon_path(cx), + None, + self.editor.clone(), + window, + cx, + ) else { + continue; + }; + drop(tx); + + let mention_task = cx + .spawn({ + let project = project.clone(); + async move |_, cx| { + let project_path = project + .update(cx, |project, cx| { + project.project_path_for_absolute_path(&file_path, cx) + }) + .map_err(|e| e.to_string())? + .ok_or_else(|| "project path not found".to_string())?; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + + buffer + .update(cx, |buffer, cx| { + let start = Point::new(*line_range.start(), 0) + .min(buffer.max_point()); + let end = Point::new(*line_range.end() + 1, 0) + .min(buffer.max_point()); + let content = buffer.text_for_range(start..end).collect(); + Mention::Text { + content, + tracked_buffers: vec![cx.entity()], + } + }) + .map_err(|e| e.to_string()) + } + }) + .shared(); + + self.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task) + }); + } + } + return; + } + if self.prompt_capabilities.borrow().image && let Some(task) = paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) @@ -573,6 +702,13 @@ impl MessageEditor { } } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { + let editor = self.editor.clone(); + window.defer(cx, move |window, cx| { + editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx)); + }); + } + pub fn insert_dragged_files( &mut self, paths: Vec, @@ -583,17 +719,18 @@ impl MessageEditor { let Some(workspace) = self.workspace.upgrade() else { return; }; - let path_style = self.project.read(cx).path_style(cx); + let project = workspace.read(cx).project().clone(); + let path_style = project.read(cx).path_style(cx); let buffer = self.editor.read(cx).buffer().clone(); let Some(buffer) = buffer.read(cx).as_singleton() else { return; }; let mut tasks = Vec::new(); for path in paths { - let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else { + let Some(entry) = project.read(cx).entry_for_path(&path, cx) else { continue; }; - let Some(worktree) = self.project.read(cx).worktree_for_id(path.worktree_id, cx) else { + let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else { continue; }; let abs_path = worktree.read(cx).absolutize(&path.path); @@ -701,9 +838,13 @@ impl MessageEditor { window: &mut Window, cx: &mut Context, ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + self.clear(window, cx); - let path_style = self.project.read(cx).path_style(cx); + let path_style = workspace.read(cx).project().read(cx).path_style(cx); let mut text = String::new(); let mut mentions = Vec::new(); @@ -746,8 +887,7 @@ impl MessageEditor { uri, data, mime_type, - annotations: _, - meta: _, + .. }) => { let mention_uri = if let Some(uri) = uri { MentionUri::parse(&uri, path_style) @@ -773,7 +913,7 @@ impl MessageEditor { }), )); } - acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {} + _ => {} } } @@ -846,6 +986,7 @@ impl Render for MessageEditor { .on_action(cx.listener(Self::chat)) .on_action(cx.listener(Self::chat_with_follow)) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::paste_raw)) .capture_action(cx.listener(Self::paste)) .flex_1() .child({ @@ -947,7 +1088,7 @@ mod tests { cx.new(|cx| { MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -1058,7 +1199,7 @@ mod tests { cx.new(|cx| { MessageEditor::new( workspace_handle.clone(), - project.clone(), + project.downgrade(), history_store.clone(), None, prompt_capabilities.clone(), @@ -1092,12 +1233,7 @@ mod tests { assert!(error_message.contains("Available commands: none")); // Now simulate Claude providing its list of available commands (which doesn't include file) - available_commands.replace(vec![acp::AvailableCommand { - name: "help".to_string(), - description: "Get help".to_string(), - input: None, - meta: None, - }]); + available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]); // Test that unsupported slash commands trigger an error when we have a list of available commands editor.update_in(cx, |editor, window, cx| { @@ -1211,20 +1347,12 @@ mod tests { let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ - acp::AvailableCommand { - name: "quick-math".to_string(), - description: "2 + 2 = 4 - 1 = 3".to_string(), - input: None, - meta: None, - }, - acp::AvailableCommand { - name: "say-hello".to_string(), - description: "Say hello to whoever you want".to_string(), - input: Some(acp::AvailableCommandInput::Unstructured { - hint: "".to_string(), - }), - meta: None, - }, + acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), + acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input( + acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new( + "", + )), + ), ])); let editor = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -1232,7 +1360,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, prompt_capabilities.clone(), @@ -1257,7 +1385,7 @@ mod tests { cx, ); }); - message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).focus_handle(cx).focus(window, cx); message_editor.read(cx).editor().clone() }); @@ -1454,7 +1582,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, prompt_capabilities.clone(), @@ -1479,7 +1607,7 @@ mod tests { cx, ); }); - message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).focus_handle(cx).focus(window, cx); let editor = message_editor.read(cx).editor().clone(); (message_editor, editor) }); @@ -1504,12 +1632,12 @@ mod tests { editor.set_text("", window, cx); }); - prompt_capabilities.replace(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }); + prompt_capabilities.replace( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ); cx.simulate_input("Lorem "); @@ -1945,7 +2073,7 @@ mod tests { cx.new(|cx| { let editor = MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -1960,11 +2088,9 @@ mod tests { cx, ); // Enable embedded context so files are actually included - editor.prompt_capabilities.replace(acp::PromptCapabilities { - embedded_context: true, - meta: None, - ..Default::default() - }); + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)); editor }) }); @@ -2043,7 +2169,7 @@ mod tests { // Create a thread metadata to insert as summary let thread_metadata = agent::DbThreadMetadata { - id: acp::SessionId("thread-123".into()), + id: acp::SessionId::new("thread-123"), title: "Previous Conversation".into(), updated_at: chrono::Utc::now(), }; @@ -2052,7 +2178,7 @@ mod tests { cx.new(|cx| { let mut editor = MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -2121,7 +2247,7 @@ mod tests { cx.new(|cx| { MessageEditor::new( workspace.downgrade(), - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -2150,14 +2276,7 @@ mod tests { .await .unwrap(); - assert_eq!( - content, - vec![acp::ContentBlock::Text(acp::TextContent { - text: "してhello world".into(), - annotations: None, - meta: None - })] - ); + assert_eq!(content, vec!["してhello world".into()]); } #[gpui::test] @@ -2191,7 +2310,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), @@ -2216,7 +2335,7 @@ mod tests { cx, ); }); - message_editor.read(cx).focus_handle(cx).focus(window); + message_editor.read(cx).focus_handle(cx).focus(window, cx); let editor = message_editor.read(cx).editor().clone(); (message_editor, editor) }); @@ -2236,38 +2355,24 @@ mod tests { .0; let main_rs_uri = if cfg!(windows) { - "file:///C:/project/src/main.rs".to_string() + "file:///C:/project/src/main.rs" } else { - "file:///project/src/main.rs".to_string() + "file:///project/src/main.rs" }; // When embedded context is `false` we should get a resource link pretty_assertions::assert_eq!( content, vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "What is in ".to_string(), - annotations: None, - meta: None - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: main_rs_uri.clone(), - name: "main.rs".to_string(), - annotations: None, - meta: None, - description: None, - mime_type: None, - size: None, - title: None, - }) + "What is in ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri)) ] ); message_editor.update(cx, |editor, _cx| { - editor.prompt_capabilities.replace(acp::PromptCapabilities { - embedded_context: true, - ..Default::default() - }) + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)) }); let content = message_editor @@ -2280,23 +2385,12 @@ mod tests { pretty_assertions::assert_eq!( content, vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "What is in ".to_string(), - annotations: None, - meta: None - }), - acp::ContentBlock::Resource(acp::EmbeddedResource { - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - text: file_content.to_string(), - uri: main_rs_uri, - mime_type: None, - meta: None - } - ), - annotations: None, - meta: None - }) + "What is in ".into(), + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(file_content, main_rs_uri) + ) + )) ] ); } @@ -2374,7 +2468,7 @@ mod tests { let message_editor = cx.new(|cx| { MessageEditor::new( workspace_handle, - project.clone(), + project.downgrade(), history_store.clone(), None, Default::default(), diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs index 2db031cafeb8a66e43120be9766debe3c16eb2d0..22af75a6e96edc4f597819e04e2e84b80ba0417a 100644 --- a/crates/agent_ui/src/acp/mode_selector.rs +++ b/crates/agent_ui/src/acp/mode_selector.rs @@ -161,7 +161,7 @@ impl Render for ModeSelector { .map(|mode| mode.name.clone()) .unwrap_or_else(|| "Unknown".into()); - let this = cx.entity(); + let this = cx.weak_entity(); let icon = if self.menu_handle.is_deployed() { IconName::ChevronUp @@ -188,25 +188,25 @@ impl Render for ModeSelector { .gap_1() .child( h_flex() - .pb_1() .gap_2() .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child(Label::new("Cycle Through Modes")) + .child(Label::new("Toggle Mode Menu")) .child(KeyBinding::for_action_in( - &CycleModeSelector, + &ToggleProfileSelector, &focus_handle, cx, )), ) .child( h_flex() + .pb_1() .gap_2() .justify_between() - .child(Label::new("Toggle Mode Menu")) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Cycle Through Modes")) .child(KeyBinding::for_action_in( - &ToggleProfileSelector, + &CycleModeSelector, &focus_handle, cx, )), @@ -222,7 +222,8 @@ impl Render for ModeSelector { y: px(-2.0), }) .menu(move |window, cx| { - Some(this.update(cx, |this, cx| this.build_context_menu(window, cx))) + this.update(cx, |this, cx| this.build_context_menu(window, cx)) + .ok() }) } } diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 8a0c3c9df90e73d0ef00ecd7232115729dd35347..c8ed636e4fead9f10e4763904e17d36a5eb6bbb6 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -1,25 +1,26 @@ use std::{cmp::Reverse, rc::Rc, sync::Arc}; -use acp_thread::{AgentModelInfo, AgentModelList, AgentModelSelector}; +use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelList, AgentModelSelector}; +use agent_client_protocol::ModelId; use agent_servers::AgentServer; use anyhow::Result; -use collections::IndexMap; +use collections::{HashSet, IndexMap}; use fs::Fs; use futures::FutureExt; use fuzzy::{StringMatchCandidate, match_strings}; use gpui::{ - Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity, + Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task, + WeakEntity, }; +use itertools::Itertools; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; -use ui::{ - DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, KeyBinding, ListItem, - ListItemSpacing, prelude::*, -}; +use settings::SettingsStore; +use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, prelude::*}; use util::ResultExt; use zed_actions::agent::OpenSettings; -use crate::ui::HoldForDefault; +use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}; pub type AcpModelSelector = Picker; @@ -41,7 +42,7 @@ pub fn acp_model_selector( enum AcpModelPickerEntry { Separator(SharedString), - Model(AgentModelInfo), + Model(AgentModelInfo, bool), } pub struct AcpModelPickerDelegate { @@ -53,7 +54,9 @@ pub struct AcpModelPickerDelegate { selected_index: usize, selected_description: Option<(usize, SharedString, bool)>, selected_model: Option, + favorites: HashSet, _refresh_models_task: Task<()>, + _settings_subscription: Subscription, focus_handle: FocusHandle, } @@ -101,6 +104,19 @@ impl AcpModelPickerDelegate { }) }; + let agent_server_for_subscription = agent_server.clone(); + let settings_subscription = + cx.observe_global_in::(window, move |picker, window, cx| { + // Only refresh if the favorites actually changed to avoid redundant work + // when other settings are modified (e.g., user editing settings.json) + let new_favorites = agent_server_for_subscription.favorite_model_ids(cx); + if new_favorites != picker.delegate.favorites { + picker.delegate.favorites = new_favorites; + picker.refresh(window, cx); + } + }); + let favorites = agent_server.favorite_model_ids(cx); + Self { selector, agent_server, @@ -110,7 +126,9 @@ impl AcpModelPickerDelegate { selected_model: None, selected_index: 0, selected_description: None, + favorites, _refresh_models_task: refresh_models_task, + _settings_subscription: settings_subscription, focus_handle, } } @@ -118,6 +136,64 @@ impl AcpModelPickerDelegate { pub fn active_model(&self) -> Option<&AgentModelInfo> { self.selected_model.as_ref() } + + pub fn favorites_count(&self) -> usize { + self.favorites.len() + } + + pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context>) { + if self.favorites.is_empty() { + return; + } + + let Some(models) = &self.models else { + return; + }; + + let all_models: Vec<&AgentModelInfo> = match models { + AgentModelList::Flat(list) => list.iter().collect(), + AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(), + }; + + let favorite_models: Vec<_> = all_models + .into_iter() + .filter(|model| self.favorites.contains(&model.id)) + .unique_by(|model| &model.id) + .collect(); + + if favorite_models.is_empty() { + return; + } + + let current_id = self.selected_model.as_ref().map(|m| &m.id); + + let current_index_in_favorites = current_id + .and_then(|id| favorite_models.iter().position(|m| &m.id == id)) + .unwrap_or(usize::MAX); + + let next_index = if current_index_in_favorites == usize::MAX { + 0 + } else { + (current_index_in_favorites + 1) % favorite_models.len() + }; + + let next_model = favorite_models[next_index].clone(); + + self.selector + .select_model(next_model.id.clone(), cx) + .detach_and_log_err(cx); + + self.selected_model = Some(next_model); + + // Keep the picker selection aligned with the newly-selected model + if let Some(new_index) = self.filtered_entries.iter().position(|entry| { + matches!(entry, AcpModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id)) + }) { + self.set_selected_index(new_index, window, cx); + } else { + cx.notify(); + } + } } impl PickerDelegate for AcpModelPickerDelegate { @@ -143,7 +219,7 @@ impl PickerDelegate for AcpModelPickerDelegate { _cx: &mut Context>, ) -> bool { match self.filtered_entries.get(ix) { - Some(AcpModelPickerEntry::Model(_)) => true, + Some(AcpModelPickerEntry::Model(_, _)) => true, Some(AcpModelPickerEntry::Separator(_)) | None => false, } } @@ -158,6 +234,8 @@ impl PickerDelegate for AcpModelPickerDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { + let favorites = self.favorites.clone(); + cx.spawn_in(window, async move |this, cx| { let filtered_models = match this .read_with(cx, |this, cx| { @@ -174,7 +252,7 @@ impl PickerDelegate for AcpModelPickerDelegate { this.update_in(cx, |this, window, cx| { this.delegate.filtered_entries = - info_list_to_picker_entries(filtered_models).collect(); + info_list_to_picker_entries(filtered_models, &favorites); // Finds the currently selected model in the list let new_index = this .delegate @@ -182,7 +260,7 @@ impl PickerDelegate for AcpModelPickerDelegate { .as_ref() .and_then(|selected| { this.delegate.filtered_entries.iter().position(|entry| { - if let AcpModelPickerEntry::Model(model_info) = entry { + if let AcpModelPickerEntry::Model(model_info, _) = entry { model_info.id == selected.id } else { false @@ -198,7 +276,7 @@ impl PickerDelegate for AcpModelPickerDelegate { } fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - if let Some(AcpModelPickerEntry::Model(model_info)) = + if let Some(AcpModelPickerEntry::Model(model_info, _)) = self.filtered_entries.get(self.selected_index) { if window.modifiers().secondary() { @@ -241,75 +319,57 @@ impl PickerDelegate for AcpModelPickerDelegate { cx: &mut Context>, ) -> Option { match self.filtered_entries.get(ix)? { - AcpModelPickerEntry::Separator(title) => Some( - div() - .px_2() - .pb_1() - .when(ix > 1, |this| { - this.mt_1() - .pt_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - Label::new(title) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - ), - AcpModelPickerEntry::Model(model_info) => { + AcpModelPickerEntry::Separator(title) => { + Some(ModelSelectorHeader::new(title, ix > 1).into_any_element()) + } + AcpModelPickerEntry::Model(model_info, is_favorite) => { let is_selected = Some(model_info) == self.selected_model.as_ref(); let default_model = self.agent_server.default_model(cx); let is_default = default_model.as_ref() == Some(&model_info.id); - let model_icon_color = if is_selected { - Color::Accent - } else { - Color::Muted + let is_favorite = *is_favorite; + let handle_action_click = { + let model_id = model_info.id.clone(); + let fs = self.fs.clone(); + let agent_server = self.agent_server.clone(); + + cx.listener(move |_, _, _, cx| { + agent_server.toggle_favorite_model( + model_id.clone(), + !is_favorite, + fs.clone(), + cx, + ); + }) }; Some( div() .id(("model-picker-menu-child", ix)) .when_some(model_info.description.clone(), |this, description| { - this - .on_hover(cx.listener(move |menu, hovered, _, cx| { - if *hovered { - menu.delegate.selected_description = Some((ix, description.clone(), is_default)); - } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) { - menu.delegate.selected_description = None; - } - cx.notify(); - })) + this.on_hover(cx.listener(move |menu, hovered, _, cx| { + if *hovered { + menu.delegate.selected_description = + Some((ix, description.clone(), is_default)); + } else if matches!(menu.delegate.selected_description, Some((id, _, _)) if id == ix) { + menu.delegate.selected_description = None; + } + cx.notify(); + })) }) .child( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_1p5() - .when_some(model_info.icon, |this, icon| { - this.child( - Icon::new(icon) - .color(model_icon_color) - .size(IconSize::Small) - ) - }) - .child(Label::new(model_info.name.clone()).truncate()), - ) - .end_slot(div().pr_3().when(is_selected, |this| { - this.child( - Icon::new(IconName::Check) - .color(Color::Accent) - .size(IconSize::Small), - ) - })), + ModelSelectorListItem::new(ix, model_info.name.clone()) + .map(|this| match &model_info.icon { + Some(AgentModelIcon::Path(path)) => this.icon_path(path.clone()), + Some(AgentModelIcon::Named(icon)) => this.icon(*icon), + None => this, + }) + .is_selected(is_selected) + .is_focused(selected) + .is_favorite(is_favorite) + .on_toggle_favorite(handle_action_click), ) - .into_any_element() + .into_any_element(), ) } } @@ -343,7 +403,7 @@ impl PickerDelegate for AcpModelPickerDelegate { fn render_footer( &self, _window: &mut Window, - cx: &mut Context>, + _cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); @@ -351,43 +411,57 @@ impl PickerDelegate for AcpModelPickerDelegate { return None; } - Some( - h_flex() - .w_full() - .p_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(OpenSettings.boxed_clone(), cx); - }), - ) - .into_any(), - ) + Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element()) } } fn info_list_to_picker_entries( model_list: AgentModelList, -) -> impl Iterator { + favorites: &HashSet, +) -> Vec { + let mut entries = Vec::new(); + + let all_models: Vec<_> = match &model_list { + AgentModelList::Flat(list) => list.iter().collect(), + AgentModelList::Grouped(index_map) => index_map.values().flatten().collect(), + }; + + let favorite_models: Vec<_> = all_models + .iter() + .filter(|m| favorites.contains(&m.id)) + .unique_by(|m| &m.id) + .collect(); + + let has_favorites = !favorite_models.is_empty(); + if has_favorites { + entries.push(AcpModelPickerEntry::Separator("Favorite".into())); + for model in favorite_models { + entries.push(AcpModelPickerEntry::Model((*model).clone(), true)); + } + } + match model_list { AgentModelList::Flat(list) => { - itertools::Either::Left(list.into_iter().map(AcpModelPickerEntry::Model)) + if has_favorites { + entries.push(AcpModelPickerEntry::Separator("All".into())); + } + for model in list { + let is_favorite = favorites.contains(&model.id); + entries.push(AcpModelPickerEntry::Model(model, is_favorite)); + } } AgentModelList::Grouped(index_map) => { - itertools::Either::Right(index_map.into_iter().flat_map(|(group_name, models)| { - std::iter::once(AcpModelPickerEntry::Separator(group_name.0)) - .chain(models.into_iter().map(AcpModelPickerEntry::Model)) - })) + for (group_name, models) in index_map { + entries.push(AcpModelPickerEntry::Separator(group_name.0)); + for model in models { + let is_favorite = favorites.contains(&model.id); + entries.push(AcpModelPickerEntry::Model(model, is_favorite)); + } + } } } + + entries } async fn fuzzy_search( @@ -403,9 +477,7 @@ async fn fuzzy_search( let candidates = model_list .iter() .enumerate() - .map(|(ix, model)| { - StringMatchCandidate::new(ix, &format!("{}/{}", model.id, model.name)) - }) + .map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref())) .collect::>(); let mut matches = match_strings( &candidates, @@ -464,7 +536,7 @@ mod tests { models .into_iter() .map(|model| acp_thread::AgentModelInfo { - id: acp::ModelId(model.to_string().into()), + id: acp::ModelId::new(model.to_string()), name: model.to_string().into(), description: None, icon: None, @@ -511,6 +583,33 @@ mod tests { } } + fn create_favorites(models: Vec<&str>) -> HashSet { + models + .into_iter() + .map(|m| ModelId::new(m.to_string())) + .collect() + } + + fn get_entry_model_ids(entries: &[AcpModelPickerEntry]) -> Vec<&str> { + entries + .iter() + .filter_map(|entry| match entry { + AcpModelPickerEntry::Model(info, _) => Some(info.id.0.as_ref()), + _ => None, + }) + .collect() + } + + fn get_entry_labels(entries: &[AcpModelPickerEntry]) -> Vec<&str> { + entries + .iter() + .map(|entry| match entry { + AcpModelPickerEntry::Model(info, _) => info.id.0.as_ref(), + AcpModelPickerEntry::Separator(s) => &s, + }) + .collect() + } + #[gpui::test] async fn test_fuzzy_match(cx: &mut TestAppContext) { let models = create_model_list(vec![ @@ -550,4 +649,185 @@ mod tests { ], ); } + + #[gpui::test] + fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) { + let models = create_model_list(vec![ + ("zed", vec!["zed/claude", "zed/gemini"]), + ("openai", vec!["openai/gpt-5"]), + ]); + let favorites = create_favorites(vec!["zed/gemini"]); + + let entries = info_list_to_picker_entries(models, &favorites); + + assert!(matches!( + entries.first(), + Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite" + )); + + let model_ids = get_entry_model_ids(&entries); + assert_eq!(model_ids[0], "zed/gemini"); + } + + #[gpui::test] + fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) { + let models = create_model_list(vec![("zed", vec!["zed/claude", "zed/gemini"])]); + let favorites = create_favorites(vec![]); + + let entries = info_list_to_picker_entries(models, &favorites); + + assert!(matches!( + entries.first(), + Some(AcpModelPickerEntry::Separator(s)) if s == "zed" + )); + } + + #[gpui::test] + fn test_models_have_correct_actions(_cx: &mut TestAppContext) { + let models = create_model_list(vec![ + ("zed", vec!["zed/claude", "zed/gemini"]), + ("openai", vec!["openai/gpt-5"]), + ]); + let favorites = create_favorites(vec!["zed/claude"]); + + let entries = info_list_to_picker_entries(models, &favorites); + + for entry in &entries { + if let AcpModelPickerEntry::Model(info, is_favorite) = entry { + if info.id.0.as_ref() == "zed/claude" { + assert!(is_favorite, "zed/claude should be a favorite"); + } else { + assert!(!is_favorite, "{} should not be a favorite", info.id.0); + } + } + } + } + + #[gpui::test] + fn test_favorites_appear_in_both_sections(_cx: &mut TestAppContext) { + let models = create_model_list(vec![ + ("zed", vec!["zed/claude", "zed/gemini"]), + ("openai", vec!["openai/gpt-5", "openai/gpt-4"]), + ]); + let favorites = create_favorites(vec!["zed/gemini", "openai/gpt-5"]); + + let entries = info_list_to_picker_entries(models, &favorites); + let model_ids = get_entry_model_ids(&entries); + + assert_eq!(model_ids[0], "zed/gemini"); + assert_eq!(model_ids[1], "openai/gpt-5"); + + assert!(model_ids[2..].contains(&"zed/gemini")); + assert!(model_ids[2..].contains(&"openai/gpt-5")); + } + + #[gpui::test] + fn test_favorites_are_not_duplicated_when_repeated_in_other_sections(_cx: &mut TestAppContext) { + let models = create_model_list(vec![ + ("Recommended", vec!["zed/claude", "anthropic/claude"]), + ("Zed", vec!["zed/claude", "zed/gpt-5"]), + ("Antropic", vec!["anthropic/claude"]), + ("OpenAI", vec!["openai/gpt-5"]), + ]); + + let favorites = create_favorites(vec!["zed/claude"]); + + let entries = info_list_to_picker_entries(models, &favorites); + let labels = get_entry_labels(&entries); + + assert_eq!( + labels, + vec![ + "Favorite", + "zed/claude", + "Recommended", + "zed/claude", + "anthropic/claude", + "Zed", + "zed/claude", + "zed/gpt-5", + "Antropic", + "anthropic/claude", + "OpenAI", + "openai/gpt-5" + ] + ); + } + + #[gpui::test] + fn test_flat_model_list_with_favorites(_cx: &mut TestAppContext) { + let models = AgentModelList::Flat(vec![ + acp_thread::AgentModelInfo { + id: acp::ModelId::new("zed/claude".to_string()), + name: "Claude".into(), + description: None, + icon: None, + }, + acp_thread::AgentModelInfo { + id: acp::ModelId::new("zed/gemini".to_string()), + name: "Gemini".into(), + description: None, + icon: None, + }, + ]); + let favorites = create_favorites(vec!["zed/gemini"]); + + let entries = info_list_to_picker_entries(models, &favorites); + + assert!(matches!( + entries.first(), + Some(AcpModelPickerEntry::Separator(s)) if s == "Favorite" + )); + + assert!(entries.iter().any(|e| matches!( + e, + AcpModelPickerEntry::Separator(s) if s == "All" + ))); + } + + #[gpui::test] + fn test_favorites_count_returns_correct_count(_cx: &mut TestAppContext) { + let empty_favorites: HashSet = HashSet::default(); + assert_eq!(empty_favorites.len(), 0); + + let one_favorite = create_favorites(vec!["model-a"]); + assert_eq!(one_favorite.len(), 1); + + let multiple_favorites = create_favorites(vec!["model-a", "model-b", "model-c"]); + assert_eq!(multiple_favorites.len(), 3); + + let with_duplicates = create_favorites(vec!["model-a", "model-a", "model-b"]); + assert_eq!(with_duplicates.len(), 2); + } + + #[gpui::test] + fn test_is_favorite_flag_set_correctly_in_entries(_cx: &mut TestAppContext) { + let models = AgentModelList::Flat(vec![ + acp_thread::AgentModelInfo { + id: acp::ModelId::new("favorite-model".to_string()), + name: "Favorite".into(), + description: None, + icon: None, + }, + acp_thread::AgentModelInfo { + id: acp::ModelId::new("regular-model".to_string()), + name: "Regular".into(), + description: None, + icon: None, + }, + ]); + let favorites = create_favorites(vec!["favorite-model"]); + + let entries = info_list_to_picker_entries(models, &favorites); + + for entry in &entries { + if let AcpModelPickerEntry::Model(info, is_favorite) = entry { + if info.id.0.as_ref() == "favorite-model" { + assert!(*is_favorite, "favorite-model should have is_favorite=true"); + } else if info.id.0.as_ref() == "regular-model" { + assert!(!*is_favorite, "regular-model should have is_favorite=false"); + } + } + } + } } diff --git a/crates/agent_ui/src/acp/model_selector_popover.rs b/crates/agent_ui/src/acp/model_selector_popover.rs index e2393c11bd6c23b79397abf274fb6539c0c7063f..34b77704cc87c963fff16ca9f90959dbe0f6d35f 100644 --- a/crates/agent_ui/src/acp/model_selector_popover.rs +++ b/crates/agent_ui/src/acp/model_selector_popover.rs @@ -1,18 +1,14 @@ use std::rc::Rc; use std::sync::Arc; -use acp_thread::{AgentModelInfo, AgentModelSelector}; -use agent_servers::AgentServer; +use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector}; use fs::Fs; use gpui::{Entity, FocusHandle}; use picker::popover_menu::PickerPopoverMenu; -use ui::{ - ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window, - prelude::*, -}; -use zed_actions::agent::ToggleModelSelector; +use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*}; use crate::acp::{AcpModelSelector, model_selector::acp_model_selector}; +use crate::ui::ModelSelectorTooltip; pub struct AcpModelSelectorPopover { selector: Entity, @@ -23,7 +19,7 @@ pub struct AcpModelSelectorPopover { impl AcpModelSelectorPopover { pub(crate) fn new( selector: Rc, - agent_server: Rc, + agent_server: Rc, fs: Arc, menu_handle: PopoverMenuHandle, focus_handle: FocusHandle, @@ -54,17 +50,24 @@ impl AcpModelSelectorPopover { pub fn active_model<'a>(&self, cx: &'a App) -> Option<&'a AgentModelInfo> { self.selector.read(cx).delegate.active_model() } + + pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context) { + self.selector.update(cx, |selector, cx| { + selector.delegate.cycle_favorite_models(window, cx); + }); + } } impl Render for AcpModelSelectorPopover { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let model = self.selector.read(cx).delegate.active_model(); + let selector = self.selector.read(cx); + let model = selector.delegate.active_model(); let model_name = model .as_ref() .map(|model| model.name.clone()) .unwrap_or_else(|| SharedString::from("Select a Model")); - let model_icon = model.as_ref().and_then(|model| model.icon); + let model_icon = model.as_ref().and_then(|model| model.icon.clone()); let focus_handle = self.focus_handle.clone(); @@ -74,12 +77,29 @@ impl Render for AcpModelSelectorPopover { (Color::Muted, IconName::ChevronDown) }; + let show_cycle_row = selector.delegate.favorites_count() > 1; + + let tooltip = Tooltip::element({ + move |_, _cx| { + ModelSelectorTooltip::new(focus_handle.clone()) + .show_cycle_row(show_cycle_row) + .into_any_element() + } + }); + PickerPopoverMenu::new( self.selector.clone(), ButtonLike::new("active-model") .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when_some(model_icon, |this, icon| { - this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) + this.child( + match icon { + AgentModelIcon::Path(path) => Icon::from_external_svg(path), + AgentModelIcon::Named(icon_name) => Icon::new(icon_name), + } + .color(color) + .size(IconSize::XSmall), + ) }) .child( Label::new(model_name) @@ -88,9 +108,7 @@ impl Render for AcpModelSelectorPopover { .ml_0p5(), ) .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)), - move |_window, cx| { - Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) - }, + tooltip, gpui::Corner::BottomRight, cx, ) diff --git a/crates/agent_ui/src/acp/thread_history.rs b/crates/agent_ui/src/acp/thread_history.rs index 1aa89b35d34c8c0543a56014fee7766b6de66eb2..a885e52a05e342dbcd81d28a970560b3047ef9c0 100644 --- a/crates/agent_ui/src/acp/thread_history.rs +++ b/crates/agent_ui/src/acp/thread_history.rs @@ -1,7 +1,7 @@ use crate::acp::AcpThreadView; use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; use agent::{HistoryEntry, HistoryStore}; -use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::{Editor, EditorEvent}; use fuzzy::StringMatchCandidate; use gpui::{ @@ -402,7 +402,22 @@ impl AcpThreadHistory { let selected = ix == self.selected_index; let hovered = Some(ix) == self.hovered_index; let timestamp = entry.updated_at().timestamp(); - let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone); + + let display_text = match format { + EntryTimeFormat::DateAndTime => { + let entry_time = entry.updated_at(); + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + let days = duration.num_days(); + + format!("{}d", days) + } + EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone), + }; + + let title = entry.title().clone(); + let full_date = + EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone); h_flex() .w_full() @@ -423,11 +438,14 @@ impl AcpThreadHistory { .truncate(), ) .child( - Label::new(thread_timestamp) + Label::new(display_text) .color(Color::Muted) .size(LabelSize::XSmall), ), ) + .tooltip(move |_, cx| { + Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) + }) .on_hover(cx.listener(move |this, is_hovered, _window, cx| { if *is_hovered { this.hovered_index = Some(ix); diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index a9b4127ea97f62dde3cb2af299050bc0e06a06bc..6ea9102695cf1206ba9b6ec40d2615dce85f8fd4 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -34,7 +34,7 @@ use language::Buffer; use language_model::LanguageModelRegistry; use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; -use project::{Project, ProjectEntryId}; +use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId}; use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; @@ -47,8 +47,9 @@ use terminal_view::terminal_panel::TerminalPanel; use text::Anchor; use theme::{AgentFontSize, ThemeSettings}; use ui::{ - Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, - PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*, + Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, DividerColor, + ElevationIndex, KeyBinding, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, + prelude::*, right_click_menu, }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use workspace::{CollaboratorId, NewTerminal, Workspace}; @@ -63,14 +64,11 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent}; use crate::agent_diff::AgentDiff; use crate::profile_selector::{ProfileProvider, ProfileSelector}; -use crate::ui::{ - AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip, - UsageCallout, -}; +use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout}; use crate::{ AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode, - CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAgentDiff, OpenHistory, - RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector, + CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, + OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector, }; #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -100,7 +98,7 @@ impl ThreadError { { Self::ModelRequestLimitReached(error.plan) } else if let Some(acp_error) = error.downcast_ref::() - && acp_error.code == acp::ErrorCode::AUTH_REQUIRED.code + && acp_error.code == acp::ErrorCode::AuthRequired { Self::AuthenticationRequired(acp_error.message.clone().into()) } else { @@ -170,7 +168,7 @@ impl ThreadFeedbackState { } } let session_id = thread.read(cx).session_id().clone(); - let agent = thread.read(cx).connection().telemetry_id(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); let rating = match feedback { ThreadFeedback::Positive => "positive", @@ -180,7 +178,7 @@ impl ThreadFeedbackState { let thread = task.await?; telemetry::event!( "Agent Thread Rated", - agent = agent, + agent = agent_telemetry_id, session_id = session_id, rating = rating, thread = thread @@ -207,13 +205,13 @@ impl ThreadFeedbackState { self.comments_editor.take(); let session_id = thread.read(cx).session_id().clone(); - let agent = thread.read(cx).connection().telemetry_id(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let task = telemetry.thread_data(&session_id, cx); cx.background_spawn(async move { let thread = task.await?; telemetry::event!( "Agent Thread Feedback Comments", - agent = agent, + agent = agent_telemetry_id, session_id = session_id, comments = comments, thread = thread @@ -256,13 +254,14 @@ impl ThreadFeedbackState { editor }); - editor.read(cx).focus_handle(cx).focus(window); + editor.read(cx).focus_handle(cx).focus(window, cx); editor } } pub struct AcpThreadView { agent: Rc, + agent_server_store: Entity, workspace: WeakEntity, project: Entity, thread_state: ThreadState, @@ -279,6 +278,7 @@ pub struct AcpThreadView { thread_retry_status: Option, thread_error: Option, thread_error_markdown: Option>, + token_limit_callout_dismissed: bool, thread_feedback: ThreadFeedbackState, list_state: ListState, auth_task: Option>, @@ -333,18 +333,25 @@ impl AcpThreadView { project: Entity, history_store: Entity, prompt_store: Option>, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> Self { let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); - let placeholder = placeholder_text(agent.name().as_ref(), false); + let agent_server_store = project.read(cx).agent_server_store().clone(); + let agent_display_name = agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(agent.name())) + .unwrap_or_else(|| agent.name()); + + let placeholder = placeholder_text(agent_display_name.as_ref(), false); let message_editor = cx.new(|cx| { let mut editor = MessageEditor::new( workspace.clone(), - project.clone(), + project.downgrade(), history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), @@ -369,7 +376,7 @@ impl AcpThreadView { let entry_view_state = cx.new(|_| { EntryViewState::new( workspace.clone(), - project.clone(), + project.downgrade(), history_store.clone(), prompt_store.clone(), prompt_capabilities.clone(), @@ -378,7 +385,6 @@ impl AcpThreadView { ) }); - let agent_server_store = project.read(cx).agent_server_store().clone(); let subscriptions = [ cx.observe_global_in::(window, Self::agent_ui_font_size_changed), cx.observe_global_in::(window, Self::agent_ui_font_size_changed), @@ -391,11 +397,24 @@ impl AcpThreadView { ), ]; - let show_codex_windows_warning = crate::ExternalAgent::parse_built_in(agent.as_ref()) - == Some(crate::ExternalAgent::Codex); + cx.on_release(|this, cx| { + for window in this.notifications.drain(..) { + window + .update(cx, |_, window, _| { + window.remove_window(); + }) + .ok(); + } + }) + .detach(); + + let show_codex_windows_warning = cfg!(windows) + && project.read(cx).is_local() + && agent.clone().downcast::().is_some(); Self { agent: agent.clone(), + agent_server_store, workspace: workspace.clone(), project: project.clone(), entry_view_state, @@ -404,6 +423,7 @@ impl AcpThreadView { resume_thread.clone(), workspace.clone(), project.clone(), + track_load_event, window, cx, ), @@ -411,13 +431,13 @@ impl AcpThreadView { message_editor, model_selector: None, profile_selector: None, - notifications: Vec::new(), notification_subscriptions: HashMap::default(), list_state: list_state, thread_retry_status: None, thread_error: None, thread_error_markdown: None, + token_limit_callout_dismissed: false, thread_feedback: Default::default(), auth_task: None, expanded_tool_calls: HashSet::default(), @@ -448,6 +468,7 @@ impl AcpThreadView { self.resume_thread_metadata.clone(), self.workspace.clone(), self.project.clone(), + true, window, cx, ); @@ -461,6 +482,7 @@ impl AcpThreadView { resume_thread: Option, workspace: WeakEntity, project: Entity, + track_load_event: bool, window: &mut Window, cx: &mut Context, ) -> ThreadState { @@ -498,17 +520,7 @@ impl AcpThreadView { Some(new_version_available_tx), ); - let agent_name = agent.name(); - let timeout = cx.background_executor().timer(Duration::from_secs(30)); - let connect_task = smol::future::or( - agent.connect(root_dir.as_deref(), delegate, cx), - async move { - timeout.await; - Err(anyhow::Error::new(LoadError::Other( - format!("{agent_name} is unable to initialize after 30 seconds.").into(), - ))) - }, - ); + let connect_task = agent.connect(root_dir.as_deref(), delegate, cx); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { Ok((connection, login)) => { @@ -529,6 +541,10 @@ impl AcpThreadView { } }; + if track_load_event { + telemetry::event!("Agent Thread Started", agent = connection.telemetry_id()); + } + let result = if let Some(native_agent) = connection .clone() .downcast::() @@ -675,7 +691,7 @@ impl AcpThreadView { }) }); - this.message_editor.focus_handle(cx).focus(window); + this.message_editor.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -694,7 +710,7 @@ impl AcpThreadView { this.new_server_version_available = Some(new_version.into()); cx.notify(); }) - .log_err(); + .ok(); } } }) @@ -730,7 +746,7 @@ impl AcpThreadView { cx: &mut App, ) { let agent_name = agent.name(); - let (configuration_view, subscription) = if let Some(provider_id) = err.provider_id { + let (configuration_view, subscription) = if let Some(provider_id) = &err.provider_id { let registry = LanguageModelRegistry::global(cx); let sub = window.subscribe(®istry, cx, { @@ -772,12 +788,11 @@ impl AcpThreadView { configuration_view, description: err .description - .clone() .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))), _subscription: subscription, }; if this.message_editor.focus_handle(cx).is_focused(window) { - this.focus_handle.focus(window) + this.focus_handle.focus(window, cx) } cx.notify(); }) @@ -797,7 +812,7 @@ impl AcpThreadView { ThreadState::LoadError(LoadError::Other(format!("{:#}", err).into())) } if self.message_editor.focus_handle(cx).is_focused(window) { - self.focus_handle.focus(window) + self.focus_handle.focus(window, cx) } cx.notify(); } @@ -1081,10 +1096,7 @@ impl AcpThreadView { window.defer(cx, |window, cx| { Self::handle_auth_required( this, - AuthRequired { - description: None, - provider_id: None, - }, + AuthRequired::new(), agent, connection, window, @@ -1143,8 +1155,8 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; - let agent_telemetry_id = self.agent.telemetry_id(); let session_id = thread.read(cx).session_id().clone(); + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); let thread = thread.downgrade(); if self.should_be_following { self.workspace @@ -1263,7 +1275,7 @@ impl AcpThreadView { } }) }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -1315,11 +1327,11 @@ impl AcpThreadView { .await?; this.update_in(cx, |this, window, cx| { this.send_impl(message_editor, window, cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })?; anyhow::Ok(()) }) - .detach(); + .detach_and_log_err(cx); } fn open_edited_buffer( @@ -1383,6 +1395,7 @@ impl AcpThreadView { fn clear_thread_error(&mut self, cx: &mut Context) { self.thread_error = None; self.thread_error_markdown = None; + self.token_limit_callout_dismissed = true; cx.notify(); } @@ -1458,7 +1471,7 @@ impl AcpThreadView { self.thread_retry_status.take(); self.thread_state = ThreadState::LoadError(error.clone()); if self.message_editor.focus_handle(cx).is_focused(window) { - self.focus_handle.focus(window) + self.focus_handle.focus(window, cx) } } AcpThreadEvent::TitleUpdated => { @@ -1486,24 +1499,20 @@ impl AcpThreadView { .iter() .any(|method| method.id.0.as_ref() == "claude-login") { - available_commands.push(acp::AvailableCommand { - name: "login".to_owned(), - description: "Authenticate".to_owned(), - input: None, - meta: None, - }); - available_commands.push(acp::AvailableCommand { - name: "logout".to_owned(), - description: "Authenticate".to_owned(), - input: None, - meta: None, - }); + available_commands.push(acp::AvailableCommand::new("login", "Authenticate")); + available_commands.push(acp::AvailableCommand::new("logout", "Authenticate")); } let has_commands = !available_commands.is_empty(); self.available_commands.replace(available_commands); - let new_placeholder = placeholder_text(self.agent.name().as_ref(), has_commands); + let agent_display_name = self + .agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(self.agent.name())) + .unwrap_or_else(|| self.agent.name()); + + let new_placeholder = placeholder_text(agent_display_name.as_ref(), has_commands); self.message_editor.update(cx, |editor, cx| { editor.set_placeholder_text(&new_placeholder, window, cx); @@ -1532,6 +1541,7 @@ impl AcpThreadView { else { return; }; + let agent_telemetry_id = connection.telemetry_id(); // Check for the experimental "terminal-auth" _meta field let auth_method = connection.auth_methods().iter().find(|m| m.id == method); @@ -1599,19 +1609,18 @@ impl AcpThreadView { ); cx.notify(); self.auth_task = Some(cx.spawn_in(window, { - let agent = self.agent.clone(); async move |this, cx| { let result = authenticate.await; match &result { Ok(_) => telemetry::event!( "Authenticate Agent Succeeded", - agent = agent.telemetry_id() + agent = agent_telemetry_id ), Err(_) => { telemetry::event!( "Authenticate Agent Failed", - agent = agent.telemetry_id(), + agent = agent_telemetry_id, ) } } @@ -1666,43 +1675,6 @@ impl AcpThreadView { }); return; } - } else if method.0.as_ref() == "anthropic-api-key" { - let registry = LanguageModelRegistry::global(cx); - let provider = registry - .read(cx) - .provider(&language_model::ANTHROPIC_PROVIDER_ID) - .unwrap(); - let this = cx.weak_entity(); - let agent = self.agent.clone(); - let connection = connection.clone(); - window.defer(cx, move |window, cx| { - if !provider.is_authenticated(cx) { - Self::handle_auth_required( - this, - AuthRequired { - description: Some("ANTHROPIC_API_KEY must be set".to_owned()), - provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID), - }, - agent, - connection, - window, - cx, - ); - } else { - this.update(cx, |this, cx| { - this.thread_state = Self::initial_state( - agent, - None, - this.workspace.clone(), - this.project.clone(), - window, - cx, - ) - }) - .ok(); - } - }); - return; } else if method.0.as_ref() == "vertex-ai" && std::env::var("GOOGLE_API_KEY").is_err() && (std::env::var("GOOGLE_CLOUD_PROJECT").is_err() @@ -1750,43 +1722,38 @@ impl AcpThreadView { connection.authenticate(method, cx) }; cx.notify(); - self.auth_task = - Some(cx.spawn_in(window, { - let agent = self.agent.clone(); - async move |this, cx| { - let result = authenticate.await; - - match &result { - Ok(_) => telemetry::event!( - "Authenticate Agent Succeeded", - agent = agent.telemetry_id() - ), - Err(_) => { - telemetry::event!( - "Authenticate Agent Failed", - agent = agent.telemetry_id(), - ) - } + self.auth_task = Some(cx.spawn_in(window, { + async move |this, cx| { + let result = authenticate.await; + + match &result { + Ok(_) => telemetry::event!( + "Authenticate Agent Succeeded", + agent = agent_telemetry_id + ), + Err(_) => { + telemetry::event!("Authenticate Agent Failed", agent = agent_telemetry_id,) } + } - this.update_in(cx, |this, window, cx| { - if let Err(err) = result { - if let ThreadState::Unauthenticated { - pending_auth_method, - .. - } = &mut this.thread_state - { - pending_auth_method.take(); - } - this.handle_thread_error(err, cx); - } else { - this.reset(window, cx); + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + if let ThreadState::Unauthenticated { + pending_auth_method, + .. + } = &mut this.thread_state + { + pending_auth_method.take(); } - this.auth_task.take() - }) - .ok(); - } - })); + this.handle_thread_error(err, cx); + } else { + this.reset(window, cx); + } + this.auth_task.take() + }) + .ok(); + } + })); } fn spawn_external_agent_login( @@ -1905,6 +1872,17 @@ impl AcpThreadView { }) } + pub fn has_user_submitted_prompt(&self, cx: &App) -> bool { + self.thread().is_some_and(|thread| { + thread.read(cx).entries().iter().any(|entry| { + matches!( + entry, + AgentThreadEntry::UserMessage(user_message) if user_message.id.is_some() + ) + }) + }) + } + fn authorize_tool_call( &mut self, tool_call_id: acp::ToolCallId, @@ -1916,10 +1894,11 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + let agent_telemetry_id = thread.read(cx).connection().telemetry_id(); telemetry::event!( "Agent Tool Call Authorized", - agent = self.agent.telemetry_id(), + agent = agent_telemetry_id, session = thread.read(cx).session_id(), option = option_kind ); @@ -1957,6 +1936,16 @@ impl AcpThreadView { window: &mut Window, cx: &Context, ) -> AnyElement { + let is_indented = entry.is_indented(); + let is_first_indented = is_indented + && self.thread().is_some_and(|thread| { + thread + .read(cx) + .entries() + .get(entry_ix.saturating_sub(1)) + .is_none_or(|entry| !entry.is_indented()) + }); + let primary = match &entry { AgentThreadEntry::UserMessage(message) => { let Some(editor) = self @@ -1989,7 +1978,9 @@ impl AcpThreadView { v_flex() .id(("user_message", entry_ix)) .map(|this| { - if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { + if is_first_indented { + this.pt_0p5() + } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { this.pt(rems_from_px(18.)) } else if rules_item.is_some() { this.pt_3() @@ -2035,6 +2026,9 @@ impl AcpThreadView { .shadow_md() .bg(cx.theme().colors().editor_background) .border_1() + .when(is_indented, |this| { + this.py_2().px_2().shadow_sm() + }) .when(editing && !editor_focus, |this| this.border_dashed()) .border_color(cx.theme().colors().border) .map(|this|{ @@ -2047,7 +2041,7 @@ impl AcpThreadView { } }) .text_xs() - .child(editor.clone().into_any_element()), + .child(editor.clone().into_any_element()) ) .when(editor_focus, |this| { let base_container = h_flex() @@ -2105,10 +2099,23 @@ impl AcpThreadView { .icon_size(IconSize::Small) .icon_color(Color::Muted) .style(ButtonStyle::Transparent) - .tooltip(move |_window, cx| { - cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone())) - .into() - }) + .tooltip(Tooltip::element({ + move |_, _| { + v_flex() + .gap_1() + .child(Label::new("Unavailable Editing")).child( + div().max_w_64().child( + Label::new(format!( + "Editing previous messages is not available for {} yet.", + agent_name.clone() + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } + })) ) ) } @@ -2116,7 +2123,11 @@ impl AcpThreadView { ) .into_any() } - AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { + AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks, + indented: _, + }) => { + let mut is_blank = true; let is_last = entry_ix + 1 == total_entries; let style = default_markdown_style(false, false, window, cx); @@ -2126,52 +2137,100 @@ impl AcpThreadView { .children(chunks.iter().enumerate().filter_map( |(chunk_ix, chunk)| match chunk { AssistantMessageChunk::Message { block } => { - block.markdown().map(|md| { - self.render_markdown(md.clone(), style.clone()) - .into_any_element() + block.markdown().and_then(|md| { + let this_is_blank = md.read(cx).source().trim().is_empty(); + is_blank = is_blank && this_is_blank; + if this_is_blank { + return None; + } + + Some( + self.render_markdown(md.clone(), style.clone()) + .into_any_element(), + ) }) } AssistantMessageChunk::Thought { block } => { - block.markdown().map(|md| { - self.render_thinking_block( - entry_ix, - chunk_ix, - md.clone(), - window, - cx, + block.markdown().and_then(|md| { + let this_is_blank = md.read(cx).source().trim().is_empty(); + is_blank = is_blank && this_is_blank; + if this_is_blank { + return None; + } + Some( + self.render_thinking_block( + entry_ix, + chunk_ix, + md.clone(), + window, + cx, + ) + .into_any_element(), ) - .into_any_element() }) } }, )) .into_any(); - v_flex() - .px_5() - .py_1p5() - .when(is_last, |this| this.pb_4()) - .w_full() - .text_ui(cx) - .child(message_body) - .into_any() + if is_blank { + Empty.into_any() + } else { + v_flex() + .px_5() + .py_1p5() + .when(is_last, |this| this.pb_4()) + .w_full() + .text_ui(cx) + .child(self.render_message_context_menu(entry_ix, message_body, cx)) + .into_any() + } } AgentThreadEntry::ToolCall(tool_call) => { let has_terminals = tool_call.terminals().next().is_some(); - div().w_full().map(|this| { - if has_terminals { - this.children(tool_call.terminals().map(|terminal| { - self.render_terminal_tool_call( - entry_ix, terminal, tool_call, window, cx, - ) - })) - } else { - this.child(self.render_tool_call(entry_ix, tool_call, window, cx)) - } - }) + div() + .w_full() + .map(|this| { + if has_terminals { + this.children(tool_call.terminals().map(|terminal| { + self.render_terminal_tool_call( + entry_ix, terminal, tool_call, window, cx, + ) + })) + } else { + this.child(self.render_tool_call(entry_ix, tool_call, window, cx)) + } + }) + .into_any() } - .into_any(), + }; + + let primary = if is_indented { + let line_top = if is_first_indented { + rems_from_px(-12.0) + } else { + rems_from_px(0.0) + }; + + div() + .relative() + .w_full() + .pl_5() + .bg(cx.theme().colors().panel_background.opacity(0.2)) + .child( + div() + .absolute() + .left(rems_from_px(18.0)) + .top(line_top) + .bottom_0() + .w_px() + .bg(cx.theme().colors().border.opacity(0.6)), + ) + .child(primary) + .into_any_element() + } else { + primary }; let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { @@ -2230,6 +2289,70 @@ impl AcpThreadView { } } + fn render_message_context_menu( + &self, + entry_ix: usize, + message_body: AnyElement, + cx: &Context, + ) -> AnyElement { + let entity = cx.entity(); + let workspace = self.workspace.clone(); + + right_click_menu(format!("agent_context_menu-{}", entry_ix)) + .trigger(move |_, _, _| message_body) + .menu(move |window, cx| { + let focus = window.focused(cx); + let entity = entity.clone(); + let workspace = workspace.clone(); + + ContextMenu::build(window, cx, move |menu, _, cx| { + let is_at_top = entity.read(cx).list_state.logical_scroll_top().item_ix == 0; + + let scroll_item = if is_at_top { + ContextMenuEntry::new("Scroll to Bottom").handler({ + let entity = entity.clone(); + move |_, cx| { + entity.update(cx, |this, cx| { + this.scroll_to_bottom(cx); + }); + } + }) + } else { + ContextMenuEntry::new("Scroll to Top").handler({ + let entity = entity.clone(); + move |_, cx| { + entity.update(cx, |this, cx| { + this.scroll_to_top(cx); + }); + } + }) + }; + + let open_thread_as_markdown = ContextMenuEntry::new("Open Thread as Markdown") + .handler({ + let entity = entity.clone(); + let workspace = workspace.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + entity + .update(cx, |this, cx| { + this.open_thread_as_markdown(workspace, window, cx) + }) + .detach_and_log_err(cx); + } + } + }); + + menu.when_some(focus, |menu, focus| menu.context(focus)) + .action("Copy", Box::new(markdown::CopyAsMarkdown)) + .separator() + .item(scroll_item) + .item(open_thread_as_markdown) + }) + }) + .into_any_element() + } + fn tool_card_header_bg(&self, cx: &Context) -> Hsla { cx.theme() .colors() @@ -2374,6 +2497,12 @@ impl AcpThreadView { let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation; let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); + let input_output_header = |label: SharedString| { + Label::new(label) + .size(LabelSize::XSmall) + .color(Color::Muted) + .buffer_font(cx) + }; let tool_output_display = if is_open { @@ -2415,18 +2544,40 @@ impl AcpThreadView { | ToolCallStatus::Completed | ToolCallStatus::Failed | ToolCallStatus::Canceled => v_flex() - .w_full() + .when(!is_edit && !is_terminal_tool, |this| { + this.mt_1p5().w_full().child( + v_flex() + .ml(rems(0.4)) + .px_3p5() + .pb_1() + .gap_1() + .border_l_1() + .border_color(self.tool_card_border_color(cx)) + .child(input_output_header("Raw Input:".into())) + .children(tool_call.raw_input_markdown.clone().map(|input| { + div().id(("tool-call-raw-input-markdown", entry_ix)).child( + self.render_markdown( + input, + default_markdown_style(false, false, window, cx), + ), + ) + })) + .child(input_output_header("Output:".into())), + ) + }) .children(tool_call.content.iter().enumerate().map( |(content_ix, content)| { - div().child(self.render_tool_call_content( - entry_ix, - content, - content_ix, - tool_call, - use_card_layout, - window, - cx, - )) + div().id(("tool-call-output", entry_ix)).child( + self.render_tool_call_content( + entry_ix, + content, + content_ix, + tool_call, + use_card_layout, + window, + cx, + ), + ) }, )) .into_any(), @@ -2514,7 +2665,7 @@ impl AcpThreadView { .gap_px() .when(is_collapsible, |this| { this.child( - Disclosure::new(("expand", entry_ix), is_open) + Disclosure::new(("expand-output", entry_ix), is_open) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) .visible_on_hover(&card_header_id) @@ -2572,7 +2723,7 @@ impl AcpThreadView { acp::ToolKind::Think => IconName::ToolThink, acp::ToolKind::Fetch => IconName::ToolWeb, acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, - acp::ToolKind::Other => IconName::ToolHammer, + acp::ToolKind::Other | _ => IconName::ToolHammer, }) } .size(IconSize::Small) @@ -2637,7 +2788,7 @@ impl AcpThreadView { ..default_markdown_style(false, true, window, cx) }, )) - .tooltip(Tooltip::text("Jump to File")) + .tooltip(Tooltip::text("Go to File")) .on_click(cx.listener(move |this, _, window, cx| { this.open_tool_call_location(entry_ix, 0, window, cx); })) @@ -2700,20 +2851,20 @@ impl AcpThreadView { let button_id = SharedString::from(format!("tool_output-{:?}", tool_call_id)); v_flex() - .mt_1p5() .gap_2() - .when(!card_layout, |this| { - this.ml(rems(0.4)) - .px_3p5() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - }) - .when(card_layout, |this| { - this.px_2().pb_2().when(context_ix > 0, |this| { - this.border_t_1() - .pt_2() + .map(|this| { + if card_layout { + this.when(context_ix > 0, |this| { + this.pt_2() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + }) + } else { + this.ml(rems(0.4)) + .px_3p5() + .border_l_1() .border_color(self.tool_card_border_color(cx)) - }) + } }) .text_xs() .text_color(cx.theme().colors().text_muted) @@ -2824,7 +2975,7 @@ impl AcpThreadView { }) .gap_0p5() .children(options.iter().map(move |option| { - let option_id = SharedString::from(option.id.0.clone()); + let option_id = SharedString::from(option.option_id.0.clone()); Button::new((option_id, entry_ix), option.name.clone()) .map(|this| { let (this, action) = match option.kind { @@ -2840,7 +2991,7 @@ impl AcpThreadView { this.icon(IconName::Close).icon_color(Color::Error), Some(&RejectOnce as &dyn Action), ), - acp::PermissionOptionKind::RejectAlways => { + acp::PermissionOptionKind::RejectAlways | _ => { (this.icon(IconName::Close).icon_color(Color::Error), None) } }; @@ -2865,7 +3016,7 @@ impl AcpThreadView { .label_size(LabelSize::Small) .on_click(cx.listener({ let tool_call_id = tool_call_id.clone(); - let option_id = option.id.clone(); + let option_id = option.option_id.clone(); let option_kind = option.kind; move |this, _, window, cx| { this.authorize_tool_call( @@ -3434,137 +3585,120 @@ impl AcpThreadView { pending_auth_method: Option<&acp::AuthMethodId>, window: &mut Window, cx: &Context, - ) -> Div { - let show_description = - configuration_view.is_none() && description.is_none() && pending_auth_method.is_none(); - + ) -> impl IntoElement { let auth_methods = connection.auth_methods(); - v_flex().flex_1().size_full().justify_end().child( - v_flex() - .p_2() - .pr_3() - .w_full() - .gap_1() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().status().warning.opacity(0.04)) - .child( - h_flex() - .gap_1p5() - .child( - Icon::new(IconName::Warning) - .color(Color::Warning) - .size(IconSize::Small), - ) - .child(Label::new("Authentication Required").size(LabelSize::Small)), - ) - .children(description.map(|desc| { - div().text_ui(cx).child(self.render_markdown( - desc.clone(), - default_markdown_style(false, false, window, cx), - )) - })) - .children( - configuration_view - .cloned() - .map(|view| div().w_full().child(view)), - ) - .when(show_description, |el| { - el.child( - Label::new(format!( - "You are not currently authenticated with {}.{}", - self.agent.name(), - if auth_methods.len() > 1 { - " Please choose one of the following options:" - } else { - "" - } - )) - .size(LabelSize::Small) - .color(Color::Muted) - .mb_1() - .ml_5(), - ) - }) - .when_some(pending_auth_method, |el, _| { - el.child( - h_flex() - .py_4() - .w_full() - .justify_center() - .gap_1() - .child( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .color(Color::Muted) - .with_rotate_animation(2), - ) - .child(Label::new("Authenticating…").size(LabelSize::Small)), - ) - }) - .when(!auth_methods.is_empty(), |this| { - this.child( - h_flex() - .justify_end() - .flex_wrap() - .gap_1() - .when(!show_description, |this| { - this.border_t_1() - .mt_1() - .pt_2() - .border_color(cx.theme().colors().border.opacity(0.8)) - }) - .children(connection.auth_methods().iter().enumerate().rev().map( - |(ix, method)| { - let (method_id, name) = if self - .project - .read(cx) - .is_via_remote_server() - && method.id.0.as_ref() == "oauth-personal" - && method.name == "Log in with Google" - { - ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) - } else { - (method.id.0.clone(), method.name.clone()) - }; + let agent_display_name = self + .agent_server_store + .read(cx) + .agent_display_name(&ExternalAgentServerName(self.agent.name())) + .unwrap_or_else(|| self.agent.name()); - Button::new(SharedString::from(method_id.clone()), name) - .label_size(LabelSize::Small) - .map(|this| { - if ix == 0 { - this.style(ButtonStyle::Tinted(TintColor::Warning)) - } else { - this.style(ButtonStyle::Outlined) - } - }) - .when_some( - method.description.clone(), - |this, description| { - this.tooltip(Tooltip::text(description)) - }, - ) - .on_click({ - cx.listener(move |this, _, window, cx| { - telemetry::event!( - "Authenticate Agent Started", - agent = this.agent.telemetry_id(), - method = method_id - ); - - this.authenticate( - acp::AuthMethodId(method_id.clone()), - window, - cx, - ) - }) - }) - }, - )), - ) - }), - ) - } + let show_fallback_description = auth_methods.len() > 1 + && configuration_view.is_none() + && description.is_none() + && pending_auth_method.is_none(); + + let auth_buttons = || { + h_flex().justify_end().flex_wrap().gap_1().children( + connection + .auth_methods() + .iter() + .enumerate() + .rev() + .map(|(ix, method)| { + let (method_id, name) = if self.project.read(cx).is_via_remote_server() + && method.id.0.as_ref() == "oauth-personal" + && method.name == "Log in with Google" + { + ("spawn-gemini-cli".into(), "Log in with Gemini CLI".into()) + } else { + (method.id.0.clone(), method.name.clone()) + }; + + let agent_telemetry_id = connection.telemetry_id(); + + Button::new(method_id.clone(), name) + .label_size(LabelSize::Small) + .map(|this| { + if ix == 0 { + this.style(ButtonStyle::Tinted(TintColor::Accent)) + } else { + this.style(ButtonStyle::Outlined) + } + }) + .when_some(method.description.clone(), |this, description| { + this.tooltip(Tooltip::text(description)) + }) + .on_click({ + cx.listener(move |this, _, window, cx| { + telemetry::event!( + "Authenticate Agent Started", + agent = agent_telemetry_id, + method = method_id + ); + + this.authenticate( + acp::AuthMethodId::new(method_id.clone()), + window, + cx, + ) + }) + }) + }), + ) + }; + + if pending_auth_method.is_some() { + return Callout::new() + .icon(IconName::Info) + .title(format!("Authenticating to {}…", agent_display_name)) + .actions_slot( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element(), + ) + .into_any_element(); + } + + Callout::new() + .icon(IconName::Info) + .title(format!("Authenticate to {}", agent_display_name)) + .when(auth_methods.len() == 1, |this| { + this.actions_slot(auth_buttons()) + }) + .description_slot( + v_flex() + .text_ui(cx) + .map(|this| { + if show_fallback_description { + this.child( + Label::new("Choose one of the following authentication options:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + this.children( + configuration_view + .cloned() + .map(|view| div().w_full().child(view)), + ) + .children(description.map(|desc| { + self.render_markdown( + desc.clone(), + default_markdown_style(false, false, window, cx), + ) + })) + } + }) + .when(auth_methods.len() > 1, |this| { + this.gap_1().child(auth_buttons()) + }), + ) + .into_any_element() + } fn render_load_error( &self, @@ -3847,10 +3981,6 @@ impl AcpThreadView { .text_xs() .text_color(cx.theme().colors().text_muted) .child(match entry.status { - acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element(), acp::PlanEntryStatus::InProgress => { Icon::new(IconName::TodoProgress) .size(IconSize::Small) @@ -3864,6 +3994,12 @@ impl AcpThreadView { .color(Color::Success) .into_any_element() } + acp::PlanEntryStatus::Pending | _ => { + Icon::new(IconName::TodoPending) + .size(IconSize::Small) + .color(Color::Muted) + .into_any_element() + } }) .child(MarkdownElement::new( entry.content.clone(), @@ -4051,6 +4187,8 @@ impl AcpThreadView { .ml_1p5() }); + let full_path = path.display(path_style).to_string(); + let file_icon = FileIcons::get_icon(path.as_std_path(), cx) .map(Icon::from_path) .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) @@ -4084,7 +4222,6 @@ impl AcpThreadView { .relative() .pr_8() .w_full() - .overflow_x_scroll() .child( h_flex() .id(("file-name-path", index)) @@ -4096,7 +4233,14 @@ impl AcpThreadView { .child(file_icon) .children(file_name) .children(file_path) - .tooltip(Tooltip::text("Go to File")) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Go to File", + None, + full_path.clone(), + cx, + ) + }) .on_click({ let buffer = buffer.clone(); cx.listener(move |this, _, window, cx| { @@ -4210,26 +4354,6 @@ impl AcpThreadView { v_flex() .on_action(cx.listener(Self::expand_message_editor)) - .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { - if let Some(profile_selector) = this.profile_selector.as_ref() { - profile_selector.read(cx).menu_handle().toggle(window, cx); - } else if let Some(mode_selector) = this.mode_selector() { - mode_selector.read(cx).menu_handle().toggle(window, cx); - } - })) - .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { - if let Some(mode_selector) = this.mode_selector() { - mode_selector.update(cx, |mode_selector, cx| { - mode_selector.cycle_mode(window, cx); - }); - } - })) - .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { - if let Some(model_selector) = this.model_selector.as_ref() { - model_selector - .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); - } - })) .p_2() .gap_2() .border_t_1() @@ -4437,7 +4561,7 @@ impl AcpThreadView { self.authorize_tool_call( tool_call.id.clone(), - option.id.clone(), + option.option_id.clone(), option.kind, window, cx, @@ -4869,6 +4993,32 @@ impl AcpThreadView { cx.notify(); } + fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { + let Some(thread) = self.thread() else { + return; + }; + + let entries = thread.read(cx).entries(); + if entries.is_empty() { + return; + } + + // Find the most recent user message and scroll it to the top of the viewport. + // (Fallback: if no user message exists, scroll to the bottom.) + if let Some(ix) = entries + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) + { + self.list_state.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: px(0.0), + }); + cx.notify(); + } else { + self.scroll_to_bottom(cx); + } + } + pub fn scroll_to_bottom(&mut self, cx: &mut Context) { if let Some(thread) = self.thread() { let entry_count = thread.read(cx).entries().len(); @@ -4964,8 +5114,8 @@ impl AcpThreadView { }); if let Some(screen_window) = cx - .open_window(options, |_, cx| { - cx.new(|_| { + .open_window(options, |_window, cx| { + cx.new(|_cx| { AgentNotification::new(title.clone(), caption.clone(), icon, project_name) }) }) @@ -5087,6 +5237,16 @@ impl AcpThreadView { } })); + let scroll_to_recent_user_prompt = + IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(Color::Ignored) + .tooltip(Tooltip::text("Scroll To Most Recent User Prompt")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_most_recent_user_prompt(cx); + })); + let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -5163,6 +5323,7 @@ impl AcpThreadView { container .child(open_as_markdown) + .child(scroll_to_recent_user_prompt) .child(scroll_to_top) .into_any_element() } @@ -5232,22 +5393,26 @@ impl AcpThreadView { cx.notify(); } - fn render_token_limit_callout( - &self, - line_height: Pixels, - cx: &mut Context, - ) -> Option { + fn render_token_limit_callout(&self, cx: &mut Context) -> Option { + if self.token_limit_callout_dismissed { + return None; + } + let token_usage = self.thread()?.read(cx).token_usage()?; let ratio = token_usage.ratio(); - let (severity, title) = match ratio { + let (severity, icon, title) = match ratio { acp_thread::TokenUsageRatio::Normal => return None, - acp_thread::TokenUsageRatio::Warning => { - (Severity::Warning, "Thread reaching the token limit soon") - } - acp_thread::TokenUsageRatio::Exceeded => { - (Severity::Error, "Thread reached the token limit") - } + acp_thread::TokenUsageRatio::Warning => ( + Severity::Warning, + IconName::Warning, + "Thread reaching the token limit soon", + ), + acp_thread::TokenUsageRatio::Exceeded => ( + Severity::Error, + IconName::XCircle, + "Thread reached the token limit", + ), }; let burn_mode_available = self.as_native_thread(cx).is_some_and(|thread| { @@ -5267,7 +5432,7 @@ impl AcpThreadView { Some( Callout::new() .severity(severity) - .line_height(line_height) + .icon(icon) .title(title) .description(description) .actions_slot( @@ -5299,7 +5464,8 @@ impl AcpThreadView { })), ) }), - ), + ) + .dismiss_action(self.dismiss_error_button(cx)), ) } @@ -5394,47 +5560,39 @@ impl AcpThreadView { ) } - fn render_codex_windows_warning(&self, cx: &mut Context) -> Option { - if self.show_codex_windows_warning { - Some( - Callout::new() - .icon(IconName::Warning) - .severity(Severity::Warning) - .title("Codex on Windows") - .description( - "For best performance, run Codex in Windows Subsystem for Linux (WSL2)", - ) - .actions_slot( - Button::new("open-wsl-modal", "Open in WSL") - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(cx.listener({ - move |_, _, _window, cx| { - #[cfg(windows)] - _window.dispatch_action( - zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), - cx, - ); - cx.notify(); - } - })), - ) - .dismiss_action( - IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Dismiss Warning")) - .on_click(cx.listener({ - move |this, _, _, cx| { - this.show_codex_windows_warning = false; - cx.notify(); - } - })), - ), + fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title("Codex on Windows") + .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)") + .actions_slot( + Button::new("open-wsl-modal", "Open in WSL") + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .on_click(cx.listener({ + move |_, _, _window, cx| { + #[cfg(windows)] + _window.dispatch_action( + zed_actions::wsl_actions::OpenWsl::default().boxed_clone(), + cx, + ); + cx.notify(); + } + })), + ) + .dismiss_action( + IconButton::new("dismiss", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Dismiss Warning")) + .on_click(cx.listener({ + move |this, _, _, cx| { + this.show_codex_windows_warning = false; + cx.notify(); + } + })), ) - } else { - None - } } fn render_thread_error(&mut self, window: &mut Window, cx: &mut Context) -> Option
{ @@ -5741,7 +5899,7 @@ impl AcpThreadView { fn dismiss_error_button(&self, cx: &mut Context) -> impl IntoElement { IconButton::new("dismiss", IconName::Close) .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Dismiss Error")) + .tooltip(Tooltip::text("Dismiss")) .on_click(cx.listener({ move |this, _, _, cx| { this.clear_thread_error(cx); @@ -5762,10 +5920,6 @@ impl AcpThreadView { }; let connection = thread.read(cx).connection().clone(); - let err = AuthRequired { - description: None, - provider_id: None, - }; this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { this.message_editor.update(cx, |editor, cx| { @@ -5774,7 +5928,14 @@ impl AcpThreadView { } let this = cx.weak_entity(); window.defer(cx, |window, cx| { - Self::handle_auth_required(this, err, agent, connection, window, cx); + Self::handle_auth_required( + this, + AuthRequired::new(), + agent, + connection, + window, + cx, + ); }) } })) @@ -5787,14 +5948,10 @@ impl AcpThreadView { }; let connection = thread.read(cx).connection().clone(); - let err = AuthRequired { - description: None, - provider_id: None, - }; self.clear_thread_error(cx); let this = cx.weak_entity(); window.defer(cx, |window, cx| { - Self::handle_auth_required(this, err, agent, connection, window, cx); + Self::handle_auth_required(this, AuthRequired::new(), agent, connection, window, cx); }) } @@ -5888,6 +6045,37 @@ impl Render for AcpThreadView { .on_action(cx.listener(Self::allow_always)) .on_action(cx.listener(Self::allow_once)) .on_action(cx.listener(Self::reject_once)) + .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { + if let Some(profile_selector) = this.profile_selector.as_ref() { + profile_selector.read(cx).menu_handle().toggle(window, cx); + } else if let Some(mode_selector) = this.mode_selector() { + mode_selector.read(cx).menu_handle().toggle(window, cx); + } + })) + .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { + if let Some(profile_selector) = this.profile_selector.as_ref() { + profile_selector.update(cx, |profile_selector, cx| { + profile_selector.cycle_profile(cx); + }); + } else if let Some(mode_selector) = this.mode_selector() { + mode_selector.update(cx, |mode_selector, cx| { + mode_selector.cycle_mode(window, cx); + }); + } + })) + .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { + if let Some(model_selector) = this.model_selector.as_ref() { + model_selector + .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); + } + })) + .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { + if let Some(model_selector) = this.model_selector.as_ref() { + model_selector.update(cx, |model_selector, cx| { + model_selector.cycle_favorite_models(window, cx); + }); + } + })) .track_focus(&self.focus_handle) .bg(cx.theme().colors().panel_background) .child(match &self.thread_state { @@ -5897,16 +6085,19 @@ impl Render for AcpThreadView { configuration_view, pending_auth_method, .. - } => self - .render_auth_required_state( + } => v_flex() + .flex_1() + .size_full() + .justify_end() + .child(self.render_auth_required_state( connection, description.as_ref(), configuration_view.as_ref(), pending_auth_method.as_ref(), window, cx, - ) - .into_any(), + )) + .into_any_element(), ThreadState::Loading { .. } => v_flex() .flex_1() .child(self.render_recent_history(cx)) @@ -5954,12 +6145,8 @@ impl Render for AcpThreadView { _ => this, }) .children(self.render_thread_retry_status_callout(window, cx)) - .children({ - if cfg!(windows) && self.project.read(cx).is_local() { - self.render_codex_windows_warning(cx) - } else { - None - } + .when(self.show_codex_windows_warning, |this| { + this.child(self.render_codex_windows_warning(cx)) }) .children(self.render_thread_error(window, cx)) .when_some( @@ -5972,7 +6159,7 @@ impl Render for AcpThreadView { if let Some(usage_callout) = self.render_usage_callout(line_height, cx) { Some(usage_callout.into_any_element()) } else { - self.render_token_limit_callout(line_height, cx) + self.render_token_limit_callout(cx) .map(|token_limit_callout| token_limit_callout.into_any_element()) }, ) @@ -6075,13 +6262,13 @@ fn default_markdown_style( }, border_color: Some(colors.border_variant), background: Some(colors.editor_background.into()), - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some(theme_settings.buffer_font.family.clone()), font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), font_features: Some(theme_settings.buffer_font.features.clone()), font_size: Some(buffer_font_size.into()), ..Default::default() - }), + }, ..Default::default() }, inline_code: TextStyleRefinement { @@ -6253,27 +6440,18 @@ pub(crate) mod tests { async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) { init_test(cx); - let tool_call_id = acp::ToolCallId("1".into()); - let tool_call = acp::ToolCall { - id: tool_call_id.clone(), - title: "Label".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Pending, - content: vec!["hi".into()], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; + let tool_call_id = acp::ToolCallId::new("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, - vec![acp::PermissionOption { - id: acp::PermissionOptionId("1".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - meta: None, - }], + vec![acp::PermissionOption::new( + "1", + "Allow", + acp::PermissionOptionKind::AllowOnce, + )], )])); connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); @@ -6401,6 +6579,57 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + let weak_view = thread_view.downgrade(); + + let message_editor = cx.read(|cx| thread_view.read(cx).message_editor.clone()); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + + cx.deactivate_window(); + + thread_view.update_in(cx, |thread_view, window, cx| { + thread_view.send(window, cx); + }); + + cx.run_until_parked(); + + // Verify notification is shown + assert!( + cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Expected notification to be shown" + ); + + // Drop the thread view (simulating navigation to a new thread) + drop(thread_view); + drop(message_editor); + // Trigger an update to flush effects, which will call release_dropped_entities + cx.update(|_window, _cx| {}); + cx.run_until_parked(); + + // Verify the entity was actually released + assert!( + !weak_view.is_upgradable(), + "Thread view entity should be released after dropping" + ); + + // The notification should be automatically closed via on_release + assert!( + !cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Notification should be closed when thread view is dropped" + ); + } + async fn setup_thread_view( agent: impl AgentServer + 'static, cx: &mut TestAppContext, @@ -6425,6 +6654,7 @@ pub(crate) mod tests { project, history_store, None, + false, window, cx, ) @@ -6492,10 +6722,7 @@ pub(crate) mod tests { fn default_response() -> Self { let conn = StubAgentConnection::new(); conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: "Default response".into(), - meta: None, - }, + acp::ContentChunk::new("Default response".into()), )]); Self::new(conn) } @@ -6505,10 +6732,6 @@ pub(crate) mod tests { where C: 'static + AgentConnection + Send + Clone, { - fn telemetry_id(&self) -> &'static str { - "test" - } - fn logo(&self) -> ui::IconName { ui::IconName::Ai } @@ -6535,8 +6758,8 @@ pub(crate) mod tests { struct SaboteurAgentConnection; impl AgentConnection for SaboteurAgentConnection { - fn telemetry_id(&self) -> &'static str { - "saboteur" + fn telemetry_id(&self) -> SharedString { + "saboteur".into() } fn new_thread( @@ -6552,13 +6775,13 @@ pub(crate) mod tests { self, project, action_log, - SessionId("test".into()), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + SessionId::new("test"), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }))) @@ -6599,8 +6822,8 @@ pub(crate) mod tests { struct RefusalAgentConnection; impl AgentConnection for RefusalAgentConnection { - fn telemetry_id(&self) -> &'static str { - "refusal" + fn telemetry_id(&self) -> SharedString { + "refusal".into() } fn new_thread( @@ -6616,13 +6839,13 @@ pub(crate) mod tests { self, project, action_log, - SessionId("test".into()), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + SessionId::new("test"), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }))) @@ -6646,10 +6869,7 @@ pub(crate) mod tests { _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { - Task::ready(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - })) + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal))) } fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { @@ -6704,6 +6924,7 @@ pub(crate) mod tests { project.clone(), history_store.clone(), None, + false, window, cx, ) @@ -6717,24 +6938,14 @@ pub(crate) mod tests { .unwrap(); // First user message - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool1".into()), - title: "Edit file 1".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/test1.txt".into(), - old_text: Some("old content 1".into()), - new_text: "new content 1".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - })]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool1", "Edit file 1") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"), + )]), + )]); thread .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx)) @@ -6760,24 +6971,14 @@ pub(crate) mod tests { }); // Second user message - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool2".into()), - title: "Edit file 2".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/test2.txt".into(), - old_text: Some("old content 2".into()), - new_text: "new content 2".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - })]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool2", "Edit file 2") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"), + )]), + )]); thread .update(cx, |thread, cx| thread.send_raw("Another one", cx)) @@ -6844,6 +7045,70 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) { + init_test(cx); + + let connection = StubAgentConnection::new(); + + // Each user prompt will result in a user message entry plus an agent message entry. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response 1".into()), + )]); + + let (thread_view, cx) = + setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + + let thread = thread_view + .read_with(cx, |view, _| view.thread().cloned()) + .unwrap(); + + thread + .update(cx, |thread, cx| thread.send_raw("Prompt 1", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response 2".into()), + )]); + + thread + .update(cx, |thread, cx| thread.send_raw("Prompt 2", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + // Move somewhere else first so we're not trivially already on the last user prompt. + thread_view.update(cx, |view, cx| { + view.scroll_to_top(cx); + }); + cx.run_until_parked(); + + thread_view.update(cx, |view, cx| { + view.scroll_to_most_recent_user_prompt(cx); + let scroll_top = view.list_state.logical_scroll_top(); + // Entries layout is: [User1, Assistant1, User2, Assistant2] + assert_eq!(scroll_top.item_ix, 2); + }); + } + + #[gpui::test] + async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + + // With no entries, scrolling should be a no-op and must not panic. + thread_view.update(cx, |view, cx| { + view.scroll_to_most_recent_user_prompt(cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 0); + }); + } + #[gpui::test] async fn test_message_editing_cancel(cx: &mut TestAppContext) { init_test(cx); @@ -6851,14 +7116,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; @@ -6944,14 +7202,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = @@ -6991,14 +7242,7 @@ pub(crate) mod tests { // Send connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "New Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("New Response".into()), )]); user_message_editor.update_in(cx, |_editor, window, cx| { @@ -7086,14 +7330,7 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())), cx, ); connection.end_turn(session_id, acp::StopReason::EndTurn); @@ -7145,10 +7382,9 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: "Message 1 resp".into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Message 1 resp".into(), + )), cx, ); }); @@ -7182,10 +7418,7 @@ pub(crate) mod tests { // Simulate a response sent after beginning to cancel connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: "onse".into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())), cx, ); }); @@ -7216,10 +7449,9 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: "Message 2 response".into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Message 2 response".into(), + )), cx, ); connection.end_turn(session_id.clone(), acp::StopReason::EndTurn); @@ -7258,14 +7490,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; @@ -7344,14 +7569,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; @@ -7399,54 +7617,4 @@ pub(crate) mod tests { assert_eq!(text, expected_txt); }) } - - #[gpui::test] - async fn test_initialize_timeout(cx: &mut TestAppContext) { - init_test(cx); - - struct InfiniteInitialize; - - impl AgentServer for InfiniteInitialize { - fn telemetry_id(&self) -> &'static str { - "test" - } - - fn logo(&self) -> ui::IconName { - ui::IconName::Ai - } - - fn name(&self) -> SharedString { - "Test".into() - } - - fn connect( - &self, - _root_dir: Option<&Path>, - _delegate: AgentServerDelegate, - cx: &mut App, - ) -> Task, Option)>> - { - cx.spawn(async |_| futures::future::pending().await) - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - let (thread_view, cx) = setup_thread_view(InfiniteInitialize, cx).await; - - cx.executor().advance_clock(Duration::from_secs(31)); - cx.run_until_parked(); - - let error = thread_view.read_with(cx, |thread_view, _| match &thread_view.thread_state { - ThreadState::LoadError(err) => err.clone(), - _ => panic!("Incorrect thread state"), - }); - - match error { - LoadError::Other(str) => assert!(str.contains("initialize")), - _ => panic!("Unexpected load error"), - } - } } diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index f831329e2cde40dbb9d4b9e882d6bc942f383422..fb2d50863c002cba0c7b0d63c2a5a4cc73224b4d 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -22,7 +22,8 @@ use gpui::{ }; use language::LanguageRegistry; use language_model::{ - LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID, + IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, + ZED_CLOUD_PROVIDER_ID, }; use language_models::AllLanguageModelSettings; use notifications::status_toast::{StatusToast, ToastIcon}; @@ -34,9 +35,9 @@ use project::{ }; use settings::{Settings, SettingsStore, update_settings_file}; use ui::{ - Button, ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, - Divider, DividerColor, ElevationIndex, IconName, IconPosition, IconSize, Indicator, LabelSize, - PopoverMenu, Switch, SwitchColor, Tooltip, WithScrollbar, prelude::*, + ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, + DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use workspace::{Workspace, create_and_open_local_file}; @@ -117,7 +118,7 @@ impl AgentConfiguration { } fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context) { - let providers = LanguageModelRegistry::read_global(cx).providers(); + let providers = LanguageModelRegistry::read_global(cx).visible_providers(); for provider in providers { self.add_provider_configuration_view(&provider, window, cx); } @@ -261,9 +262,12 @@ impl AgentConfiguration { .w_full() .gap_1p5() .child( - Icon::new(provider.icon()) - .size(IconSize::Small) - .color(Color::Muted), + 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() @@ -416,7 +420,7 @@ impl AgentConfiguration { &mut self, cx: &mut Context, ) -> impl IntoElement { - let providers = LanguageModelRegistry::read_global(cx).providers(); + let providers = LanguageModelRegistry::read_global(cx).visible_providers(); let popover_menu = PopoverMenu::new("add-provider-popover") .trigger( @@ -838,7 +842,7 @@ impl AgentConfiguration { .min_w_0() .child( h_flex() - .id(SharedString::from(format!("tooltip-{}", item_id))) + .id(format!("tooltip-{}", item_id)) .h_full() .w_3() .mr_2() @@ -879,7 +883,6 @@ impl AgentConfiguration { .child(context_server_configuration_menu) .child( Switch::new("context-server-switch", is_running.into()) - .color(SwitchColor::Accent) .on_click({ let context_server_manager = self.context_server_store.clone(); let fs = self.fs.clone(); @@ -976,9 +979,12 @@ impl AgentConfiguration { let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { AgentIcon::Path(icon_path) } else { - AgentIcon::Name(IconName::Ai) + AgentIcon::Name(IconName::Sparkle) }; - (name, icon) + let display_name = agent_server_store + .agent_display_name(&name) + .unwrap_or_else(|| name.0.clone()); + (name, icon, display_name) }) .collect(); @@ -1085,6 +1091,7 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiClaude), "Claude Code", + "Claude Code", false, cx, )) @@ -1092,6 +1099,7 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiOpenAi), "Codex CLI", + "Codex CLI", false, cx, )) @@ -1099,16 +1107,23 @@ impl AgentConfiguration { .child(self.render_agent_server( AgentIcon::Name(IconName::AiGemini), "Gemini CLI", + "Gemini CLI", false, cx, )) .map(|mut parent| { - for (name, icon) in user_defined_agents { + for (name, icon, display_name) in user_defined_agents { parent = parent .child( Divider::horizontal().color(DividerColor::BorderFaded), ) - .child(self.render_agent_server(icon, name, true, cx)); + .child(self.render_agent_server( + icon, + name, + display_name, + true, + cx, + )); } parent }), @@ -1119,11 +1134,14 @@ impl AgentConfiguration { fn render_agent_server( &self, icon: AgentIcon, - name: impl Into, + id: impl Into, + display_name: impl Into, external: bool, cx: &mut Context, ) -> impl IntoElement { - let name = name.into(); + let id = id.into(); + let display_name = display_name.into(); + let icon = match icon { AgentIcon::Name(icon_name) => Icon::new(icon_name) .size(IconSize::Small) @@ -1133,12 +1151,15 @@ impl AgentConfiguration { .color(Color::Muted), }; - let tooltip_id = SharedString::new(format!("agent-source-{}", name)); - let tooltip_message = format!("The {} agent was installed from an extension.", name); + let tooltip_id = SharedString::new(format!("agent-source-{}", id)); + let tooltip_message = format!( + "The {} agent was installed from an extension.", + display_name + ); - let agent_server_name = ExternalAgentServerName(name.clone()); + let agent_server_name = ExternalAgentServerName(id.clone()); - let uninstall_btn_id = SharedString::from(format!("uninstall-{}", name)); + let uninstall_btn_id = SharedString::from(format!("uninstall-{}", id)); let uninstall_button = IconButton::new(uninstall_btn_id, IconName::Trash) .icon_color(Color::Muted) .icon_size(IconSize::Small) @@ -1162,7 +1183,7 @@ impl AgentConfiguration { h_flex() .gap_1p5() .child(icon) - .child(Label::new(name)) + .child(Label::new(display_name)) .when(external, |this| { this.child( div() @@ -1349,6 +1370,7 @@ async fn open_new_agent_servers_entry_in_settings_editor( env: Some(HashMap::default()), default_mode: None, default_model: None, + favorite_models: vec![], }, ); } diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 02269511bb9a4d9b95fe27b66e3ca0a9e5c498c5..e443df33b4ddcaeba32b9b2623c0fdca85fac51c 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -446,17 +446,17 @@ impl AddLlmProviderModal { }) } - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); } fn on_tab_prev( &mut self, _: &menu::SelectPrevious, window: &mut Window, - _: &mut Context, + cx: &mut Context, ) { - window.focus_prev(); + window.focus_prev(cx); } } @@ -493,7 +493,7 @@ impl Render for AddLlmProviderModal { .on_action(cx.listener(Self::on_tab)) .on_action(cx.listener(Self::on_tab_prev)) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .child( Modal::new("configure-context-server", None) diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index a0f0be886a1bf5e1485a2d36440b9f91648ef0c6..b30f1494f0d4dcbf3ef63cc7f549d16374f4899b 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -831,7 +831,7 @@ impl Render for ConfigureContextServerModal { }), ) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .child( Modal::new("configure-context-server", None) diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs index 3573c8b67ee81ef9cd1decacefb52017dabdb178..5115e2f70c0ae87cdd3ca3901a64aed09de68b0f 100644 --- 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 @@ -87,7 +87,7 @@ impl ConfigureContextServerToolsModal { v_flex() .child( h_flex() - .id(SharedString::from(format!("tool-header-{}", index))) + .id(format!("tool-header-{}", index)) .py_1() .pl_1() .pr_2() diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index 7e03dc46b704c22b4665bbce0f3b818134b56634..c7f395ebbd813cfd7c28f33a7e69ec32f6d90fca 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -8,6 +8,7 @@ use editor::Editor; use fs::Fs; use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, prelude::*}; use language_model::{LanguageModel, LanguageModelRegistry}; +use settings::SettingsStore; use settings::{ LanguageModelProviderSetting, LanguageModelSelection, Settings as _, update_settings_file, }; @@ -94,6 +95,7 @@ pub struct ViewProfileMode { configure_default_model: NavigableEntry, configure_tools: NavigableEntry, configure_mcps: NavigableEntry, + delete_profile: NavigableEntry, cancel_item: NavigableEntry, } @@ -109,6 +111,7 @@ pub struct ManageProfilesModal { active_model: Option>, focus_handle: FocusHandle, mode: Mode, + _settings_subscription: Subscription, } impl ManageProfilesModal { @@ -148,18 +151,29 @@ impl ManageProfilesModal { ) -> Self { let focus_handle = cx.focus_handle(); + // Keep this modal in sync with settings changes (including profile deletion). + let settings_subscription = + cx.observe_global_in::(window, |this, window, cx| { + if matches!(this.mode, Mode::ChooseProfile(_)) { + this.mode = Mode::choose_profile(window, cx); + this.focus_handle(cx).focus(window, cx); + cx.notify(); + } + }); + Self { fs, active_model, context_server_registry, focus_handle, mode: Mode::choose_profile(window, cx), + _settings_subscription: settings_subscription, } } fn choose_profile(&mut self, window: &mut Window, cx: &mut Context) { self.mode = Mode::choose_profile(window, cx); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn new_profile( @@ -177,7 +191,7 @@ impl ManageProfilesModal { name_editor, base_profile_id, }); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } pub fn view_profile( @@ -192,9 +206,10 @@ impl ManageProfilesModal { configure_default_model: NavigableEntry::focusable(cx), configure_tools: NavigableEntry::focusable(cx), configure_mcps: NavigableEntry::focusable(cx), + delete_profile: NavigableEntry::focusable(cx), cancel_item: NavigableEntry::focusable(cx), }); - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn configure_default_model( @@ -207,7 +222,6 @@ impl ManageProfilesModal { let profile_id_for_closure = profile_id.clone(); let model_picker = cx.new(|cx| { - let fs = fs.clone(); let profile_id = profile_id_for_closure.clone(); language_model_selector( @@ -235,22 +249,36 @@ impl ManageProfilesModal { }) } }, - move |model, cx| { - let provider = model.provider_id().0.to_string(); - let model_id = model.id().0.to_string(); - let profile_id = profile_id.clone(); + { + let fs = fs.clone(); + move |model, cx| { + let provider = model.provider_id().0.to_string(); + let model_id = model.id().0.to_string(); + let profile_id = profile_id.clone(); - update_settings_file(fs.clone(), cx, move |settings, _cx| { - let agent_settings = settings.agent.get_or_insert_default(); - if let Some(profiles) = agent_settings.profiles.as_mut() { - if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) { - profile.default_model = Some(LanguageModelSelection { - provider: LanguageModelProviderSetting(provider.clone()), - model: model_id.clone(), - }); + update_settings_file(fs.clone(), cx, move |settings, _cx| { + let agent_settings = settings.agent.get_or_insert_default(); + if let Some(profiles) = agent_settings.profiles.as_mut() { + if let Some(profile) = profiles.get_mut(profile_id.0.as_ref()) { + profile.default_model = Some(LanguageModelSelection { + provider: LanguageModelProviderSetting(provider.clone()), + model: model_id.clone(), + }); + } } - } - }); + }); + } + }, + { + let fs = fs.clone(); + move |model, should_be_favorite, cx| { + crate::favorite_models::toggle_in_settings( + model, + should_be_favorite, + fs.clone(), + cx, + ); + } }, false, // Do not use popover styles for the model picker self.focus_handle.clone(), @@ -272,7 +300,7 @@ impl ManageProfilesModal { model_picker, _subscription: dismiss_subscription, }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn configure_mcp_tools( @@ -308,7 +336,7 @@ impl ManageProfilesModal { tool_picker, _subscription: dismiss_subscription, }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn configure_builtin_tools( @@ -349,7 +377,7 @@ impl ManageProfilesModal { tool_picker, _subscription: dismiss_subscription, }; - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } fn confirm(&mut self, window: &mut Window, cx: &mut Context) { @@ -369,6 +397,42 @@ impl ManageProfilesModal { } } + fn delete_profile( + &mut self, + profile_id: AgentProfileId, + window: &mut Window, + cx: &mut Context, + ) { + if builtin_profiles::is_builtin(&profile_id) { + self.view_profile(profile_id, window, cx); + return; + } + + let fs = self.fs.clone(); + + update_settings_file(fs, cx, move |settings, _cx| { + let Some(agent_settings) = settings.agent.as_mut() else { + return; + }; + + let Some(profiles) = agent_settings.profiles.as_mut() else { + return; + }; + + profiles.shift_remove(profile_id.0.as_ref()); + + if agent_settings + .default_profile + .as_deref() + .is_some_and(|default_profile| default_profile == profile_id.0.as_ref()) + { + agent_settings.default_profile = Some(AgentProfileId::default().0); + } + }); + + self.choose_profile(window, cx); + } + fn cancel(&mut self, window: &mut Window, cx: &mut Context) { match &self.mode { Mode::ChooseProfile { .. } => { @@ -422,7 +486,7 @@ impl ManageProfilesModal { let is_focused = profile.navigation.focus_handle.contains_focused(window, cx); div() - .id(SharedString::from(format!("profile-{}", profile.id))) + .id(format!("profile-{}", profile.id)) .track_focus(&profile.navigation.focus_handle) .on_action({ let profile_id = profile.id.clone(); @@ -431,7 +495,7 @@ impl ManageProfilesModal { }) }) .child( - ListItem::new(SharedString::from(format!("profile-{}", profile.id))) + ListItem::new(format!("profile-{}", profile.id)) .toggle_state(is_focused) .inset(true) .spacing(ListItemSpacing::Sparse) @@ -756,6 +820,40 @@ impl ManageProfilesModal { }), ), ) + .child( + div() + .id("delete-profile") + .track_focus(&mode.delete_profile.focus_handle) + .on_action({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _: &menu::Confirm, window, cx| { + this.delete_profile(profile_id.clone(), window, cx); + }) + }) + .child( + ListItem::new("delete-profile") + .toggle_state( + mode.delete_profile + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Trash) + .size(IconSize::Small) + .color(Color::Error), + ) + .child(Label::new("Delete Profile").color(Color::Error)) + .disabled(builtin_profiles::is_builtin(&mode.profile_id)) + .on_click({ + let profile_id = mode.profile_id.clone(); + cx.listener(move |this, _, window, cx| { + this.delete_profile(profile_id.clone(), window, cx); + }) + }), + ), + ) .child(ListSeparator) .child( div() @@ -805,6 +903,7 @@ impl ManageProfilesModal { .entry(mode.configure_default_model) .entry(mode.configure_tools) .entry(mode.configure_mcps) + .entry(mode.delete_profile) .entry(mode.cancel_item) } } @@ -852,7 +951,7 @@ impl Render for ManageProfilesModal { .on_action(cx.listener(|this, _: &menu::Cancel, window, cx| this.cancel(window, cx))) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| this.confirm(window, cx))) .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); })) .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent))) .child(match &self.mode { diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 8ddd77b9edd4e575e75686751bdb1146e4cf98f8..a6dafd17d97ef07af29b3201a29c34d0db670679 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -17,7 +17,7 @@ use gpui::{ Global, SharedString, Subscription, Task, WeakEntity, Window, prelude::*, }; -use language::{Buffer, Capability, DiskState, OffsetRangeExt, Point}; +use language::{Buffer, Capability, OffsetRangeExt, Point}; use multi_buffer::PathKey; use project::{Project, ProjectItem, ProjectPath}; use settings::{Settings, SettingsStore}; @@ -130,7 +130,12 @@ impl AgentDiffPane { .action_log() .read(cx) .changed_buffers(cx); - let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::>(); + let mut paths_to_delete = self + .multibuffer + .read(cx) + .paths() + .cloned() + .collect::>(); for (buffer, diff_handle) in changed_buffers { if buffer.read(cx).file().is_none() { @@ -187,7 +192,7 @@ impl AgentDiffPane { && buffer .read(cx) .file() - .is_some_and(|file| file.disk_state() == DiskState::Deleted) + .is_some_and(|file| file.disk_state().is_deleted()) { editor.fold_buffer(snapshot.text.remote_id(), cx) } @@ -207,10 +212,10 @@ impl AgentDiffPane { .focus_handle(cx) .contains_focused(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { self.editor.update(cx, |editor, cx| { - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); }); } } @@ -869,12 +874,12 @@ impl AgentDiffToolbar { match active_item { AgentDiffToolbarItem::Pane(agent_diff) => { if let Some(agent_diff) = agent_diff.upgrade() { - agent_diff.focus_handle(cx).focus(window); + agent_diff.focus_handle(cx).focus(window, cx); } } AgentDiffToolbarItem::Editor { editor, .. } => { if let Some(editor) = editor.upgrade() { - editor.read(cx).focus_handle(cx).focus(window); + editor.read(cx).focus_handle(cx).focus(window, cx); } } } diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index 43982cdda7bd887b8fd9970e836090a0e549ae11..caeba3d26d892aefbb879f6a4e0c9dad603e478f 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -1,14 +1,15 @@ use crate::{ ModelUsageContext, language_model_selector::{LanguageModelSelector, language_model_selector}, + ui::ModelSelectorTooltip, }; use fs::Fs; use gpui::{Entity, FocusHandle, SharedString}; +use language_model::IconOrSvg; use picker::popover_menu::PickerPopoverMenu; use settings::update_settings_file; use std::sync::Arc; use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*}; -use zed_actions::agent::ToggleModelSelector; pub struct AgentModelSelector { selector: Entity, @@ -29,26 +30,39 @@ impl AgentModelSelector { Self { selector: cx.new(move |cx| { - let fs = fs.clone(); language_model_selector( { let model_context = model_usage_context.clone(); move |cx| model_context.configured_model(cx) }, - move |model, cx| { - let provider = model.provider_id().0.to_string(); - let model_id = model.id().0.to_string(); - match &model_usage_context { - ModelUsageContext::InlineAssistant => { - update_settings_file(fs.clone(), cx, move |settings, _cx| { - settings - .agent - .get_or_insert_default() - .set_inline_assistant_model(provider.clone(), model_id); - }); + { + let fs = fs.clone(); + move |model, cx| { + let provider = model.provider_id().0.to_string(); + let model_id = model.id().0.to_string(); + match &model_usage_context { + ModelUsageContext::InlineAssistant => { + update_settings_file(fs.clone(), cx, move |settings, _cx| { + settings + .agent + .get_or_insert_default() + .set_inline_assistant_model(provider.clone(), model_id); + }); + } } } }, + { + let fs = fs.clone(); + move |model, should_be_favorite, cx| { + crate::favorite_models::toggle_in_settings( + model, + should_be_favorite, + fs.clone(), + cx, + ); + } + }, true, // Use popover styles for picker focus_handle_clone, window, @@ -63,6 +77,16 @@ impl AgentModelSelector { pub fn toggle(&self, window: &mut Window, cx: &mut Context) { self.menu_handle.toggle(window, cx); } + + pub fn active_model(&self, cx: &App) -> Option { + self.selector.read(cx).delegate.active_model(cx) + } + + pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context) { + self.selector.update(cx, |selector, cx| { + selector.delegate.cycle_favorite_models(window, cx); + }); + } } impl Render for AgentModelSelector { @@ -80,13 +104,30 @@ impl Render for AgentModelSelector { Color::Muted }; + let show_cycle_row = self.selector.read(cx).delegate.favorites_count() > 1; + let focus_handle = self.focus_handle.clone(); + let tooltip = Tooltip::element({ + move |_, _cx| { + ModelSelectorTooltip::new(focus_handle.clone()) + .show_cycle_row(show_cycle_row) + .into_any_element() + } + }); + PickerPopoverMenu::new( self.selector.clone(), ButtonLike::new("active-model") .when_some(provider_icon, |this, icon| { - this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) + this.child( + match icon { + IconOrSvg::Svg(path) => Icon::from_external_svg(path), + IconOrSvg::Icon(name) => Icon::new(name), + } + .color(color) + .size(IconSize::XSmall), + ) }) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child( @@ -100,9 +141,7 @@ impl Render for AgentModelSelector { .color(color) .size(IconSize::XSmall), ), - move |_window, cx| { - Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) - }, + tooltip, gpui::Corner::TopRight, cx, ) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 9dd77774ff4e6f00bdfd26d024e9ee4b389b7f7e..a050f75120cd73949251c09c8424314e3616c705 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2,6 +2,7 @@ use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; use acp_thread::AcpThread; use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore}; +use agent_servers::AgentServer; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use project::{ ExternalAgentServerName, @@ -259,7 +260,7 @@ impl AgentType { Self::Gemini => Some(IconName::AiGemini), Self::ClaudeCode => Some(IconName::AiClaude), Self::Codex => Some(IconName::AiOpenAi), - Self::Custom { .. } => Some(IconName::Terminal), + Self::Custom { .. } => Some(IconName::Sparkle), } } } @@ -287,7 +288,7 @@ impl ActiveView { } } - pub fn native_agent( + fn native_agent( fs: Arc, prompt_store: Option>, history_store: Entity, @@ -305,6 +306,7 @@ impl ActiveView { project, history_store, prompt_store, + false, window, cx, ) @@ -441,6 +443,7 @@ pub struct AgentPanel { pending_serialization: Option>>, onboarding: Entity, selected_agent: AgentType, + show_trust_workspace_message: bool, } impl AgentPanel { @@ -691,6 +694,7 @@ impl AgentPanel { history_store, selected_agent: AgentType::default(), loading: false, + show_trust_workspace_message: false, }; // Initial sync of agent servers from extensions @@ -818,7 +822,7 @@ impl AgentPanel { window, cx, ); - text_thread_editor.focus_handle(cx).focus(window); + text_thread_editor.focus_handle(cx).focus(window, cx); } fn external_thread( @@ -884,39 +888,21 @@ impl AgentPanel { }; let server = ext_agent.server(fs, history); - - if !loading { - telemetry::event!("Agent Thread Started", agent = server.telemetry_id()); - } - - this.update_in(cx, |this, window, cx| { - let selected_agent = ext_agent.into(); - if this.selected_agent != selected_agent { - this.selected_agent = selected_agent; - this.serialize(cx); - } - - let thread_view = cx.new(|cx| { - crate::acp::AcpThreadView::new( - server, - resume_thread, - summarize_thread, - workspace.clone(), - project, - this.history_store.clone(), - this.prompt_store.clone(), - window, - cx, - ) - }); - - this.set_active_view( - ActiveView::ExternalAgentThread { thread_view }, - !loading, + this.update_in(cx, |agent_panel, window, cx| { + agent_panel._external_thread( + server, + resume_thread, + summarize_thread, + workspace, + project, + loading, + ext_agent, window, cx, ); - }) + })?; + + anyhow::Ok(()) }) .detach_and_log_err(cx); } @@ -949,7 +935,7 @@ impl AgentPanel { if let Some(thread_view) = self.active_thread_view() { thread_view.update(cx, |view, cx| { view.expand_message_editor(&ExpandMessageEditor, window, cx); - view.focus_handle(cx).focus(window); + view.focus_handle(cx).focus(window, cx); }); } } @@ -1030,12 +1016,12 @@ impl AgentPanel { match &self.active_view { ActiveView::ExternalAgentThread { thread_view } => { - thread_view.focus_handle(cx).focus(window); + thread_view.focus_handle(cx).focus(window, cx); } ActiveView::TextThread { text_thread_editor, .. } => { - text_thread_editor.focus_handle(cx).focus(window); + text_thread_editor.focus_handle(cx).focus(window, cx); } ActiveView::History | ActiveView::Configuration => {} } @@ -1183,7 +1169,7 @@ impl AgentPanel { Self::handle_agent_configuration_event, )); - configuration.focus_handle(cx).focus(window); + configuration.focus_handle(cx).focus(window, cx); } } @@ -1319,7 +1305,7 @@ impl AgentPanel { } if focus { - self.focus_handle(cx).focus(window); + self.focus_handle(cx).focus(window, cx); } } @@ -1479,6 +1465,47 @@ impl AgentPanel { cx, ); } + + fn _external_thread( + &mut self, + server: Rc, + resume_thread: Option, + summarize_thread: Option, + workspace: WeakEntity, + project: Entity, + loading: bool, + ext_agent: ExternalAgent, + window: &mut Window, + cx: &mut Context, + ) { + let selected_agent = AgentType::from(ext_agent); + if self.selected_agent != selected_agent { + self.selected_agent = selected_agent; + self.serialize(cx); + } + + let thread_view = cx.new(|cx| { + crate::acp::AcpThreadView::new( + server, + resume_thread, + summarize_thread, + workspace.clone(), + project, + self.history_store.clone(), + self.prompt_store.clone(), + !loading, + window, + cx, + ) + }); + + self.set_active_view( + ActiveView::ExternalAgentThread { thread_view }, + !loading, + window, + cx, + ); + } } impl Focusable for AgentPanel { @@ -1593,14 +1620,19 @@ impl AgentPanel { let content = match &self.active_view { ActiveView::ExternalAgentThread { thread_view } => { + let is_generating_title = thread_view + .read(cx) + .as_native_thread(cx) + .map_or(false, |t| t.read(cx).is_generating_title()); + if let Some(title_editor) = thread_view.read(cx).title_editor() { - div() + let container = div() .w_full() .on_action({ let thread_view = thread_view.downgrade(); move |_: &menu::Confirm, window, cx| { if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window); + thread_view.focus_handle(cx).focus(window, cx); } } }) @@ -1608,12 +1640,25 @@ impl AgentPanel { let thread_view = thread_view.downgrade(); move |_: &editor::actions::Cancel, window, cx| { if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window); + thread_view.focus_handle(cx).focus(window, cx); } } }) - .child(title_editor) - .into_any_element() + .child(title_editor); + + if is_generating_title { + container + .with_animation( + "generating_title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |div, delta| div.opacity(delta), + ) + .into_any_element() + } else { + container.into_any_element() + } } else { Label::new(thread_view.read(cx).title(cx)) .color(Color::Muted) @@ -1643,6 +1688,13 @@ impl AgentPanel { Label::new(LOADING_SUMMARY_PLACEHOLDER) .truncate() .color(Color::Muted) + .with_animation( + "generating_title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) .into_any_element() } } @@ -1686,6 +1738,25 @@ impl AgentPanel { .into_any() } + fn handle_regenerate_thread_title(thread_view: Entity, cx: &mut App) { + thread_view.update(cx, |thread_view, cx| { + if let Some(thread) = thread_view.as_native_thread(cx) { + thread.update(cx, |thread, cx| { + thread.generate_title(cx); + }); + } + }); + } + + fn handle_regenerate_text_thread_title( + text_thread_editor: Entity, + cx: &mut App, + ) { + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor.regenerate_summary(cx); + }); + } + fn render_panel_options_menu( &self, window: &mut Window, @@ -1705,6 +1776,35 @@ impl AgentPanel { let selected_agent = self.selected_agent.clone(); + let text_thread_view = match &self.active_view { + ActiveView::TextThread { + text_thread_editor, .. + } => Some(text_thread_editor.clone()), + _ => None, + }; + let text_thread_with_messages = match &self.active_view { + ActiveView::TextThread { + text_thread_editor, .. + } => text_thread_editor + .read(cx) + .text_thread() + .read(cx) + .messages(cx) + .any(|message| message.role == language_model::Role::Assistant), + _ => false, + }; + + let thread_view = match &self.active_view { + ActiveView::ExternalAgentThread { thread_view } => Some(thread_view.clone()), + _ => None, + }; + let thread_with_messages = match &self.active_view { + ActiveView::ExternalAgentThread { thread_view } => { + thread_view.read(cx).has_user_submitted_prompt(cx) + } + _ => false, + }; + PopoverMenu::new("agent-options-menu") .trigger_with_tooltip( IconButton::new("agent-options-menu", IconName::Ellipsis) @@ -1727,6 +1827,7 @@ impl AgentPanel { move |window, cx| { Some(ContextMenu::build(window, cx, |mut menu, _window, _| { menu = menu.context(focus_handle.clone()); + if let Some(usage) = usage { menu = menu .header_with_link("Prompt Usage", "Manage", account_url.clone()) @@ -1764,6 +1865,38 @@ impl AgentPanel { .separator() } + if thread_with_messages | text_thread_with_messages { + menu = menu.header("Current Thread"); + + if let Some(text_thread_view) = text_thread_view.as_ref() { + menu = menu + .entry("Regenerate Thread Title", None, { + let text_thread_view = text_thread_view.clone(); + move |_, cx| { + Self::handle_regenerate_text_thread_title( + text_thread_view.clone(), + cx, + ); + } + }) + .separator(); + } + + if let Some(thread_view) = thread_view.as_ref() { + menu = menu + .entry("Regenerate Thread Title", None, { + let thread_view = thread_view.clone(); + move |_, cx| { + Self::handle_regenerate_thread_title( + thread_view.clone(), + cx, + ); + } + }) + .separator(); + } + } + menu = menu .header("MCP Servers") .action( @@ -1853,14 +1986,17 @@ impl AgentPanel { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); - // Get custom icon path for selected agent before building menu (to avoid borrow issues) - let selected_agent_custom_icon = + let (selected_agent_custom_icon, selected_agent_label) = if let AgentType::Custom { name, .. } = &self.selected_agent { - agent_server_store - .read(cx) - .agent_icon(&ExternalAgentServerName(name.clone())) + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + + let label = store + .agent_display_name(&ExternalAgentServerName(name.clone())) + .unwrap_or_else(|| self.selected_agent.label()); + (icon, label) } else { - None + (None, self.selected_agent.label()) }; let active_thread = match &self.active_view { @@ -2083,13 +2219,16 @@ impl AgentPanel { for agent_name in agent_names { let icon_path = agent_server_store.agent_icon(&agent_name); + let display_name = agent_server_store + .agent_display_name(&agent_name) + .unwrap_or_else(|| agent_name.0.clone()); - let mut entry = ContextMenuEntry::new(agent_name.clone()); + let mut entry = ContextMenuEntry::new(display_name); if let Some(icon_path) = icon_path { entry = entry.custom_icon_svg(icon_path); } else { - entry = entry.icon(IconName::Terminal); + entry = entry.icon(IconName::Sparkle); } entry = entry .when( @@ -2153,8 +2292,6 @@ impl AgentPanel { } }); - let selected_agent_label = self.selected_agent.label(); - let is_thread_loading = self .active_thread_view() .map(|thread| thread.read(cx).is_loading()) @@ -2291,7 +2428,7 @@ impl AgentPanel { let history_is_empty = self.history_store.read(cx).is_empty(cx); let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) - .providers() + .visible_providers() .iter() .any(|provider| { provider.is_authenticated(cx) @@ -2555,6 +2692,38 @@ impl AgentPanel { } } + fn render_workspace_trust_message(&self, cx: &Context) -> Option { + if !self.show_trust_workspace_message { + return None; + } + + let description = "To protect your system, third-party code—like MCP servers—won't run until you mark this workspace as safe."; + + Some( + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .border_position(ui::BorderPosition::Bottom) + .title("You're in Restricted Mode") + .description(description) + .actions_slot( + Button::new("open-trust-modal", "Configure Project Trust") + .label_size(LabelSize::Small) + .style(ButtonStyle::Outlined) + .on_click({ + cx.listener(move |this, _, window, cx| { + this.workspace + .update(cx, |workspace, cx| { + workspace + .show_worktree_trust_security_modal(true, window, cx) + }) + .log_err(); + }) + }), + ), + ) + } + fn key_context(&self) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); @@ -2607,6 +2776,7 @@ impl Render for AgentPanel { } })) .child(self.render_toolbar(window, cx)) + .children(self.render_workspace_trust_message(cx)) .children(self.render_onboarding(window, cx)) .map(|parent| match &self.active_view { ActiveView::ExternalAgentThread { thread_view, .. } => parent @@ -2685,16 +2855,17 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { return; }; let project = workspace.read(cx).project().downgrade(); + let thread_store = panel.read(cx).thread_store().clone(); assistant.assist( prompt_editor, self.workspace.clone(), project, - panel.read(cx).thread_store().clone(), + thread_store, None, initial_prompt, window, cx, - ) + ); }) } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 5f5682b7dcc90d2b779744ba353380987a5907a1..401b506b302d9c2a86a36ddce0fc72df075f4c18 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,4 +1,4 @@ -mod acp; +pub mod acp; mod agent_configuration; mod agent_diff; mod agent_model_selector; @@ -7,6 +7,7 @@ mod buffer_codegen; mod completion_provider; mod context; mod context_server_configuration; +mod favorite_models; mod inline_assistant; mod inline_prompt_editor; mod language_model_selector; @@ -26,7 +27,7 @@ use agent_settings::{AgentProfileId, AgentSettings}; use assistant_slash_command::SlashCommandRegistry; use client::Client; use command_palette_hooks::CommandPaletteFilter; -use feature_flags::FeatureFlagAppExt as _; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{Action, App, Entity, SharedString, actions}; use language::{ @@ -67,6 +68,8 @@ actions!( ToggleProfileSelector, /// Cycles through available session modes. CycleModeSelector, + /// Cycles through favorited models in the ACP model selector. + CycleFavoriteModels, /// Expands the message editor to full size. ExpandMessageEditor, /// Removes all thread history. @@ -158,16 +161,6 @@ pub enum ExternalAgent { } impl ExternalAgent { - pub fn parse_built_in(server: &dyn agent_servers::AgentServer) -> Option { - match server.telemetry_id() { - "gemini-cli" => Some(Self::Gemini), - "claude-code" => Some(Self::ClaudeCode), - "codex" => Some(Self::Codex), - "zed" => Some(Self::NativeAgent), - _ => None, - } - } - pub fn server( &self, fs: Arc, @@ -224,7 +217,7 @@ pub fn init( is_eval: bool, cx: &mut App, ) { - assistant_text_thread::init(client.clone(), cx); + assistant_text_thread::init(client, cx); rules_library::init(cx); if !is_eval { // Initializing the language model from the user settings messes with the eval, so we only initialize them when @@ -237,13 +230,8 @@ pub fn init( TextThreadEditor::init(cx); register_slash_commands(cx); - inline_assistant::init( - fs.clone(), - prompt_builder.clone(), - client.telemetry().clone(), - cx, - ); - terminal_inline_assistant::init(fs.clone(), prompt_builder, client.telemetry().clone(), cx); + inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); + terminal_inline_assistant::init(fs.clone(), prompt_builder, cx); cx.observe_new(move |workspace, window, cx| { ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) }) @@ -259,23 +247,31 @@ pub fn init( update_command_palette_filter(app_cx); }) .detach(); + + cx.on_flags_ready(|_, cx| { + update_command_palette_filter(cx); + }) + .detach(); } fn update_command_palette_filter(cx: &mut App) { let disable_ai = DisableAiSettings::get_global(cx).disable_ai; let agent_enabled = AgentSettings::get_global(cx).enabled; + let agent_v2_enabled = cx.has_flag::(); let edit_prediction_provider = AllLanguageSettings::get_global(cx) .edit_predictions .provider; CommandPaletteFilter::update_global(cx, |filter, _| { use editor::actions::{ - AcceptEditPrediction, AcceptPartialEditPrediction, NextEditPrediction, - PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, + AcceptEditPrediction, AcceptNextLineEditPrediction, AcceptNextWordEditPrediction, + NextEditPrediction, PreviousEditPrediction, ShowEditPrediction, ToggleEditPrediction, }; let edit_prediction_actions = [ TypeId::of::(), - TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), TypeId::of::(), TypeId::of::(), TypeId::of::(), @@ -284,6 +280,7 @@ fn update_command_palette_filter(cx: &mut App) { if disable_ai { filter.hide_namespace("agent"); + filter.hide_namespace("agents"); filter.hide_namespace("assistant"); filter.hide_namespace("copilot"); filter.hide_namespace("supermaven"); @@ -295,8 +292,10 @@ fn update_command_palette_filter(cx: &mut App) { } else { if agent_enabled { filter.show_namespace("agent"); + filter.show_namespace("agents"); } else { filter.hide_namespace("agent"); + filter.hide_namespace("agents"); } filter.show_namespace("assistant"); @@ -332,6 +331,9 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("zed_predict_onboarding"); filter.show_action_types(&[TypeId::of::()]); + if !agent_v2_enabled { + filter.hide_action_types(&[TypeId::of::()]); + } } }); } @@ -346,7 +348,8 @@ fn init_language_model_settings(cx: &mut App) { |_, event: &language_model::Event, cx| match event { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) - | language_model::Event::RemovedProvider(_) => { + | language_model::Event::RemovedProvider(_) + | language_model::Event::ProvidersChanged => { update_active_language_model_from_settings(cx); } _ => {} @@ -430,7 +433,7 @@ mod tests { use gpui::{BorrowAppContext, TestAppContext, px}; use project::DisableAiSettings; use settings::{ - DefaultAgentView, DockPosition, NotifyWhenAgentWaiting, Settings, SettingsStore, + DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting, Settings, SettingsStore, }; #[gpui::test] @@ -449,13 +452,16 @@ mod tests { enabled: true, button: true, dock: DockPosition::Right, + agents_panel_dock: DockSide::Left, default_width: px(300.), default_height: px(600.), default_model: None, inline_assistant_model: None, + inline_assistant_use_streaming_tools: false, commit_message_model: None, thread_summary_model: None, inline_alternatives: vec![], + favorite_models: vec![], default_profile: AgentProfileId::default(), default_view: DefaultAgentView::Thread, profiles: Default::default(), diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 1ac3ec1aec38c8d44d7557e1cf1e3ff09832c9d9..a296d4d20918fba6eb32bfcf7fcc657f9db2b3ac 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -1,26 +1,34 @@ use crate::{context::LoadedContext, inline_prompt_editor::CodegenStatus}; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; +use uuid::Uuid; + use cloud_llm_client::CompletionIntent; use collections::HashSet; use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint}; +use feature_flags::{FeatureFlagAppExt as _, InlineAssistantUseToolFeatureFlag}; use futures::{ SinkExt, Stream, StreamExt, TryStreamExt as _, channel::mpsc, future::{LocalBoxFuture, Shared}, join, + stream::BoxStream, }; -use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Subscription, Task}; -use language::{Buffer, IndentKind, Point, TransactionId, line_diff}; +use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task}; +use language::{Buffer, IndentKind, LanguageName, Point, TransactionId, line_diff}; use language_model::{ - LanguageModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelTextStream, Role, report_assistant_event, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelRequestTool, LanguageModelTextStream, LanguageModelToolChoice, + LanguageModelToolUse, Role, TokenUsage, }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use prompt_store::PromptBuilder; use rope::Rope; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings as _; use smol::future::FutureExt; use std::{ cmp, @@ -33,7 +41,26 @@ use std::{ time::Instant, }; use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + +/// Use this tool when you cannot or should not make a rewrite. This includes: +/// - The user's request is unclear, ambiguous, or nonsensical +/// - The requested change cannot be made by only editing the section +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct FailureMessageInput { + /// A brief message to the user explaining why you're unable to fulfill the request or to ask a question about the request. + #[serde(default)] + pub message: String, +} + +/// Replaces text in tags with your replacement_text. +/// Only use this tool when you are confident you understand the user's request and can fulfill it +/// by editing the marked section. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct RewriteSectionInput { + /// The text to replace the section with. + #[serde(default)] + pub replacement_text: String, +} pub struct BufferCodegen { alternatives: Vec>, @@ -43,17 +70,20 @@ pub struct BufferCodegen { buffer: Entity, range: Range, initial_transaction_id: Option, - telemetry: Arc, builder: Arc, pub is_insertion: bool, + session_id: Uuid, } +pub const REWRITE_SECTION_TOOL_NAME: &str = "rewrite_section"; +pub const FAILURE_MESSAGE_TOOL_NAME: &str = "failure_message"; + impl BufferCodegen { pub fn new( buffer: Entity, range: Range, initial_transaction_id: Option, - telemetry: Arc, + session_id: Uuid, builder: Arc, cx: &mut Context, ) -> Self { @@ -62,8 +92,8 @@ impl BufferCodegen { buffer.clone(), range.clone(), false, - Some(telemetry.clone()), builder.clone(), + session_id, cx, ) }); @@ -76,8 +106,8 @@ impl BufferCodegen { buffer, range, initial_transaction_id, - telemetry, builder, + session_id, }; this.activate(0, cx); this @@ -92,10 +122,18 @@ impl BufferCodegen { .push(cx.subscribe(&codegen, |_, _, event, cx| cx.emit(*event))); } + pub fn active_completion(&self, cx: &App) -> Option { + self.active_alternative().read(cx).current_completion() + } + pub fn active_alternative(&self) -> &Entity { &self.alternatives[self.active_alternative] } + pub fn language_name(&self, cx: &App) -> Option { + self.active_alternative().read(cx).language_name(cx) + } + pub fn status<'a>(&self, cx: &'a App) -> &'a CodegenStatus { &self.active_alternative().read(cx).status } @@ -154,8 +192,8 @@ impl BufferCodegen { self.buffer.clone(), self.range.clone(), false, - Some(self.telemetry.clone()), self.builder.clone(), + self.session_id, cx, ) })); @@ -214,6 +252,14 @@ impl BufferCodegen { pub fn last_equal_ranges<'a>(&self, cx: &'a App) -> &'a [Range] { self.active_alternative().read(cx).last_equal_ranges() } + + pub fn selected_text<'a>(&self, cx: &'a App) -> Option<&'a str> { + self.active_alternative().read(cx).selected_text() + } + + pub fn session_id(&self) -> Uuid { + self.session_id + } } impl EventEmitter for BufferCodegen {} @@ -229,7 +275,6 @@ pub struct CodegenAlternative { status: CodegenStatus, generation: Task<()>, diff: Diff, - telemetry: Option>, _subscription: gpui::Subscription, builder: Arc, active: bool, @@ -237,7 +282,11 @@ pub struct CodegenAlternative { line_operations: Vec, elapsed_time: Option, completion: Option, + selected_text: Option, pub message_id: Option, + session_id: Uuid, + pub description: Option, + pub failure: Option, } impl EventEmitter for CodegenAlternative {} @@ -247,8 +296,8 @@ impl CodegenAlternative { buffer: Entity, range: Range, active: bool, - telemetry: Option>, builder: Arc, + session_id: Uuid, cx: &mut Context, ) -> Self { let snapshot = buffer.read(cx).snapshot(cx); @@ -287,18 +336,28 @@ impl CodegenAlternative { status: CodegenStatus::Idle, generation: Task::ready(()), diff: Diff::default(), - telemetry, - _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), builder, - active, + active: active, edits: Vec::new(), line_operations: Vec::new(), range, elapsed_time: None, completion: None, + selected_text: None, + session_id, + description: None, + failure: None, + _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), } } + pub fn language_name(&self, cx: &App) -> Option { + self.old_buffer + .read(cx) + .language() + .map(|language| language.name()) + } + pub fn set_active(&mut self, active: bool, cx: &mut Context) { if active != self.active { self.active = active; @@ -340,6 +399,12 @@ impl CodegenAlternative { &self.last_equal_ranges } + pub fn use_streaming_tools(model: &dyn LanguageModel, cx: &App) -> bool { + model.supports_streaming_tools() + && cx.has_flag::() + && AgentSettings::get_global(cx).inline_assistant_use_streaming_tools + } + pub fn start( &mut self, user_prompt: String, @@ -347,6 +412,9 @@ impl CodegenAlternative { model: Arc, cx: &mut Context, ) -> Result<()> { + // Clear the model explanation since the user has started a new generation. + self.description = None; + if let Some(transformation_transaction_id) = self.transformation_transaction_id.take() { self.buffer.update(cx, |buffer, cx| { buffer.undo_transaction(transformation_transaction_id, cx); @@ -355,21 +423,132 @@ impl CodegenAlternative { self.edit_position = Some(self.range.start.bias_right(&self.snapshot)); - let api_key = model.api_key(cx); - let telemetry_id = model.telemetry_id(); - let provider_id = model.provider_id(); - let stream: LocalBoxFuture> = - if user_prompt.trim().to_lowercase() == "delete" { - async { Ok(LanguageModelTextStream::default()) }.boxed_local() + if Self::use_streaming_tools(model.as_ref(), cx) { + let request = self.build_request(&model, user_prompt, context_task, cx)?; + let completion_events = cx.spawn({ + let model = model.clone(); + async move |_, cx| model.stream_completion(request.await, cx).await + }); + self.generation = self.handle_completion(model, completion_events, cx); + } else { + let stream: LocalBoxFuture> = + if user_prompt.trim().to_lowercase() == "delete" { + async { Ok(LanguageModelTextStream::default()) }.boxed_local() + } else { + let request = self.build_request(&model, user_prompt, context_task, cx)?; + cx.spawn({ + let model = model.clone(); + async move |_, cx| { + Ok(model.stream_completion_text(request.await, cx).await?) + } + }) + .boxed_local() + }; + self.generation = + self.handle_stream(model, /* strip_invalid_spans: */ true, stream, cx); + } + + Ok(()) + } + + fn build_request_tools( + &self, + model: &Arc, + user_prompt: String, + context_task: Shared>>, + cx: &mut App, + ) -> Result> { + let buffer = self.buffer.read(cx).snapshot(cx); + let language = buffer.language_at(self.range.start); + let language_name = if let Some(language) = language.as_ref() { + if Arc::ptr_eq(language, &language::PLAIN_TEXT) { + None } else { - let request = self.build_request(&model, user_prompt, context_task, cx)?; - cx.spawn(async move |_, cx| { - Ok(model.stream_completion_text(request.await, cx).await?) - }) - .boxed_local() + Some(language.name()) + } + } else { + None + }; + + let language_name = language_name.as_ref(); + let start = buffer.point_to_buffer_offset(self.range.start); + let end = buffer.point_to_buffer_offset(self.range.end); + let (buffer, range) = if let Some((start, end)) = start.zip(end) { + let (start_buffer, start_buffer_offset) = start; + let (end_buffer, end_buffer_offset) = end; + if start_buffer.remote_id() == end_buffer.remote_id() { + (start_buffer.clone(), start_buffer_offset..end_buffer_offset) + } else { + anyhow::bail!("invalid transformation range"); + } + } else { + anyhow::bail!("invalid transformation range"); + }; + + let system_prompt = self + .builder + .generate_inline_transformation_prompt_tools( + language_name, + buffer, + range.start.0..range.end.0, + ) + .context("generating content prompt")?; + + let temperature = AgentSettings::temperature_for_model(model, cx); + + let tool_input_format = model.tool_input_format(); + let tool_choice = model + .supports_tool_choice(LanguageModelToolChoice::Any) + .then_some(LanguageModelToolChoice::Any); + + Ok(cx.spawn(async move |_cx| { + let mut messages = vec![LanguageModelRequestMessage { + role: Role::System, + content: vec![system_prompt.into()], + cache: false, + reasoning_details: None, + }]; + + let mut user_message = LanguageModelRequestMessage { + role: Role::User, + content: Vec::new(), + cache: false, + reasoning_details: None, }; - self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx); - Ok(()) + + if let Some(context) = context_task.await { + context.add_to_request_message(&mut user_message); + } + + user_message.content.push(user_prompt.into()); + 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(), + }, + 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(), + }, + ]; + + LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: Some(CompletionIntent::InlineAssist), + mode: None, + tools, + tool_choice, + stop: Vec::new(), + temperature, + messages, + thinking_allowed: false, + } + })) } fn build_request( @@ -379,6 +558,10 @@ impl CodegenAlternative { context_task: Shared>>, cx: &mut App, ) -> Result> { + if Self::use_streaming_tools(model.as_ref(), cx) { + return self.build_request_tools(model, user_prompt, context_task, cx); + } + let buffer = self.buffer.read(cx).snapshot(cx); let language = buffer.language_at(self.range.start); let language_name = if let Some(language) = language.as_ref() { @@ -449,12 +632,15 @@ impl CodegenAlternative { pub fn handle_stream( &mut self, - model_telemetry_id: String, - model_provider_id: String, - model_api_key: Option, + model: Arc, + strip_invalid_spans: bool, stream: impl 'static + Future>, cx: &mut Context, - ) { + ) -> Task<()> { + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); let start_time = Instant::now(); // Make a new snapshot and re-resolve anchor in case the document was modified. @@ -469,6 +655,8 @@ impl CodegenAlternative { .text_for_range(self.range.start..self.range.end) .collect::(); + self.selected_text = Some(selected_text.to_string()); + let selection_start = self.range.start.to_point(&snapshot); // Start with the indentation of the first line in the selection @@ -490,8 +678,6 @@ impl CodegenAlternative { } } - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); let language_name = { let multibuffer = self.buffer.read(cx); let snapshot = multibuffer.snapshot(cx); @@ -508,8 +694,10 @@ impl CodegenAlternative { let completion = Arc::new(Mutex::new(String::new())); let completion_clone = completion.clone(); - self.generation = cx.spawn(async move |codegen, cx| { + cx.notify(); + cx.spawn(async move |codegen, cx| { let stream = stream.await; + let token_usage = stream .as_ref() .ok() @@ -522,17 +710,25 @@ impl CodegenAlternative { let model_telemetry_id = model_telemetry_id.clone(); let model_provider_id = model_provider_id.clone(); let (mut diff_tx, mut diff_rx) = mpsc::channel(1); - let executor = cx.background_executor().clone(); let message_id = message_id.clone(); - let line_based_stream_diff: Task> = - cx.background_spawn(async move { + let line_based_stream_diff: Task> = cx.background_spawn({ + let anthropic_reporter = anthropic_reporter.clone(); + let language_name = language_name.clone(); + async move { let mut response_latency = None; let request_start = Instant::now(); let diff = async { - let chunks = StripInvalidSpans::new( - stream?.stream.map_err(|error| error.into()), - ); - futures::pin_mut!(chunks); + let raw_stream = stream?.stream.map_err(|error| error.into()); + + let stripped; + let mut chunks: Pin> + Send>> = + if strip_invalid_spans { + stripped = StripInvalidSpans::new(raw_stream); + Box::pin(stripped) + } else { + Box::pin(raw_stream) + }; + let mut diff = StreamingDiff::new(selected_text.to_string()); let mut line_diff = LineDiff::default(); @@ -621,27 +817,30 @@ impl CodegenAlternative { let result = diff.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - message_id, - kind: AssistantKind::Inline, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id, - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - telemetry, - http_client, - model_api_key, - &executor, + telemetry::event!( + "Assistant Responded", + kind = "inline", + phase = "response", + session_id = session_id.to_string(), + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name.as_ref().map(|n| n.to_string()), + message_id = message_id.as_deref(), + response_latency = response_latency, + error_message = error_message.as_deref(), ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Response, + language_name: language_name.map(|n| n.to_string()), + message_id, + }); + result?; Ok(()) - }); + } + }); while let Some((char_ops, line_ops)) = diff_rx.next().await { codegen.update(cx, |codegen, cx| { @@ -719,12 +918,30 @@ impl CodegenAlternative { output_tokens = usage.output_tokens, ) } + cx.emit(CodegenEvent::Finished); cx.notify(); }) .ok(); - }); - cx.notify(); + }) + } + + pub fn current_completion(&self) -> Option { + self.completion.clone() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn current_description(&self) -> Option { + self.description.clone() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn current_failure(&self) -> Option { + self.failure.clone() + } + + pub fn selected_text(&self) -> Option<&str> { + self.selected_text.as_deref() } pub fn stop(&mut self, cx: &mut Context) { @@ -898,6 +1115,224 @@ impl CodegenAlternative { .ok(); }) } + + fn handle_completion( + &mut self, + model: Arc, + completion_stream: Task< + Result< + BoxStream< + 'static, + Result, + >, + LanguageModelCompletionError, + >, + >, + cx: &mut Context, + ) -> Task<()> { + self.diff = Diff::default(); + self.status = CodegenStatus::Pending; + + cx.notify(); + // Leaving this in generation so that STOP equivalent events are respected even + // while we're still pre-processing the completion event + cx.spawn(async move |codegen, cx| { + let finish_with_status = |status: CodegenStatus, cx: &mut AsyncApp| { + let _ = codegen.update(cx, |this, cx| { + this.status = status; + cx.emit(CodegenEvent::Finished); + cx.notify(); + }); + }; + + let mut completion_events = match completion_stream.await { + Ok(events) => events, + Err(err) => { + finish_with_status(CodegenStatus::Error(err.into()), cx); + return; + } + }; + + enum ToolUseOutput { + Rewrite { + text: String, + description: Option, + }, + Failure(String), + } + + enum ModelUpdate { + Description(String), + Failure(String), + } + + let chars_read_so_far = Arc::new(Mutex::new(0usize)); + let process_tool_use = move |tool_use: LanguageModelToolUse| -> Option { + let mut chars_read_so_far = chars_read_so_far.lock(); + match tool_use.name.as_ref() { + REWRITE_SECTION_TOOL_NAME => { + let Ok(input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + let text = input.replacement_text[*chars_read_so_far..].to_string(); + *chars_read_so_far = input.replacement_text.len(); + Some(ToolUseOutput::Rewrite { + text, + description: None, + }) + } + FAILURE_MESSAGE_TOOL_NAME => { + let Ok(mut input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + Some(ToolUseOutput::Failure(std::mem::take(&mut input.message))) + } + _ => None, + } + }; + + let (message_tx, mut message_rx) = futures::channel::mpsc::unbounded::(); + + cx.spawn({ + let codegen = codegen.clone(); + async move |cx| { + while let Some(update) = message_rx.next().await { + let _ = codegen.update(cx, |this, _cx| match update { + ModelUpdate::Description(d) => this.description = Some(d), + ModelUpdate::Failure(f) => this.failure = Some(f), + }); + } + } + }) + .detach(); + + let mut message_id = None; + let mut first_text = None; + let last_token_usage = Arc::new(Mutex::new(TokenUsage::default())); + let total_text = Arc::new(Mutex::new(String::new())); + + loop { + if let Some(first_event) = completion_events.next().await { + match first_event { + Ok(LanguageModelCompletionEvent::StartMessage { message_id: id }) => { + message_id = Some(id); + } + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + if let Some(output) = process_tool_use(tool_use) { + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.unbounded_send(update); + } + first_text = text; + if first_text.is_some() { + break; + } + } + } + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + } + Ok(e) => { + log::warn!("Unexpected event: {:?}", e); + break; + } + Err(e) => { + finish_with_status(CodegenStatus::Error(e.into()), cx); + break; + } + } + } + } + + let Some(first_text) = first_text else { + finish_with_status(CodegenStatus::Done, cx); + return; + }; + + let move_last_token_usage = last_token_usage.clone(); + + let text_stream = Box::pin(futures::stream::once(async { Ok(first_text) }).chain( + completion_events.filter_map(move |e| { + let process_tool_use = process_tool_use.clone(); + let last_token_usage = move_last_token_usage.clone(); + let total_text = total_text.clone(); + let mut message_tx = message_tx.clone(); + async move { + match e { + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + let Some(output) = process_tool_use(tool_use) else { + return None; + }; + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.send(update).await; + } + text.map(Ok) + } + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + None + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + None + } + Ok(LanguageModelCompletionEvent::Stop(_reason)) => None, + e => { + log::error!("UNEXPECTED EVENT {:?}", e); + None + } + } + } + }), + )); + + let language_model_text_stream = LanguageModelTextStream { + message_id: message_id, + stream: text_stream, + last_token_usage, + }; + + let Some(task) = codegen + .update(cx, move |codegen, cx| { + codegen.handle_stream( + model, + /* strip_invalid_spans: */ false, + async { Ok(language_model_text_stream) }, + cx, + ) + }) + .ok() + else { + return; + }; + + task.await; + }) + } } #[derive(Copy, Clone, Debug)] @@ -1059,8 +1494,13 @@ mod tests { }; use gpui::TestAppContext; use indoc::indoc; - use language::{Buffer, Language, LanguageConfig, LanguageMatcher, Point, tree_sitter_rust}; - use language_model::{LanguageModelRegistry, TokenUsage}; + use language::{Buffer, Point}; + use language_model::fake_provider::FakeLanguageModel; + use language_model::{ + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRegistry, + LanguageModelToolUse, StopReason, TokenUsage, + }; + use languages::rust_lang; use rand::prelude::*; use settings::SettingsStore; use std::{future, sync::Arc}; @@ -1077,7 +1517,7 @@ mod tests { } } "}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); @@ -1089,8 +1529,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1139,7 +1579,7 @@ mod tests { le } "}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); @@ -1151,8 +1591,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1203,7 +1643,7 @@ mod tests { " \n", "}\n" // ); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); @@ -1215,8 +1655,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1279,8 +1719,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1319,7 +1759,7 @@ mod tests { let x = 0; } "}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); let range = buffer.read_with(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); @@ -1331,8 +1771,8 @@ mod tests { buffer.clone(), range.clone(), false, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1371,6 +1811,51 @@ mod tests { ); } + // When not streaming tool calls, we strip backticks as part of parsing the model's + // plain text response. This is a regression test for a bug where we stripped + // backticks incorrectly. + #[gpui::test] + async fn test_allows_model_to_output_backticks(cx: &mut TestAppContext) { + init_test(cx); + let text = "- Improved; `cmd+click` behavior. Now requires `cmd` to be pressed before the click starts or it doesn't run. ([#44579](https://github.com/zed-industries/zed/pull/44579); thanks [Zachiah](https://github.com/Zachiah))"; + let buffer = cx.new(|cx| Buffer::local("", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let range = buffer.read_with(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + snapshot.anchor_before(Point::new(0, 0))..snapshot.anchor_after(Point::new(0, 0)) + }); + let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); + let codegen = cx.new(|cx| { + CodegenAlternative::new( + buffer.clone(), + range.clone(), + true, + prompt_builder, + Uuid::new_v4(), + cx, + ) + }); + + let events_tx = simulate_tool_based_completion(&codegen, cx); + let chunk_len = text.find('`').unwrap(); + events_tx + .unbounded_send(rewrite_tool_use("tool_1", &text[..chunk_len], false)) + .unwrap(); + events_tx + .unbounded_send(rewrite_tool_use("tool_2", &text, true)) + .unwrap(); + events_tx + .unbounded_send(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)) + .unwrap(); + drop(events_tx); + cx.run_until_parked(); + + assert_eq!( + buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx).text()), + text + ); + } + #[gpui::test] async fn test_strip_invalid_spans_from_codeblock() { assert_chunks("Lorem ipsum dolor", "Lorem ipsum dolor").await; @@ -1421,11 +1906,11 @@ mod tests { cx: &mut TestAppContext, ) -> mpsc::UnboundedSender { let (chunks_tx, chunks_rx) = mpsc::unbounded(); + let model = Arc::new(FakeLanguageModel::default()); codegen.update(cx, |codegen, cx| { - codegen.handle_stream( - String::new(), - String::new(), - None, + codegen.generation = codegen.handle_stream( + model, + /* strip_invalid_spans: */ false, future::ready(Ok(LanguageModelTextStream { message_id: None, stream: chunks_rx.map(Ok).boxed(), @@ -1437,26 +1922,38 @@ mod tests { chunks_tx } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_indents_query( - r#" - (call_expression) @indent - (field_expression) @indent - (_ "(" ")" @end) @indent - (_ "{" "}" @end) @indent - "#, - ) - .unwrap() + fn simulate_tool_based_completion( + codegen: &Entity, + cx: &mut TestAppContext, + ) -> mpsc::UnboundedSender { + let (events_tx, events_rx) = mpsc::unbounded(); + let model = Arc::new(FakeLanguageModel::default()); + codegen.update(cx, |codegen, cx| { + let completion_stream = Task::ready(Ok(events_rx.map(Ok).boxed() + as BoxStream< + 'static, + Result, + >)); + codegen.generation = codegen.handle_completion(model, completion_stream, cx); + }); + events_tx + } + + fn rewrite_tool_use( + id: &str, + replacement_text: &str, + is_complete: bool, + ) -> LanguageModelCompletionEvent { + let input = RewriteSectionInput { + replacement_text: replacement_text.into(), + }; + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + id: id.into(), + name: REWRITE_SECTION_TOOL_NAME.into(), + raw_input: serde_json::to_string(&input).unwrap(), + input: serde_json::to_value(&input).unwrap(), + is_input_complete: is_complete, + thought_signature: None, + }) } } diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 2e3cf0d551fc649e61ae26e47fa53301def2aacc..a7b955b81ef3a7edccca98f15fa73bb40787a2c9 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -20,7 +20,7 @@ use project::{ Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, PathMatchCandidateSet, Project, ProjectPath, Symbol, WorktreeId, }; -use prompt_store::{PromptId, PromptStore, UserPromptId}; +use prompt_store::{PromptStore, UserPromptId}; use rope::Point; use text::{Anchor, ToPoint as _}; use ui::prelude::*; @@ -1114,7 +1114,6 @@ impl CompletionProvider for PromptCompletio position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { let buffer = buffer.read(cx); @@ -1586,13 +1585,10 @@ pub(crate) fn search_rules( if metadata.default { None } else { - match metadata.id { - PromptId::EditWorkflow => None, - PromptId::User { uuid } => Some(RulesContextEntry { - prompt_id: uuid, - title: metadata.title?, - }), - } + Some(RulesContextEntry { + prompt_id: metadata.id.as_user()?, + title: metadata.title?, + }) } }) .collect::>() diff --git a/crates/agent_ui/src/favorite_models.rs b/crates/agent_ui/src/favorite_models.rs new file mode 100644 index 0000000000000000000000000000000000000000..d11bf5dda00d29f6668559348dc1f4abd937571f --- /dev/null +++ b/crates/agent_ui/src/favorite_models.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use fs::Fs; +use language_model::LanguageModel; +use settings::{LanguageModelSelection, update_settings_file}; +use ui::App; + +fn language_model_to_selection(model: &Arc) -> LanguageModelSelection { + LanguageModelSelection { + provider: model.provider_id().to_string().into(), + model: model.id().0.to_string(), + } +} + +pub fn toggle_in_settings( + model: Arc, + should_be_favorite: bool, + fs: Arc, + cx: &mut App, +) { + let selection = language_model_to_selection(&model); + update_settings_file(fs, cx, move |settings, _| { + let agent = settings.agent.get_or_insert_default(); + if should_be_favorite { + agent.add_favorite_model(selection.clone()); + } else { + agent.remove_favorite_model(&selection); + } + }); +} diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 3f27d0985991f19148cc852c44bfa60c57eaf750..b3c14c5a0ec332f66c300023759db9f09b94dc6f 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -1,8 +1,11 @@ +use language_model::AnthropicEventData; +use language_model::report_anthropic_event; use std::cmp; use std::mem; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; +use uuid::Uuid; use crate::context::load_context; use crate::mention_set::MentionSet; @@ -15,7 +18,6 @@ use crate::{ use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; use collections::{HashMap, HashSet, VecDeque, hash_map}; use editor::EditorSnapshot; use editor::MultiBufferOffset; @@ -32,21 +34,19 @@ use editor::{ }, }; use fs::Fs; -use futures::FutureExt; +use futures::{FutureExt, channel::mpsc}; use gpui::{ App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal, WeakEntity, Window, point, }; use language::{Buffer, Point, Selection, TransactionId}; -use language_model::{ - ConfigurationError, ConfiguredModel, LanguageModelRegistry, report_assistant_event, -}; +use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry}; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use project::{CodeAction, DisableAiSettings, LspAction, Project, ProjectTransaction}; use prompt_store::{PromptBuilder, PromptStore}; use settings::{Settings, SettingsStore}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::{OffsetRangeExt, ToPoint as _}; use ui::prelude::*; @@ -54,13 +54,8 @@ use util::{RangeExt, ResultExt, maybe}; use workspace::{ItemHandle, Toast, Workspace, dock::Panel, notifications::NotificationId}; use zed_actions::agent::OpenSettings; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(InlineAssistant::new(fs, prompt_builder)); cx.observe_global::(|cx| { if DisableAiSettings::get_global(cx).disable_ai { @@ -100,18 +95,14 @@ pub struct InlineAssistant { confirmed_assists: HashMap>, prompt_history: VecDeque, prompt_builder: Arc, - telemetry: Arc, fs: Arc, + _inline_assistant_completions: Option>>, } impl Global for InlineAssistant {} impl InlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: InlineAssistId::default(), next_assist_group_id: InlineAssistGroupId::default(), @@ -121,8 +112,8 @@ impl InlineAssistant { confirmed_assists: HashMap::default(), prompt_history: VecDeque::default(), prompt_builder, - telemetry, fs, + _inline_assistant_completions: None, } } @@ -287,7 +278,7 @@ impl InlineAssistant { action.prompt.clone(), window, cx, - ) + ); }) } InlineAssistTarget::Terminal(active_terminal) => { @@ -301,8 +292,8 @@ impl InlineAssistant { action.prompt.clone(), window, cx, - ) - }) + ); + }); } }; @@ -377,17 +368,9 @@ impl InlineAssistant { let mut selections = Vec::>::new(); let mut newest_selection = None; for mut selection in initial_selections { - if selection.end > selection.start { - selection.start.column = 0; - // If the selection ends at the start of the line, we don't want to include it. - if selection.end.column == 0 { - selection.end.row -= 1; - } - selection.end.column = snapshot - .buffer_snapshot() - .line_len(MultiBufferRow(selection.end.row)); - } else if let Some(fold) = - snapshot.crease_for_buffer_row(MultiBufferRow(selection.end.row)) + if selection.end == selection.start + && let Some(fold) = + snapshot.crease_for_buffer_row(MultiBufferRow(selection.end.row)) { selection.start = fold.range().start; selection.end = fold.range().end; @@ -414,6 +397,15 @@ impl InlineAssistant { } } } + } else { + selection.start.column = 0; + // If the selection ends at the start of the line, we don't want to include it. + if selection.end.column == 0 && selection.start.row != selection.end.row { + selection.end.row -= 1; + } + selection.end.column = snapshot + .buffer_snapshot() + .line_len(MultiBufferRow(selection.end.row)); } if let Some(prev_selection) = selections.last_mut() @@ -446,17 +438,25 @@ impl InlineAssistant { codegen_ranges.push(anchor_range); if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { - self.telemetry.report_assistant_event(AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, - phase: AssistantPhase::Invoked, - message_id: None, - model: model.model.telemetry_id(), - model_provider: model.provider.id().to_string(), - response_latency: None, - error_message: None, - language_name: buffer.language().map(|language| language.name().to_proto()), - }); + telemetry::event!( + "Assistant Invoked", + kind = "inline", + phase = "invoked", + model = model.model.telemetry_id(), + model_provider = model.provider.id().to_string(), + language_name = buffer.language().map(|language| language.name().to_proto()) + ); + + report_anthropic_event( + &model.model, + AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Invoked, + language_name: buffer.language().map(|language| language.name().to_proto()), + message_id: None, + }, + cx, + ); } } @@ -480,6 +480,7 @@ impl InlineAssistant { let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); let assist_group_id = self.next_assist_group_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), @@ -497,7 +498,7 @@ impl InlineAssistant { editor.read(cx).buffer().clone(), range.clone(), initial_transaction_id, - self.telemetry.clone(), + session_id, self.prompt_builder.clone(), cx, ) @@ -511,6 +512,7 @@ impl InlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen.clone(), + session_id, self.fs.clone(), thread_store.clone(), prompt_store.clone(), @@ -534,14 +536,15 @@ impl InlineAssistant { } } - let [prompt_block_id, end_block_id] = - self.insert_assist_blocks(editor, &range, &prompt_editor, cx); + let [prompt_block_id, tool_description_block_id, end_block_id] = + self.insert_assist_blocks(&editor, &range, &prompt_editor, cx); assists.push(( assist_id, range.clone(), prompt_editor, prompt_block_id, + tool_description_block_id, end_block_id, )); } @@ -560,7 +563,15 @@ impl InlineAssistant { }; let mut assist_group = InlineAssistGroup::new(); - for (assist_id, range, prompt_editor, prompt_block_id, end_block_id) in assists { + for ( + assist_id, + range, + prompt_editor, + prompt_block_id, + tool_description_block_id, + end_block_id, + ) in assists + { let codegen = prompt_editor.read(cx).codegen().clone(); self.assists.insert( @@ -571,6 +582,7 @@ impl InlineAssistant { editor, &prompt_editor, prompt_block_id, + tool_description_block_id, end_block_id, range, codegen, @@ -598,13 +610,13 @@ impl InlineAssistant { initial_prompt: Option, window: &mut Window, cx: &mut App, - ) { + ) -> Option { let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); let Some((codegen_ranges, newest_selection)) = self.codegen_ranges(editor, &snapshot, window, cx) else { - return; + return None; }; let assist_to_focus = self.batch_assist( @@ -624,6 +636,8 @@ impl InlineAssistant { if let Some(assist_id) = assist_to_focus { self.focus_assist(assist_id, window, cx); } + + assist_to_focus } pub fn suggest_assist( @@ -677,7 +691,7 @@ impl InlineAssistant { range: &Range, prompt_editor: &Entity>, cx: &mut App, - ) -> [CustomBlockId; 2] { + ) -> [CustomBlockId; 3] { let prompt_editor_height = prompt_editor.update(cx, |prompt_editor, cx| { prompt_editor .editor @@ -691,6 +705,14 @@ impl InlineAssistant { render: build_assist_editor_renderer(prompt_editor), priority: 0, }, + // Placeholder for tool description - will be updated dynamically + BlockProperties { + style: BlockStyle::Flex, + placement: BlockPlacement::Below(range.end), + height: Some(0), + render: Arc::new(|_cx| div().into_any_element()), + priority: 0, + }, BlockProperties { style: BlockStyle::Sticky, placement: BlockPlacement::Below(range.end), @@ -709,7 +731,7 @@ impl InlineAssistant { editor.update(cx, |editor, cx| { let block_ids = editor.insert_blocks(assist_blocks, None, cx); - [block_ids[0], block_ids[1]] + [block_ids[0], block_ids[1], block_ids[2]] }) } @@ -1038,8 +1060,6 @@ impl InlineAssistant { } let active_alternative = assist.codegen.read(cx).active_alternative().clone(); - let message_id = active_alternative.read(cx).message_id.clone(); - if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { let language_name = assist.editor.upgrade().and_then(|editor| { let multibuffer = editor.read(cx).buffer().read(cx); @@ -1048,28 +1068,49 @@ impl InlineAssistant { ranges .first() .and_then(|(buffer, _, _)| buffer.language()) - .map(|language| language.name()) + .map(|language| language.name().0.to_string()) }); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, + + let codegen = assist.codegen.read(cx); + let session_id = codegen.session_id(); + let message_id = active_alternative.read(cx).message_id.clone(); + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + telemetry::event!( + event_type, + phase, + session_id = session_id.to_string(), + kind = "inline", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + message_id = message_id.as_deref(), + ); + + report_anthropic_event( + &model.model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: anthropic_event_type, + language_name, message_id, - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.model.telemetry_id(), - model_provider: model.model.provider_id().to_string(), - response_latency: None, - error_message: None, - language_name: language_name.map(|name| name.to_proto()), }, - Some(self.telemetry.clone()), - cx.http_client(), - model.model.api_key(cx), - cx.background_executor(), + cx, ); } @@ -1101,6 +1142,9 @@ impl InlineAssistant { let mut to_remove = decorations.removed_line_block_ids; to_remove.insert(decorations.prompt_block_id); to_remove.insert(decorations.end_block_id); + if let Some(tool_description_block_id) = decorations.model_explanation { + to_remove.insert(tool_description_block_id); + } editor.remove_blocks(to_remove, None, cx); }); @@ -1153,7 +1197,7 @@ impl InlineAssistant { assist .editor - .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))) + .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) .ok(); } @@ -1165,7 +1209,7 @@ impl InlineAssistant { if let Some(decorations) = assist.decorations.as_ref() { decorations.prompt_editor.update(cx, |prompt_editor, cx| { prompt_editor.editor.update(cx, |editor, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.select_all(&SelectAll, window, cx); }) }); @@ -1215,28 +1259,26 @@ impl InlineAssistant { let bottom = top + 1.0; (top, bottom) }); - let mut scroll_target_top = scroll_target_range.0; - let mut scroll_target_bottom = scroll_target_range.1; - - scroll_target_top -= editor.vertical_scroll_margin() as ScrollOffset; - scroll_target_bottom += editor.vertical_scroll_margin() as ScrollOffset; - let height_in_lines = editor.visible_line_count().unwrap_or(0.); + let vertical_scroll_margin = editor.vertical_scroll_margin() as ScrollOffset; + let scroll_target_top = (scroll_target_range.0 - vertical_scroll_margin) + // Don't scroll up too far in the case of a large vertical_scroll_margin. + .max(scroll_target_range.0 - height_in_lines / 2.0); + let scroll_target_bottom = (scroll_target_range.1 + vertical_scroll_margin) + // Don't scroll down past where the top would still be visible. + .min(scroll_target_top + height_in_lines); + let scroll_top = editor.scroll_position(cx).y; let scroll_bottom = scroll_top + height_in_lines; if scroll_target_top < scroll_top { editor.set_scroll_position(point(0., scroll_target_top), window, cx); } else if scroll_target_bottom > scroll_bottom { - if (scroll_target_bottom - scroll_target_top) <= height_in_lines { - editor.set_scroll_position( - point(0., scroll_target_bottom - height_in_lines), - window, - cx, - ); - } else { - editor.set_scroll_position(point(0., scroll_target_top), window, cx); - } + editor.set_scroll_position( + point(0., scroll_target_bottom - height_in_lines), + window, + cx, + ); } }); } @@ -1541,6 +1583,27 @@ impl InlineAssistant { .map(InlineAssistTarget::Terminal) } } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_completion_receiver( + &mut self, + sender: mpsc::UnboundedSender>, + ) { + self._inline_assistant_completions = Some(sender); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_codegen( + &mut self, + assist_id: InlineAssistId, + cx: &mut App, + ) -> Option> { + self.assists.get(&assist_id).map(|inline_assist| { + inline_assist + .codegen + .update(cx, |codegen, _cx| codegen.active_alternative().clone()) + }) + } } struct EditorInlineAssists { @@ -1674,6 +1737,7 @@ impl InlineAssist { editor: &Entity, prompt_editor: &Entity>, prompt_block_id: CustomBlockId, + tool_description_block_id: CustomBlockId, end_block_id: CustomBlockId, range: Range, codegen: Entity, @@ -1688,7 +1752,8 @@ impl InlineAssist { decorations: Some(InlineAssistDecorations { prompt_block_id, prompt_editor: prompt_editor.clone(), - removed_line_block_ids: HashSet::default(), + removed_line_block_ids: Default::default(), + model_explanation: Some(tool_description_block_id), end_block_id, }), range, @@ -1740,6 +1805,16 @@ impl InlineAssist { && assist.decorations.is_none() && let Some(workspace) = assist.workspace.upgrade() { + #[cfg(any(test, feature = "test-support"))] + if let Some(sender) = &mut this._inline_assistant_completions { + sender + .unbounded_send(Err(anyhow::anyhow!( + "Inline assistant error: {}", + error + ))) + .ok(); + } + let error = format!("Inline assistant error: {}", error); workspace.update(cx, |workspace, cx| { struct InlineAssistantError; @@ -1750,6 +1825,11 @@ impl InlineAssist { workspace.show_toast(Toast::new(id, error), cx); }) + } else { + #[cfg(any(test, feature = "test-support"))] + if let Some(sender) = &mut this._inline_assistant_completions { + sender.unbounded_send(Ok(assist_id)).ok(); + } } if assist.decorations.is_none() { @@ -1777,6 +1857,7 @@ struct InlineAssistDecorations { prompt_block_id: CustomBlockId, prompt_editor: Entity>, removed_line_block_ids: HashSet, + model_explanation: Option, end_block_id: CustomBlockId, } @@ -1943,3 +2024,387 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } } + +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] +pub mod test { + + use std::sync::Arc; + + use agent::HistoryStore; + use assistant_text_thread::TextThreadStore; + use client::{Client, UserStore}; + use editor::{Editor, MultiBuffer, MultiBufferOffset}; + use fs::FakeFs; + use futures::channel::mpsc; + use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; + use language::Buffer; + use project::Project; + use prompt_store::PromptBuilder; + use smol::stream::StreamExt as _; + use util::test::marked_text_ranges; + use workspace::Workspace; + + use crate::InlineAssistant; + + #[derive(Debug)] + pub enum InlineAssistantOutput { + Success { + completion: Option, + description: Option, + full_buffer_text: String, + }, + Failure { + failure: String, + }, + // These fields are used for logging + #[allow(unused)] + Malformed { + completion: Option, + description: Option, + failure: Option, + }, + } + + pub fn run_inline_assistant_test( + base_buffer: String, + prompt: String, + setup: SetupF, + test: TestF, + cx: &mut TestAppContext, + ) -> InlineAssistantOutput + where + SetupF: FnOnce(&mut gpui::VisualTestContext), + TestF: FnOnce(&mut gpui::VisualTestContext), + { + let fs = FakeFs::new(cx.executor()); + let app_state = cx.update(|cx| workspace::AppState::test(cx)); + let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); + let http = Arc::new(reqwest_client::ReqwestClient::user_agent("agent tests").unwrap()); + let client = cx.update(|cx| { + cx.set_http_client(http); + Client::production(cx) + }); + let mut inline_assistant = InlineAssistant::new(fs.clone(), prompt_builder); + + let (tx, mut completion_rx) = mpsc::unbounded(); + inline_assistant.set_completion_receiver(tx); + + // Initialize settings and client + cx.update(|cx| { + gpui_tokio::init(cx); + settings::init(cx); + client::init(&client, cx); + workspace::init(app_state.clone(), cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(client.clone(), cx); + language_models::init(user_store, client.clone(), cx); + + cx.set_global(inline_assistant); + }); + + let project = cx + .executor() + .block_test(async { Project::test(fs.clone(), [], cx).await }); + + // Create workspace with window + let (workspace, cx) = cx.add_window_view(|window, cx| { + window.activate_window(); + Workspace::new(None, project.clone(), app_state.clone(), window, cx) + }); + + setup(cx); + + let (_editor, buffer) = cx.update(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let editor = cx.new(|cx| Editor::for_multibuffer(multibuffer, None, window, cx)); + editor.update(cx, |editor, cx| { + let (unmarked_text, selection_ranges) = marked_text_ranges(&base_buffer, true); + editor.set_text(unmarked_text, window, cx); + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges( + selection_ranges.into_iter().map(|range| { + MultiBufferOffset(range.start)..MultiBufferOffset(range.end) + }), + ) + }) + }); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Add editor to workspace + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + + // Call assist method + InlineAssistant::update_global(cx, |inline_assistant, cx| { + let assist_id = inline_assistant + .assist( + &editor, + workspace.downgrade(), + project.downgrade(), + history_store, // thread_store + None, // prompt_store + Some(prompt), + window, + cx, + ) + .unwrap(); + + inline_assistant.start_assist(assist_id, window, cx); + }); + + (editor, buffer) + }); + + cx.run_until_parked(); + + test(cx); + + let assist_id = cx + .executor() + .block_test(async { completion_rx.next().await }) + .unwrap() + .unwrap(); + + let (completion, description, failure) = cx.update(|_, cx| { + InlineAssistant::update_global(cx, |inline_assistant, cx| { + let codegen = inline_assistant.get_codegen(assist_id, cx).unwrap(); + + let completion = codegen.read(cx).current_completion(); + let description = codegen.read(cx).current_description(); + let failure = codegen.read(cx).current_failure(); + + (completion, description, failure) + }) + }); + + if failure.is_some() && (completion.is_some() || description.is_some()) { + InlineAssistantOutput::Malformed { + completion, + description, + failure, + } + } else if let Some(failure) = failure { + InlineAssistantOutput::Failure { failure } + } else { + InlineAssistantOutput::Success { + completion, + description, + full_buffer_text: buffer.read_with(cx, |buffer, _| buffer.text()), + } + } + } +} + +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] +pub mod evals { + use std::str::FromStr; + + use eval_utils::{EvalOutput, NoProcessor}; + use gpui::TestAppContext; + use language_model::{LanguageModelRegistry, SelectedModel}; + use rand::{SeedableRng as _, rngs::StdRng}; + + use crate::inline_assistant::test::{InlineAssistantOutput, run_inline_assistant_test}; + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_single_cursor_edit() { + run_eval( + 20, + 1.0, + "Rename this variable to buffer_text".to_string(), + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "} + .to_string(), + exact_buffer_match(indoc::indoc! {" + struct EvalExampleStruct { + buffer_text: String, + prompt: String, + } + "}), + ); + } + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_cant_do() { + run_eval( + 20, + 0.95, + "Rename the struct to EvalExampleStructNope", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_unclear() { + run_eval( + 20, + 0.95, + "Make exactly the change I want you to make", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_empty_buffer() { + run_eval( + 20, + 1.0, + "Write a Python hello, world program".to_string(), + "ˇ".to_string(), + |output| match output { + InlineAssistantOutput::Success { + full_buffer_text, .. + } => { + if full_buffer_text.is_empty() { + EvalOutput::failed("expected some output".to_string()) + } else { + EvalOutput::passed(format!("Produced {full_buffer_text}")) + } + } + o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + }, + ); + } + + fn run_eval( + iterations: usize, + expected_pass_ratio: f32, + prompt: impl Into, + buffer: impl Into, + judge: impl Fn(InlineAssistantOutput) -> eval_utils::EvalOutput<()> + Send + Sync + 'static, + ) { + let buffer = buffer.into(); + let prompt = prompt.into(); + + eval_utils::eval(iterations, expected_pass_ratio, NoProcessor, move || { + let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); + let mut cx = TestAppContext::build(dispatcher, None); + cx.skip_drawing(); + + let output = run_inline_assistant_test( + buffer.clone(), + prompt.clone(), + |cx| { + // Reconfigure to use a real model instead of the fake one + let model_name = std::env::var("ZED_AGENT_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-latest".into()); + + let selected_model = SelectedModel::from_str(&model_name) + .expect("Invalid model format. Use 'provider/model-id'"); + + log::info!("Selected model: {selected_model:?}"); + + cx.update(|_, cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.select_inline_assistant_model(Some(&selected_model), cx); + }); + }); + }, + |_cx| { + log::info!("Waiting for actual response from the LLM..."); + }, + &mut cx, + ); + + cx.quit(); + + judge(output) + }); + } + + fn uncertain_output(output: InlineAssistantOutput) -> EvalOutput<()> { + match &output { + o @ InlineAssistantOutput::Success { + completion, + description, + .. + } => { + if description.is_some() && completion.is_none() { + EvalOutput::passed(format!( + "Assistant produced no completion, but a description:\n{}", + description.as_ref().unwrap() + )) + } else { + EvalOutput::failed(format!("Assistant produced a completion:\n{:?}", o)) + } + } + InlineAssistantOutput::Failure { + failure: error_message, + } => EvalOutput::passed(format!( + "Assistant produced a failure message: {}", + error_message + )), + o @ InlineAssistantOutput::Malformed { .. } => { + EvalOutput::failed(format!("Assistant produced a malformed response:\n{:?}", o)) + } + } + } + + fn exact_buffer_match( + correct_output: impl Into, + ) -> impl Fn(InlineAssistantOutput) -> EvalOutput<()> { + let correct_output = correct_output.into(); + move |output| match output { + InlineAssistantOutput::Success { + description, + full_buffer_text, + .. + } => { + if full_buffer_text == correct_output && description.is_none() { + EvalOutput::passed("Assistant output matches") + } else if full_buffer_text == correct_output { + EvalOutput::failed(format!( + "Assistant output produced an unescessary description description:\n{:?}", + description + )) + } else { + EvalOutput::failed(format!( + "Assistant output does not match expected output:\n{:?}\ndescription:\n{:?}", + full_buffer_text, description + )) + } + } + o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + } + } +} diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index b9e8d9ada230ba497ffcd4e577d3312dd440e604..3542959dbf146d39d40a5851b6fa9ce00a5014cd 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -8,12 +8,14 @@ use editor::{ ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, actions::{MoveDown, MoveUp}, }; +use feature_flags::{FeatureFlagAppExt, InlineAssistantUseToolFeatureFlag}; use fs::Fs; use gpui::{ - AnyElement, App, Context, CursorStyle, Entity, EventEmitter, FocusHandle, Focusable, - Subscription, TextStyle, WeakEntity, Window, + AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, + Subscription, TextStyle, TextStyleRefinement, WeakEntity, Window, actions, }; use language_model::{LanguageModel, LanguageModelRegistry}; +use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::Project; use prompt_store::PromptStore; @@ -25,18 +27,35 @@ use std::sync::Arc; use theme::ThemeSettings; use ui::utils::WithRemSize; use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; -use workspace::Workspace; +use uuid::Uuid; +use workspace::notifications::NotificationId; +use workspace::{Toast, Workspace}; use zed_actions::agent::ToggleModelSelector; use crate::agent_model_selector::AgentModelSelector; -use crate::buffer_codegen::BufferCodegen; +use crate::buffer_codegen::{BufferCodegen, CodegenAlternative}; use crate::completion_provider::{ PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextType, }; use crate::mention_set::paste_images_as_context; use crate::mention_set::{MentionSet, crease_for_mention}; use crate::terminal_codegen::TerminalCodegen; -use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext}; +use crate::{ + CycleFavoriteModels, CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext, +}; + +actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]); + +enum CompletionState { + Pending, + Generated { completion_text: Option }, + Rated, +} + +struct SessionState { + session_id: Uuid, + completion: CompletionState, +} pub struct PromptEditor { pub editor: Entity, @@ -53,6 +72,7 @@ pub struct PromptEditor { _codegen_subscription: Subscription, editor_subscriptions: Vec, show_rate_limit_notice: bool, + session_state: SessionState, _phantom: std::marker::PhantomData, } @@ -65,7 +85,7 @@ impl Render for PromptEditor { const RIGHT_PADDING: Pixels = px(9.); - let (left_gutter_width, right_padding) = match &self.mode { + let (left_gutter_width, right_padding, explanation) = match &self.mode { PromptEditorMode::Buffer { id: _, codegen, @@ -83,17 +103,23 @@ impl Render for PromptEditor { let left_gutter_width = gutter.full_width() + (gutter.margin / 2.0); let right_padding = editor_margins.right + RIGHT_PADDING; - (left_gutter_width, right_padding) + let active_alternative = codegen.active_alternative().read(cx); + let explanation = active_alternative + .description + .clone() + .or_else(|| active_alternative.failure.clone()); + + (left_gutter_width, right_padding, explanation) } PromptEditorMode::Terminal { .. } => { // Give the equivalent of the same left-padding that we're using on the right - (Pixels::from(40.0), Pixels::from(24.)) + (Pixels::from(40.0), Pixels::from(24.), None) } }; let bottom_padding = match &self.mode { PromptEditorMode::Buffer { .. } => rems_from_px(2.0), - PromptEditorMode::Terminal { .. } => rems_from_px(8.0), + PromptEditorMode::Terminal { .. } => rems_from_px(4.0), }; buttons.extend(self.render_buttons(window, cx)); @@ -111,42 +137,60 @@ impl Render for PromptEditor { this.trigger_completion_menu(window, cx); })); + let markdown = window.use_state(cx, |_, cx| Markdown::new("".into(), None, None, cx)); + + if let Some(explanation) = &explanation { + markdown.update(cx, |markdown, cx| { + markdown.reset(SharedString::from(explanation), cx); + }); + } + + let explanation_label = self + .render_markdown(markdown, markdown_style(window, cx)) + .into_any_element(); + v_flex() - .key_context("PromptEditor") + .key_context("InlineAssistant") .capture_action(cx.listener(Self::paste)) - .bg(cx.theme().colors().editor_background) .block_mouse_except_scroll() - .gap_0p5() - .border_y_1() - .border_color(cx.theme().status().info_border) .size_full() .pt_0p5() .pb(bottom_padding) .pr(right_padding) + .gap_0p5() + .justify_center() + .border_y_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) .child( h_flex() - .items_start() - .cursor(CursorStyle::Arrow) - .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { - this.model_selector - .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); - })) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::move_down)) + .on_action(cx.listener(Self::thumbs_up)) + .on_action(cx.listener(Self::thumbs_down)) .capture_action(cx.listener(Self::cycle_prev)) .capture_action(cx.listener(Self::cycle_next)) + .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { + this.model_selector + .update(cx, |model_selector, cx| model_selector.toggle(window, cx)); + })) + .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { + this.model_selector.update(cx, |model_selector, cx| { + model_selector.cycle_favorite_models(window, cx); + }); + })) .child( WithRemSize::new(ui_font_size) + .h_full() + .w(left_gutter_width) .flex() .flex_row() .flex_shrink_0() .items_center() - .h_full() - .w(left_gutter_width) .justify_center() - .gap_2() + .gap_1() .child(self.render_close_button(cx)) .map(|el| { let CodegenStatus::Error(error) = self.codegen_status(cx) else { @@ -177,26 +221,83 @@ impl Render for PromptEditor { .flex_row() .items_center() .gap_1() + .child(add_context_button) + .child(self.model_selector.clone()) .children(buttons), ), ), ) - .child( - WithRemSize::new(ui_font_size) - .flex() - .flex_row() - .items_center() - .child(h_flex().flex_shrink_0().w(left_gutter_width)) - .child( - h_flex() - .w_full() - .pl_1() - .items_start() - .justify_between() - .child(add_context_button) - .child(self.model_selector.clone()), - ), - ) + .when_some(explanation, |this, _| { + this.child( + h_flex() + .size_full() + .justify_center() + .child(div().w(left_gutter_width + px(6.))) + .child( + div() + .size_full() + .min_w_0() + .pt(rems_from_px(3.)) + .pl_0p5() + .flex_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(explanation_label), + ), + ) + }) + } +} + +fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let theme_settings = ThemeSettings::get_global(cx); + let colors = cx.theme().colors(); + let mut text_style = window.text_style(); + + text_style.refine(&TextStyleRefinement { + font_family: Some(theme_settings.ui_font.family.clone()), + color: Some(colors.text), + ..Default::default() + }); + + MarkdownStyle { + base_text_style: text_style.clone(), + syntax: cx.theme().syntax().clone(), + selection_background_color: colors.element_selection_background, + heading_level_styles: Some(HeadingLevelStyles { + h1: Some(TextStyleRefinement { + font_size: Some(rems(1.15).into()), + ..Default::default() + }), + h2: Some(TextStyleRefinement { + font_size: Some(rems(1.1).into()), + ..Default::default() + }), + h3: Some(TextStyleRefinement { + font_size: Some(rems(1.05).into()), + ..Default::default() + }), + h4: Some(TextStyleRefinement { + font_size: Some(rems(1.).into()), + ..Default::default() + }), + h5: Some(TextStyleRefinement { + font_size: Some(rems(0.95).into()), + ..Default::default() + }), + h6: Some(TextStyleRefinement { + font_size: Some(rems(0.875).into()), + ..Default::default() + }), + }), + inline_code: TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), + font_features: Some(theme_settings.buffer_font.features.clone()), + background_color: Some(colors.editor_foreground.opacity(0.08)), + ..Default::default() + }, + ..Default::default() } } @@ -263,7 +364,7 @@ impl PromptEditor { creases = insert_message_creases(&mut editor, &existing_creases, window, cx); if focus { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } editor }); @@ -354,6 +455,7 @@ impl PromptEditor { } self.edited_since_done = true; + self.session_state.completion = CompletionState::Pending; cx.notify(); } EditorEvent::Blurred => { @@ -425,22 +527,207 @@ impl PromptEditor { fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { match self.codegen_status(cx) { CodegenStatus::Idle => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } CodegenStatus::Pending => {} CodegenStatus::Done => { if self.edited_since_done { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } else { cx.emit(PromptEditorEvent::ConfirmRequested { execute: false }); } } CodegenStatus::Error(_) => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } } } + fn fire_started_telemetry(&self, cx: &Context) { + let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() else { + return; + }; + + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.provider.id().to_string(); + + let (kind, language_name) = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + let codegen = codegen.read(cx); + ( + "inline", + codegen.language_name(cx).map(|name| name.to_string()), + ) + } + PromptEditorMode::Terminal { .. } => ("inline_terminal", None), + }; + + telemetry::event!( + "Assistant Started", + session_id = self.session_state.session_id.to_string(), + kind = kind, + phase = "started", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + ); + } + + fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); + return; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let (model_id, use_streaming_tools) = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) + }; + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let prompt = self.editor.read(cx).text(cx); + + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; + + telemetry::event!( + "Inline Assistant Rated", + rating = "positive", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + use_streaming_tools + ); + + self.session_state.completion = CompletionState::Rated; + + cx.notify(); + } + } + } + + fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); + return; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let (model_telemetry_id, use_streaming_tools) = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) + }; + + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; + + let prompt = self.editor.read(cx).text(cx); + + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; + + telemetry::event!( + "Inline Assistant Rated", + rating = "negative", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_telemetry_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + use_streaming_tools + ); + + self.session_state.completion = CompletionState::Rated; + + cx.notify(); + } + } + } + + fn toast(&mut self, msg: &str, uuid: Option, cx: &mut Context<'_, PromptEditor>) { + self.workspace + .update(cx, |workspace, cx| { + enum InlinePromptRating {} + workspace.show_toast( + { + let mut toast = Toast::new( + NotificationId::unique::(), + msg.to_string(), + ) + .autohide(); + + if let Some(uuid) = uuid { + toast = toast.on_click("Click to copy rating ID", move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(uuid.to_string())); + }); + }; + + toast + }, + cx, + ); + }) + .ok(); + } + fn move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { if let Some(ix) = self.prompt_history_ix { if ix > 0 { @@ -546,6 +833,9 @@ impl PromptEditor { .into_any_element(), ] } else { + let show_rating_buttons = cx.has_flag::(); + let rated = matches!(self.session_state.completion, CompletionState::Rated); + let accept = IconButton::new("accept", IconName::Check) .icon_color(Color::Info) .shape(IconButtonShape::Square) @@ -557,25 +847,106 @@ impl PromptEditor { })) .into_any_element(); - match &self.mode { - PromptEditorMode::Terminal { .. } => vec![ - accept, - IconButton::new("confirm", IconName::PlayFilled) - .icon_color(Color::Info) - .shape(IconButtonShape::Square) - .tooltip(|_window, cx| { - Tooltip::for_action( - "Execute Generated Command", - &menu::SecondaryConfirm, - cx, - ) - }) - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(PromptEditorEvent::ConfirmRequested { execute: true }); - })) + let mut buttons = Vec::new(); + + if show_rating_buttons { + buttons.push( + h_flex() + .pl_1() + .gap_1() + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .child( + IconButton::new("thumbs-up", IconName::ThumbsUp) + .shape(IconButtonShape::Square) + .map(|this| { + if rated { + this.disabled(true) + .icon_color(Color::Disabled) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Good Result", + None, + "You already rated this result", + cx, + ) + }) + } else { + this.icon_color(Color::Muted).tooltip( + move |_, cx| { + Tooltip::for_action( + "Good Result", + &ThumbsUpResult, + cx, + ) + }, + ) + } + }) + .on_click(cx.listener(|this, _, window, cx| { + this.thumbs_up(&ThumbsUpResult, window, cx); + })), + ) + .child( + IconButton::new("thumbs-down", IconName::ThumbsDown) + .shape(IconButtonShape::Square) + .map(|this| { + if rated { + this.disabled(true) + .icon_color(Color::Disabled) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Bad Result", + None, + "You already rated this result", + cx, + ) + }) + } else { + this.icon_color(Color::Muted).tooltip( + move |_, cx| { + Tooltip::for_action( + "Bad Result", + &ThumbsDownResult, + cx, + ) + }, + ) + } + }) + .on_click(cx.listener(|this, _, window, cx| { + this.thumbs_down(&ThumbsDownResult, window, cx); + })), + ) .into_any_element(), - ], - PromptEditorMode::Buffer { .. } => vec![accept], + ); + } + + buttons.push(accept); + + match &self.mode { + PromptEditorMode::Terminal { .. } => { + buttons.push( + IconButton::new("confirm", IconName::PlayFilled) + .icon_color(Color::Info) + .shape(IconButtonShape::Square) + .tooltip(|_window, cx| { + Tooltip::for_action( + "Execute Generated Command", + &menu::SecondaryConfirm, + cx, + ) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(PromptEditorEvent::ConfirmRequested { + execute: true, + }); + })) + .into_any_element(), + ); + buttons + } + PromptEditorMode::Buffer { .. } => buttons, } } } @@ -610,10 +981,21 @@ impl PromptEditor { } fn render_close_button(&self, cx: &mut Context) -> AnyElement { + let focus_handle = self.editor.focus_handle(cx); + IconButton::new("cancel", IconName::Close) .icon_color(Color::Muted) .shape(IconButtonShape::Square) - .tooltip(Tooltip::text("Close Assistant")) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Close Assistant", + &editor::actions::Cancel, + &focus_handle, + cx, + ) + } + }) .on_click(cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::CancelRequested))) .into_any_element() } @@ -727,7 +1109,6 @@ impl PromptEditor { let colors = cx.theme().colors(); div() - .key_context("InlineAssistEditor") .size_full() .p_2() .pl_1() @@ -759,6 +1140,10 @@ impl PromptEditor { }) .into_any_element() } + + fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { + MarkdownElement::new(markdown, style) + } } pub enum PromptEditorMode { @@ -830,6 +1215,7 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, history_store: Entity, prompt_store: Option>, @@ -900,6 +1286,10 @@ impl PromptEditor { editor_subscriptions: Vec::new(), show_rate_limit_notice: false, mode, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; @@ -910,7 +1300,7 @@ impl PromptEditor { fn handle_codegen_changed( &mut self, - _: Entity, + codegen: Entity, cx: &mut Context>, ) { match self.codegen_status(cx) { @@ -919,10 +1309,15 @@ impl PromptEditor { .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done => { + let completion = codegen.read(cx).active_completion(cx); + self.session_state.completion = CompletionState::Generated { + completion_text: completion, + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); @@ -978,6 +1373,7 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, history_store: Entity, prompt_store: Option>, @@ -1043,6 +1439,10 @@ impl PromptEditor { editor_subscriptions: Vec::new(), mode, show_rate_limit_notice: false, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; this.count_lines(cx); @@ -1075,17 +1475,21 @@ impl PromptEditor { } } - fn handle_codegen_changed(&mut self, _: Entity, cx: &mut Context) { + fn handle_codegen_changed(&mut self, codegen: Entity, cx: &mut Context) { match &self.codegen().read(cx).status { CodegenStatus::Idle => { self.editor .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done | CodegenStatus::Error(_) => { + self.session_state.completion = CompletionState::Generated { + completion_text: codegen.read(cx).completion(), + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 5b5a4513c6dca32e985c966e07ad84e84fc9a872..64b1397b02ca4a7fc9c20fdfc8abf10141712bbb 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -1,27 +1,33 @@ use std::{cmp::Reverse, sync::Arc}; -use collections::IndexMap; +use agent_settings::AgentSettings; +use collections::{HashMap, HashSet, IndexMap}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task, }; use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, - LanguageModelRegistry, + AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId, + LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; -use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*}; +use settings::Settings; +use ui::prelude::*; use zed_actions::agent::OpenSettings; +use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem}; + type OnModelChanged = Arc, &mut App) + 'static>; type GetActiveModel = Arc Option + 'static>; +type OnToggleFavorite = Arc, bool, &mut App) + 'static>; pub type LanguageModelSelector = Picker; pub fn language_model_selector( get_active_model: impl Fn(&App) -> Option + 'static, on_model_changed: impl Fn(Arc, &mut App) + 'static, + on_toggle_favorite: impl Fn(Arc, bool, &mut App) + 'static, popover_styles: bool, focus_handle: FocusHandle, window: &mut Window, @@ -30,6 +36,7 @@ pub fn language_model_selector( let delegate = LanguageModelPickerDelegate::new( get_active_model, on_model_changed, + on_toggle_favorite, popover_styles, focus_handle, window, @@ -47,7 +54,17 @@ pub fn language_model_selector( } fn all_models(cx: &App) -> GroupedModels { - let providers = LanguageModelRegistry::global(cx).read(cx).providers(); + let lm_registry = LanguageModelRegistry::global(cx).read(cx); + let providers = lm_registry.visible_providers(); + + let mut favorites_index = FavoritesIndex::default(); + + for sel in &AgentSettings::get_global(cx).favorite_models { + favorites_index + .entry(sel.provider.0.clone().into()) + .or_default() + .insert(sel.model.clone().into()); + } let recommended = providers .iter() @@ -55,10 +72,7 @@ fn all_models(cx: &App) -> GroupedModels { provider .recommended_models(cx) .into_iter() - .map(|model| ModelInfo { - model, - icon: provider.icon(), - }) + .map(|model| ModelInfo::new(&**provider, model, &favorites_index)) }) .collect(); @@ -68,25 +82,44 @@ fn all_models(cx: &App) -> GroupedModels { provider .provided_models(cx) .into_iter() - .map(|model| ModelInfo { - model, - icon: provider.icon(), - }) + .map(|model| ModelInfo::new(&**provider, model, &favorites_index)) }) .collect(); GroupedModels::new(all, recommended) } +type FavoritesIndex = HashMap>; + #[derive(Clone)] struct ModelInfo { model: Arc, - icon: IconName, + icon: IconOrSvg, + is_favorite: bool, +} + +impl ModelInfo { + fn new( + provider: &dyn LanguageModelProvider, + model: Arc, + favorites_index: &FavoritesIndex, + ) -> Self { + let is_favorite = favorites_index + .get(&provider.id()) + .map_or(false, |set| set.contains(&model.id())); + + Self { + model, + icon: provider.icon(), + is_favorite, + } + } } pub struct LanguageModelPickerDelegate { on_model_changed: OnModelChanged, get_active_model: GetActiveModel, + on_toggle_favorite: OnToggleFavorite, all_models: Arc, filtered_entries: Vec, selected_index: usize, @@ -100,6 +133,7 @@ impl LanguageModelPickerDelegate { fn new( get_active_model: impl Fn(&App) -> Option + 'static, on_model_changed: impl Fn(Arc, &mut App) + 'static, + on_toggle_favorite: impl Fn(Arc, bool, &mut App) + 'static, popover_styles: bool, focus_handle: FocusHandle, window: &mut Window, @@ -115,6 +149,7 @@ impl LanguageModelPickerDelegate { selected_index: Self::get_active_model_index(&entries, get_active_model(cx)), filtered_entries: entries, get_active_model: Arc::new(get_active_model), + on_toggle_favorite: Arc::new(on_toggle_favorite), _authenticate_all_providers_task: Self::authenticate_all_providers(cx), _subscriptions: vec![cx.subscribe_in( &LanguageModelRegistry::global(cx), @@ -168,7 +203,7 @@ impl LanguageModelPickerDelegate { fn authenticate_all_providers(cx: &mut App) -> Task<()> { let authenticate_all_providers = LanguageModelRegistry::global(cx) .read(cx) - .providers() + .visible_providers() .iter() .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) .collect::>(); @@ -214,15 +249,61 @@ impl LanguageModelPickerDelegate { pub fn active_model(&self, cx: &App) -> Option { (self.get_active_model)(cx) } + + pub fn favorites_count(&self) -> usize { + self.all_models.favorites.len() + } + + pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context>) { + if self.all_models.favorites.is_empty() { + return; + } + + let active_model = (self.get_active_model)(cx); + let active_provider_id = active_model.as_ref().map(|m| m.provider.id()); + let active_model_id = active_model.as_ref().map(|m| m.model.id()); + + let current_index = self + .all_models + .favorites + .iter() + .position(|info| { + Some(info.model.provider_id()) == active_provider_id + && Some(info.model.id()) == active_model_id + }) + .unwrap_or(usize::MAX); + + let next_index = if current_index == usize::MAX { + 0 + } else { + (current_index + 1) % self.all_models.favorites.len() + }; + + let next_model = self.all_models.favorites[next_index].model.clone(); + + (self.on_model_changed)(next_model, cx); + + // Align the picker selection with the newly-active model + let new_index = + Self::get_active_model_index(&self.filtered_entries, (self.get_active_model)(cx)); + self.set_selected_index(new_index, window, cx); + } } struct GroupedModels { + favorites: Vec, recommended: Vec, all: IndexMap>, } impl GroupedModels { pub fn new(all: Vec, recommended: Vec) -> Self { + let favorites = all + .iter() + .filter(|info| info.is_favorite) + .cloned() + .collect(); + let mut all_by_provider: IndexMap<_, Vec> = IndexMap::default(); for model in all { let provider = model.model.provider_id(); @@ -234,6 +315,7 @@ impl GroupedModels { } Self { + favorites, recommended, all: all_by_provider, } @@ -242,13 +324,18 @@ impl GroupedModels { fn entries(&self) -> Vec { let mut entries = Vec::new(); + if !self.favorites.is_empty() { + entries.push(LanguageModelPickerEntry::Separator("Favorite".into())); + for info in &self.favorites { + entries.push(LanguageModelPickerEntry::Model(info.clone())); + } + } + if !self.recommended.is_empty() { entries.push(LanguageModelPickerEntry::Separator("Recommended".into())); - entries.extend( - self.recommended - .iter() - .map(|info| LanguageModelPickerEntry::Model(info.clone())), - ); + for info in &self.recommended { + entries.push(LanguageModelPickerEntry::Model(info.clone())); + } } for models in self.all.values() { @@ -258,12 +345,11 @@ impl GroupedModels { entries.push(LanguageModelPickerEntry::Separator( models[0].model.provider_name().0, )); - entries.extend( - models - .iter() - .map(|info| LanguageModelPickerEntry::Model(info.clone())), - ); + for info in models { + entries.push(LanguageModelPickerEntry::Model(info.clone())); + } } + entries } } @@ -392,7 +478,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { let configured_providers = language_model_registry .read(cx) - .providers() + .visible_providers() .into_iter() .filter(|provider| provider.is_authenticated(cx)) .collect::>(); @@ -464,23 +550,9 @@ impl PickerDelegate for LanguageModelPickerDelegate { cx: &mut Context>, ) -> Option { match self.filtered_entries.get(ix)? { - LanguageModelPickerEntry::Separator(title) => Some( - div() - .px_2() - .pb_1() - .when(ix > 1, |this| { - this.mt_1() - .pt_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - }) - .child( - Label::new(title) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - ), + LanguageModelPickerEntry::Separator(title) => { + Some(ModelSelectorHeader::new(title, ix > 1).into_any_element()) + } LanguageModelPickerEntry::Model(model_info) => { let active_model = (self.get_active_model)(cx); let active_provider_id = active_model.as_ref().map(|m| m.provider.id()); @@ -489,35 +561,26 @@ impl PickerDelegate for LanguageModelPickerDelegate { let is_selected = Some(model_info.model.provider_id()) == active_provider_id && Some(model_info.model.id()) == active_model_id; - let model_icon_color = if is_selected { - Color::Accent - } else { - Color::Muted + let is_favorite = model_info.is_favorite; + let handle_action_click = { + let model = model_info.model.clone(); + let on_toggle_favorite = self.on_toggle_favorite.clone(); + cx.listener(move |picker, _, window, cx| { + on_toggle_favorite(model.clone(), !is_favorite, cx); + picker.refresh(window, cx); + }) }; Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_1p5() - .child( - Icon::new(model_info.icon) - .color(model_icon_color) - .size(IconSize::Small), - ) - .child(Label::new(model_info.model.name().0).truncate()), - ) - .end_slot(div().pr_3().when(is_selected, |this| { - this.child( - Icon::new(IconName::Check) - .color(Color::Accent) - .size(IconSize::Small), - ) - })) + ModelSelectorListItem::new(ix, model_info.model.name().0) + .map(|this| match &model_info.icon { + IconOrSvg::Icon(icon_name) => this.icon(*icon_name), + IconOrSvg::Svg(icon_path) => this.icon_path(icon_path.clone()), + }) + .is_selected(is_selected) + .is_focused(selected) + .is_favorite(is_favorite) + .on_toggle_favorite(handle_action_click) .into_any_element(), ) } @@ -527,7 +590,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { fn render_footer( &self, _window: &mut Window, - cx: &mut Context>, + _cx: &mut Context>, ) -> Option { let focus_handle = self.focus_handle.clone(); @@ -535,26 +598,7 @@ impl PickerDelegate for LanguageModelPickerDelegate { return None; } - Some( - h_flex() - .w_full() - .p_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Button::new("configure", "Configure") - .full_width() - .style(ButtonStyle::Outlined) - .key_binding( - KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(OpenSettings.boxed_clone(), cx); - }), - ) - .into_any(), - ) + Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element()) } } @@ -653,11 +697,24 @@ mod tests { } fn create_models(model_specs: Vec<(&str, &str)>) -> Vec { + create_models_with_favorites(model_specs, vec![]) + } + + fn create_models_with_favorites( + model_specs: Vec<(&str, &str)>, + favorites: Vec<(&str, &str)>, + ) -> Vec { model_specs .into_iter() - .map(|(provider, name)| ModelInfo { - model: Arc::new(TestLanguageModel::new(name, provider)), - icon: IconName::Ai, + .map(|(provider, name)| { + let is_favorite = favorites + .iter() + .any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name); + ModelInfo { + model: Arc::new(TestLanguageModel::new(name, provider)), + icon: IconOrSvg::Icon(IconName::Ai), + is_favorite, + } }) .collect() } @@ -795,4 +852,93 @@ mod tests { vec!["zed/claude", "zed/gemini", "copilot/claude"], ); } + + #[gpui::test] + fn test_favorites_section_appears_when_favorites_exist(_cx: &mut TestAppContext) { + let recommended_models = create_models(vec![("zed", "claude")]); + let all_models = create_models_with_favorites( + vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")], + vec![("zed", "gemini")], + ); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + let entries = grouped_models.entries(); + + assert!(matches!( + entries.first(), + Some(LanguageModelPickerEntry::Separator(s)) if s == "Favorite" + )); + + assert_models_eq(grouped_models.favorites, vec!["zed/gemini"]); + } + + #[gpui::test] + fn test_no_favorites_section_when_no_favorites(_cx: &mut TestAppContext) { + let recommended_models = create_models(vec![("zed", "claude")]); + let all_models = create_models(vec![("zed", "claude"), ("zed", "gemini")]); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + let entries = grouped_models.entries(); + + assert!(matches!( + entries.first(), + Some(LanguageModelPickerEntry::Separator(s)) if s == "Recommended" + )); + + assert!(grouped_models.favorites.is_empty()); + } + + #[gpui::test] + fn test_models_have_correct_actions(_cx: &mut TestAppContext) { + let recommended_models = + create_models_with_favorites(vec![("zed", "claude")], vec![("zed", "claude")]); + let all_models = create_models_with_favorites( + vec![("zed", "claude"), ("zed", "gemini"), ("openai", "gpt-4")], + vec![("zed", "claude")], + ); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + let entries = grouped_models.entries(); + + for entry in &entries { + if let LanguageModelPickerEntry::Model(info) = entry { + if info.model.telemetry_id() == "zed/claude" { + assert!(info.is_favorite, "zed/claude should be a favorite"); + } else { + assert!( + !info.is_favorite, + "{} should not be a favorite", + info.model.telemetry_id() + ); + } + } + } + } + + #[gpui::test] + fn test_favorites_appear_in_other_sections(_cx: &mut TestAppContext) { + let favorites = vec![("zed", "gemini"), ("openai", "gpt-4")]; + + let recommended_models = + create_models_with_favorites(vec![("zed", "claude")], favorites.clone()); + + let all_models = create_models_with_favorites( + vec![ + ("zed", "claude"), + ("zed", "gemini"), + ("openai", "gpt-4"), + ("openai", "gpt-3.5"), + ], + favorites, + ); + + let grouped_models = GroupedModels::new(all_models, recommended_models); + + assert_models_eq(grouped_models.favorites, vec!["zed/gemini", "openai/gpt-4"]); + assert_models_eq(grouped_models.recommended, vec!["zed/claude"]); + assert_models_eq( + grouped_models.all.values().flatten().cloned().collect(), + vec!["zed/claude", "zed/gemini", "openai/gpt-4", "openai/gpt-3.5"], + ); + } } diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index c1949d22e268e8744db7834a58d1a3303fa4e236..327d2c67e2d5e87e67935ecdfa7fb6cd41acbcb5 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -1,4 +1,4 @@ -use crate::{ManageProfiles, ToggleProfileSelector}; +use crate::{CycleModeSelector, ManageProfiles, ToggleProfileSelector}; use agent_settings::{ AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles, }; @@ -70,6 +70,29 @@ impl ProfileSelector { self.picker_handle.clone() } + pub fn cycle_profile(&mut self, cx: &mut Context) { + if !self.provider.profiles_supported(cx) { + return; + } + + let profiles = AgentProfile::available_profiles(cx); + if profiles.is_empty() { + return; + } + + let current_profile_id = self.provider.profile_id(cx); + let current_index = profiles + .keys() + .position(|id| id == ¤t_profile_id) + .unwrap_or(0); + + let next_index = (current_index + 1) % profiles.len(); + + if let Some((next_profile_id, _)) = profiles.get_index(next_index) { + self.provider.set_profile(next_profile_id.clone(), cx); + } + } + fn ensure_picker( &mut self, window: &mut Window, @@ -163,14 +186,29 @@ impl Render for ProfileSelector { PickerPopoverMenu::new( picker, trigger_button, - move |_window, cx| { - Tooltip::for_action_in( - "Toggle Profile Menu", - &ToggleProfileSelector, - &focus_handle, - cx, - ) - }, + Tooltip::element({ + move |_window, cx| { + let container = || h_flex().gap_1().justify_between(); + v_flex() + .gap_1() + .child(container().child(Label::new("Toggle Profile Menu")).child( + KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx), + )) + .child( + container() + .pb_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Label::new("Cycle Through Profiles")) + .child(KeyBinding::for_action_in( + &CycleModeSelector, + &focus_handle, + cx, + )), + ) + .into_any() + } + }), gpui::Corner::BottomRight, cx, ) @@ -542,7 +580,7 @@ impl PickerDelegate for ProfilePickerDelegate { let is_active = active_id == candidate.id; Some( - ListItem::new(SharedString::from(candidate.id.0.clone())) + ListItem::new(candidate.id.0.clone()) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/agent_ui/src/slash_command.rs b/crates/agent_ui/src/slash_command.rs index 7d3ea0105a0aafb4cfccf4076cb95e28c99dec28..e328ef6725e5e789bd402667da91417ad69a372d 100644 --- a/crates/agent_ui/src/slash_command.rs +++ b/crates/agent_ui/src/slash_command.rs @@ -341,7 +341,6 @@ impl CompletionProvider for SlashCommandCompletionProvider { position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { let buffer = buffer.read(cx); diff --git a/crates/agent_ui/src/terminal_codegen.rs b/crates/agent_ui/src/terminal_codegen.rs index 5a4a9d560a16e858dcaedf706f2067a24bc12c5f..e93d3d3991378ddb4156b264be1f0a5ab4d4faac 100644 --- a/crates/agent_ui/src/terminal_codegen.rs +++ b/crates/agent_ui/src/terminal_codegen.rs @@ -1,37 +1,38 @@ use crate::inline_prompt_editor::CodegenStatus; -use client::telemetry::Telemetry; use futures::{SinkExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; -use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, report_assistant_event, -}; -use std::{sync::Arc, time::Instant}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; +use language_model::{ConfiguredModel, LanguageModelRegistry, LanguageModelRequest}; +use std::time::Instant; use terminal::Terminal; +use uuid::Uuid; pub struct TerminalCodegen { pub status: CodegenStatus, - pub telemetry: Option>, terminal: Entity, generation: Task<()>, pub message_id: Option, transaction: Option, + session_id: Uuid, } impl EventEmitter for TerminalCodegen {} impl TerminalCodegen { - pub fn new(terminal: Entity, telemetry: Option>) -> Self { + pub fn new(terminal: Entity, session_id: Uuid) -> Self { Self { terminal, - telemetry, status: CodegenStatus::Idle, generation: Task::ready(()), message_id: None, transaction: None, + session_id, } } + pub fn session_id(&self) -> Uuid { + self.session_id + } + pub fn start(&mut self, prompt_task: Task, cx: &mut Context) { let Some(ConfiguredModel { model, .. }) = LanguageModelRegistry::read_global(cx).inline_assistant_model() @@ -39,15 +40,15 @@ impl TerminalCodegen { return; }; - let model_api_key = model.api_key(cx); - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + self.status = CodegenStatus::Pending; self.transaction = Some(TerminalTransaction::start(self.terminal.clone())); self.generation = cx.spawn(async move |this, cx| { let prompt = prompt_task.await; - let model_telemetry_id = model.telemetry_id(); - let model_provider_id = model.provider_id(); let response = model.stream_completion_text(prompt, cx).await; let generate = async { let message_id = response @@ -59,7 +60,7 @@ impl TerminalCodegen { let task = cx.background_spawn({ let message_id = message_id.clone(); - let executor = cx.background_executor().clone(); + let anthropic_reporter = anthropic_reporter.clone(); async move { let mut response_latency = None; let request_start = Instant::now(); @@ -79,24 +80,27 @@ impl TerminalCodegen { let result = task.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id.to_string(), - response_latency, - error_message, - language_name: None, - }, - telemetry, - http_client, - model_api_key, - &executor, + + telemetry::event!( + "Assistant Responded", + session_id = session_id.to_string(), + kind = "inline_terminal", + phase = "response", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = Option::<&str>::None, + message_id = message_id, + response_latency = response_latency, + error_message = error_message, ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: language_model::AnthropicEventType::Response, + language_name: None, + message_id, + }); + result?; anyhow::Ok(()) } @@ -135,6 +139,12 @@ impl TerminalCodegen { cx.notify(); } + pub fn completion(&self) -> Option { + self.transaction + .as_ref() + .map(|transaction| transaction.completion.clone()) + } + pub fn stop(&mut self, cx: &mut Context) { self.status = CodegenStatus::Done; self.generation = Task::ready(()); @@ -167,27 +177,32 @@ pub const CLEAR_INPUT: &str = "\x03"; const CARRIAGE_RETURN: &str = "\x0d"; struct TerminalTransaction { + completion: String, terminal: Entity, } impl TerminalTransaction { pub fn start(terminal: Entity) -> Self { - Self { terminal } + Self { + completion: String::new(), + terminal, + } } pub fn push(&mut self, hunk: String, cx: &mut App) { // Ensure that the assistant cannot accidentally execute commands that are streamed into the terminal let input = Self::sanitize_input(hunk); + self.completion.push_str(&input); self.terminal .update(cx, |terminal, _| terminal.input(input.into_bytes())); } - pub fn undo(&self, cx: &mut App) { + pub fn undo(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CLEAR_INPUT.as_bytes())); } - pub fn complete(&self, cx: &mut App) { + pub fn complete(self, cx: &mut App) { self.terminal .update(cx, |terminal, _| terminal.input(CARRIAGE_RETURN.as_bytes())); } diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 43ea697bece318699f350259a0e2e38d1a4f4d8d..cacbc316bb84e74e5c369451791f777a9bf58e82 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -8,7 +8,7 @@ use crate::{ use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; + use cloud_llm_client::CompletionIntent; use collections::{HashMap, VecDeque}; use editor::{MultiBuffer, actions::SelectAll}; @@ -17,24 +17,19 @@ use gpui::{App, Entity, Focusable, Global, Subscription, Task, UpdateGlobal, Wea use language::Buffer; use language_model::{ ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - Role, report_assistant_event, + Role, report_anthropic_event, }; use project::Project; use prompt_store::{PromptBuilder, PromptStore}; use std::sync::Arc; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; use terminal_view::TerminalView; use ui::prelude::*; use util::ResultExt; +use uuid::Uuid; use workspace::{Toast, Workspace, notifications::NotificationId}; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder)); } const DEFAULT_CONTEXT_LINES: usize = 50; @@ -44,7 +39,6 @@ pub struct TerminalInlineAssistant { next_assist_id: TerminalInlineAssistId, assists: HashMap, prompt_history: VecDeque, - telemetry: Option>, fs: Arc, prompt_builder: Arc, } @@ -52,16 +46,11 @@ pub struct TerminalInlineAssistant { impl Global for TerminalInlineAssistant {} impl TerminalInlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: TerminalInlineAssistId::default(), assists: HashMap::default(), prompt_history: VecDeque::default(), - telemetry: Some(telemetry), fs, prompt_builder, } @@ -80,13 +69,14 @@ impl TerminalInlineAssistant { ) { let terminal = terminal_view.read(cx).terminal().clone(); let assist_id = self.next_assist_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), cx, ) }); - let codegen = cx.new(|_| TerminalCodegen::new(terminal, self.telemetry.clone())); + let codegen = cx.new(|_| TerminalCodegen::new(terminal, session_id)); let prompt_editor = cx.new(|cx| { PromptEditor::new_terminal( @@ -94,6 +84,7 @@ impl TerminalInlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen, + session_id, self.fs.clone(), thread_store.clone(), prompt_store.clone(), @@ -136,7 +127,7 @@ impl TerminalInlineAssistant { if let Some(prompt_editor) = assist.prompt_editor.as_ref() { prompt_editor.update(cx, |this, cx| { this.editor.update(cx, |editor, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.select_all(&SelectAll, window, cx); }); }); @@ -301,7 +292,7 @@ impl TerminalInlineAssistant { .terminal .update(cx, |this, cx| { this.clear_block_below_cursor(cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); }) .log_err(); @@ -309,27 +300,45 @@ impl TerminalInlineAssistant { LanguageModelRegistry::read_global(cx).inline_assistant_model() { let codegen = assist.codegen.read(cx); - let executor = cx.background_executor().clone(); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id: codegen.message_id.clone(), - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency: None, - error_message: None, + let session_id = codegen.session_id(); + let message_id = codegen.message_id.clone(); + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + // Fire Zed telemetry + telemetry::event!( + event_type, + kind = "inline_terminal", + phase = phase, + model = model_telemetry_id, + model_provider = model_provider_id, + message_id = message_id, + session_id = session_id, + ); + + report_anthropic_event( + &model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: anthropic_event_type, language_name: None, + message_id, }, - codegen.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - &executor, + cx, ); } @@ -360,7 +369,7 @@ impl TerminalInlineAssistant { .terminal .update(cx, |this, cx| { this.clear_block_below_cursor(cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); }) .is_ok() } diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 6d5e226b6a5f1ae441314d45f2546a57c84ca664..3a790dd354afb9ae21cc49687da08256f167b19d 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1,6 +1,6 @@ use crate::{ language_model_selector::{LanguageModelSelector, language_model_selector}, - ui::BurnModeTooltip, + ui::{BurnModeTooltip, ModelSelectorTooltip}, }; use agent_settings::CompletionMode; use anyhow::Result; @@ -33,7 +33,8 @@ use language::{ language_settings::{SoftWrap, all_language_settings}, }; use language_model::{ - ConfigurationError, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, Role, + ConfigurationError, IconOrSvg, LanguageModelExt, LanguageModelImage, LanguageModelRegistry, + Role, }; use multi_buffer::MultiBufferRow; use picker::{Picker, popover_menu::PickerPopoverMenu}; @@ -71,7 +72,9 @@ use workspace::{ pane, searchable::{SearchEvent, SearchableItem}, }; -use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector}; +use zed_actions::agent::{AddSelectionToThread, PasteRaw, ToggleModelSelector}; + +use crate::CycleFavoriteModels; use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker}; use assistant_text_thread::{ @@ -304,17 +307,31 @@ impl TextThreadEditor { language_model_selector: cx.new(|cx| { language_model_selector( |cx| LanguageModelRegistry::read_global(cx).default_model(), - move |model, cx| { - update_settings_file(fs.clone(), cx, move |settings, _| { - let provider = model.provider_id().0.to_string(); - let model = model.id().0.to_string(); - settings.agent.get_or_insert_default().set_model( - LanguageModelSelection { - provider: LanguageModelProviderSetting(provider), - model, - }, - ) - }); + { + let fs = fs.clone(); + move |model, cx| { + update_settings_file(fs.clone(), cx, move |settings, _| { + let provider = model.provider_id().0.to_string(); + let model = model.id().0.to_string(); + settings.agent.get_or_insert_default().set_model( + LanguageModelSelection { + provider: LanguageModelProviderSetting(provider), + model, + }, + ) + }); + } + }, + { + let fs = fs.clone(); + move |model, should_be_favorite, cx| { + crate::favorite_models::toggle_in_settings( + model, + should_be_favorite, + fs.clone(), + cx, + ); + } }, true, // Use popover styles for picker focus_handle, @@ -1325,7 +1342,7 @@ impl TextThreadEditor { if let Some((text, _)) = Self::get_selection_or_code_block(&context_editor_view, cx) { active_editor_view.update(cx, |editor, cx| { editor.insert(&text, window, cx); - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); }) } } @@ -1682,6 +1699,118 @@ impl TextThreadEditor { window: &mut Window, cx: &mut Context, ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let editor_clipboard_selections = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + ClipboardEntry::String(text) => { + text.metadata_json::>() + } + _ => None, + }); + + // Insert creases for pasted clipboard selections that: + // 1. Contain exactly one selection + // 2. Have an associated file path + // 3. Span multiple lines (not single-line selections) + // 4. Belong to a file that exists in the current project + let should_insert_creases = util::maybe!({ + let selections = editor_clipboard_selections.as_ref()?; + if selections.len() > 1 { + return Some(false); + } + let selection = selections.first()?; + let file_path = selection.file_path.as_ref()?; + let line_range = selection.line_range.as_ref()?; + + if line_range.start() == line_range.end() { + return Some(false); + } + + Some( + workspace + .read(cx) + .project() + .read(cx) + .project_path_for_absolute_path(file_path, cx) + .is_some(), + ) + }) + .unwrap_or(false); + + if should_insert_creases && let Some(clipboard_item) = cx.read_from_clipboard() { + if let Some(ClipboardEntry::String(clipboard_text)) = clipboard_item.entries().first() { + if let Some(selections) = editor_clipboard_selections { + cx.stop_propagation(); + + let text = clipboard_text.text(); + self.editor.update(cx, |editor, cx| { + let mut current_offset = 0; + let weak_editor = cx.entity().downgrade(); + + for selection in selections { + if let (Some(file_path), Some(line_range)) = + (selection.file_path, selection.line_range) + { + let selected_text = + &text[current_offset..current_offset + selection.len]; + let fence = assistant_slash_commands::codeblock_fence_for_path( + file_path.to_str(), + Some(line_range.clone()), + ); + let formatted_text = format!("{fence}{selected_text}\n```"); + + let insert_point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + let start_row = MultiBufferRow(insert_point.row); + + editor.insert(&formatted_text, window, cx); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor_before = snapshot.anchor_after(insert_point); + let anchor_after = editor + .selections + .newest_anchor() + .head() + .bias_left(&snapshot); + + editor.insert("\n", window, cx); + + let crease_text = acp_thread::selection_name( + Some(file_path.as_ref()), + &line_range, + ); + + let fold_placeholder = quote_selection_fold_placeholder( + crease_text, + weak_editor.clone(), + ); + let crease = Crease::inline( + anchor_before..anchor_after, + fold_placeholder, + render_quote_selection_output_toggle, + |_, _, _, _| Empty.into_any(), + ); + editor.insert_creases(vec![crease], cx); + editor.fold_at(start_row, window, cx); + + current_offset += selection.len; + if !selection.is_entire_line && current_offset < text.len() { + current_offset += 1; + } + } + } + }); + return; + } + } + } + cx.stop_propagation(); let mut images = if let Some(item) = cx.read_from_clipboard() { @@ -1836,6 +1965,12 @@ impl TextThreadEditor { } } + fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.paste(&editor::actions::Paste, window, cx); + }); + } + fn update_image_blocks(&mut self, cx: &mut Context) { self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); @@ -2097,18 +2232,41 @@ impl TextThreadEditor { .default_model() .map(|default| default.provider); - let provider_icon = match active_provider { - Some(provider) => provider.icon(), - None => IconName::Ai, - }; + let provider_icon = active_provider + .as_ref() + .map(|p| p.icon()) + .unwrap_or(IconOrSvg::Icon(IconName::Ai)); let focus_handle = self.editor().focus_handle(cx); + let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() { (Color::Accent, IconName::ChevronUp) } else { (Color::Muted, IconName::ChevronDown) }; + let provider_icon_element = match provider_icon { + IconOrSvg::Svg(path) => Icon::from_external_svg(path), + IconOrSvg::Icon(name) => Icon::new(name), + } + .color(color) + .size(IconSize::XSmall); + + let show_cycle_row = self + .language_model_selector + .read(cx) + .delegate + .favorites_count() + > 1; + + let tooltip = Tooltip::element({ + move |_, _cx| { + ModelSelectorTooltip::new(focus_handle.clone()) + .show_cycle_row(show_cycle_row) + .into_any_element() + } + }); + PickerPopoverMenu::new( self.language_model_selector.clone(), ButtonLike::new("active-model") @@ -2116,7 +2274,7 @@ impl TextThreadEditor { .child( h_flex() .gap_0p5() - .child(Icon::new(provider_icon).color(color).size(IconSize::XSmall)) + .child(provider_icon_element) .child( Label::new(model_name) .color(color) @@ -2125,9 +2283,7 @@ impl TextThreadEditor { ) .child(Icon::new(icon).color(color).size(IconSize::XSmall)), ), - move |_window, cx| { - Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) - }, + tooltip, gpui::Corner::BottomRight, cx, ) @@ -2480,6 +2636,7 @@ impl Render for TextThreadEditor { .capture_action(cx.listener(TextThreadEditor::copy)) .capture_action(cx.listener(TextThreadEditor::cut)) .capture_action(cx.listener(TextThreadEditor::paste)) + .on_action(cx.listener(TextThreadEditor::paste_raw)) .capture_action(cx.listener(TextThreadEditor::cycle_message_role)) .capture_action(cx.listener(TextThreadEditor::confirm_command)) .on_action(cx.listener(TextThreadEditor::assist)) @@ -2487,6 +2644,11 @@ impl Render for TextThreadEditor { .on_action(move |_: &ToggleModelSelector, window, cx| { language_model_selector.toggle(window, cx); }) + .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { + this.language_model_selector.update(cx, |selector, cx| { + selector.delegate.cycle_favorite_models(window, cx); + }); + })) .size_full() .child( div() @@ -2622,11 +2784,13 @@ impl SearchableItem for TextThreadEditor { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ) { - self.editor - .update(cx, |editor, cx| editor.update_matches(matches, window, cx)); + self.editor.update(cx, |editor, cx| { + editor.update_matches(matches, active_match_index, window, cx) + }); } fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { @@ -3230,7 +3394,6 @@ mod tests { let mut text_thread = TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index e604df416e2725a6f1b7bff8eed883a8cc36e184..b484fdb6c6c480f1cffe78eea7a51f635d3906a1 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -4,8 +4,8 @@ mod burn_mode_tooltip; mod claude_code_onboarding_modal; mod end_trial_upsell; mod hold_for_default; +mod model_selector_components; mod onboarding_modal; -mod unavailable_editing_tooltip; mod usage_callout; pub use acp_onboarding_modal::*; @@ -14,6 +14,6 @@ pub use burn_mode_tooltip::*; pub use claude_code_onboarding_modal::*; pub use end_trial_upsell::*; pub use hold_for_default::*; +pub use model_selector_components::*; pub use onboarding_modal::*; -pub use unavailable_editing_tooltip::*; pub use usage_callout::*; diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs index 8433904fb3b540c2d78c8634b7a6755303d6e15c..e48a36bd5af3eff578e230195dc2247900977173 100644 --- a/crates/agent_ui/src/ui/acp_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -222,8 +222,8 @@ impl Render for AcpOnboardingModal { acp_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child(illustration) .child( diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index af2a022f147b79a0a299c17dd26c7e9a8b62aeb9..34ca0bb32a82aa23d1b954554ce2dfec436bfe1c 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -106,9 +106,6 @@ impl Render for AgentNotification { .font(ui_font) .border_color(cx.theme().colors().border) .rounded_xl() - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(AgentNotificationEvent::Accepted); - })) .child( h_flex() .items_start() diff --git a/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs index 06980f18977aefe228bb7f09962e69fe2b3a5068..a8f007666d8957a7195fdf36b612b578b16f543c 100644 --- a/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/claude_code_onboarding_modal.rs @@ -230,8 +230,8 @@ impl Render for ClaudeCodeOnboardingModal { claude_code_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child(illustration) .child( diff --git a/crates/agent_ui/src/ui/hold_for_default.rs b/crates/agent_ui/src/ui/hold_for_default.rs index 409e5d59707caa3a6bc62bbf470e33cb150183f5..1972f5de4d38fd5ba47ff91709be6ded302b61ae 100644 --- a/crates/agent_ui/src/ui/hold_for_default.rs +++ b/crates/agent_ui/src/ui/hold_for_default.rs @@ -27,7 +27,7 @@ impl RenderOnce for HoldForDefault { PlatformStyle::platform(), None, Some(TextSize::Default.rems(cx).into()), - true, + false, ))) .child(div().map(|this| { if self.is_default { diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs new file mode 100644 index 0000000000000000000000000000000000000000..de4036b8dec27b735e13a3b4f0de80cfa11111df --- /dev/null +++ b/crates/agent_ui/src/ui/model_selector_components.rs @@ -0,0 +1,249 @@ +use gpui::{Action, ClickEvent, FocusHandle, prelude::*}; +use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use zed_actions::agent::ToggleModelSelector; + +use crate::CycleFavoriteModels; + +enum ModelIcon { + Name(IconName), + Path(SharedString), +} + +#[derive(IntoElement)] +pub struct ModelSelectorHeader { + title: SharedString, + has_border: bool, +} + +impl ModelSelectorHeader { + pub fn new(title: impl Into, has_border: bool) -> Self { + Self { + title: title.into(), + has_border, + } + } +} + +impl RenderOnce for ModelSelectorHeader { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .px_2() + .pb_1() + .when(self.has_border, |this| { + this.mt_1() + .pt_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }) + .child( + Label::new(self.title) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + } +} + +#[derive(IntoElement)] +pub struct ModelSelectorListItem { + index: usize, + title: SharedString, + icon: Option, + is_selected: bool, + is_focused: bool, + is_favorite: bool, + on_toggle_favorite: Option>, +} + +impl ModelSelectorListItem { + pub fn new(index: usize, title: impl Into) -> Self { + Self { + index, + title: title.into(), + icon: None, + is_selected: false, + is_focused: false, + is_favorite: false, + on_toggle_favorite: None, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(ModelIcon::Name(icon)); + self + } + + pub fn icon_path(mut self, path: SharedString) -> Self { + self.icon = Some(ModelIcon::Path(path)); + self + } + + pub fn is_selected(mut self, is_selected: bool) -> Self { + self.is_selected = is_selected; + self + } + + pub fn is_focused(mut self, is_focused: bool) -> Self { + self.is_focused = is_focused; + self + } + + pub fn is_favorite(mut self, is_favorite: bool) -> Self { + self.is_favorite = is_favorite; + self + } + + pub fn on_toggle_favorite( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_toggle_favorite = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for ModelSelectorListItem { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let model_icon_color = if self.is_selected { + Color::Accent + } else { + Color::Muted + }; + + let is_favorite = self.is_favorite; + + ListItem::new(self.index) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(self.is_focused) + .child( + h_flex() + .w_full() + .gap_1p5() + .when_some(self.icon, |this, icon| { + this.child( + match icon { + ModelIcon::Name(icon_name) => Icon::new(icon_name), + ModelIcon::Path(icon_path) => Icon::from_external_svg(icon_path), + } + .color(model_icon_color) + .size(IconSize::Small), + ) + }) + .child(Label::new(self.title).truncate()), + ) + .end_slot(div().pr_2().when(self.is_selected, |this| { + this.child(Icon::new(IconName::Check).color(Color::Accent)) + })) + .end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, { + |this, handle_click| { + let (icon, color, tooltip) = if is_favorite { + (IconName::StarFilled, Color::Accent, "Unfavorite Model") + } else { + (IconName::Star, Color::Default, "Favorite Model") + }; + this.child( + IconButton::new(("toggle-favorite", self.index), icon) + .layer(ElevationIndex::ElevatedSurface) + .icon_color(color) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text(tooltip)) + .on_click(move |event, window, cx| (handle_click)(event, window, cx)), + ) + } + })) + } +} + +#[derive(IntoElement)] +pub struct ModelSelectorFooter { + action: Box, + focus_handle: FocusHandle, +} + +impl ModelSelectorFooter { + pub fn new(action: Box, focus_handle: FocusHandle) -> Self { + Self { + action, + focus_handle, + } + } +} + +impl RenderOnce for ModelSelectorFooter { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let action = self.action; + let focus_handle = self.focus_handle; + + h_flex() + .w_full() + .p_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("configure", "Configure") + .full_width() + .style(ButtonStyle::Outlined) + .key_binding( + KeyBinding::for_action_in(action.as_ref(), &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(move |_, window, cx| { + window.dispatch_action(action.boxed_clone(), cx); + }), + ) + } +} + +#[derive(IntoElement)] +pub struct ModelSelectorTooltip { + focus_handle: FocusHandle, + show_cycle_row: bool, +} + +impl ModelSelectorTooltip { + pub fn new(focus_handle: FocusHandle) -> Self { + Self { + focus_handle, + show_cycle_row: true, + } + } + + pub fn show_cycle_row(mut self, show: bool) -> Self { + self.show_cycle_row = show; + self + } +} + +impl RenderOnce for ModelSelectorTooltip { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Change Model")) + .child(KeyBinding::for_action_in( + &ToggleModelSelector, + &self.focus_handle, + cx, + )), + ) + .when(self.show_cycle_row, |this| { + this.child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child(Label::new("Cycle Favorited Models")) + .child(KeyBinding::for_action_in( + &CycleFavoriteModels, + &self.focus_handle, + cx, + )), + ) + }) + } +} diff --git a/crates/agent_ui/src/ui/onboarding_modal.rs b/crates/agent_ui/src/ui/onboarding_modal.rs index ad404afa784974631f914e6fece2de6b6c7d6a46..b8ec2b00657efca29fede32a5cc23b669ede66e7 100644 --- a/crates/agent_ui/src/ui/onboarding_modal.rs +++ b/crates/agent_ui/src/ui/onboarding_modal.rs @@ -83,8 +83,8 @@ impl Render for AgentOnboardingModal { agent_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div() diff --git a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs b/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs deleted file mode 100644 index 2993fb89a989619ecfe3d79b06d82a2a6f71fc31..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/ui/unavailable_editing_tooltip.rs +++ /dev/null @@ -1,29 +0,0 @@ -use gpui::{Context, IntoElement, Render, Window}; -use ui::{prelude::*, tooltip_container}; - -pub struct UnavailableEditingTooltip { - agent_name: SharedString, -} - -impl UnavailableEditingTooltip { - pub fn new(agent_name: SharedString) -> Self { - Self { agent_name } - } -} - -impl Render for UnavailableEditingTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, |this, _| { - this.child(Label::new("Unavailable Editing")).child( - div().max_w_64().child( - Label::new(format!( - "Editing previous messages is not available for {} yet.", - self.agent_name - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - } -} diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..2b2cf337adf578432d594ce14f2f58e5911c45fb --- /dev/null +++ b/crates/agent_ui_v2/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "agent_ui_v2" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_ui_v2.rs" +doctest = false + +[features] +test-support = ["agent/test-support"] + + +[dependencies] +agent.workspace = true +agent_servers.workspace = true +agent_settings.workspace = true +agent_ui.workspace = true +anyhow.workspace = true +assistant_text_thread.workspace = true +chrono.workspace = true +db.workspace = true +editor.workspace = true +feature_flags.workspace = true +fs.workspace = true +fuzzy.workspace = true +gpui.workspace = true +menu.workspace = true +project.workspace = true +prompt_store.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +text.workspace = true +time.workspace = true +time_format.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +agent = { workspace = true, features = ["test-support"] } diff --git a/crates/cloud_zeta2_prompt/LICENSE-GPL b/crates/agent_ui_v2/LICENSE-GPL similarity index 100% rename from crates/cloud_zeta2_prompt/LICENSE-GPL rename to crates/agent_ui_v2/LICENSE-GPL diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs new file mode 100644 index 0000000000000000000000000000000000000000..72886f87eca38c630ec29b9b410930f1d3936b50 --- /dev/null +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -0,0 +1,287 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; +use agent_servers::AgentServer; +use agent_settings::AgentSettings; +use agent_ui::acp::AcpThreadView; +use fs::Fs; +use gpui::{ + Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*, +}; +use project::Project; +use prompt_store::PromptStore; +use serde::{Deserialize, Serialize}; +use settings::DockSide; +use settings::Settings as _; +use std::rc::Rc; +use std::sync::Arc; +use ui::{Tab, Tooltip, prelude::*}; +use workspace::{ + Workspace, + dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition}, + utility_pane::UtilityPaneSlot, +}; + +pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0); + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum SerializedHistoryEntryId { + AcpThread(String), + TextThread(String), +} + +impl From for SerializedHistoryEntryId { + fn from(id: HistoryEntryId) -> Self { + match id { + HistoryEntryId::AcpThread(session_id) => { + SerializedHistoryEntryId::AcpThread(session_id.0.to_string()) + } + HistoryEntryId::TextThread(path) => { + SerializedHistoryEntryId::TextThread(path.to_string_lossy().to_string()) + } + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SerializedAgentThreadPane { + pub expanded: bool, + pub width: Option, + pub thread_id: Option, +} + +pub enum AgentsUtilityPaneEvent { + StateChanged, +} + +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} + +struct ActiveThreadView { + view: Entity, + thread_id: HistoryEntryId, + _notify: Subscription, +} + +pub struct AgentThreadPane { + focus_handle: gpui::FocusHandle, + expanded: bool, + width: Option, + thread_view: Option, + workspace: WeakEntity, +} + +impl AgentThreadPane { + pub fn new(workspace: WeakEntity, cx: &mut ui::Context) -> Self { + let focus_handle = cx.focus_handle(); + Self { + focus_handle, + expanded: false, + width: None, + thread_view: None, + workspace, + } + } + + pub fn thread_id(&self) -> Option { + self.thread_view.as_ref().map(|tv| tv.thread_id.clone()) + } + + pub fn serialize(&self) -> SerializedAgentThreadPane { + SerializedAgentThreadPane { + expanded: self.expanded, + width: self.width, + thread_id: self.thread_id().map(SerializedHistoryEntryId::from), + } + } + + pub fn open_thread( + &mut self, + entry: HistoryEntry, + fs: Arc, + workspace: WeakEntity, + project: Entity, + history_store: Entity, + prompt_store: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let thread_id = entry.id(); + + let resume_thread = match &entry { + HistoryEntry::AcpThread(thread) => Some(thread.clone()), + HistoryEntry::TextThread(_) => None, + }; + + let agent: Rc = Rc::new(NativeAgentServer::new(fs, history_store.clone())); + + let thread_view = cx.new(|cx| { + AcpThreadView::new( + agent, + resume_thread, + None, + workspace, + project, + history_store, + prompt_store, + true, + window, + cx, + ) + }); + + let notify = cx.observe(&thread_view, |_, _, cx| { + cx.notify(); + }); + + self.thread_view = Some(ActiveThreadView { + view: thread_view, + thread_id, + _notify: notify, + }); + + cx.notify(); + } + + fn title(&self, cx: &App) -> SharedString { + if let Some(active_thread_view) = &self.thread_view { + let thread_view = active_thread_view.view.read(cx); + if let Some(thread) = thread_view.thread() { + let title = thread.read(cx).title(); + if !title.is_empty() { + return title; + } + } + thread_view.title(cx) + } else { + "Thread".into() + } + } + + fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let position = self.position(window, cx); + let slot = match position { + UtilityPanePosition::Left => UtilityPaneSlot::Left, + UtilityPanePosition::Right => UtilityPaneSlot::Right, + }; + + let workspace = self.workspace.clone(); + let toggle_icon = self.toggle_icon(cx); + let title = self.title(cx); + + let pane_toggle_button = |workspace: WeakEntity| { + IconButton::new("toggle_utility_pane", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) + .on_click(move |_, window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane(slot, window, cx) + }) + .ok(); + }) + }; + + h_flex() + .id("utility-pane-header") + .w_full() + .h(Tab::container_height(cx)) + .px_1p5() + .gap(DynamicSpacing::Base06.rems(cx)) + .when(slot == UtilityPaneSlot::Right, |this| { + this.flex_row_reverse() + }) + .flex_none() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(pane_toggle_button(workspace)) + .child( + h_flex() + .size_full() + .min_w_0() + .gap_1() + .map(|this| { + if slot == UtilityPaneSlot::Right { + this.flex_row_reverse().justify_start() + } else { + this.justify_between() + } + }) + .child(Label::new(title).truncate()) + .child( + IconButton::new("close_btn", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Close Agent Pane")) + .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| { + cx.emit(ClosePane); + this.thread_view = None; + cx.notify() + })), + ), + ) + } +} + +impl Focusable for AgentThreadPane { + fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle { + if let Some(thread_view) = &self.thread_view { + thread_view.view.focus_handle(cx) + } else { + self.focus_handle.clone() + } + } +} + +impl UtilityPane for AgentThreadPane { + fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition { + match AgentSettings::get_global(cx).agents_panel_dock { + DockSide::Left => UtilityPanePosition::Left, + DockSide::Right => UtilityPanePosition::Right, + } + } + + fn toggle_icon(&self, _cx: &App) -> IconName { + IconName::Thread + } + + fn expanded(&self, _cx: &App) -> bool { + self.expanded + } + + fn set_expanded(&mut self, expanded: bool, cx: &mut Context) { + self.expanded = expanded; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } + + fn width(&self, _cx: &App) -> Pixels { + self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH) + } + + fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } +} + +impl Render for AgentThreadPane { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = if let Some(thread_view) = &self.thread_view { + div().size_full().child(thread_view.view.clone()) + } else { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Label::new("Select a thread to view details").size(LabelSize::Default)) + }; + + div() + .size_full() + .flex() + .flex_col() + .child(self.render_header(window, cx)) + .child(content) + } +} diff --git a/crates/agent_ui_v2/src/agent_ui_v2.rs b/crates/agent_ui_v2/src/agent_ui_v2.rs new file mode 100644 index 0000000000000000000000000000000000000000..92a4144e304e9afbdcdde54623a3bbf3c65b8746 --- /dev/null +++ b/crates/agent_ui_v2/src/agent_ui_v2.rs @@ -0,0 +1,4 @@ +mod agent_thread_pane; +mod thread_history; + +pub mod agents_panel; diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..254b8d2999dd3f9ce99c07a20273cbb1ca9cb929 --- /dev/null +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -0,0 +1,437 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore}; +use agent_settings::AgentSettings; +use anyhow::Result; +use assistant_text_thread::TextThreadStore; +use db::kvp::KEY_VALUE_STORE; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use fs::Fs; +use gpui::{ + Action, AsyncWindowContext, Entity, EventEmitter, Focusable, Pixels, Subscription, Task, + WeakEntity, actions, prelude::*, +}; +use project::Project; +use prompt_store::{PromptBuilder, PromptStore}; +use serde::{Deserialize, Serialize}; +use settings::{Settings as _, update_settings_file}; +use std::sync::Arc; +use ui::{App, Context, IconName, IntoElement, ParentElement, Render, Styled, Window}; +use util::ResultExt; +use workspace::{ + Panel, Workspace, + dock::{ClosePane, DockPosition, PanelEvent, UtilityPane}, + utility_pane::{UtilityPaneSlot, utility_slot_for_dock_position}, +}; + +use crate::agent_thread_pane::{ + AgentThreadPane, AgentsUtilityPaneEvent, SerializedAgentThreadPane, SerializedHistoryEntryId, +}; +use crate::thread_history::{AcpThreadHistory, ThreadHistoryEvent}; + +const AGENTS_PANEL_KEY: &str = "agents_panel"; + +#[derive(Serialize, Deserialize, Debug)] +struct SerializedAgentsPanel { + width: Option, + pane: Option, +} + +actions!( + agents, + [ + /// Toggle the visibility of the agents panel. + ToggleAgentsPanel + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| { + workspace.register_action(|workspace, _: &ToggleAgentsPanel, window, cx| { + workspace.toggle_panel_focus::(window, cx); + }); + }) + .detach(); +} + +pub struct AgentsPanel { + focus_handle: gpui::FocusHandle, + workspace: WeakEntity, + project: Entity, + agent_thread_pane: Option>, + history: Entity, + history_store: Entity, + prompt_store: Option>, + fs: Arc, + width: Option, + pending_serialization: Task>, + _subscriptions: Vec, +} + +impl AgentsPanel { + pub fn load( + workspace: WeakEntity, + cx: AsyncWindowContext, + ) -> Task, anyhow::Error>> { + cx.spawn(async move |cx| { + let serialized_panel = cx + .background_spawn(async move { + KEY_VALUE_STORE + .read_kvp(AGENTS_PANEL_KEY) + .ok() + .flatten() + .and_then(|panel| { + serde_json::from_str::(&panel).ok() + }) + }) + .await; + + let (fs, project, prompt_builder) = workspace.update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + let project = workspace.project().clone(); + let prompt_builder = PromptBuilder::load(fs.clone(), false, cx); + (fs, project, prompt_builder) + })?; + + let text_thread_store = workspace + .update(cx, |_, cx| { + TextThreadStore::new( + project.clone(), + prompt_builder.clone(), + Default::default(), + cx, + ) + })? + .await?; + + let prompt_store = workspace + .update(cx, |_, cx| PromptStore::global(cx))? + .await + .log_err(); + + workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + let mut panel = Self::new( + workspace.clone(), + fs, + project, + prompt_store, + text_thread_store, + window, + cx, + ); + if let Some(serialized_panel) = serialized_panel { + panel.width = serialized_panel.width; + if let Some(serialized_pane) = serialized_panel.pane { + panel.restore_utility_pane(serialized_pane, window, cx); + } + } + panel + }) + }) + }) + } + + fn new( + workspace: WeakEntity, + fs: Arc, + project: Entity, + prompt_store: Option>, + text_thread_store: Entity, + window: &mut Window, + cx: &mut ui::Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx)); + + let this = cx.weak_entity(); + let subscriptions = vec![ + cx.subscribe_in(&history, window, Self::handle_history_event), + cx.on_flags_ready(move |_, cx| { + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }), + ]; + + Self { + focus_handle, + workspace, + project, + agent_thread_pane: None, + history, + history_store, + prompt_store, + fs, + width: None, + pending_serialization: Task::ready(None), + _subscriptions: subscriptions, + } + } + + fn restore_utility_pane( + &mut self, + serialized_pane: SerializedAgentThreadPane, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread_id) = &serialized_pane.thread_id else { + return; + }; + + let entry = self + .history_store + .read(cx) + .entries() + .find(|e| match (&e.id(), thread_id) { + ( + HistoryEntryId::AcpThread(session_id), + SerializedHistoryEntryId::AcpThread(id), + ) => session_id.to_string() == *id, + (HistoryEntryId::TextThread(path), SerializedHistoryEntryId::TextThread(id)) => { + path.to_string_lossy() == *id + } + _ => false, + }); + + if let Some(entry) = entry { + self.open_thread( + entry, + serialized_pane.expanded, + serialized_pane.width, + window, + cx, + ); + } + } + + fn handle_utility_pane_event( + &mut self, + _utility_pane: Entity, + event: &AgentsUtilityPaneEvent, + cx: &mut Context, + ) { + match event { + AgentsUtilityPaneEvent::StateChanged => { + self.serialize(cx); + cx.notify(); + } + } + } + + fn handle_close_pane_event( + &mut self, + _utility_pane: Entity, + _event: &ClosePane, + cx: &mut Context, + ) { + self.agent_thread_pane = None; + self.serialize(cx); + cx.notify(); + } + + fn handle_history_event( + &mut self, + _history: &Entity, + event: &ThreadHistoryEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + ThreadHistoryEvent::Open(entry) => { + self.open_thread(entry.clone(), true, None, window, cx); + } + } + } + + fn open_thread( + &mut self, + entry: HistoryEntry, + expanded: bool, + width: Option, + window: &mut Window, + cx: &mut Context, + ) { + let entry_id = entry.id(); + + if let Some(existing_pane) = &self.agent_thread_pane { + if existing_pane.read(cx).thread_id() == Some(entry_id) { + existing_pane.update(cx, |pane, cx| { + pane.set_expanded(true, cx); + }); + return; + } + } + + let fs = self.fs.clone(); + let workspace = self.workspace.clone(); + let project = self.project.clone(); + let history_store = self.history_store.clone(); + let prompt_store = self.prompt_store.clone(); + + let agent_thread_pane = cx.new(|cx| { + let mut pane = AgentThreadPane::new(workspace.clone(), cx); + pane.open_thread( + entry, + fs, + workspace.clone(), + project, + history_store, + prompt_store, + window, + cx, + ); + if let Some(width) = width { + pane.set_width(Some(width), cx); + } + pane.set_expanded(expanded, cx); + pane + }); + + let state_subscription = cx.subscribe(&agent_thread_pane, Self::handle_utility_pane_event); + let close_subscription = cx.subscribe(&agent_thread_pane, Self::handle_close_pane_event); + + self._subscriptions.push(state_subscription); + self._subscriptions.push(close_subscription); + + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, agent_thread_pane.clone(), cx); + }); + } + + self.agent_thread_pane = Some(agent_thread_pane); + self.serialize(cx); + cx.notify(); + } + + fn utility_slot(&self, window: &Window, cx: &App) -> UtilityPaneSlot { + let position = self.position(window, cx); + utility_slot_for_dock_position(position) + } + + fn re_register_utility_pane(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(pane) = &self.agent_thread_pane { + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + let pane = pane.clone(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, pane, cx); + }); + } + } + } + + fn serialize(&mut self, cx: &mut Context) { + let width = self.width; + let pane = self + .agent_thread_pane + .as_ref() + .map(|pane| pane.read(cx).serialize()); + + self.pending_serialization = cx.background_spawn(async move { + KEY_VALUE_STORE + .write_kvp( + AGENTS_PANEL_KEY.into(), + serde_json::to_string(&SerializedAgentsPanel { width, pane }).unwrap(), + ) + .await + .log_err() + }); + } +} + +impl EventEmitter for AgentsPanel {} + +impl Focusable for AgentsPanel { + fn focus_handle(&self, _cx: &ui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Panel for AgentsPanel { + fn persistent_name() -> &'static str { + "AgentsPanel" + } + + fn panel_key() -> &'static str { + AGENTS_PANEL_KEY + } + + fn position(&self, _window: &Window, cx: &App) -> DockPosition { + match AgentSettings::get_global(cx).agents_panel_dock { + settings::DockSide::Left => DockPosition::Left, + settings::DockSide::Right => DockPosition::Right, + } + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + position != DockPosition::Bottom + } + + fn set_position( + &mut self, + position: DockPosition, + window: &mut Window, + cx: &mut Context, + ) { + update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.agent.get_or_insert_default().agents_panel_dock = Some(match position { + DockPosition::Left => settings::DockSide::Left, + DockPosition::Right | DockPosition::Bottom => settings::DockSide::Right, + }); + }); + self.re_register_utility_pane(window, cx); + } + + fn size(&self, window: &Window, cx: &App) -> Pixels { + let settings = AgentSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or(settings.default_width) + } + DockPosition::Bottom => self.width.unwrap_or(settings.default_height), + } + } + + fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => {} + } + self.serialize(cx); + cx.notify(); + } + + fn icon(&self, _window: &Window, cx: &App) -> Option { + (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAgentTwo) + } + + fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { + Some("Agents Panel") + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleAgentsPanel) + } + + fn activation_priority(&self) -> u32 { + 4 + } + + fn enabled(&self, cx: &App) -> bool { + AgentSettings::get_global(cx).enabled(cx) && cx.has_flag::() + } +} + +impl Render for AgentsPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div().size_full().child(self.history.clone()) + } +} diff --git a/crates/agent_ui_v2/src/thread_history.rs b/crates/agent_ui_v2/src/thread_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e379a24fc3047e6a686046ea16a94ef25efb52c --- /dev/null +++ b/crates/agent_ui_v2/src/thread_history.rs @@ -0,0 +1,753 @@ +use agent::{HistoryEntry, HistoryStore}; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, Window, actions, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, + prelude::*, +}; + +actions!( + agents, + [ + /// Removes all thread history. + RemoveHistory, + /// Removes the currently selected thread. + RemoveSelectedThread, + ] +); + +pub struct AcpThreadHistory { + pub(crate) history_store: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + confirming_delete_history: bool, + _update_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: HistoryEntry, + format: EntryTimeFormat, + }, + SearchResult { + entry: HistoryEntry, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&HistoryEntry> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +#[allow(dead_code)] +pub enum ThreadHistoryEvent { + Open(HistoryEntry), +} + +impl EventEmitter for AcpThreadHistory {} + +impl AcpThreadHistory { + pub fn new( + history_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_store_subscription = cx.observe(&history_store, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history_store, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + confirming_delete_history: false, + _subscriptions: vec![search_editor_subscription, history_store_subscription], + _update_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self + .history_store + .update(cx, |store, _| store.entries().collect()); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._update_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + let history_entry_id = history_entry.id(); + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.id() == history_entry_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators(&self, entries: Vec, cx: &App) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_date = entry + .updated_at() + .with_timezone(&Local) + .naive_local() + .date(); + let entry_bucket = TimeBucket::from_dates(today, entry_date); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, entry.title())); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&HistoryEntry> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.is_empty() { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + pub fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + pub fn select_next( + &mut self, + _: &menu::SelectNext, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + + let task = match entry { + HistoryEntry::AcpThread(thread) => self + .history_store + .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), + HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| { + this.delete_text_thread(text_thread.path.clone(), cx) + }), + }; + task.detach_and_log_err(cx); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.history_store.update(cx, |store, cx| { + store.delete_threads(cx).detach_and_log_err(cx) + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &HistoryEntry, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let timestamp = entry.updated_at().timestamp(); + + let display_text = match format { + EntryTimeFormat::DateAndTime => { + let entry_time = entry.updated_at(); + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + let days = duration.num_days(); + + format!("{}d", days) + } + EntryTimeFormat::TimeOnly => format.format_timestamp(timestamp, self.local_timezone), + }; + + let title = entry.title().clone(); + let full_date = + EntryTimeFormat::DateAndTime.format_timestamp(timestamp, self.local_timezone); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(entry.title(), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(display_text) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) + }) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + + cx.notify(); + })) + .end_slot::(if hovered { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for AcpThreadHistory { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for AcpThreadHistory { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history_store.read(cx).is_empty(cx); + + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(&self.scroll_handle) + .flex_grow(), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } + }) + .when(!has_no_history, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); + + let date = today; + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); + + let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); + + let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + // All: not in this week or last week + let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); + + // Test year boundary cases + let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + + let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); + assert_eq!( + TimeBucket::from_dates(new_year, date), + TimeBucket::Yesterday + ); + + let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); + assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); + } +} diff --git a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs index fadc4222ae44f3dbad862fd9479b89321dbd3016..47197ec2331b97dd4d7561d9f14c91c7f91c9fa0 100644 --- a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs +++ b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs @@ -1,9 +1,9 @@ use gpui::{Action, IntoElement, ParentElement, RenderOnce, point}; -use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; +use language_model::{IconOrSvg, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; use ui::{Divider, List, ListBulletItem, prelude::*}; pub struct ApiKeysWithProviders { - configured_providers: Vec<(IconName, SharedString)>, + configured_providers: Vec<(IconOrSvg, SharedString)>, } impl ApiKeysWithProviders { @@ -13,7 +13,8 @@ impl ApiKeysWithProviders { |this: &mut Self, _registry, event: &language_model::Event, cx| match event { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) - | language_model::Event::RemovedProvider(_) => { + | language_model::Event::RemovedProvider(_) + | language_model::Event::ProvidersChanged => { this.configured_providers = Self::compute_configured_providers(cx) } _ => {} @@ -26,9 +27,9 @@ impl ApiKeysWithProviders { } } - fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> { + fn compute_configured_providers(cx: &App) -> Vec<(IconOrSvg, SharedString)> { LanguageModelRegistry::read_global(cx) - .providers() + .visible_providers() .iter() .filter(|provider| { provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID @@ -47,7 +48,14 @@ impl Render for ApiKeysWithProviders { .map(|(icon, name)| { h_flex() .gap_1p5() - .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) + .child( + match icon { + IconOrSvg::Icon(icon_name) => Icon::new(icon_name), + IconOrSvg::Svg(icon_path) => Icon::from_external_svg(icon_path), + } + .size(IconSize::XSmall) + .color(Color::Muted), + ) .child(Label::new(name)) }); div() diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs index 3c8ffc1663e0660829698b5449a006de5b3c6009..c2756927136449d649996ec3b4b87471114aca38 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -11,7 +11,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding}; pub struct AgentPanelOnboarding { user_store: Entity, client: Arc, - configured_providers: Vec<(IconName, SharedString)>, + has_configured_providers: bool, continue_with_zed_ai: Arc, } @@ -27,8 +27,9 @@ impl AgentPanelOnboarding { |this: &mut Self, _registry, event: &language_model::Event, cx| match event { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) - | language_model::Event::RemovedProvider(_) => { - this.configured_providers = Self::compute_available_providers(cx) + | language_model::Event::RemovedProvider(_) + | language_model::Event::ProvidersChanged => { + this.has_configured_providers = Self::has_configured_providers(cx) } _ => {} }, @@ -38,20 +39,16 @@ impl AgentPanelOnboarding { Self { user_store, client, - configured_providers: Self::compute_available_providers(cx), + has_configured_providers: Self::has_configured_providers(cx), continue_with_zed_ai: Arc::new(continue_with_zed_ai), } } - fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> { + fn has_configured_providers(cx: &App) -> bool { LanguageModelRegistry::read_global(cx) - .providers() + .visible_providers() .iter() - .filter(|provider| { - provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID - }) - .map(|provider| (provider.icon(), provider.name().0)) - .collect() + .any(|provider| provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID) } } @@ -81,7 +78,7 @@ impl Render for AgentPanelOnboarding { }), ) .map(|this| { - if enrolled_in_trial || is_pro_user || !self.configured_providers.is_empty() { + if enrolled_in_trial || is_pro_user || self.has_configured_providers { this } else { this.child(ApiKeysWithoutProviders::new()) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 041401418c427251a944fc39bb8ac83a0e22bc13..f0dde3eedea657ea2d2ebe9ede457e329bd8b9a5 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -12,6 +12,8 @@ pub use settings::{AnthropicAvailableModel as AvailableModel, ModelMode}; use strum::{EnumIter, EnumString}; use thiserror::Error; +pub mod batches; + pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -427,10 +429,24 @@ impl Model { let mut headers = vec![]; match self { + Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeSonnet4_5 + | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5Thinking + | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5Thinking => { + // Fine-grained tool streaming for newer models + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); + } Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => { // Try beta token-efficient tool use (supported in Claude 3.7 Sonnet only) // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/token-efficient-tool-use headers.push("token-efficient-tools-2025-02-19".to_string()); + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); } Self::Custom { extra_beta_headers, .. @@ -465,6 +481,7 @@ impl Model { } } +/// Generate completion with streaming. pub async fn stream_completion( client: &dyn HttpClient, api_url: &str, @@ -477,6 +494,101 @@ pub async fn stream_completion( .map(|output| output.0) } +/// Generate completion without streaming. +pub async fn non_streaming_completion( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: Request, + beta_headers: Option, +) -> Result { + let (mut response, rate_limits) = + send_request(client, api_url, api_key, &request, beta_headers).await?; + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(handle_error_response(response, rate_limits).await) + } +} + +async fn send_request( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: impl Serialize, + beta_headers: Option, +) -> Result<(http::Response, RateLimitInfo), AnthropicError> { + let uri = format!("{api_url}/v1/messages"); + + let mut request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()) + .header("Content-Type", "application/json"); + + if let Some(beta_headers) = beta_headers { + request_builder = request_builder.header("Anthropic-Beta", beta_headers); + } + + let serialized_request = + serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; + let request = request_builder + .body(AsyncBody::from(serialized_request)) + .map_err(AnthropicError::BuildRequestBody)?; + + let response = client + .send(request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + Ok((response, rate_limits)) +} + +async fn handle_error_response( + mut response: http::Response, + rate_limits: RateLimitInfo, +) -> AnthropicError { + if response.status().as_u16() == 529 { + return AnthropicError::ServerOverloaded { + retry_after: rate_limits.retry_after, + }; + } + + if let Some(retry_after) = rate_limits.retry_after { + return AnthropicError::RateLimit { retry_after }; + } + + let mut body = String::new(); + let read_result = response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse); + + if let Err(err) = read_result { + return err; + } + + match serde_json::from_str::(&body) { + Ok(Event::Error { error }) => AnthropicError::ApiError(error), + Ok(_) | Err(_) => AnthropicError::HttpResponseError { + status_code: response.status(), + message: body, + }, + } +} + /// An individual rate limit. #[derive(Debug)] pub struct RateLimit { @@ -580,30 +692,10 @@ pub async fn stream_completion_with_rate_limit_info( base: request, stream: true, }; - let uri = format!("{api_url}/v1/messages"); - let mut request_builder = HttpRequest::builder() - .method(Method::POST) - .uri(uri) - .header("Anthropic-Version", "2023-06-01") - .header("X-Api-Key", api_key.trim()) - .header("Content-Type", "application/json"); - - if let Some(beta_headers) = beta_headers { - request_builder = request_builder.header("Anthropic-Beta", beta_headers); - } + let (response, rate_limits) = + send_request(client, api_url, api_key, &request, beta_headers).await?; - let serialized_request = - serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; - let request = request_builder - .body(AsyncBody::from(serialized_request)) - .map_err(AnthropicError::BuildRequestBody)?; - - let mut response = client - .send(request) - .await - .map_err(AnthropicError::HttpSend)?; - let rate_limits = RateLimitInfo::from_headers(response.headers()); if response.status().is_success() { let reader = BufReader::new(response.into_body()); let stream = reader @@ -622,27 +714,8 @@ pub async fn stream_completion_with_rate_limit_info( }) .boxed(); Ok((stream, Some(rate_limits))) - } else if response.status().as_u16() == 529 { - Err(AnthropicError::ServerOverloaded { - retry_after: rate_limits.retry_after, - }) - } else if let Some(retry_after) = rate_limits.retry_after { - Err(AnthropicError::RateLimit { retry_after }) } else { - let mut body = String::new(); - response - .body_mut() - .read_to_string(&mut body) - .await - .map_err(AnthropicError::ReadResponse)?; - - match serde_json::from_str::(&body) { - Ok(Event::Error { error }) => Err(AnthropicError::ApiError(error)), - Ok(_) | Err(_) => Err(AnthropicError::HttpResponseError { - status_code: response.status(), - message: body, - }), - } + Err(handle_error_response(response, rate_limits).await) } } @@ -979,6 +1052,71 @@ pub fn parse_prompt_too_long(message: &str) -> Option { .ok() } +/// Request body for the token counting API. +/// Similar to `Request` but without `max_tokens` since it's not needed for counting. +#[derive(Debug, Serialize)] +pub struct CountTokensRequest { + pub model: String, + pub messages: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, +} + +/// Response from the token counting API. +#[derive(Debug, Deserialize)] +pub struct CountTokensResponse { + pub input_tokens: u64, +} + +/// Count the number of tokens in a message without creating it. +pub async fn count_tokens( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: CountTokensRequest, +) -> Result { + let uri = format!("{api_url}/v1/messages/count_tokens"); + + let request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()) + .header("Content-Type", "application/json"); + + let serialized_request = + serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; + let http_request = request_builder + .body(AsyncBody::from(serialized_request)) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(handle_error_response(response, rate_limits).await) + } +} + #[test] fn test_match_window_exceeded() { let error = ApiError { diff --git a/crates/anthropic/src/batches.rs b/crates/anthropic/src/batches.rs new file mode 100644 index 0000000000000000000000000000000000000000..5fb594348d45c84e8c246c2611f7cde3aa77a18d --- /dev/null +++ b/crates/anthropic/src/batches.rs @@ -0,0 +1,190 @@ +use anyhow::Result; +use futures::AsyncReadExt; +use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; +use serde::{Deserialize, Serialize}; + +use crate::{AnthropicError, ApiError, RateLimitInfo, Request, Response}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct BatchRequest { + pub custom_id: String, + pub params: Request, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateBatchRequest { + pub requests: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MessageBatchRequestCounts { + pub processing: u64, + pub succeeded: u64, + pub errored: u64, + pub canceled: u64, + pub expired: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MessageBatch { + pub id: String, + #[serde(rename = "type")] + pub batch_type: String, + pub processing_status: String, + pub request_counts: MessageBatchRequestCounts, + pub ended_at: Option, + pub created_at: String, + pub expires_at: String, + pub archived_at: Option, + pub cancel_initiated_at: Option, + pub results_url: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum BatchResult { + #[serde(rename = "succeeded")] + Succeeded { message: Response }, + #[serde(rename = "errored")] + Errored { error: ApiError }, + #[serde(rename = "canceled")] + Canceled, + #[serde(rename = "expired")] + Expired, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BatchIndividualResponse { + pub custom_id: String, + pub result: BatchResult, +} + +pub async fn create_batch( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: CreateBatchRequest, +) -> Result { + let uri = format!("{api_url}/v1/messages/batches"); + + let request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()) + .header("Content-Type", "application/json"); + + let serialized_request = + serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; + let http_request = request_builder + .body(AsyncBody::from(serialized_request)) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(crate::handle_error_response(response, rate_limits).await) + } +} + +pub async fn retrieve_batch( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + message_batch_id: &str, +) -> Result { + let uri = format!("{api_url}/v1/messages/batches/{message_batch_id}"); + + let request_builder = HttpRequest::builder() + .method(Method::GET) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()); + + let http_request = request_builder + .body(AsyncBody::default()) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) + } else { + Err(crate::handle_error_response(response, rate_limits).await) + } +} + +pub async fn retrieve_batch_results( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + message_batch_id: &str, +) -> Result, AnthropicError> { + let uri = format!("{api_url}/v1/messages/batches/{message_batch_id}/results"); + + let request_builder = HttpRequest::builder() + .method(Method::GET) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()); + + let http_request = request_builder + .body(AsyncBody::default()) + .map_err(AnthropicError::BuildRequestBody)?; + + let mut response = client + .send(http_request) + .await + .map_err(AnthropicError::HttpSend)?; + + let rate_limits = RateLimitInfo::from_headers(response.headers()); + + if response.status().is_success() { + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(AnthropicError::ReadResponse)?; + + let mut results = Vec::new(); + for line in body.lines() { + if line.trim().is_empty() { + continue; + } + let result: BatchIndividualResponse = + serde_json::from_str(line).map_err(AnthropicError::DeserializeResponse)?; + results.push(result); + } + + Ok(results) + } else { + Err(crate::handle_error_response(response, rate_limits).await) + } +} diff --git a/crates/assistant_slash_commands/Cargo.toml b/crates/assistant_slash_commands/Cargo.toml index 85dd92501f93fb79ba1d3f70b3a06f1077356cfa..b2a70449f449f73c7d0017c5d2ba3707e271559a 100644 --- a/crates/assistant_slash_commands/Cargo.toml +++ b/crates/assistant_slash_commands/Cargo.toml @@ -22,7 +22,6 @@ feature_flags.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true -globset.workspace = true gpui.workspace = true html_to_markdown.workspace = true http_client.workspace = true diff --git a/crates/assistant_slash_commands/src/file_command.rs b/crates/assistant_slash_commands/src/file_command.rs index a17e198ed300f00f70d35149cbe0286af3a65a57..ae4e8363b40d520b9ea33e5cba5ffa68d783ab04 100644 --- a/crates/assistant_slash_commands/src/file_command.rs +++ b/crates/assistant_slash_commands/src/file_command.rs @@ -226,10 +226,10 @@ fn collect_files( let Ok(matchers) = glob_inputs .iter() .map(|glob_input| { - custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()]) + util::paths::PathMatcher::new(&[glob_input.to_owned()], project.read(cx).path_style(cx)) .with_context(|| format!("invalid path {glob_input}")) }) - .collect::>>() + .collect::>>() else { return futures::stream::once(async { anyhow::bail!("invalid path"); @@ -250,6 +250,7 @@ fn collect_files( let worktree_id = snapshot.id(); let path_style = snapshot.path_style(); let mut directory_stack: Vec> = Vec::new(); + let mut folded_directory_path: Option> = None; let mut folded_directory_names: Arc = RelPath::empty().into(); let mut is_top_level_directory = true; @@ -277,6 +278,16 @@ fn collect_files( )))?; } + if let Some(folded_path) = &folded_directory_path { + if !entry.path.starts_with(folded_path) { + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; + if directory_stack.is_empty() { + is_top_level_directory = true; + } + } + } + let filename = entry.path.file_name().unwrap_or_default().to_string(); if entry.is_dir() { @@ -292,13 +303,17 @@ fn collect_files( folded_directory_names = folded_directory_names.join(RelPath::unix(&filename).unwrap()); } + folded_directory_path = Some(entry.path.clone()); continue; } } else { // Skip empty directories folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; continue; } + + // Render the directory (either folded or normal) if folded_directory_names.is_empty() { let label = if is_top_level_directory { is_top_level_directory = false; @@ -334,6 +349,8 @@ fn collect_files( }, )))?; directory_stack.push(entry.path.clone()); + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; } events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { @@ -447,87 +464,6 @@ pub fn build_entry_output_section( } } -/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix -/// check. Only subpaths pass the prefix check, rather than any prefix. -mod custom_path_matcher { - use globset::{Glob, GlobSet, GlobSetBuilder}; - use std::fmt::Debug as _; - use util::{paths::SanitizedPath, rel_path::RelPath}; - - #[derive(Clone, Debug, Default)] - pub struct PathMatcher { - sources: Vec, - sources_with_trailing_slash: Vec, - glob: GlobSet, - } - - impl std::fmt::Display for PathMatcher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.sources.fmt(f) - } - } - - impl PartialEq for PathMatcher { - fn eq(&self, other: &Self) -> bool { - self.sources.eq(&other.sources) - } - } - - impl Eq for PathMatcher {} - - impl PathMatcher { - pub fn new(globs: &[String]) -> Result { - let globs = globs - .iter() - .map(|glob| Glob::new(&SanitizedPath::new(glob).to_string())) - .collect::, _>>()?; - let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect(); - let sources_with_trailing_slash = globs - .iter() - .map(|glob| glob.glob().to_string() + "/") - .collect(); - let mut glob_builder = GlobSetBuilder::new(); - for single_glob in globs { - glob_builder.add(single_glob); - } - let glob = glob_builder.build()?; - Ok(PathMatcher { - glob, - sources, - sources_with_trailing_slash, - }) - } - - pub fn is_match(&self, other: &RelPath) -> bool { - self.sources - .iter() - .zip(self.sources_with_trailing_slash.iter()) - .any(|(source, with_slash)| { - let as_bytes = other.as_unix_str().as_bytes(); - let with_slash = if source.ends_with('/') { - source.as_bytes() - } else { - with_slash.as_bytes() - }; - - as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes()) - }) - || self.glob.is_match(other.as_std_path()) - || self.check_with_end_separator(other) - } - - fn check_with_end_separator(&self, path: &RelPath) -> bool { - let path_str = path.as_unix_str(); - let separator = "/"; - if path_str.ends_with(separator) { - false - } else { - self.glob.is_match(path_str.to_string() + separator) - } - } - } -} - pub fn append_buffer_to_output( buffer: &BufferSnapshot, path: Option<&str>, diff --git a/crates/assistant_text_thread/Cargo.toml b/crates/assistant_text_thread/Cargo.toml index 7c8fcca3bfa81f6f2de570fa68ecc795cb81b257..5ad429758ea1785ecb4fcecb2f3ad83a71afda0d 100644 --- a/crates/assistant_text_thread/Cargo.toml +++ b/crates/assistant_text_thread/Cargo.toml @@ -46,7 +46,7 @@ serde_json.workspace = true settings.workspace = true smallvec.workspace = true smol.workspace = true -telemetry_events.workspace = true +telemetry.workspace = true text.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/assistant_text_thread/src/assistant_text_thread_tests.rs b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs index 0743641bf5ce33850f28987d834b2e79771cff6f..7232a03c212a9dfc4bfe9bcce4a78667d9210ad8 100644 --- a/crates/assistant_text_thread/src/assistant_text_thread_tests.rs +++ b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs @@ -50,7 +50,6 @@ fn test_inserting_and_removing_messages(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -189,7 +188,6 @@ fn test_message_splitting(cx: &mut App) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -294,7 +292,6 @@ fn test_messages_for_offsets(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -405,7 +402,6 @@ async fn test_slash_commands(cx: &mut TestAppContext) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -677,7 +673,6 @@ async fn test_serialization(cx: &mut TestAppContext) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -724,7 +719,6 @@ async fn test_serialization(cx: &mut TestAppContext) { prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); @@ -780,7 +774,6 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); @@ -1041,7 +1034,6 @@ fn test_mark_cache_anchors(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -1368,7 +1360,6 @@ fn setup_context_editor_with_fake_model( TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, diff --git a/crates/assistant_text_thread/src/text_thread.rs b/crates/assistant_text_thread/src/text_thread.rs index 7f24c8f665f8d34aed199562dce1131797f13c9d..5ec72eb0814f9ac09aba36f52d6f011af5b47249 100644 --- a/crates/assistant_text_thread/src/text_thread.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -5,7 +5,7 @@ use assistant_slash_command::{ SlashCommandResult, SlashCommandWorkingSet, }; use assistant_slash_commands::FileCommandMetadata; -use client::{self, ModelRequestUsage, RequestUsage, proto, telemetry::Telemetry}; +use client::{self, ModelRequestUsage, RequestUsage, proto}; use clock::ReplicaId; use cloud_llm_client::{CompletionIntent, UsageLimit}; use collections::{HashMap, HashSet}; @@ -14,15 +14,16 @@ use fs::{Fs, RenameOptions}; use futures::{FutureExt, StreamExt, future::Shared}; use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, RenderImage, SharedString, Subscription, - Task, + Task, WeakEntity, }; use itertools::Itertools as _; use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset}; use language_model::{ - LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionEvent, - LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + AnthropicCompletionType, AnthropicEventData, AnthropicEventType, LanguageModel, + LanguageModelCacheConfiguration, LanguageModelCompletionEvent, LanguageModelImage, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolUseId, MessageContent, PaymentRequiredError, Role, StopReason, - report_assistant_event, + report_anthropic_event, }; use open_ai::Model as OpenAiModel; use paths::text_threads_dir; @@ -40,7 +41,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use text::{BufferSnapshot, ToPoint}; use ui::IconName; use util::{ResultExt, TryFutureExt, post_inc}; @@ -686,9 +687,8 @@ pub struct TextThread { pending_cache_warming_task: Task>, path: Option>, _subscriptions: Vec, - telemetry: Option>, language_registry: Arc, - project: Option>, + project: Option>, prompt_builder: Arc, completion_mode: agent_settings::CompletionMode, } @@ -708,8 +708,7 @@ impl EventEmitter for TextThread {} impl TextThread { pub fn local( language_registry: Arc, - project: Option>, - telemetry: Option>, + project: Option>, prompt_builder: Arc, slash_commands: Arc, cx: &mut Context, @@ -722,7 +721,6 @@ impl TextThread { prompt_builder, slash_commands, project, - telemetry, cx, ) } @@ -742,8 +740,7 @@ impl TextThread { language_registry: Arc, prompt_builder: Arc, slash_commands: Arc, - project: Option>, - telemetry: Option>, + project: Option>, cx: &mut Context, ) -> Self { let buffer = cx.new(|_cx| { @@ -784,7 +781,6 @@ impl TextThread { completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, path: None, buffer, - telemetry, project, language_registry, slash_commands, @@ -873,8 +869,7 @@ impl TextThread { language_registry: Arc, prompt_builder: Arc, slash_commands: Arc, - project: Option>, - telemetry: Option>, + project: Option>, cx: &mut Context, ) -> Self { let id = saved_context.id.clone().unwrap_or_else(TextThreadId::new); @@ -886,7 +881,6 @@ impl TextThread { prompt_builder, slash_commands, project, - telemetry, cx, ); this.path = Some(path); @@ -1167,10 +1161,6 @@ impl TextThread { self.language_registry.clone() } - pub fn project(&self) -> Option> { - self.project.clone() - } - pub fn prompt_builder(&self) -> Arc { self.prompt_builder.clone() } @@ -2216,24 +2206,26 @@ impl TextThread { .read(cx) .language() .map(|language| language.name()); - report_assistant_event( - AssistantEventData { - conversation_id: Some(this.id.0.clone()), - kind: AssistantKind::Panel, - phase: AssistantPhase::Response, - message_id: None, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - this.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - cx.background_executor(), + + telemetry::event!( + "Assistant Responded", + conversation_id = this.id.0.clone(), + kind = "panel", + phase = "response", + model = model.telemetry_id(), + model_provider = model.provider_id().to_string(), + response_latency, + error_message, + language_name = language_name.as_ref().map(|name| name.to_proto()), ); + report_anthropic_event(&model, AnthropicEventData { + completion_type: AnthropicCompletionType::Panel, + event: AnthropicEventType::Response, + language_name: language_name.map(|name| name.to_proto()), + message_id: None, + }, cx); + if let Ok(stop_reason) = result { match stop_reason { StopReason::ToolUse => {} @@ -2967,7 +2959,7 @@ impl TextThread { } fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut App) { - let Some(project) = &self.project else { + let Some(project) = self.project.as_ref().and_then(|project| project.upgrade()) else { return; }; project.read(cx).user_store().update(cx, |user_store, cx| { diff --git a/crates/assistant_text_thread/src/text_thread_store.rs b/crates/assistant_text_thread/src/text_thread_store.rs index 19c317baf0fa728c77faebc388b5e36008aa39b3..483baa73134334162ea30d269a1f955dd8fe023a 100644 --- a/crates/assistant_text_thread/src/text_thread_store.rs +++ b/crates/assistant_text_thread/src/text_thread_store.rs @@ -4,7 +4,7 @@ use crate::{ }; use anyhow::{Context as _, Result}; use assistant_slash_command::{SlashCommandId, SlashCommandWorkingSet}; -use client::{Client, TypedEnvelope, proto, telemetry::Telemetry}; +use client::{Client, TypedEnvelope, proto}; use clock::ReplicaId; use collections::HashMap; use context_server::ContextServerId; @@ -48,10 +48,9 @@ pub struct TextThreadStore { fs: Arc, languages: Arc, slash_commands: Arc, - telemetry: Arc, _watch_updates: Task>, client: Arc, - project: Entity, + project: WeakEntity, project_is_shared: bool, client_subscription: Option, _project_subscriptions: Vec, @@ -88,7 +87,6 @@ impl TextThreadStore { ) -> Task>> { let fs = project.read(cx).fs().clone(); let languages = project.read(cx).languages().clone(); - let telemetry = project.read(cx).client().telemetry().clone(); cx.spawn(async move |cx| { const CONTEXT_WATCH_DURATION: Duration = Duration::from_millis(100); let (mut events, _) = fs.watch(text_threads_dir(), CONTEXT_WATCH_DURATION).await; @@ -102,7 +100,6 @@ impl TextThreadStore { fs, languages, slash_commands, - telemetry, _watch_updates: cx.spawn(async move |this, cx| { async move { while events.next().await.is_some() { @@ -119,10 +116,10 @@ impl TextThreadStore { ], project_is_shared: false, client: project.read(cx).client(), - project: project.clone(), + project: project.downgrade(), prompt_builder, }; - this.handle_project_shared(project.clone(), cx); + this.handle_project_shared(cx); this.synchronize_contexts(cx); this.register_context_server_handlers(cx); this.reload(cx).detach_and_log_err(cx); @@ -143,10 +140,9 @@ impl TextThreadStore { fs: project.read(cx).fs().clone(), languages: project.read(cx).languages().clone(), slash_commands: Arc::default(), - telemetry: project.read(cx).client().telemetry().clone(), _watch_updates: Task::ready(None), client: project.read(cx).client(), - project, + project: project.downgrade(), project_is_shared: false, client_subscription: None, _project_subscriptions: Default::default(), @@ -180,8 +176,10 @@ impl TextThreadStore { ) -> Result { let context_id = TextThreadId::from_proto(envelope.payload.context_id); let operations = this.update(&mut cx, |this, cx| { + let project = this.project.upgrade().context("project not found")?; + anyhow::ensure!( - !this.project.read(cx).is_via_collab(), + !project.read(cx).is_via_collab(), "only the host contexts can be opened" ); @@ -211,8 +209,9 @@ impl TextThreadStore { mut cx: AsyncApp, ) -> Result { let (context_id, operations) = this.update(&mut cx, |this, cx| { + let project = this.project.upgrade().context("project not found")?; anyhow::ensure!( - !this.project.read(cx).is_via_collab(), + !project.read(cx).is_via_collab(), "can only create contexts as the host" ); @@ -255,8 +254,9 @@ impl TextThreadStore { mut cx: AsyncApp, ) -> Result { this.update(&mut cx, |this, cx| { + let project = this.project.upgrade().context("project not found")?; anyhow::ensure!( - !this.project.read(cx).is_via_collab(), + !project.read(cx).is_via_collab(), "only the host can synchronize contexts" ); @@ -293,8 +293,12 @@ impl TextThreadStore { })? } - fn handle_project_shared(&mut self, _: Entity, cx: &mut Context) { - let is_shared = self.project.read(cx).is_shared(); + fn handle_project_shared(&mut self, cx: &mut Context) { + let Some(project) = self.project.upgrade() else { + return; + }; + + let is_shared = project.read(cx).is_shared(); let was_shared = mem::replace(&mut self.project_is_shared, is_shared); if is_shared == was_shared { return; @@ -309,7 +313,7 @@ impl TextThreadStore { false } }); - let remote_id = self.project.read(cx).remote_id().unwrap(); + let remote_id = project.read(cx).remote_id().unwrap(); self.client_subscription = self .client .subscribe_to_entity(remote_id) @@ -323,13 +327,13 @@ impl TextThreadStore { fn handle_project_event( &mut self, - project: Entity, + _project: Entity, event: &project::Event, cx: &mut Context, ) { match event { project::Event::RemoteIdChanged(_) => { - self.handle_project_shared(project, cx); + self.handle_project_shared(cx); } project::Event::Reshared => { self.advertise_contexts(cx); @@ -371,7 +375,6 @@ impl TextThreadStore { TextThread::local( self.languages.clone(), Some(self.project.clone()), - Some(self.telemetry.clone()), self.prompt_builder.clone(), self.slash_commands.clone(), cx, @@ -382,7 +385,10 @@ impl TextThreadStore { } pub fn create_remote(&mut self, cx: &mut Context) -> Task>> { - let project = self.project.read(cx); + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow::anyhow!("project was dropped"))); + }; + let project = project.read(cx); let Some(project_id) = project.remote_id() else { return Task::ready(Err(anyhow::anyhow!("project was not remote"))); }; @@ -391,7 +397,7 @@ impl TextThreadStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); + let prompt_builder = self.prompt_builder.clone(); let slash_commands = self.slash_commands.clone(); let request = self.client.request(proto::CreateContext { project_id }); @@ -408,7 +414,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -446,7 +451,6 @@ impl TextThreadStore { let fs = self.fs.clone(); let languages = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let load = cx.background_spawn({ let path = path.clone(); async move { @@ -467,7 +471,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -541,7 +544,10 @@ impl TextThreadStore { text_thread_id: TextThreadId, cx: &mut Context, ) -> Task>> { - let project = self.project.read(cx); + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow::anyhow!("project was dropped"))); + }; + let project = project.read(cx); let Some(project_id) = project.remote_id() else { return Task::ready(Err(anyhow::anyhow!("project was not remote"))); }; @@ -554,7 +560,6 @@ impl TextThreadStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let request = self.client.request(proto::OpenContext { project_id, context_id: text_thread_id.to_proto(), @@ -573,7 +578,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -618,7 +622,10 @@ impl TextThreadStore { event: &TextThreadEvent, cx: &mut Context, ) { - let Some(project_id) = self.project.read(cx).remote_id() else { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_id) = project.read(cx).remote_id() else { return; }; @@ -652,12 +659,14 @@ impl TextThreadStore { } fn advertise_contexts(&self, cx: &App) { - let Some(project_id) = self.project.read(cx).remote_id() else { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_id) = project.read(cx).remote_id() else { return; }; - // For now, only the host can advertise their open contexts. - if self.project.read(cx).is_via_collab() { + if project.read(cx).is_via_collab() { return; } @@ -689,7 +698,10 @@ impl TextThreadStore { } fn synchronize_contexts(&mut self, cx: &mut Context) { - let Some(project_id) = self.project.read(cx).remote_id() else { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_id) = project.read(cx).remote_id() else { return; }; @@ -828,7 +840,10 @@ impl TextThreadStore { } fn register_context_server_handlers(&self, cx: &mut Context) { - let context_server_store = self.project.read(cx).context_server_store(); + let Some(project) = self.project.upgrade() else { + return; + }; + let context_server_store = project.read(cx).context_server_store(); cx.subscribe(&context_server_store, Self::handle_context_server_event) .detach(); diff --git a/crates/bedrock/src/bedrock.rs b/crates/bedrock/src/bedrock.rs index ec0b4070906fdfd31195668312b3e7b425cd28ee..744dde38076a5a12c9bc957a75e2435b1b753d96 100644 --- a/crates/bedrock/src/bedrock.rs +++ b/crates/bedrock/src/bedrock.rs @@ -87,7 +87,7 @@ pub async fn stream_completion( Ok(None) => None, Err(err) => Some(( Err(BedrockError::ClientError(anyhow!( - "{:?}", + "{}", aws_sdk_bedrockruntime::error::DisplayErrorContext(err) ))), stream, diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index f3b276a8d2f30e8062931e76608bbc3a302ad734..51e1b29f9ad3cf953605c5c59090785f3ab45eac 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -584,41 +584,100 @@ impl Model { } } - pub fn cross_region_inference_id(&self, region: &str) -> anyhow::Result { + pub fn cross_region_inference_id( + &self, + region: &str, + allow_global: bool, + ) -> anyhow::Result { + // List derived from here: + // https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system + let model_id = self.request_id(); + + let supports_global = matches!( + self, + Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking + | Model::ClaudeHaiku4_5 + | Model::ClaudeSonnet4 + | Model::ClaudeSonnet4Thinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking + ); + let region_group = if region.starts_with("us-gov-") { "us-gov" - } else if region.starts_with("us-") { - "us" + } else if region.starts_with("us-") + || region.starts_with("ca-") + || region.starts_with("sa-") + { + if allow_global && supports_global { + "global" + } else { + "us" + } } else if region.starts_with("eu-") { - "eu" + if allow_global && supports_global { + "global" + } else { + "eu" + } } else if region.starts_with("ap-") || region == "me-central-1" || region == "me-south-1" { - "apac" - } else if region.starts_with("ca-") || region.starts_with("sa-") { - // Canada and South America regions - default to US profiles - "us" + if allow_global && supports_global { + "global" + } else { + "apac" + } } else { anyhow::bail!("Unsupported Region {region}"); }; - let model_id = self.request_id(); + match (self, region_group, region) { + (Model::Custom { .. }, _, _) => Ok(self.request_id().into()), - match (self, region_group) { - // Custom models can't have CRI IDs - (Model::Custom { .. }, _) => Ok(self.request_id().into()), + ( + Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking + | Model::ClaudeHaiku4_5 + | Model::ClaudeSonnet4 + | Model::ClaudeSonnet4Thinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking, + "global", + _, + ) => Ok(format!("{}.{}", region_group, model_id)), - // Models with US Gov only - (Model::Claude3_5Sonnet, "us-gov") | (Model::Claude3Haiku, "us-gov") => { - Ok(format!("{}.{}", region_group, model_id)) - } + ( + Model::Claude3Haiku + | Model::Claude3_5Sonnet + | Model::Claude3_7Sonnet + | Model::Claude3_7SonnetThinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking, + "us-gov", + _, + ) => Ok(format!("{}.{}", region_group, model_id)), - // Available everywhere - (Model::AmazonNovaLite | Model::AmazonNovaMicro | Model::AmazonNovaPro, _) => { - Ok(format!("{}.{}", region_group, model_id)) + ( + Model::ClaudeHaiku4_5 | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking, + "apac", + "ap-southeast-2" | "ap-southeast-4", + ) => Ok(format!("au.{}", model_id)), + + ( + Model::ClaudeHaiku4_5 | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking, + "apac", + "ap-northeast-1" | "ap-northeast-3", + ) => Ok(format!("jp.{}", model_id)), + + (Model::AmazonNovaLite, "us", r) if r.starts_with("ca-") => { + Ok(format!("ca.{}", model_id)) } - // Models in US ( Model::AmazonNovaPremier + | Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro | Model::Claude3_5Haiku | Model::ClaudeHaiku4_5 | Model::Claude3_5Sonnet @@ -655,16 +714,18 @@ impl Model { | Model::PalmyraWriterX4 | Model::PalmyraWriterX5, "us", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Models available in EU ( - Model::Claude3_5Sonnet + Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro + | Model::Claude3_5Sonnet | Model::ClaudeHaiku4_5 | Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking | Model::ClaudeSonnet4 - | Model::ClaudeSonnet4Thinking | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking | Model::Claude3Haiku @@ -673,26 +734,26 @@ impl Model { | Model::MetaLlama323BInstructV1 | Model::MistralPixtralLarge2502V1, "eu", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Models available in APAC ( - Model::Claude3_5Sonnet + Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro + | Model::Claude3_5Sonnet | Model::Claude3_5SonnetV2 | Model::ClaudeHaiku4_5 - | Model::Claude3Haiku - | Model::Claude3Sonnet | Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking | Model::ClaudeSonnet4 - | Model::ClaudeSonnet4Thinking - | Model::ClaudeSonnet4_5 - | Model::ClaudeSonnet4_5Thinking, + | Model::Claude3Haiku + | Model::Claude3Sonnet, "apac", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Any other combination is not supported - _ => Ok(self.request_id().into()), + _ => Ok(model_id.into()), } } } @@ -705,15 +766,15 @@ mod tests { fn test_us_region_inference_ids() -> anyhow::Result<()> { // Test US regions assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("us-east-1")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("us-west-2")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("us-west-2", false)?, "us.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::AmazonNovaPro.cross_region_inference_id("us-east-2")?, + Model::AmazonNovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" ); Ok(()) @@ -723,19 +784,19 @@ mod tests { fn test_eu_region_inference_ids() -> anyhow::Result<()> { // Test European regions assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1")?, + Model::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")?, + Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::Claude3Sonnet.cross_region_inference_id("eu-west-1")?, + Model::Claude3Sonnet.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-3-sonnet-20240229-v1:0" ); assert_eq!( - Model::AmazonNovaMicro.cross_region_inference_id("eu-north-1")?, + Model::AmazonNovaMicro.cross_region_inference_id("eu-north-1", false)?, "eu.amazon.nova-micro-v1:0" ); Ok(()) @@ -745,15 +806,15 @@ mod tests { fn test_apac_region_inference_ids() -> anyhow::Result<()> { // Test Asia-Pacific regions assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1", false)?, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2", false)?, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::AmazonNovaLite.cross_region_inference_id("ap-south-1")?, + Model::AmazonNovaLite.cross_region_inference_id("ap-south-1", false)?, "apac.amazon.nova-lite-v1:0" ); Ok(()) @@ -763,11 +824,11 @@ mod tests { fn test_gov_region_inference_ids() -> anyhow::Result<()> { // Test Government regions assert_eq!( - Model::Claude3_5Sonnet.cross_region_inference_id("us-gov-east-1")?, + Model::Claude3_5Sonnet.cross_region_inference_id("us-gov-east-1", false)?, "us-gov.anthropic.claude-3-5-sonnet-20240620-v1:0" ); assert_eq!( - Model::Claude3Haiku.cross_region_inference_id("us-gov-west-1")?, + Model::Claude3Haiku.cross_region_inference_id("us-gov-west-1", false)?, "us-gov.anthropic.claude-3-haiku-20240307-v1:0" ); Ok(()) @@ -777,15 +838,15 @@ mod tests { fn test_meta_models_inference_ids() -> anyhow::Result<()> { // Test Meta models assert_eq!( - Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1")?, + Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1", false)?, "meta.llama3-70b-instruct-v1:0" ); assert_eq!( - Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1")?, + Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1", false)?, "us.meta.llama3-1-70b-instruct-v1:0" ); assert_eq!( - Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1")?, + Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1", false)?, "eu.meta.llama3-2-1b-instruct-v1:0" ); Ok(()) @@ -796,11 +857,11 @@ mod tests { // Mistral models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::MistralMistralLarge2402V1.cross_region_inference_id("us-east-1")?, + Model::MistralMistralLarge2402V1.cross_region_inference_id("us-east-1", false)?, "mistral.mistral-large-2402-v1:0" ); assert_eq!( - Model::MistralMixtral8x7BInstructV0.cross_region_inference_id("eu-west-1")?, + Model::MistralMixtral8x7BInstructV0.cross_region_inference_id("eu-west-1", false)?, "mistral.mixtral-8x7b-instruct-v0:1" ); Ok(()) @@ -811,11 +872,11 @@ mod tests { // AI21 models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::AI21J2UltraV1.cross_region_inference_id("us-east-1")?, + Model::AI21J2UltraV1.cross_region_inference_id("us-east-1", false)?, "ai21.j2-ultra-v1" ); assert_eq!( - Model::AI21JambaInstructV1.cross_region_inference_id("eu-west-1")?, + Model::AI21JambaInstructV1.cross_region_inference_id("eu-west-1", false)?, "ai21.jamba-instruct-v1:0" ); Ok(()) @@ -826,11 +887,11 @@ mod tests { // Cohere models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::CohereCommandRV1.cross_region_inference_id("us-east-1")?, + Model::CohereCommandRV1.cross_region_inference_id("us-east-1", false)?, "cohere.command-r-v1:0" ); assert_eq!( - Model::CohereCommandTextV14_4k.cross_region_inference_id("ap-southeast-1")?, + Model::CohereCommandTextV14_4k.cross_region_inference_id("ap-southeast-1", false)?, "cohere.command-text-v14:7:4k" ); Ok(()) @@ -850,10 +911,17 @@ mod tests { // Custom model should return its name unchanged assert_eq!( - custom_model.cross_region_inference_id("us-east-1")?, + custom_model.cross_region_inference_id("us-east-1", false)?, "custom.my-model-v1:0" ); + // Test that models without global support fall back to regional when allow_global is true + assert_eq!( + Model::AmazonNovaPro.cross_region_inference_id("us-east-1", true)?, + "us.amazon.nova-pro-v1:0", + "Nova Pro should fall back to regional profile even when allow_global is true" + ); + Ok(()) } @@ -892,3 +960,28 @@ mod tests { ); } } + +#[test] +fn test_global_inference_ids() -> anyhow::Result<()> { + // Test global inference for models that support it when allow_global is true + assert_eq!( + Model::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)?, + "global.anthropic.claude-sonnet-4-5-20250929-v1:0" + ); + assert_eq!( + Model::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?, + "global.anthropic.claude-haiku-4-5-20251001-v1:0" + ); + + // Test that regional prefix is used when allow_global is false + assert_eq!( + Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-sonnet-4-20250514-v1:0" + ); + + Ok(()) +} diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 91f1f4a46f40867c460c9f05acfec090e26e2b95..dcd340b9844656710586b86adeb4704d77ac9876 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -212,6 +212,12 @@ impl BufferDiffSnapshot { self.inner.hunks.is_empty() } + pub fn base_text_string(&self) -> Option { + self.inner + .base_text_exists + .then(|| self.inner.base_text.text()) + } + pub fn secondary_diff(&self) -> Option<&BufferDiffSnapshot> { self.secondary_diff.as_deref() } @@ -1206,6 +1212,34 @@ impl BufferDiff { new_index_text } + pub fn stage_or_unstage_all_hunks( + &mut self, + stage: bool, + buffer: &text::BufferSnapshot, + file_exists: bool, + cx: &mut Context, + ) { + let hunks = self + .snapshot(cx) + .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer) + .collect::>(); + let Some(secondary) = self.secondary_diff.clone() else { + return; + }; + let secondary = secondary.read(cx).inner.clone(); + self.inner + .stage_or_unstage_hunks_impl(&secondary, stage, &hunks, buffer, file_exists, cx); + if let Some((first, last)) = hunks.first().zip(hunks.last()) { + 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; + cx.emit(BufferDiffEvent::DiffChanged { + changed_range: Some(changed_range), + base_text_changed_range: Some(base_text_changed_range), + }); + } + } + pub fn update_diff( &self, buffer: text::BufferSnapshot, @@ -2164,7 +2198,7 @@ mod tests { Point::new(0, 0)..Point::new(10, 0) ); - // Edit does not affect the diff. + // Edit does affects the diff because it recalculates word diffs. buffer.edit_via_marked_text( &" one @@ -2179,9 +2213,15 @@ mod tests { .unindent(), ); let diff_2 = BufferDiffSnapshot::new_sync(buffer.clone(), base_text.clone(), cx); + let (range, base_text_range) = + compare_hunks(&diff_1.inner.hunks, &empty_diff.inner.hunks, &buffer); + assert_eq!( + range.unwrap().to_point(&buffer), + Point::new(4, 0)..Point::new(5, 0), + ); assert_eq!( - (None, None), - compare_hunks(&diff_2.inner.hunks, &diff_1.inner.hunks, &buffer) + base_text_range.unwrap().to_point(diff_2.base_text()), + Point::new(0, 0)..Point::new(0, 0), ); // Edit turns a deletion hunk into a modification. diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index fc15b4e4395ae7aa3100a165d942a6906cf1976d..ccc8c067c25a91aa44c01911be89c21f0ea9367c 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -305,6 +305,7 @@ impl Room { pub(crate) fn leave(&mut self, cx: &mut Context) -> Task> { cx.notify(); + self.emit_video_track_unsubscribed_events(cx); self.leave_internal(cx) } @@ -352,6 +353,14 @@ impl Room { self.maintain_connection.take(); } + fn emit_video_track_unsubscribed_events(&self, cx: &mut Context) { + for participant in self.remote_participants.values() { + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } + } + } + async fn maintain_connection( this: WeakEntity, client: Arc, @@ -882,6 +891,9 @@ impl Room { project_id: project.id, }); } + for sid in participant.video_tracks.keys() { + cx.emit(Event::RemoteVideoTrackUnsubscribed { sid: sid.clone() }); + } false } }); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 7988f001dab37858d36f791fa8a184fe329c4be5..e1a7a1481b56633364cb011f46cd55e616244f2c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -32,7 +32,7 @@ struct Detect; trait InstalledApp { fn zed_version_string(&self) -> String; - fn launch(&self, ipc_url: String) -> anyhow::Result<()>; + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()>; fn run_foreground( &self, ipc_url: String, @@ -61,6 +61,8 @@ Examples: )] struct Args { /// Wait for all of the given paths to be opened/closed before exiting. + /// + /// When opening a directory, waits until the created window is closed. #[arg(short, long)] wait: bool, /// Add files to the currently open workspace @@ -588,7 +590,7 @@ fn main() -> Result<()> { if args.foreground { app.run_foreground(url, user_data_dir.as_deref())?; } else { - app.launch(url)?; + app.launch(url, user_data_dir.as_deref())?; sender.join().unwrap()?; if let Some(handle) = stdin_pipe_handle { handle.join().unwrap()?; @@ -709,14 +711,18 @@ mod linux { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { - let sock_path = paths::data_dir().join(format!( + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { + let data_dir = user_data_dir + .map(PathBuf::from) + .unwrap_or_else(|| paths::data_dir().clone()); + + let sock_path = data_dir.join(format!( "zed-{}.sock", *release_channel::RELEASE_CHANNEL_NAME )); let sock = UnixDatagram::unbound()?; if sock.connect(&sock_path).is_err() { - self.boot_background(ipc_url)?; + self.boot_background(ipc_url, user_data_dir)?; } else { sock.send(ipc_url.as_bytes())?; } @@ -742,7 +748,11 @@ mod linux { } impl App { - fn boot_background(&self, ipc_url: String) -> anyhow::Result<()> { + fn boot_background( + &self, + ipc_url: String, + user_data_dir: Option<&str>, + ) -> anyhow::Result<()> { let path = &self.0; match fork::fork() { @@ -756,8 +766,13 @@ mod linux { if fork::close_fd().is_err() { eprintln!("failed to close_fd: {}", std::io::Error::last_os_error()); } - let error = - exec::execvp(path.clone(), &[path.as_os_str(), &OsString::from(ipc_url)]); + let mut args: Vec = + vec![path.as_os_str().to_owned(), OsString::from(ipc_url)]; + if let Some(dir) = user_data_dir { + args.push(OsString::from("--user-data-dir")); + args.push(OsString::from(dir)); + } + let error = exec::execvp(path.clone(), &args); // if exec succeeded, we never get here. eprintln!("failed to exec {:?}: {}", path, error); process::exit(1) @@ -943,11 +958,14 @@ mod windows { ) } - fn launch(&self, ipc_url: String) -> anyhow::Result<()> { + fn launch(&self, ipc_url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { if check_single_instance() { - std::process::Command::new(self.0.clone()) - .arg(ipc_url) - .spawn()?; + let mut cmd = std::process::Command::new(self.0.clone()); + cmd.arg(ipc_url); + if let Some(dir) = user_data_dir { + cmd.arg("--user-data-dir").arg(dir); + } + cmd.spawn()?; } else { unsafe { let pipe = CreateFileW( @@ -1096,7 +1114,7 @@ mod mac_os { format!("Zed {} – {}", self.version(), self.path().display(),) } - fn launch(&self, url: String) -> anyhow::Result<()> { + fn launch(&self, url: String, user_data_dir: Option<&str>) -> anyhow::Result<()> { match self { Self::App { app_bundle, .. } => { let app_path = app_bundle; @@ -1146,8 +1164,11 @@ mod mac_os { format!("Cloning descriptor for file {subprocess_stdout_file:?}") })?; let mut command = std::process::Command::new(executable); - let command = command - .env(FORCE_CLI_MODE_ENV_VAR_NAME, "") + command.env(FORCE_CLI_MODE_ENV_VAR_NAME, ""); + if let Some(dir) = user_data_dir { + command.arg("--user-data-dir").arg(dir); + } + command .stderr(subprocess_stdout_file) .stdout(subprocess_stdin_file) .arg(url); diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 7149ad4f55feaae5b596a39a3dd460d71cc5daa5..50cf12b977a62d56bf9d4a036165917a5dfff2fc 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -53,7 +53,7 @@ text.workspace = true thiserror.workspace = true time.workspace = true tiny_http.workspace = true -tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io"] } +tokio-socks.workspace = true tokio.workspace = true url.workspace = true util.workspace = true diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 96b15dc9fb13deea3cdc706f1927c4d6f016b57a..801c8c3de8d3f02e3d73809df2c651c6973f231a 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -150,9 +150,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach_and_log_err(cx); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client.clone(); move |_: &SignOut, cx| { if let Some(client) = client.upgrade() { @@ -162,9 +161,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach(); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client; move |_: &Reconnect, cx| { if let Some(client) = client.upgrade() { @@ -1723,28 +1721,68 @@ impl ProtoClient for Client { fn is_via_collab(&self) -> bool { true } + + fn has_wsl_interop(&self) -> bool { + false + } } /// prefix for the zed:// url scheme pub const ZED_URL_SCHEME: &str = "zed"; +/// A parsed Zed link that can be handled internally by the application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ZedLink { + /// Join a channel: `zed.dev/channel/channel-name-123` or `zed://channel/channel-name-123` + Channel { channel_id: u64 }, + /// Open channel notes: `zed.dev/channel/channel-name-123/notes` or with heading `notes#heading` + ChannelNotes { + channel_id: u64, + heading: Option, + }, +} + /// Parses the given link into a Zed link. /// -/// Returns a [`Some`] containing the unprefixed link if the link is a Zed link. -/// Returns [`None`] otherwise. -pub fn parse_zed_link<'a>(link: &'a str, cx: &App) -> Option<&'a str> { +/// Returns a [`Some`] containing the parsed link if the link is a recognized Zed link +/// that should be handled internally by the application. +/// Returns [`None`] for links that should be opened in the browser. +pub fn parse_zed_link(link: &str, cx: &App) -> Option { let server_url = &ClientSettings::get_global(cx).server_url; - if let Some(stripped) = link + let path = link .strip_prefix(server_url) .and_then(|result| result.strip_prefix('/')) - { - return Some(stripped); + .or_else(|| { + link.strip_prefix(ZED_URL_SCHEME) + .and_then(|result| result.strip_prefix("://")) + })?; + + let mut parts = path.split('/'); + + if parts.next() != Some("channel") { + return None; } - if let Some(stripped) = link - .strip_prefix(ZED_URL_SCHEME) - .and_then(|result| result.strip_prefix("://")) - { - return Some(stripped); + + let slug = parts.next()?; + let id_str = slug.split('-').next_back()?; + let channel_id = id_str.parse::().ok()?; + + let Some(next) = parts.next() else { + return Some(ZedLink::Channel { channel_id }); + }; + + if let Some(heading) = next.strip_prefix("notes#") { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: Some(heading.to_string()), + }); + } + + if next == "notes" { + return Some(ZedLink::ChannelNotes { + channel_id, + heading: None, + }); } None diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 917929a985c85610b907e682792e132cb84d8403..2c5b2649000bb071b9d206d9d2c204f1eea9bda1 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -371,6 +371,8 @@ pub struct LanguageModel { pub supports_images: bool, pub supports_thinking: bool, pub supports_max_mode: bool, + #[serde(default)] + pub supports_streaming_tools: bool, // only used by OpenAI and xAI #[serde(default)] pub supports_parallel_tool_calls: bool, diff --git a/crates/cloud_llm_client/src/predict_edits_v3.rs b/crates/cloud_llm_client/src/predict_edits_v3.rs index de8d69dc14870c5583679753c9a75a477e0cc759..9e590dc4cf48a82ecdda8b007c38ab15f3b602be 100644 --- a/crates/cloud_llm_client/src/predict_edits_v3.rs +++ b/crates/cloud_llm_client/src/predict_edits_v3.rs @@ -31,18 +31,10 @@ pub struct PredictEditsRequest { /// Within `signatures` pub excerpt_parent: Option, #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub included_files: Vec, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub signatures: Vec, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub referenced_declarations: Vec, + pub related_files: Vec, pub events: Vec>, #[serde(default)] pub can_collect_data: bool, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub diagnostic_groups: Vec, - #[serde(skip_serializing_if = "is_default", default)] - pub diagnostic_groups_truncated: bool, /// Info about the git repository state, only present when can_collect_data is true. #[serde(skip_serializing_if = "Option::is_none", default)] pub git_info: Option, @@ -58,7 +50,7 @@ pub struct PredictEditsRequest { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IncludedFile { +pub struct RelatedFile { pub path: Arc, pub max_row: Line, pub excerpts: Vec, @@ -72,11 +64,9 @@ pub struct Excerpt { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumIter)] pub enum PromptFormat { - MarkedExcerpt, - LabeledSections, - NumLinesUniDiff, + /// XML old_tex/new_text OldTextNewText, - /// Prompt format intended for use via zeta_cli + /// Prompt format intended for use via edit_prediction_cli OnlySnippets, /// One-sentence instructions used in fine-tuned models Minimal, @@ -87,7 +77,7 @@ pub enum PromptFormat { } impl PromptFormat { - pub const DEFAULT: PromptFormat = PromptFormat::NumLinesUniDiff; + pub const DEFAULT: PromptFormat = PromptFormat::Minimal; } impl Default for PromptFormat { @@ -105,10 +95,7 @@ impl PromptFormat { impl std::fmt::Display for PromptFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PromptFormat::MarkedExcerpt => write!(f, "Marked Excerpt"), - PromptFormat::LabeledSections => write!(f, "Labeled Sections"), PromptFormat::OnlySnippets => write!(f, "Only Snippets"), - PromptFormat::NumLinesUniDiff => write!(f, "Numbered Lines / Unified Diff"), PromptFormat::OldTextNewText => write!(f, "Old Text / New Text"), PromptFormat::Minimal => write!(f, "Minimal"), PromptFormat::MinimalQwen => write!(f, "Minimal + Qwen FIM"), @@ -178,67 +165,6 @@ impl<'a> std::fmt::Display for DiffPathFmt<'a> { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Signature { - pub text: String, - pub text_is_truncated: bool, - #[serde(skip_serializing_if = "Option::is_none", default)] - pub parent_index: Option, - /// Range of `text` within the file, possibly truncated according to `text_is_truncated`. The - /// file is implicitly the file that contains the descendant declaration or excerpt. - pub range: Range, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReferencedDeclaration { - pub path: Arc, - pub text: String, - pub text_is_truncated: bool, - /// Range of `text` within file, possibly truncated according to `text_is_truncated` - pub range: Range, - /// Range within `text` - pub signature_range: Range, - /// Index within `signatures`. - #[serde(skip_serializing_if = "Option::is_none", default)] - pub parent_index: Option, - pub score_components: DeclarationScoreComponents, - pub signature_score: f32, - pub declaration_score: f32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeclarationScoreComponents { - pub is_same_file: bool, - pub is_referenced_nearby: bool, - pub is_referenced_in_breadcrumb: bool, - pub reference_count: usize, - pub same_file_declaration_count: usize, - pub declaration_count: usize, - pub reference_line_distance: u32, - pub declaration_line_distance: u32, - pub excerpt_vs_item_jaccard: f32, - pub excerpt_vs_signature_jaccard: f32, - pub adjacent_vs_item_jaccard: f32, - pub adjacent_vs_signature_jaccard: f32, - pub excerpt_vs_item_weighted_overlap: f32, - pub excerpt_vs_signature_weighted_overlap: f32, - pub adjacent_vs_item_weighted_overlap: f32, - pub adjacent_vs_signature_weighted_overlap: f32, - pub path_import_match_count: usize, - pub wildcard_path_import_match_count: usize, - pub import_similarity: f32, - pub max_import_similarity: f32, - pub normalized_import_similarity: f32, - pub wildcard_import_similarity: f32, - pub normalized_wildcard_import_similarity: f32, - pub included_by_others: usize, - pub includes_others: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(transparent)] -pub struct DiagnosticGroup(pub Box); - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PredictEditsResponse { pub request_id: Uuid, @@ -262,10 +188,6 @@ pub struct Edit { pub content: String, } -fn is_default(value: &T) -> bool { - *value == T::default() -} - #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd, Eq, Ord)] pub struct Point { pub line: Line, diff --git a/crates/cloud_zeta2_prompt/Cargo.toml b/crates/cloud_zeta2_prompt/Cargo.toml deleted file mode 100644 index fa8246950f8d03029388e0276954de946efc2346..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "cloud_zeta2_prompt" -version = "0.1.0" -publish.workspace = true -edition.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/cloud_zeta2_prompt.rs" - -[dependencies] -anyhow.workspace = true -cloud_llm_client.workspace = true -indoc.workspace = true -ordered-float.workspace = true -rustc-hash.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -strum.workspace = true diff --git a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs b/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs deleted file mode 100644 index d67190c17556c5eb8b901e9baad73cc2691a9c78..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/src/cloud_zeta2_prompt.rs +++ /dev/null @@ -1,1075 +0,0 @@ -//! Zeta2 prompt planning and generation code shared with cloud. -pub mod retrieval_prompt; - -use anyhow::{Context as _, Result, anyhow}; -use cloud_llm_client::predict_edits_v3::{ - self, DiffPathFmt, Event, Excerpt, IncludedFile, Line, Point, PromptFormat, - ReferencedDeclaration, -}; -use indoc::indoc; -use ordered_float::OrderedFloat; -use rustc_hash::{FxHashMap, FxHashSet}; -use serde::Serialize; -use std::cmp; -use std::fmt::Write; -use std::sync::Arc; -use std::{cmp::Reverse, collections::BinaryHeap, ops::Range, path::Path}; -use strum::{EnumIter, IntoEnumIterator}; - -pub const DEFAULT_MAX_PROMPT_BYTES: usize = 10 * 1024; - -pub const CURSOR_MARKER: &str = "<|user_cursor|>"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_START_MARKER_WITH_NEWLINE: &str = "<|editable_region_start|>\n"; -/// NOTE: Differs from zed version of constant - includes a newline -pub const EDITABLE_REGION_END_MARKER_WITH_NEWLINE: &str = "<|editable_region_end|>\n"; - -// TODO: use constants for markers? -const MARKED_EXCERPT_INSTRUCTIONS: &str = indoc! {" - You are a code completion assistant and your task is to analyze user edits and then rewrite an excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking into account the cursor location. - - The excerpt to edit will be wrapped in markers <|editable_region_start|> and <|editable_region_end|>. The cursor position is marked with <|user_cursor|>. Please respond with edited code for that region. - - Other code is provided for context, and `…` indicates when code has been skipped. - - ## Edit History - -"}; - -const LABELED_SECTIONS_INSTRUCTIONS: &str = indoc! {r#" - You are a code completion assistant and your task is to analyze user edits, and suggest an edit to one of the provided sections of code. - - Sections of code are grouped by file and then labeled by `<|section_N|>` (e.g `<|section_8|>`). - - The cursor position is marked with `<|user_cursor|>` and it will appear within a special section labeled `<|current_section|>`. Prefer editing the current section until no more changes are needed within it. - - Respond ONLY with the name of the section to edit on a single line, followed by all of the code that should replace that section. For example: - - <|current_section|> - for i in 0..16 { - println!("{i}"); - } - - ## Edit History - -"#}; - -const NUMBERED_LINES_INSTRUCTIONS: &str = indoc! {r#" - # Instructions - - You are an edit prediction agent in a code editor. - Your job is to predict the next edit that the user will make, - based on their last few edits and their current cursor location. - - ## Output Format - - You must briefly explain your understanding of the user's goal, in one - or two sentences, and then specify their next edit in the form of a - unified diff, like this: - - ``` - --- a/src/myapp/cli.py - +++ b/src/myapp/cli.py - @@ ... @@ - import os - import time - import sys - +from constants import LOG_LEVEL_WARNING - @@ ... @@ - config.headless() - config.set_interactive(false) - -config.set_log_level(LOG_L) - +config.set_log_level(LOG_LEVEL_WARNING) - config.set_use_color(True) - ``` - - ## Edit History - -"#}; - -const STUDENT_MODEL_INSTRUCTIONS: &str = indoc! {r#" - You are a code completion assistant that analyzes edit history to identify and systematically complete incomplete refactorings or patterns across the entire codebase. - - ## Edit History - - "#}; - -const UNIFIED_DIFF_REMINDER: &str = indoc! {" - --- - - Analyze the edit history and the files, then provide the unified diff for your predicted edits. - Do not include the cursor marker in your output. - Your diff should include edited file paths in its file headers (lines beginning with `---` and `+++`). - Do not include line numbers in the hunk headers, use `@@ ... @@`. - Removed lines begin with `-`. - Added lines begin with `+`. - Context lines begin with an extra space. - Context and removed lines are used to match the target edit location, so make sure to include enough of them - to uniquely identify it amongst all excerpts of code provided. -"}; - -const MINIMAL_PROMPT_REMINDER: &str = indoc! {" - --- - - Please analyze the edit history and the files, then provide the unified diff for your predicted edits. - Do not include the cursor marker in your output. - If you're editing multiple files, be sure to reflect filename in the hunk's header. - "}; - -const XML_TAGS_INSTRUCTIONS: &str = indoc! {r#" - # Instructions - - You are an edit prediction agent in a code editor. - - Analyze the history of edits made by the user in order to infer what they are currently trying to accomplish. - Then complete the remainder of the current change if it is incomplete, or predict the next edit the user intends to make. - Always continue along the user's current trajectory, rather than changing course. - - ## Output Format - - You should briefly explain your understanding of the user's overall goal in one sentence, then explain what the next change - along the users current trajectory will be in another, and finally specify the next edit using the following XML-like format: - - - - OLD TEXT 1 HERE - - - NEW TEXT 1 HERE - - - - OLD TEXT 1 HERE - - - NEW TEXT 1 HERE - - - - - Specify the file to edit using the `path` attribute. - - Use `` and `` tags to replace content - - `` must exactly match existing file content, including indentation - - `` cannot be empty - - Do not escape quotes, newlines, or other characters within tags - - Always close all tags properly - - Don't include the <|user_cursor|> marker in your output. - - ## Edit History - -"#}; - -const OLD_TEXT_NEW_TEXT_REMINDER: &str = indoc! {r#" - --- - - Remember that the edits in the edit history have already been applied. -"#}; - -pub fn build_prompt( - request: &predict_edits_v3::PredictEditsRequest, -) -> Result<(String, SectionLabels)> { - let mut section_labels = Default::default(); - - let prompt_data = PromptData { - events: request.events.clone(), - cursor_point: request.cursor_point, - cursor_path: request.excerpt_path.clone(), - included_files: request.included_files.clone(), - }; - match request.prompt_format { - PromptFormat::MinimalQwen => { - return Ok((MinimalQwenPrompt.render(&prompt_data), section_labels)); - } - PromptFormat::SeedCoder1120 => { - return Ok((SeedCoder1120Prompt.render(&prompt_data), section_labels)); - } - _ => (), - }; - - let mut insertions = match request.prompt_format { - PromptFormat::MarkedExcerpt => vec![ - ( - Point { - line: request.excerpt_line_range.start, - column: 0, - }, - EDITABLE_REGION_START_MARKER_WITH_NEWLINE, - ), - (request.cursor_point, CURSOR_MARKER), - ( - Point { - line: request.excerpt_line_range.end, - column: 0, - }, - EDITABLE_REGION_END_MARKER_WITH_NEWLINE, - ), - ], - PromptFormat::LabeledSections - | PromptFormat::NumLinesUniDiff - | PromptFormat::Minimal - | PromptFormat::OldTextNewText => { - vec![(request.cursor_point, CURSOR_MARKER)] - } - PromptFormat::OnlySnippets => vec![], - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - }; - - let mut prompt = match request.prompt_format { - PromptFormat::MarkedExcerpt => MARKED_EXCERPT_INSTRUCTIONS.to_string(), - PromptFormat::LabeledSections => LABELED_SECTIONS_INSTRUCTIONS.to_string(), - PromptFormat::NumLinesUniDiff => NUMBERED_LINES_INSTRUCTIONS.to_string(), - PromptFormat::OldTextNewText => XML_TAGS_INSTRUCTIONS.to_string(), - PromptFormat::OnlySnippets => String::new(), - PromptFormat::Minimal => STUDENT_MODEL_INSTRUCTIONS.to_string(), - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - }; - - if request.events.is_empty() { - prompt.push_str("(No edit history)\n\n"); - } else { - let edit_preamble = if request.prompt_format == PromptFormat::Minimal { - "The following are the latest edits made by the user, from earlier to later.\n\n" - } else { - "Here are the latest edits made by the user, from earlier to later.\n\n" - }; - prompt.push_str(edit_preamble); - push_events(&mut prompt, &request.events); - } - - let excerpts_preamble = match request.prompt_format { - PromptFormat::Minimal => indoc! {" - ## Part of the file under the cursor - - (The cursor marker <|user_cursor|> indicates the current user cursor position. - The file is in current state, edits from edit history has been applied. - We only show part of the file around the cursor. - You can only edit exactly this part of the file. - We prepend line numbers (e.g., `123|`); they are not part of the file.) - "}, - PromptFormat::NumLinesUniDiff | PromptFormat::OldTextNewText => indoc! {" - ## Code Excerpts - - Here is some excerpts of code that you should take into account to predict the next edit. - - The cursor position is marked by `<|user_cursor|>` as it stands after the last edit in the history. - - In addition other excerpts are included to better understand what the edit will be, including the declaration - or references of symbols around the cursor, or other similar code snippets that may need to be updated - following patterns that appear in the edit history. - - Consider each of them carefully in relation to the edit history, and that the user may not have navigated - to the next place they want to edit yet. - - Lines starting with `…` indicate omitted line ranges. These may appear inside multi-line code constructs. - "}, - _ => indoc! {" - ## Code Excerpts - - The cursor marker <|user_cursor|> indicates the current user cursor position. - The file is in current state, edits from edit history have been applied. - "}, - }; - - prompt.push_str(excerpts_preamble); - prompt.push('\n'); - - if !request.referenced_declarations.is_empty() || !request.signatures.is_empty() { - let syntax_based_prompt = SyntaxBasedPrompt::populate(request)?; - section_labels = syntax_based_prompt.write(&mut insertions, &mut prompt)?; - } else { - if request.prompt_format == PromptFormat::LabeledSections { - anyhow::bail!("PromptFormat::LabeledSections cannot be used with ContextMode::Llm"); - } - - let include_line_numbers = matches!( - request.prompt_format, - PromptFormat::NumLinesUniDiff | PromptFormat::Minimal - ); - for related_file in &request.included_files { - if request.prompt_format == PromptFormat::Minimal { - write_codeblock_with_filename( - &related_file.path, - &related_file.excerpts, - if related_file.path == request.excerpt_path { - &insertions - } else { - &[] - }, - related_file.max_row, - include_line_numbers, - &mut prompt, - ); - } else { - write_codeblock( - &related_file.path, - &related_file.excerpts, - if related_file.path == request.excerpt_path { - &insertions - } else { - &[] - }, - related_file.max_row, - include_line_numbers, - &mut prompt, - ); - } - } - } - - match request.prompt_format { - PromptFormat::NumLinesUniDiff => { - prompt.push_str(UNIFIED_DIFF_REMINDER); - } - PromptFormat::OldTextNewText => { - prompt.push_str(OLD_TEXT_NEW_TEXT_REMINDER); - } - PromptFormat::Minimal => { - prompt.push_str(MINIMAL_PROMPT_REMINDER); - } - _ => {} - } - - Ok((prompt, section_labels)) -} - -pub fn generation_params(prompt_format: PromptFormat) -> GenerationParams { - match prompt_format { - PromptFormat::SeedCoder1120 => SeedCoder1120Prompt::generation_params(), - _ => GenerationParams::default(), - } -} - -pub fn write_codeblock<'a>( - path: &Path, - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &'a mut String, -) { - writeln!(output, "`````{}", DiffPathFmt(path)).unwrap(); - - write_excerpts( - excerpts, - sorted_insertions, - file_line_count, - include_line_numbers, - output, - ); - write!(output, "`````\n\n").unwrap(); -} - -fn write_codeblock_with_filename<'a>( - path: &Path, - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &'a mut String, -) { - writeln!(output, "`````filename={}", DiffPathFmt(path)).unwrap(); - - write_excerpts( - excerpts, - sorted_insertions, - file_line_count, - include_line_numbers, - output, - ); - write!(output, "`````\n\n").unwrap(); -} - -pub fn write_excerpts<'a>( - excerpts: impl IntoIterator, - sorted_insertions: &[(Point, &str)], - file_line_count: Line, - include_line_numbers: bool, - output: &mut String, -) { - let mut current_row = Line(0); - let mut sorted_insertions = sorted_insertions.iter().peekable(); - - for excerpt in excerpts { - if excerpt.start_line > current_row { - writeln!(output, "…").unwrap(); - } - if excerpt.text.is_empty() { - return; - } - - current_row = excerpt.start_line; - - for mut line in excerpt.text.lines() { - if include_line_numbers { - write!(output, "{}|", current_row.0 + 1).unwrap(); - } - - while let Some((insertion_location, insertion_marker)) = sorted_insertions.peek() { - match current_row.cmp(&insertion_location.line) { - cmp::Ordering::Equal => { - let (prefix, suffix) = line.split_at(insertion_location.column as usize); - output.push_str(prefix); - output.push_str(insertion_marker); - line = suffix; - sorted_insertions.next(); - } - cmp::Ordering::Less => break, - cmp::Ordering::Greater => { - sorted_insertions.next(); - break; - } - } - } - output.push_str(line); - output.push('\n'); - current_row.0 += 1; - } - } - - if current_row < file_line_count { - writeln!(output, "…").unwrap(); - } -} - -pub fn push_events(output: &mut String, events: &[Arc]) { - if events.is_empty() { - return; - }; - - writeln!(output, "`````diff").unwrap(); - for event in events { - writeln!(output, "{}", event).unwrap(); - } - writeln!(output, "`````\n").unwrap(); -} - -pub struct SyntaxBasedPrompt<'a> { - request: &'a predict_edits_v3::PredictEditsRequest, - /// Snippets to include in the prompt. These may overlap - they are merged / deduplicated in - /// `to_prompt_string`. - snippets: Vec>, - budget_used: usize, -} - -#[derive(Clone, Debug)] -pub struct PlannedSnippet<'a> { - path: Arc, - range: Range, - text: &'a str, - // TODO: Indicate this in the output - #[allow(dead_code)] - text_is_truncated: bool, -} - -#[derive(EnumIter, Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] -pub enum DeclarationStyle { - Signature, - Declaration, -} - -#[derive(Default, Clone, Debug, Serialize)] -pub struct SectionLabels { - pub excerpt_index: usize, - pub section_ranges: Vec<(Arc, Range)>, -} - -impl<'a> SyntaxBasedPrompt<'a> { - /// Greedy one-pass knapsack algorithm to populate the prompt plan. Does the following: - /// - /// Initializes a priority queue by populating it with each snippet, finding the - /// DeclarationStyle that minimizes `score_density = score / snippet.range(style).len()`. When a - /// "signature" snippet is popped, insert an entry for the "declaration" variant that reflects - /// the cost of upgrade. - /// - /// TODO: Implement an early halting condition. One option might be to have another priority - /// queue where the score is the size, and update it accordingly. Another option might be to - /// have some simpler heuristic like bailing after N failed insertions, or based on how much - /// budget is left. - /// - /// TODO: Has the current known sources of imprecision: - /// - /// * Does not consider snippet overlap when ranking. For example, it might add a field to the - /// plan even though the containing struct is already included. - /// - /// * Does not consider cost of signatures when ranking snippets - this is tricky since - /// signatures may be shared by multiple snippets. - /// - /// * Does not include file paths / other text when considering max_bytes. - pub fn populate(request: &'a predict_edits_v3::PredictEditsRequest) -> Result { - let mut this = Self { - request, - snippets: Vec::new(), - budget_used: request.excerpt.len(), - }; - let mut included_parents = FxHashSet::default(); - let additional_parents = this.additional_parent_signatures( - &request.excerpt_path, - request.excerpt_parent, - &included_parents, - )?; - this.add_parents(&mut included_parents, additional_parents); - - let max_bytes = request.prompt_max_bytes.unwrap_or(DEFAULT_MAX_PROMPT_BYTES); - - if this.budget_used > max_bytes { - return Err(anyhow!( - "Excerpt + signatures size of {} already exceeds budget of {}", - this.budget_used, - max_bytes - )); - } - - #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] - struct QueueEntry { - score_density: OrderedFloat, - declaration_index: usize, - style: DeclarationStyle, - } - - // Initialize priority queue with the best score for each snippet. - let mut queue: BinaryHeap = BinaryHeap::new(); - for (declaration_index, declaration) in request.referenced_declarations.iter().enumerate() { - let (style, score_density) = DeclarationStyle::iter() - .map(|style| { - ( - style, - OrderedFloat(declaration_score_density(&declaration, style)), - ) - }) - .max_by_key(|(_, score_density)| *score_density) - .unwrap(); - queue.push(QueueEntry { - score_density, - declaration_index, - style, - }); - } - - // Knapsack selection loop - while let Some(queue_entry) = queue.pop() { - let Some(declaration) = request - .referenced_declarations - .get(queue_entry.declaration_index) - else { - return Err(anyhow!( - "Invalid declaration index {}", - queue_entry.declaration_index - )); - }; - - let mut additional_bytes = declaration_size(declaration, queue_entry.style); - if this.budget_used + additional_bytes > max_bytes { - continue; - } - - let additional_parents = this.additional_parent_signatures( - &declaration.path, - declaration.parent_index, - &mut included_parents, - )?; - additional_bytes += additional_parents - .iter() - .map(|(_, snippet)| snippet.text.len()) - .sum::(); - if this.budget_used + additional_bytes > max_bytes { - continue; - } - - this.budget_used += additional_bytes; - this.add_parents(&mut included_parents, additional_parents); - let planned_snippet = match queue_entry.style { - DeclarationStyle::Signature => { - let Some(text) = declaration.text.get(declaration.signature_range.clone()) - else { - return Err(anyhow!( - "Invalid declaration signature_range {:?} with text.len() = {}", - declaration.signature_range, - declaration.text.len() - )); - }; - let signature_start_line = declaration.range.start - + Line( - declaration.text[..declaration.signature_range.start] - .lines() - .count() as u32, - ); - let signature_end_line = signature_start_line - + Line( - declaration.text - [declaration.signature_range.start..declaration.signature_range.end] - .lines() - .count() as u32, - ); - let range = signature_start_line..signature_end_line; - - PlannedSnippet { - path: declaration.path.clone(), - range, - text, - text_is_truncated: declaration.text_is_truncated, - } - } - DeclarationStyle::Declaration => PlannedSnippet { - path: declaration.path.clone(), - range: declaration.range.clone(), - text: &declaration.text, - text_is_truncated: declaration.text_is_truncated, - }, - }; - this.snippets.push(planned_snippet); - - // When a Signature is consumed, insert an entry for Definition style. - if queue_entry.style == DeclarationStyle::Signature { - let signature_size = declaration_size(&declaration, DeclarationStyle::Signature); - let declaration_size = - declaration_size(&declaration, DeclarationStyle::Declaration); - let signature_score = declaration_score(&declaration, DeclarationStyle::Signature); - let declaration_score = - declaration_score(&declaration, DeclarationStyle::Declaration); - - let score_diff = declaration_score - signature_score; - let size_diff = declaration_size.saturating_sub(signature_size); - if score_diff > 0.0001 && size_diff > 0 { - queue.push(QueueEntry { - declaration_index: queue_entry.declaration_index, - score_density: OrderedFloat(score_diff / (size_diff as f32)), - style: DeclarationStyle::Declaration, - }); - } - } - } - - anyhow::Ok(this) - } - - fn add_parents( - &mut self, - included_parents: &mut FxHashSet, - snippets: Vec<(usize, PlannedSnippet<'a>)>, - ) { - for (parent_index, snippet) in snippets { - included_parents.insert(parent_index); - self.budget_used += snippet.text.len(); - self.snippets.push(snippet); - } - } - - fn additional_parent_signatures( - &self, - path: &Arc, - parent_index: Option, - included_parents: &FxHashSet, - ) -> Result)>> { - let mut results = Vec::new(); - self.additional_parent_signatures_impl(path, parent_index, included_parents, &mut results)?; - Ok(results) - } - - fn additional_parent_signatures_impl( - &self, - path: &Arc, - parent_index: Option, - included_parents: &FxHashSet, - results: &mut Vec<(usize, PlannedSnippet<'a>)>, - ) -> Result<()> { - let Some(parent_index) = parent_index else { - return Ok(()); - }; - if included_parents.contains(&parent_index) { - return Ok(()); - } - let Some(parent_signature) = self.request.signatures.get(parent_index) else { - return Err(anyhow!("Invalid parent index {}", parent_index)); - }; - results.push(( - parent_index, - PlannedSnippet { - path: path.clone(), - range: parent_signature.range.clone(), - text: &parent_signature.text, - text_is_truncated: parent_signature.text_is_truncated, - }, - )); - self.additional_parent_signatures_impl( - path, - parent_signature.parent_index, - included_parents, - results, - ) - } - - /// Renders the planned context. Each file starts with "```FILE_PATH\n` and ends with triple - /// backticks, with a newline after each file. Outputs a line with "..." between nonconsecutive - /// chunks. - pub fn write( - &'a self, - excerpt_file_insertions: &mut Vec<(Point, &'static str)>, - prompt: &mut String, - ) -> Result { - let mut file_to_snippets: FxHashMap<&'a std::path::Path, Vec<&PlannedSnippet<'a>>> = - FxHashMap::default(); - for snippet in &self.snippets { - file_to_snippets - .entry(&snippet.path) - .or_default() - .push(snippet); - } - - // Reorder so that file with cursor comes last - let mut file_snippets = Vec::new(); - let mut excerpt_file_snippets = Vec::new(); - for (file_path, snippets) in file_to_snippets { - if file_path == self.request.excerpt_path.as_ref() { - excerpt_file_snippets = snippets; - } else { - file_snippets.push((file_path, snippets, false)); - } - } - let excerpt_snippet = PlannedSnippet { - path: self.request.excerpt_path.clone(), - range: self.request.excerpt_line_range.clone(), - text: &self.request.excerpt, - text_is_truncated: false, - }; - excerpt_file_snippets.push(&excerpt_snippet); - file_snippets.push((&self.request.excerpt_path, excerpt_file_snippets, true)); - - let section_labels = - self.push_file_snippets(prompt, excerpt_file_insertions, file_snippets)?; - - Ok(section_labels) - } - - fn push_file_snippets( - &self, - output: &mut String, - excerpt_file_insertions: &mut Vec<(Point, &'static str)>, - file_snippets: Vec<(&'a Path, Vec<&'a PlannedSnippet>, bool)>, - ) -> Result { - let mut section_ranges = Vec::new(); - let mut excerpt_index = None; - - for (file_path, mut snippets, is_excerpt_file) in file_snippets { - snippets.sort_by_key(|s| (s.range.start, Reverse(s.range.end))); - - // TODO: What if the snippets get expanded too large to be editable? - let mut current_snippet: Option<(&PlannedSnippet, Range)> = None; - let mut disjoint_snippets: Vec<(&PlannedSnippet, Range)> = Vec::new(); - for snippet in snippets { - if let Some((_, current_snippet_range)) = current_snippet.as_mut() - && snippet.range.start <= current_snippet_range.end - { - current_snippet_range.end = current_snippet_range.end.max(snippet.range.end); - continue; - } - if let Some(current_snippet) = current_snippet.take() { - disjoint_snippets.push(current_snippet); - } - current_snippet = Some((snippet, snippet.range.clone())); - } - if let Some(current_snippet) = current_snippet.take() { - disjoint_snippets.push(current_snippet); - } - - writeln!(output, "`````path={}", file_path.display()).ok(); - let mut skipped_last_snippet = false; - for (snippet, range) in disjoint_snippets { - let section_index = section_ranges.len(); - - match self.request.prompt_format { - PromptFormat::MarkedExcerpt - | PromptFormat::OnlySnippets - | PromptFormat::OldTextNewText - | PromptFormat::Minimal - | PromptFormat::NumLinesUniDiff => { - if range.start.0 > 0 && !skipped_last_snippet { - output.push_str("…\n"); - } - } - PromptFormat::LabeledSections => { - if is_excerpt_file - && range.start <= self.request.excerpt_line_range.start - && range.end >= self.request.excerpt_line_range.end - { - writeln!(output, "<|current_section|>").ok(); - } else { - writeln!(output, "<|section_{}|>", section_index).ok(); - } - } - PromptFormat::MinimalQwen => unreachable!(), - PromptFormat::SeedCoder1120 => unreachable!(), - } - - let push_full_snippet = |output: &mut String| { - if self.request.prompt_format == PromptFormat::NumLinesUniDiff { - for (i, line) in snippet.text.lines().enumerate() { - writeln!(output, "{}|{}", i as u32 + range.start.0 + 1, line)?; - } - } else { - output.push_str(&snippet.text); - } - anyhow::Ok(()) - }; - - if is_excerpt_file { - if self.request.prompt_format == PromptFormat::OnlySnippets { - if range.start >= self.request.excerpt_line_range.start - && range.end <= self.request.excerpt_line_range.end - { - skipped_last_snippet = true; - } else { - skipped_last_snippet = false; - output.push_str(snippet.text); - } - } else if !excerpt_file_insertions.is_empty() { - let lines = snippet.text.lines().collect::>(); - let push_line = |output: &mut String, line_ix: usize| { - if self.request.prompt_format == PromptFormat::NumLinesUniDiff { - write!(output, "{}|", line_ix as u32 + range.start.0 + 1)?; - } - anyhow::Ok(writeln!(output, "{}", lines[line_ix])?) - }; - let mut last_line_ix = 0; - let mut insertion_ix = 0; - while insertion_ix < excerpt_file_insertions.len() { - let (point, insertion) = &excerpt_file_insertions[insertion_ix]; - let found = point.line >= range.start && point.line <= range.end; - if found { - excerpt_index = Some(section_index); - let insertion_line_ix = (point.line.0 - range.start.0) as usize; - for line_ix in last_line_ix..insertion_line_ix { - push_line(output, line_ix)?; - } - if let Some(next_line) = lines.get(insertion_line_ix) { - if self.request.prompt_format == PromptFormat::NumLinesUniDiff { - write!( - output, - "{}|", - insertion_line_ix as u32 + range.start.0 + 1 - )? - } - output.push_str(&next_line[..point.column as usize]); - output.push_str(insertion); - writeln!(output, "{}", &next_line[point.column as usize..])?; - } else { - writeln!(output, "{}", insertion)?; - } - last_line_ix = insertion_line_ix + 1; - excerpt_file_insertions.remove(insertion_ix); - continue; - } - insertion_ix += 1; - } - skipped_last_snippet = false; - for line_ix in last_line_ix..lines.len() { - push_line(output, line_ix)?; - } - } else { - skipped_last_snippet = false; - push_full_snippet(output)?; - } - } else { - skipped_last_snippet = false; - push_full_snippet(output)?; - } - - section_ranges.push((snippet.path.clone(), range)); - } - - output.push_str("`````\n\n"); - } - - Ok(SectionLabels { - // TODO: Clean this up - excerpt_index: match self.request.prompt_format { - PromptFormat::OnlySnippets => 0, - _ => excerpt_index.context("bug: no snippet found for excerpt")?, - }, - section_ranges, - }) - } -} - -fn declaration_score_density(declaration: &ReferencedDeclaration, style: DeclarationStyle) -> f32 { - declaration_score(declaration, style) / declaration_size(declaration, style) as f32 -} - -fn declaration_score(declaration: &ReferencedDeclaration, style: DeclarationStyle) -> f32 { - match style { - DeclarationStyle::Signature => declaration.signature_score, - DeclarationStyle::Declaration => declaration.declaration_score, - } -} - -fn declaration_size(declaration: &ReferencedDeclaration, style: DeclarationStyle) -> usize { - match style { - DeclarationStyle::Signature => declaration.signature_range.len(), - DeclarationStyle::Declaration => declaration.text.len(), - } -} - -struct PromptData { - events: Vec>, - cursor_point: Point, - cursor_path: Arc, // TODO: make a common struct with cursor_point - included_files: Vec, -} - -#[derive(Default)] -pub struct GenerationParams { - pub temperature: Option, - pub top_p: Option, - pub stop: Option>, -} - -trait PromptFormatter { - fn render(&self, data: &PromptData) -> String; - - fn generation_params() -> GenerationParams { - return GenerationParams::default(); - } -} - -struct MinimalQwenPrompt; - -impl PromptFormatter for MinimalQwenPrompt { - fn render(&self, data: &PromptData) -> String { - let edit_history = self.fmt_edit_history(data); - let context = self.fmt_context(data); - - format!( - "{instructions}\n\n{edit_history}\n\n{context}", - instructions = MinimalQwenPrompt::INSTRUCTIONS, - edit_history = edit_history, - context = context - ) - } -} - -impl MinimalQwenPrompt { - const INSTRUCTIONS: &str = "You are a code completion assistant that analyzes edit history to identify and systematically complete incomplete refactorings or patterns across the entire codebase.\n"; - - fn fmt_edit_history(&self, data: &PromptData) -> String { - if data.events.is_empty() { - "(No edit history)\n\n".to_string() - } else { - let mut events_str = String::new(); - push_events(&mut events_str, &data.events); - format!( - "The following are the latest edits made by the user, from earlier to later.\n\n{}", - events_str - ) - } - } - - fn fmt_context(&self, data: &PromptData) -> String { - let mut context = String::new(); - let include_line_numbers = true; - - for related_file in &data.included_files { - writeln!(context, "<|file_sep|>{}", DiffPathFmt(&related_file.path)).unwrap(); - - if related_file.path == data.cursor_path { - write!(context, "<|fim_prefix|>").unwrap(); - write_excerpts( - &related_file.excerpts, - &[(data.cursor_point, "<|fim_suffix|>")], - related_file.max_row, - include_line_numbers, - &mut context, - ); - writeln!(context, "<|fim_middle|>").unwrap(); - } else { - write_excerpts( - &related_file.excerpts, - &[], - related_file.max_row, - include_line_numbers, - &mut context, - ); - } - } - context - } -} - -struct SeedCoder1120Prompt; - -impl PromptFormatter for SeedCoder1120Prompt { - fn render(&self, data: &PromptData) -> String { - let edit_history = self.fmt_edit_history(data); - let context = self.fmt_context(data); - - format!( - "# Edit History:\n{edit_history}\n\n{context}", - edit_history = edit_history, - context = context - ) - } - - fn generation_params() -> GenerationParams { - GenerationParams { - temperature: Some(0.2), - top_p: Some(0.9), - stop: Some(vec!["<[end_of_sentence]>".into()]), - } - } -} - -impl SeedCoder1120Prompt { - fn fmt_edit_history(&self, data: &PromptData) -> String { - if data.events.is_empty() { - "(No edit history)\n\n".to_string() - } else { - let mut events_str = String::new(); - push_events(&mut events_str, &data.events); - events_str - } - } - - fn fmt_context(&self, data: &PromptData) -> String { - let mut context = String::new(); - let include_line_numbers = true; - - for related_file in &data.included_files { - writeln!(context, "# Path: {}\n", DiffPathFmt(&related_file.path)).unwrap(); - - if related_file.path == data.cursor_path { - let fim_prompt = self.fmt_fim(&related_file, data.cursor_point); - context.push_str(&fim_prompt); - } else { - write_excerpts( - &related_file.excerpts, - &[], - related_file.max_row, - include_line_numbers, - &mut context, - ); - } - } - context - } - - fn fmt_fim(&self, file: &IncludedFile, cursor_point: Point) -> String { - let mut buf = String::new(); - const FIM_SUFFIX: &str = "<[fim-suffix]>"; - const FIM_PREFIX: &str = "<[fim-prefix]>"; - const FIM_MIDDLE: &str = "<[fim-middle]>"; - write!(buf, "{}", FIM_PREFIX).unwrap(); - write_excerpts( - &file.excerpts, - &[(cursor_point, FIM_SUFFIX)], - file.max_row, - true, - &mut buf, - ); - - // Swap prefix and suffix parts - let index = buf.find(FIM_SUFFIX).unwrap(); - let prefix = &buf[..index]; - let suffix = &buf[index..]; - - format!("{}{}{}", suffix, prefix, FIM_MIDDLE) - } -} diff --git a/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs b/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs deleted file mode 100644 index fd35f63f03ff967491a28d817852f6622e4919ca..0000000000000000000000000000000000000000 --- a/crates/cloud_zeta2_prompt/src/retrieval_prompt.rs +++ /dev/null @@ -1,244 +0,0 @@ -use anyhow::Result; -use cloud_llm_client::predict_edits_v3::{self, Excerpt}; -use indoc::indoc; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::fmt::Write; - -use crate::{push_events, write_codeblock}; - -pub fn build_prompt(request: predict_edits_v3::PlanContextRetrievalRequest) -> Result { - let mut prompt = SEARCH_INSTRUCTIONS.to_string(); - - if !request.events.is_empty() { - writeln!(&mut prompt, "\n## User Edits\n\n")?; - push_events(&mut prompt, &request.events); - } - - writeln!(&mut prompt, "## Cursor context\n")?; - write_codeblock( - &request.excerpt_path, - &[Excerpt { - start_line: request.excerpt_line_range.start, - text: request.excerpt.into(), - }], - &[], - request.cursor_file_max_row, - true, - &mut prompt, - ); - - writeln!(&mut prompt, "{TOOL_USE_REMINDER}")?; - - Ok(prompt) -} - -/// Search for relevant code -/// -/// For the best results, run multiple queries at once with a single invocation of this tool. -#[derive(Clone, Deserialize, Serialize, JsonSchema)] -pub struct SearchToolInput { - /// An array of queries to run for gathering context relevant to the next prediction - #[schemars(length(max = 3))] - #[serde(deserialize_with = "deserialize_queries")] - pub queries: Box<[SearchToolQuery]>, -} - -fn deserialize_queries<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - use serde::de::Error; - - #[derive(Deserialize)] - #[serde(untagged)] - enum QueryCollection { - Array(Box<[SearchToolQuery]>), - DoubleArray(Box<[Box<[SearchToolQuery]>]>), - Single(SearchToolQuery), - } - - #[derive(Deserialize)] - #[serde(untagged)] - enum MaybeDoubleEncoded { - SingleEncoded(QueryCollection), - DoubleEncoded(String), - } - - let result = MaybeDoubleEncoded::deserialize(deserializer)?; - - let normalized = match result { - MaybeDoubleEncoded::SingleEncoded(value) => value, - MaybeDoubleEncoded::DoubleEncoded(value) => { - serde_json::from_str(&value).map_err(D::Error::custom)? - } - }; - - Ok(match normalized { - QueryCollection::Array(items) => items, - QueryCollection::Single(search_tool_query) => Box::new([search_tool_query]), - QueryCollection::DoubleArray(double_array) => double_array.into_iter().flatten().collect(), - }) -} - -/// Search for relevant code by path, syntax hierarchy, and content. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Hash)] -pub struct SearchToolQuery { - /// 1. A glob pattern to match file paths in the codebase to search in. - pub glob: String, - /// 2. Regular expressions to match syntax nodes **by their first line** and hierarchy. - /// - /// Subsequent regexes match nodes within the full content of the nodes matched by the previous regexes. - /// - /// Example: Searching for a `User` class - /// ["class\s+User"] - /// - /// Example: Searching for a `get_full_name` method under a `User` class - /// ["class\s+User", "def\sget_full_name"] - /// - /// Skip this field to match on content alone. - #[schemars(length(max = 3))] - #[serde(default)] - pub syntax_node: Vec, - /// 3. An optional regular expression to match the final content that should appear in the results. - /// - /// - Content will be matched within all lines of the matched syntax nodes. - /// - If syntax node regexes are provided, this field can be skipped to include as much of the node itself as possible. - /// - If no syntax node regexes are provided, the content will be matched within the entire file. - pub content: Option, -} - -pub const TOOL_NAME: &str = "search"; - -const SEARCH_INSTRUCTIONS: &str = indoc! {r#" - You are part of an edit prediction system in a code editor. - Your role is to search for code that will serve as context for predicting the next edit. - - - Analyze the user's recent edits and current cursor context - - Use the `search` tool to find code that is relevant for predicting the next edit - - Focus on finding: - - Code patterns that might need similar changes based on the recent edits - - Functions, variables, types, and constants referenced in the current cursor context - - Related implementations, usages, or dependencies that may require consistent updates - - How items defined in the cursor excerpt are used or altered - - You will not be able to filter results or perform subsequent queries, so keep searches as targeted as possible - - Use `syntax_node` parameter whenever you're looking for a particular type, class, or function - - Avoid using wildcard globs if you already know the file path of the content you're looking for -"#}; - -const TOOL_USE_REMINDER: &str = indoc! {" - -- - Analyze the user's intent in one to two sentences, then call the `search` tool. -"}; - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn test_deserialize_queries() { - let single_query_json = indoc! {r#"{ - "queries": { - "glob": "**/*.rs", - "syntax_node": ["fn test"], - "content": "assert" - } - }"#}; - - let flat_input: SearchToolInput = serde_json::from_str(single_query_json).unwrap(); - assert_eq!(flat_input.queries.len(), 1); - assert_eq!(flat_input.queries[0].glob, "**/*.rs"); - assert_eq!(flat_input.queries[0].syntax_node, vec!["fn test"]); - assert_eq!(flat_input.queries[0].content, Some("assert".to_string())); - - let flat_json = indoc! {r#"{ - "queries": [ - { - "glob": "**/*.rs", - "syntax_node": ["fn test"], - "content": "assert" - }, - { - "glob": "**/*.ts", - "syntax_node": [], - "content": null - } - ] - }"#}; - - let flat_input: SearchToolInput = serde_json::from_str(flat_json).unwrap(); - assert_eq!(flat_input.queries.len(), 2); - assert_eq!(flat_input.queries[0].glob, "**/*.rs"); - assert_eq!(flat_input.queries[0].syntax_node, vec!["fn test"]); - assert_eq!(flat_input.queries[0].content, Some("assert".to_string())); - assert_eq!(flat_input.queries[1].glob, "**/*.ts"); - assert_eq!(flat_input.queries[1].syntax_node.len(), 0); - assert_eq!(flat_input.queries[1].content, None); - - let nested_json = indoc! {r#"{ - "queries": [ - [ - { - "glob": "**/*.rs", - "syntax_node": ["fn test"], - "content": "assert" - } - ], - [ - { - "glob": "**/*.ts", - "syntax_node": [], - "content": null - } - ] - ] - }"#}; - - let nested_input: SearchToolInput = serde_json::from_str(nested_json).unwrap(); - - assert_eq!(nested_input.queries.len(), 2); - - assert_eq!(nested_input.queries[0].glob, "**/*.rs"); - assert_eq!(nested_input.queries[0].syntax_node, vec!["fn test"]); - assert_eq!(nested_input.queries[0].content, Some("assert".to_string())); - assert_eq!(nested_input.queries[1].glob, "**/*.ts"); - assert_eq!(nested_input.queries[1].syntax_node.len(), 0); - assert_eq!(nested_input.queries[1].content, None); - - let double_encoded_queries = serde_json::to_string(&json!({ - "queries": serde_json::to_string(&json!([ - { - "glob": "**/*.rs", - "syntax_node": ["fn test"], - "content": "assert" - }, - { - "glob": "**/*.ts", - "syntax_node": [], - "content": null - } - ])).unwrap() - })) - .unwrap(); - - let double_encoded_input: SearchToolInput = - serde_json::from_str(&double_encoded_queries).unwrap(); - - assert_eq!(double_encoded_input.queries.len(), 2); - - assert_eq!(double_encoded_input.queries[0].glob, "**/*.rs"); - assert_eq!(double_encoded_input.queries[0].syntax_node, vec!["fn test"]); - assert_eq!( - double_encoded_input.queries[0].content, - Some("assert".to_string()) - ); - assert_eq!(double_encoded_input.queries[1].glob, "**/*.ts"); - assert_eq!(double_encoded_input.queries[1].syntax_node.len(), 0); - assert_eq!(double_encoded_input.queries[1].content, None); - - // ### ERROR Switching from var declarations to lexical declarations [RUN 073] - // invalid search json {"queries": ["express/lib/response.js", "var\\s+[a-zA-Z_][a-zA-Z0-9_]*\\s*=.*;", "function.*\\(.*\\).*\\{.*\\}"]} - } -} diff --git a/crates/codestral/Cargo.toml b/crates/codestral/Cargo.toml index b402274a33530424349081da764a4b6766e419e9..7f3bf3b22dda8f9dbde1923c76855342c6cbac4c 100644 --- a/crates/codestral/Cargo.toml +++ b/crates/codestral/Cargo.toml @@ -10,7 +10,7 @@ path = "src/codestral.rs" [dependencies] anyhow.workspace = true -edit_prediction.workspace = true +edit_prediction_types.workspace = true edit_prediction_context.workspace = true futures.workspace = true gpui.workspace = true diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 6a500acbf6ec5eea63c35a8deb83a8545cee497e..9cf2fab80b78ba06c6a2523013e2f73934f50052 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -1,6 +1,6 @@ use anyhow::{Context as _, Result}; -use edit_prediction::{Direction, EditPrediction, EditPredictionProvider}; use edit_prediction_context::{EditPredictionExcerpt, EditPredictionExcerptOptions}; +use edit_prediction_types::{EditPrediction, EditPredictionDelegate}; use futures::AsyncReadExt; use gpui::{App, Context, Entity, Task}; use http_client::HttpClient; @@ -43,17 +43,17 @@ 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. fn interpolate(&self, new_snapshot: &BufferSnapshot) -> Option, Arc)>> { - edit_prediction::interpolate_edits(&self.snapshot, new_snapshot, &self.edits) + edit_prediction_types::interpolate_edits(&self.snapshot, new_snapshot, &self.edits) } } -pub struct CodestralCompletionProvider { +pub struct CodestralEditPredictionDelegate { http_client: Arc, pending_request: Option>>, current_completion: Option, } -impl CodestralCompletionProvider { +impl CodestralEditPredictionDelegate { pub fn new(http_client: Arc) -> Self { Self { http_client, @@ -165,7 +165,7 @@ impl CodestralCompletionProvider { } } -impl EditPredictionProvider for CodestralCompletionProvider { +impl EditPredictionDelegate for CodestralEditPredictionDelegate { fn name() -> &'static str { "codestral" } @@ -174,7 +174,7 @@ impl EditPredictionProvider for CodestralCompletionProvider { "Codestral" } - fn show_completions_in_menu() -> bool { + fn show_predictions_in_menu() -> bool { true } @@ -239,7 +239,6 @@ impl EditPredictionProvider for CodestralCompletionProvider { cursor_point, &snapshot, &EXCERPT_OPTIONS, - None, ) .context("Line containing cursor doesn't fit in excerpt max bytes")?; @@ -301,16 +300,6 @@ impl EditPredictionProvider for CodestralCompletionProvider { })); } - fn cycle( - &mut self, - _buffer: Entity, - _cursor_position: Anchor, - _direction: Direction, - _cx: &mut Context, - ) { - // Codestral doesn't support multiple completions, so cycling does nothing - } - fn accept(&mut self, _cx: &mut Context) { log::debug!("Codestral: Completion accepted"); self.pending_request = None; diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index b8a4c035499d45adc494c9f8175a772d15aa96df..79fc21fe33423d7eb887744b4ad84094a022862e 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -65,7 +65,7 @@ tokio = { workspace = true, features = ["full"] } toml.workspace = true tower = "0.4" tower-http = { workspace = true, features = ["trace"] } -tracing = "0.1.40" +tracing.workspace = true tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "registry", "tracing-log"] } # workaround for https://github.com/tokio-rs/tracing/issues/2927 util.workspace = true uuid.workspace = true diff --git a/crates/collab/README.md b/crates/collab/README.md index 0ec6d8008ba313357c4ac8e44555ff978d9a1121..902c9841e2d7fb0b52b3143002fd0da29b980802 100644 --- a/crates/collab/README.md +++ b/crates/collab/README.md @@ -63,15 +63,3 @@ Deployment is triggered by pushing to the `collab-staging` (or `collab-productio - `./script/deploy-collab production` You can tell what is currently deployed with `./script/what-is-deployed`. - -# Database Migrations - -To create a new migration: - -```sh -./script/create-migration -``` - -Migrations are run automatically on service start, so run `foreman start` again. The service will crash if the migrations fail. - -When you create a new migration, you also need to update the [SQLite schema](./migrations.sqlite/20221109000000_test_schema.sql) that is used for testing. diff --git a/crates/collab/k8s/migrate.template.yml b/crates/collab/k8s/migrate.template.yml deleted file mode 100644 index c890d7b330c0eca260ca327e5f7db10259f91eaa..0000000000000000000000000000000000000000 --- a/crates/collab/k8s/migrate.template.yml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - namespace: ${ZED_KUBE_NAMESPACE} - name: ${ZED_MIGRATE_JOB_NAME} -spec: - template: - spec: - restartPolicy: Never - containers: - - name: migrator - imagePullPolicy: Always - image: ${ZED_IMAGE_ID} - args: - - migrate - env: - - name: DATABASE_URL - valueFrom: - secretKeyRef: - name: database - key: url diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index a736ddfd1fe3334b1b847e820bd1816cb625ddca..32a2ed2e1331fc7b16f859accd895a7bce055804 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -121,6 +121,8 @@ CREATE TABLE "project_repositories" ( "merge_message" VARCHAR, "branch_summary" VARCHAR, "head_commit_details" VARCHAR, + "remote_upstream_url" VARCHAR, + "remote_origin_url" VARCHAR, PRIMARY KEY (project_id, id) ); diff --git a/crates/collab/migrations/20210527024318_initial_schema.sql b/crates/collab/migrations/20210527024318_initial_schema.sql deleted file mode 100644 index 4b065318484a5c352dde00da9ba55744c4da9adb..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210527024318_initial_schema.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE IF NOT EXISTS "sessions" ( - "id" VARCHAR NOT NULL PRIMARY KEY, - "expires" TIMESTAMP WITH TIME ZONE NULL, - "session" TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS "users" ( - "id" SERIAL PRIMARY KEY, - "github_login" VARCHAR, - "admin" BOOLEAN -); - -CREATE UNIQUE INDEX "index_users_github_login" ON "users" ("github_login"); - -CREATE TABLE IF NOT EXISTS "signups" ( - "id" SERIAL PRIMARY KEY, - "github_login" VARCHAR, - "email_address" VARCHAR, - "about" TEXT -); diff --git a/crates/collab/migrations/20210607190313_create_access_tokens.sql b/crates/collab/migrations/20210607190313_create_access_tokens.sql deleted file mode 100644 index 60745a98bae9ac8bc3e2016e598480e74e0b6473..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210607190313_create_access_tokens.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS "access_tokens" ( - "id" SERIAL PRIMARY KEY, - "user_id" INTEGER REFERENCES users (id), - "hash" VARCHAR(128) -); - -CREATE INDEX "index_access_tokens_user_id" ON "access_tokens" ("user_id"); diff --git a/crates/collab/migrations/20210805175147_create_chat_tables.sql b/crates/collab/migrations/20210805175147_create_chat_tables.sql deleted file mode 100644 index 5bba4689d9c21e65d989cf05e2e1eedb0151621d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210805175147_create_chat_tables.sql +++ /dev/null @@ -1,46 +0,0 @@ -CREATE TABLE IF NOT EXISTS "orgs" ( - "id" SERIAL PRIMARY KEY, - "name" VARCHAR NOT NULL, - "slug" VARCHAR NOT NULL -); - -CREATE UNIQUE INDEX "index_orgs_slug" ON "orgs" ("slug"); - -CREATE TABLE IF NOT EXISTS "org_memberships" ( - "id" SERIAL PRIMARY KEY, - "org_id" INTEGER REFERENCES orgs (id) NOT NULL, - "user_id" INTEGER REFERENCES users (id) NOT NULL, - "admin" BOOLEAN NOT NULL -); - -CREATE INDEX "index_org_memberships_user_id" ON "org_memberships" ("user_id"); -CREATE UNIQUE INDEX "index_org_memberships_org_id_and_user_id" ON "org_memberships" ("org_id", "user_id"); - -CREATE TABLE IF NOT EXISTS "channels" ( - "id" SERIAL PRIMARY KEY, - "owner_id" INTEGER NOT NULL, - "owner_is_user" BOOLEAN NOT NULL, - "name" VARCHAR NOT NULL -); - -CREATE UNIQUE INDEX "index_channels_owner_and_name" ON "channels" ("owner_is_user", "owner_id", "name"); - -CREATE TABLE IF NOT EXISTS "channel_memberships" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER REFERENCES channels (id) NOT NULL, - "user_id" INTEGER REFERENCES users (id) NOT NULL, - "admin" BOOLEAN NOT NULL -); - -CREATE INDEX "index_channel_memberships_user_id" ON "channel_memberships" ("user_id"); -CREATE UNIQUE INDEX "index_channel_memberships_channel_id_and_user_id" ON "channel_memberships" ("channel_id", "user_id"); - -CREATE TABLE IF NOT EXISTS "channel_messages" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER REFERENCES channels (id) NOT NULL, - "sender_id" INTEGER REFERENCES users (id) NOT NULL, - "body" TEXT NOT NULL, - "sent_at" TIMESTAMP -); - -CREATE INDEX "index_channel_messages_channel_id" ON "channel_messages" ("channel_id"); diff --git a/crates/collab/migrations/20210916123647_add_nonce_to_channel_messages.sql b/crates/collab/migrations/20210916123647_add_nonce_to_channel_messages.sql deleted file mode 100644 index ee4d4aa319f6417e854137332011115570153eae..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210916123647_add_nonce_to_channel_messages.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE "channel_messages" -ADD "nonce" UUID NOT NULL DEFAULT gen_random_uuid(); - -CREATE UNIQUE INDEX "index_channel_messages_nonce" ON "channel_messages" ("nonce"); diff --git a/crates/collab/migrations/20210920192001_add_interests_to_signups.sql b/crates/collab/migrations/20210920192001_add_interests_to_signups.sql deleted file mode 100644 index 2457abfc757a3d3c6171d9639e2c280981689ead..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20210920192001_add_interests_to_signups.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE "signups" - ADD "wants_releases" BOOLEAN, - ADD "wants_updates" BOOLEAN, - ADD "wants_community" BOOLEAN; \ No newline at end of file diff --git a/crates/collab/migrations/20220421165757_drop_signups.sql b/crates/collab/migrations/20220421165757_drop_signups.sql deleted file mode 100644 index d7cd6e204c95e2ea10ee4b4b8183cbe701abb4c0..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220421165757_drop_signups.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS "signups"; diff --git a/crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql b/crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql deleted file mode 100644 index 3d6fd3179a236bf8407464f69f1e67469eb31d27..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220505144506_add_trigram_index_to_users.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS pg_trgm; -CREATE INDEX trigram_index_users_on_github_login ON users USING GIN(github_login gin_trgm_ops); diff --git a/crates/collab/migrations/20220506130724_create_contacts.sql b/crates/collab/migrations/20220506130724_create_contacts.sql deleted file mode 100644 index 56beb70fd06ce8a3b7bb00d2f0ada2e465906c69..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220506130724_create_contacts.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS "contacts" ( - "id" SERIAL PRIMARY KEY, - "user_id_a" INTEGER REFERENCES users (id) NOT NULL, - "user_id_b" INTEGER REFERENCES users (id) NOT NULL, - "a_to_b" BOOLEAN NOT NULL, - "should_notify" BOOLEAN NOT NULL, - "accepted" BOOLEAN NOT NULL -); - -CREATE UNIQUE INDEX "index_contacts_user_ids" ON "contacts" ("user_id_a", "user_id_b"); -CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b"); diff --git a/crates/collab/migrations/20220518151305_add_invites_to_users.sql b/crates/collab/migrations/20220518151305_add_invites_to_users.sql deleted file mode 100644 index 2ac89b649e8adab60ac93aeba36476d92484dc93..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220518151305_add_invites_to_users.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE users -ADD email_address VARCHAR(255) DEFAULT NULL, -ADD invite_code VARCHAR(64), -ADD invite_count INTEGER NOT NULL DEFAULT 0, -ADD inviter_id INTEGER REFERENCES users (id), -ADD connected_once BOOLEAN NOT NULL DEFAULT false, -ADD created_at TIMESTAMP NOT NULL DEFAULT NOW(); - -CREATE UNIQUE INDEX "index_invite_code_users" ON "users" ("invite_code"); diff --git a/crates/collab/migrations/20220523232954_allow_user_deletes.sql b/crates/collab/migrations/20220523232954_allow_user_deletes.sql deleted file mode 100644 index ddf3f6f9bd094bfa96f75efe72b64da06e47d0c5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220523232954_allow_user_deletes.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE contacts DROP CONSTRAINT contacts_user_id_a_fkey; -ALTER TABLE contacts DROP CONSTRAINT contacts_user_id_b_fkey; -ALTER TABLE contacts ADD CONSTRAINT contacts_user_id_a_fkey FOREIGN KEY (user_id_a) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE contacts ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES users(id) ON DELETE CASCADE; -ALTER TABLE users DROP CONSTRAINT users_inviter_id_fkey; -ALTER TABLE users ADD CONSTRAINT users_inviter_id_fkey FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE SET NULL; diff --git a/crates/collab/migrations/20220620211403_create_projects.sql b/crates/collab/migrations/20220620211403_create_projects.sql deleted file mode 100644 index d813c9f7a1811e50227c312c66df0fd679c35166..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220620211403_create_projects.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE IF NOT EXISTS "projects" ( - "id" SERIAL PRIMARY KEY, - "host_user_id" INTEGER REFERENCES users (id) NOT NULL, - "unregistered" BOOLEAN NOT NULL DEFAULT false -); - -CREATE TABLE IF NOT EXISTS "worktree_extensions" ( - "id" SERIAL PRIMARY KEY, - "project_id" INTEGER REFERENCES projects (id) NOT NULL, - "worktree_id" INTEGER NOT NULL, - "extension" VARCHAR(255), - "count" INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS "project_activity_periods" ( - "id" SERIAL PRIMARY KEY, - "duration_millis" INTEGER NOT NULL, - "ended_at" TIMESTAMP NOT NULL, - "user_id" INTEGER REFERENCES users (id) NOT NULL, - "project_id" INTEGER REFERENCES projects (id) NOT NULL -); - -CREATE INDEX "index_project_activity_periods_on_ended_at" ON "project_activity_periods" ("ended_at"); -CREATE UNIQUE INDEX "index_worktree_extensions_on_project_id_and_worktree_id_and_extension" ON "worktree_extensions" ("project_id", "worktree_id", "extension"); \ No newline at end of file diff --git a/crates/collab/migrations/20220913211150_create_signups.sql b/crates/collab/migrations/20220913211150_create_signups.sql deleted file mode 100644 index 19559b747c33b0fc146572201ba3dd1d1c37bf47..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220913211150_create_signups.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE IF NOT EXISTS "signups" ( - "id" SERIAL PRIMARY KEY, - "email_address" VARCHAR NOT NULL, - "email_confirmation_code" VARCHAR(64) NOT NULL, - "email_confirmation_sent" BOOLEAN NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "device_id" VARCHAR, - "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE, - "inviting_user_id" INTEGER REFERENCES users (id) ON DELETE SET NULL, - - "platform_mac" BOOLEAN NOT NULL, - "platform_linux" BOOLEAN NOT NULL, - "platform_windows" BOOLEAN NOT NULL, - "platform_unknown" BOOLEAN NOT NULL, - - "editor_features" VARCHAR[], - "programming_languages" VARCHAR[] -); - -CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_address"); -CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent"); - -ALTER TABLE "users" - ADD "github_user_id" INTEGER; - -CREATE INDEX "index_users_on_email_address" ON "users" ("email_address"); -CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"); diff --git a/crates/collab/migrations/20220929182110_add_metrics_id.sql b/crates/collab/migrations/20220929182110_add_metrics_id.sql deleted file mode 100644 index 665d6323bf13b5553859ae6763392770dc33bebb..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20220929182110_add_metrics_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "users" - ADD "metrics_id" uuid NOT NULL DEFAULT gen_random_uuid(); diff --git a/crates/collab/migrations/20221111092550_reconnection_support.sql b/crates/collab/migrations/20221111092550_reconnection_support.sql deleted file mode 100644 index 3289f6bbddb63e08acdc5e89a900193359423b2c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221111092550_reconnection_support.sql +++ /dev/null @@ -1,90 +0,0 @@ -CREATE TABLE IF NOT EXISTS "rooms" ( - "id" SERIAL PRIMARY KEY, - "live_kit_room" VARCHAR NOT NULL -); - -ALTER TABLE "projects" - ADD "room_id" INTEGER REFERENCES rooms (id), - ADD "host_connection_id" INTEGER, - ADD "host_connection_epoch" UUID; -CREATE INDEX "index_projects_on_host_connection_epoch" ON "projects" ("host_connection_epoch"); - -CREATE TABLE "worktrees" ( - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "id" INT8 NOT NULL, - "root_name" VARCHAR NOT NULL, - "abs_path" VARCHAR NOT NULL, - "visible" BOOL NOT NULL, - "scan_id" INT8 NOT NULL, - "is_complete" BOOL NOT NULL, - PRIMARY KEY(project_id, id) -); -CREATE INDEX "index_worktrees_on_project_id" ON "worktrees" ("project_id"); - -CREATE TABLE "worktree_entries" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "id" INT8 NOT NULL, - "is_dir" BOOL NOT NULL, - "path" VARCHAR NOT NULL, - "inode" INT8 NOT NULL, - "mtime_seconds" INT8 NOT NULL, - "mtime_nanos" INTEGER NOT NULL, - "is_symlink" BOOL NOT NULL, - "is_ignored" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, id), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_entries_on_project_id" ON "worktree_entries" ("project_id"); -CREATE INDEX "index_worktree_entries_on_project_id_and_worktree_id" ON "worktree_entries" ("project_id", "worktree_id"); - -CREATE TABLE "worktree_diagnostic_summaries" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "path" VARCHAR NOT NULL, - "language_server_id" INT8 NOT NULL, - "error_count" INTEGER NOT NULL, - "warning_count" INTEGER NOT NULL, - PRIMARY KEY(project_id, worktree_id, path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_diagnostic_summaries_on_project_id" ON "worktree_diagnostic_summaries" ("project_id"); -CREATE INDEX "index_worktree_diagnostic_summaries_on_project_id_and_worktree_id" ON "worktree_diagnostic_summaries" ("project_id", "worktree_id"); - -CREATE TABLE "language_servers" ( - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "id" INT8 NOT NULL, - "name" VARCHAR NOT NULL, - PRIMARY KEY(project_id, id) -); -CREATE INDEX "index_language_servers_on_project_id" ON "language_servers" ("project_id"); - -CREATE TABLE "project_collaborators" ( - "id" SERIAL PRIMARY KEY, - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "connection_id" INTEGER NOT NULL, - "connection_epoch" UUID NOT NULL, - "user_id" INTEGER NOT NULL, - "replica_id" INTEGER NOT NULL, - "is_host" BOOLEAN NOT NULL -); -CREATE INDEX "index_project_collaborators_on_project_id" ON "project_collaborators" ("project_id"); -CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_and_replica_id" ON "project_collaborators" ("project_id", "replica_id"); -CREATE INDEX "index_project_collaborators_on_connection_epoch" ON "project_collaborators" ("connection_epoch"); - -CREATE TABLE "room_participants" ( - "id" SERIAL PRIMARY KEY, - "room_id" INTEGER NOT NULL REFERENCES rooms (id), - "user_id" INTEGER NOT NULL REFERENCES users (id), - "answering_connection_id" INTEGER, - "answering_connection_epoch" UUID, - "location_kind" INTEGER, - "location_project_id" INTEGER, - "initial_project_id" INTEGER, - "calling_user_id" INTEGER NOT NULL REFERENCES users (id), - "calling_connection_id" INTEGER NOT NULL, - "calling_connection_epoch" UUID NOT NULL -); -CREATE UNIQUE INDEX "index_room_participants_on_user_id" ON "room_participants" ("user_id"); -CREATE INDEX "index_room_participants_on_answering_connection_epoch" ON "room_participants" ("answering_connection_epoch"); -CREATE INDEX "index_room_participants_on_calling_connection_epoch" ON "room_participants" ("calling_connection_epoch"); diff --git a/crates/collab/migrations/20221125192125_add_added_to_mailing_list_to_signups.sql b/crates/collab/migrations/20221125192125_add_added_to_mailing_list_to_signups.sql deleted file mode 100644 index b154396df1259aa73b5e1a17c9db27d04510e062..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221125192125_add_added_to_mailing_list_to_signups.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "signups" - ADD "added_to_mailing_list" BOOLEAN NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/crates/collab/migrations/20221207165001_add_connection_lost_to_room_participants.sql b/crates/collab/migrations/20221207165001_add_connection_lost_to_room_participants.sql deleted file mode 100644 index ed0cf972bc97f517fb878806b0929e8122b2b8a2..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221207165001_add_connection_lost_to_room_participants.sql +++ /dev/null @@ -1,7 +0,0 @@ -ALTER TABLE "room_participants" - ADD "answering_connection_lost" BOOLEAN NOT NULL DEFAULT FALSE; - -CREATE INDEX "index_project_collaborators_on_connection_id" ON "project_collaborators" ("connection_id"); -CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_epoch" ON "project_collaborators" ("project_id", "connection_id", "connection_epoch"); -CREATE INDEX "index_room_participants_on_answering_connection_id" ON "room_participants" ("answering_connection_id"); -CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_epoch" ON "room_participants" ("answering_connection_id", "answering_connection_epoch"); diff --git a/crates/collab/migrations/20221213125710_index_room_participants_on_room_id.sql b/crates/collab/migrations/20221213125710_index_room_participants_on_room_id.sql deleted file mode 100644 index f40ca81906f41e8c30530ce349f892bb69111657..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221213125710_index_room_participants_on_room_id.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id"); diff --git a/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql b/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql deleted file mode 100644 index 5e02f76ce25d59d799d5e5d9719e4e038d1bac02..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE TABLE servers ( - id SERIAL PRIMARY KEY, - environment VARCHAR NOT NULL -); - -DROP TABLE worktree_extensions; -DROP TABLE project_activity_periods; -DELETE from projects; -ALTER TABLE projects - DROP COLUMN host_connection_epoch, - ADD COLUMN host_connection_server_id INTEGER REFERENCES servers (id) ON DELETE CASCADE; -CREATE INDEX "index_projects_on_host_connection_server_id" ON "projects" ("host_connection_server_id"); -CREATE INDEX "index_projects_on_host_connection_id_and_host_connection_server_id" ON "projects" ("host_connection_id", "host_connection_server_id"); - -DELETE FROM project_collaborators; -ALTER TABLE project_collaborators - DROP COLUMN connection_epoch, - ADD COLUMN connection_server_id INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE; -CREATE INDEX "index_project_collaborators_on_connection_server_id" ON "project_collaborators" ("connection_server_id"); -CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_server_id" ON "project_collaborators" ("project_id", "connection_id", "connection_server_id"); - -DELETE FROM room_participants; -ALTER TABLE room_participants - DROP COLUMN answering_connection_epoch, - DROP COLUMN calling_connection_epoch, - ADD COLUMN answering_connection_server_id INTEGER REFERENCES servers (id) ON DELETE CASCADE, - ADD COLUMN calling_connection_server_id INTEGER REFERENCES servers (id) ON DELETE SET NULL; -CREATE INDEX "index_room_participants_on_answering_connection_server_id" ON "room_participants" ("answering_connection_server_id"); -CREATE INDEX "index_room_participants_on_calling_connection_server_id" ON "room_participants" ("calling_connection_server_id"); -CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_server_id" ON "room_participants" ("answering_connection_id", "answering_connection_server_id"); diff --git a/crates/collab/migrations/20221219181850_project_reconnection_support.sql b/crates/collab/migrations/20221219181850_project_reconnection_support.sql deleted file mode 100644 index 6efef5571c5855beb0c4b59d5f52ff92b323bb20..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20221219181850_project_reconnection_support.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "worktree_entries" - ADD COLUMN "scan_id" INT8, - ADD COLUMN "is_deleted" BOOL; diff --git a/crates/collab/migrations/20230103200902_replace_is_completed_with_completed_scan_id.sql b/crates/collab/migrations/20230103200902_replace_is_completed_with_completed_scan_id.sql deleted file mode 100644 index 1894d888b92a89508981abe5de7f5fc3e710184f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230103200902_replace_is_completed_with_completed_scan_id.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE worktrees - ALTER COLUMN is_complete SET DEFAULT FALSE, - ADD COLUMN completed_scan_id INT8; diff --git a/crates/collab/migrations/20230202155735_followers.sql b/crates/collab/migrations/20230202155735_followers.sql deleted file mode 100644 index c82d6ba3bdaa4f2b2a60771bca7401c47678f247..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230202155735_followers.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE IF NOT EXISTS "followers" ( - "id" SERIAL PRIMARY KEY, - "room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE, - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, - "leader_connection_id" INTEGER NOT NULL, - "follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, - "follower_connection_id" INTEGER NOT NULL -); - -CREATE UNIQUE INDEX - "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id" -ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id"); - -CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); diff --git a/crates/collab/migrations/20230508211523_add-repository-entries.sql b/crates/collab/migrations/20230508211523_add-repository-entries.sql deleted file mode 100644 index 1e593479394c8434f56f3519b41ce2fa2a9fc2a3..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230508211523_add-repository-entries.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE "worktree_repositories" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "work_directory_id" INT8 NOT NULL, - "scan_id" INT8 NOT NULL, - "branch" VARCHAR, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, work_directory_id), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE, - FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_worktree_repositories_on_project_id" ON "worktree_repositories" ("project_id"); -CREATE INDEX "index_worktree_repositories_on_project_id_and_worktree_id" ON "worktree_repositories" ("project_id", "worktree_id"); diff --git a/crates/collab/migrations/20230511004019_add_repository_statuses.sql b/crates/collab/migrations/20230511004019_add_repository_statuses.sql deleted file mode 100644 index 862561c6866d361ca628924a15b925d97d0c39cb..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230511004019_add_repository_statuses.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE "worktree_repository_statuses" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "work_directory_id" INT8 NOT NULL, - "repo_path" VARCHAR NOT NULL, - "status" INT8 NOT NULL, - "scan_id" INT8 NOT NULL, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY(project_id, worktree_id, work_directory_id, repo_path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE, - FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_wt_repos_statuses_on_project_id" ON "worktree_repository_statuses" ("project_id"); -CREATE INDEX "index_wt_repos_statuses_on_project_id_and_wt_id" ON "worktree_repository_statuses" ("project_id", "worktree_id"); -CREATE INDEX "index_wt_repos_statuses_on_project_id_and_wt_id_and_wd_id" ON "worktree_repository_statuses" ("project_id", "worktree_id", "work_directory_id"); diff --git a/crates/collab/migrations/20230529164700_add_worktree_settings_files.sql b/crates/collab/migrations/20230529164700_add_worktree_settings_files.sql deleted file mode 100644 index 973a40af0f21908e5dbe0d5a30373629f24b7f1e..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230529164700_add_worktree_settings_files.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE "worktree_settings_files" ( - "project_id" INTEGER NOT NULL, - "worktree_id" INT8 NOT NULL, - "path" VARCHAR NOT NULL, - "content" TEXT NOT NULL, - PRIMARY KEY(project_id, worktree_id, path), - FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE -); -CREATE INDEX "index_settings_files_on_project_id" ON "worktree_settings_files" ("project_id"); -CREATE INDEX "index_settings_files_on_project_id_and_wt_id" ON "worktree_settings_files" ("project_id", "worktree_id"); diff --git a/crates/collab/migrations/20230605191135_remove_repository_statuses.sql b/crates/collab/migrations/20230605191135_remove_repository_statuses.sql deleted file mode 100644 index 3e5f907c442d3604ebff5f2fbf60e9c34caa25d9..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230605191135_remove_repository_statuses.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "git_status" INT8; diff --git a/crates/collab/migrations/20230616134535_add_is_external_to_worktree_entries.sql b/crates/collab/migrations/20230616134535_add_is_external_to_worktree_entries.sql deleted file mode 100644 index e4348af0cc5c12a43fac3adecc106eb16a6de005..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230616134535_add_is_external_to_worktree_entries.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "is_external" BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20230727150500_add_channels.sql b/crates/collab/migrations/20230727150500_add_channels.sql deleted file mode 100644 index df981838bf72d7ef7392ed6f4e302ffdc57631db..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230727150500_add_channels.sql +++ /dev/null @@ -1,30 +0,0 @@ -DROP TABLE "channel_messages"; -DROP TABLE "channel_memberships"; -DROP TABLE "org_memberships"; -DROP TABLE "orgs"; -DROP TABLE "channels"; - -CREATE TABLE "channels" ( - "id" SERIAL PRIMARY KEY, - "name" VARCHAR NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT now() -); - -CREATE TABLE "channel_paths" ( - "id_path" VARCHAR NOT NULL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE -); -CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id"); - -CREATE TABLE "channel_members" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "admin" BOOLEAN NOT NULL DEFAULT false, - "accepted" BOOLEAN NOT NULL DEFAULT false, - "updated_at" TIMESTAMP NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id"); - -ALTER TABLE rooms ADD COLUMN "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE; diff --git a/crates/collab/migrations/20230819154600_add_channel_buffers.sql b/crates/collab/migrations/20230819154600_add_channel_buffers.sql deleted file mode 100644 index 5e6e7ce3393a628c86cbcdabf2349ebfa6667bd6..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230819154600_add_channel_buffers.sql +++ /dev/null @@ -1,40 +0,0 @@ -CREATE TABLE "buffers" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL DEFAULT 0 -); - -CREATE INDEX "index_buffers_on_channel_id" ON "buffers" ("channel_id"); - -CREATE TABLE "buffer_operations" ( - "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL, - "replica_id" INTEGER NOT NULL, - "lamport_timestamp" INTEGER NOT NULL, - "value" BYTEA NOT NULL, - PRIMARY KEY(buffer_id, epoch, lamport_timestamp, replica_id) -); - -CREATE TABLE "buffer_snapshots" ( - "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL, - "text" TEXT NOT NULL, - "operation_serialization_version" INTEGER NOT NULL, - PRIMARY KEY(buffer_id, epoch) -); - -CREATE TABLE "channel_buffer_collaborators" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "connection_id" INTEGER NOT NULL, - "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, - "connection_lost" BOOLEAN NOT NULL DEFAULT FALSE, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "replica_id" INTEGER NOT NULL -); - -CREATE INDEX "index_channel_buffer_collaborators_on_channel_id" ON "channel_buffer_collaborators" ("channel_id"); -CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_and_replica_id" ON "channel_buffer_collaborators" ("channel_id", "replica_id"); -CREATE INDEX "index_channel_buffer_collaborators_on_connection_server_id" ON "channel_buffer_collaborators" ("connection_server_id"); -CREATE INDEX "index_channel_buffer_collaborators_on_connection_id" ON "channel_buffer_collaborators" ("connection_id"); -CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_connection_id_and_server_id" ON "channel_buffer_collaborators" ("channel_id", "connection_id", "connection_server_id"); diff --git a/crates/collab/migrations/20230825190322_add_server_feature_flags.sql b/crates/collab/migrations/20230825190322_add_server_feature_flags.sql deleted file mode 100644 index fffde54a20e4869ccbef2093de4e7fe5044132e2..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230825190322_add_server_feature_flags.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE "feature_flags" ( - "id" SERIAL PRIMARY KEY, - "flag" VARCHAR(255) NOT NULL UNIQUE -); - -CREATE UNIQUE INDEX "index_feature_flags" ON "feature_flags" ("id"); - -CREATE TABLE "user_features" ( - "user_id" INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - "feature_id" INTEGER NOT NULL REFERENCES feature_flags(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, feature_id) -); - -CREATE UNIQUE INDEX "index_user_features_user_id_and_feature_id" ON "user_features" ("user_id", "feature_id"); -CREATE INDEX "index_user_features_on_user_id" ON "user_features" ("user_id"); -CREATE INDEX "index_user_features_on_feature_id" ON "user_features" ("feature_id"); diff --git a/crates/collab/migrations/20230907114200_add_channel_messages.sql b/crates/collab/migrations/20230907114200_add_channel_messages.sql deleted file mode 100644 index abe7753ca69fb45a1f0a56b732963d8dc5605e31..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230907114200_add_channel_messages.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE IF NOT EXISTS "channel_messages" ( - "id" SERIAL PRIMARY KEY, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "sender_id" INTEGER NOT NULL REFERENCES users (id), - "body" TEXT NOT NULL, - "sent_at" TIMESTAMP, - "nonce" UUID NOT NULL -); -CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id"); -CREATE UNIQUE INDEX "index_channel_messages_on_nonce" ON "channel_messages" ("nonce"); - -CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( - "id" SERIAL PRIMARY KEY, - "user_id" INTEGER NOT NULL REFERENCES users (id), - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "connection_id" INTEGER NOT NULL, - "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE -); -CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_participants" ("channel_id"); diff --git a/crates/collab/migrations/20230925210437_add_channel_changes.sql b/crates/collab/migrations/20230925210437_add_channel_changes.sql deleted file mode 100644 index 250a9ac731b59489e85cf34a6754307bfac543ee..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230925210437_add_channel_changes.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE IF NOT EXISTS "observed_buffer_edits" ( - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, - "epoch" INTEGER NOT NULL, - "lamport_timestamp" INTEGER NOT NULL, - "replica_id" INTEGER NOT NULL, - PRIMARY KEY (user_id, buffer_id) -); - -CREATE UNIQUE INDEX "index_observed_buffer_user_and_buffer_id" ON "observed_buffer_edits" ("user_id", "buffer_id"); - -CREATE TABLE IF NOT EXISTS "observed_channel_messages" ( - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "channel_message_id" INTEGER NOT NULL, - PRIMARY KEY (user_id, channel_id) -); - -CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id"); diff --git a/crates/collab/migrations/20230926102500_add_participant_index_to_room_participants.sql b/crates/collab/migrations/20230926102500_add_participant_index_to_room_participants.sql deleted file mode 100644 index 1493119e2a97ac42f5d69ebc82ac3d3d0dc4dd63..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20230926102500_add_participant_index_to_room_participants.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE room_participants ADD COLUMN participant_index INTEGER; diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql deleted file mode 100644 index 93c282c631f3d5545593b7c71f013d8457cd088a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE "notification_kinds" ( - "id" SERIAL PRIMARY KEY, - "name" VARCHAR NOT NULL -); - -CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name"); - -CREATE TABLE notifications ( - "id" SERIAL PRIMARY KEY, - "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), - "entity_id" INTEGER, - "content" TEXT, - "is_read" BOOLEAN NOT NULL DEFAULT FALSE, - "response" BOOLEAN -); - -CREATE INDEX - "index_notifications_on_recipient_id_is_read_kind_entity_id" - ON "notifications" - ("recipient_id", "is_read", "kind", "entity_id"); diff --git a/crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql b/crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql deleted file mode 100644 index 8f3a704adde0c385b26bd553d273eff322a17702..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE rooms ADD COLUMN enviroment TEXT; diff --git a/crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql b/crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql deleted file mode 100644 index 21ec4cfbb75a574ad3704179a0ae14c8050149d1..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id"); diff --git a/crates/collab/migrations/20231011214412_add_guest_role.sql b/crates/collab/migrations/20231011214412_add_guest_role.sql deleted file mode 100644 index 17135471583a03bd6b39e82b3644b683cfc96d57..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231011214412_add_guest_role.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE channel_members ADD COLUMN role TEXT; -UPDATE channel_members SET role = CASE WHEN admin THEN 'admin' ELSE 'member' END; - -ALTER TABLE channels ADD COLUMN visibility TEXT NOT NULL DEFAULT 'members'; diff --git a/crates/collab/migrations/20231017185833_projects_room_id_fkey_on_delete_cascade.sql b/crates/collab/migrations/20231017185833_projects_room_id_fkey_on_delete_cascade.sql deleted file mode 100644 index be535ff7fa6e707182b8698647ecc92d9f976183..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231017185833_projects_room_id_fkey_on_delete_cascade.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Add migration script here - -ALTER TABLE projects - DROP CONSTRAINT projects_room_id_fkey, - ADD CONSTRAINT projects_room_id_fkey - FOREIGN KEY (room_id) - REFERENCES rooms (id) - ON DELETE CASCADE; diff --git a/crates/collab/migrations/20231018102700_create_mentions.sql b/crates/collab/migrations/20231018102700_create_mentions.sql deleted file mode 100644 index 221a1748cfe16276deb4fc3dd2329983340307e7..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231018102700_create_mentions.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE "channel_message_mentions" ( - "message_id" INTEGER NOT NULL REFERENCES channel_messages (id) ON DELETE CASCADE, - "start_offset" INTEGER NOT NULL, - "end_offset" INTEGER NOT NULL, - "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - PRIMARY KEY(message_id, start_offset) -); - --- We use 'on conflict update' with this index, so it should be per-user. -CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce"); -DROP INDEX "index_channel_messages_on_nonce"; diff --git a/crates/collab/migrations/20231024085546_move_channel_paths_to_channels_table.sql b/crates/collab/migrations/20231024085546_move_channel_paths_to_channels_table.sql deleted file mode 100644 index d9fc6c872267b89fe30c958b4b01bb7fdf1fc448..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20231024085546_move_channel_paths_to_channels_table.sql +++ /dev/null @@ -1,12 +0,0 @@ -ALTER TABLE channels ADD COLUMN parent_path TEXT; - -UPDATE channels -SET parent_path = substr( - channel_paths.id_path, - 2, - length(channel_paths.id_path) - length('/' || channel_paths.channel_id::text || '/') -) -FROM channel_paths -WHERE channel_paths.channel_id = channels.id; - -CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path"); diff --git a/crates/collab/migrations/20240103025509_add_role_to_room_participants.sql b/crates/collab/migrations/20240103025509_add_role_to_room_participants.sql deleted file mode 100644 index 2748e00ebaa18ec375111c648a7accafe90c5dbb..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240103025509_add_role_to_room_participants.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE room_participants ADD COLUMN role TEXT; diff --git a/crates/collab/migrations/20240111085546_fix_column_name.sql b/crates/collab/migrations/20240111085546_fix_column_name.sql deleted file mode 100644 index 3f32ee35c59107e12fda98159911dbba6e13434a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240111085546_fix_column_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE rooms ADD COLUMN environment TEXT; diff --git a/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql b/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql deleted file mode 100644 index 8c79640cd88bfad58e5f9eafda90ae2d80e4e834..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240117150300_add_impersonator_to_access_tokens.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE access_tokens ADD COLUMN impersonated_user_id integer; diff --git a/crates/collab/migrations/20240122174606_add_contributors.sql b/crates/collab/migrations/20240122174606_add_contributors.sql deleted file mode 100644 index 16bec82d4f2bd0a1b3f4221366cd822ebcd70bb1..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240122174606_add_contributors.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE contributors ( - user_id INTEGER REFERENCES users(id), - signed_at TIMESTAMP NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id) -); diff --git a/crates/collab/migrations/20240122224506_add_requires_zed_cla_column_to_channels.sql b/crates/collab/migrations/20240122224506_add_requires_zed_cla_column_to_channels.sql deleted file mode 100644 index a9248d294a2178b73986ab20cd06383d0397626b..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240122224506_add_requires_zed_cla_column_to_channels.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "channels" ADD COLUMN "requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20240129193601_fix_parent_path_index.sql b/crates/collab/migrations/20240129193601_fix_parent_path_index.sql deleted file mode 100644 index 73dd6e37cdf82f2f5d77e8b3cd14a2fbe43f2320..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240129193601_fix_parent_path_index.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add migration script here - -DROP INDEX index_channels_on_parent_path; -CREATE INDEX index_channels_on_parent_path ON channels (parent_path text_pattern_ops); diff --git a/crates/collab/migrations/20240203113741_add_reply_to_message.sql b/crates/collab/migrations/20240203113741_add_reply_to_message.sql deleted file mode 100644 index 6f40b62822bb4936f0f90e3be65f640e323d09d0..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240203113741_add_reply_to_message.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE channel_messages ADD reply_to_message_id INTEGER DEFAULT NULL diff --git a/crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql b/crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql deleted file mode 100644 index 09463c6e784d4e13df5376ad3cd53c8cb9ebcf45..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240207041417_add_in_call_column_to_room_participants.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add migration script here - -ALTER TABLE room_participants ADD COLUMN in_call BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20240213200201_remove_unused_room_columns.sql b/crates/collab/migrations/20240213200201_remove_unused_room_columns.sql deleted file mode 100644 index dc4897af48afd3fa9ebc403b6f8103b933993ac4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240213200201_remove_unused_room_columns.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add migration script here -ALTER TABLE rooms DROP COLUMN enviroment; -ALTER TABLE rooms DROP COLUMN environment; -ALTER TABLE room_participants DROP COLUMN in_call; diff --git a/crates/collab/migrations/20240214102900_add_extensions.sql b/crates/collab/migrations/20240214102900_add_extensions.sql deleted file mode 100644 index b32094036d6a8993a8dbc6dc2407dec0e53aea47..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240214102900_add_extensions.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE IF NOT EXISTS extensions ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - external_id TEXT NOT NULL, - latest_version TEXT NOT NULL, - total_download_count BIGINT NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS extension_versions ( - extension_id INTEGER REFERENCES extensions(id), - version TEXT NOT NULL, - published_at TIMESTAMP NOT NULL DEFAULT now(), - authors TEXT NOT NULL, - repository TEXT NOT NULL, - description TEXT NOT NULL, - download_count BIGINT NOT NULL DEFAULT 0, - PRIMARY KEY(extension_id, version) -); - -CREATE UNIQUE INDEX "index_extensions_external_id" ON "extensions" ("external_id"); -CREATE INDEX "trigram_index_extensions_name" ON "extensions" USING GIN(name gin_trgm_ops); -CREATE INDEX "index_extensions_total_download_count" ON "extensions" ("total_download_count"); diff --git a/crates/collab/migrations/20240220234826_add_rate_buckets.sql b/crates/collab/migrations/20240220234826_add_rate_buckets.sql deleted file mode 100644 index 864a4373034fc53ea357f0b4d46b1b127a9f8db5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240220234826_add_rate_buckets.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS rate_buckets ( - user_id INT NOT NULL, - rate_limit_name VARCHAR(255) NOT NULL, - token_count INT NOT NULL, - last_refill TIMESTAMP WITHOUT TIME ZONE NOT NULL, - PRIMARY KEY (user_id, rate_limit_name), - CONSTRAINT fk_user - FOREIGN KEY (user_id) REFERENCES users(id) -); - -CREATE INDEX idx_user_id_rate_limit ON rate_buckets (user_id, rate_limit_name); diff --git a/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql b/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql deleted file mode 100644 index 1d07b07de7bf382fe0610ce170a639ab769567d4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240221151017_add_edited_at_field_to_channel_message.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE channel_messages ADD edited_at TIMESTAMP DEFAULT NULL; diff --git a/crates/collab/migrations/20240226163408_hosted_projects.sql b/crates/collab/migrations/20240226163408_hosted_projects.sql deleted file mode 100644 index c6ade7161cce7eafc00564f3e1b934be93b08186..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240226163408_hosted_projects.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Add migration script here - -CREATE TABLE hosted_projects ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - channel_id INT NOT NULL REFERENCES channels(id), - name TEXT NOT NULL, - visibility TEXT NOT NULL, - deleted_at TIMESTAMP NULL -); -CREATE INDEX idx_hosted_projects_on_channel_id ON hosted_projects (channel_id); -CREATE UNIQUE INDEX uix_hosted_projects_on_channel_id_and_name ON hosted_projects (channel_id, name) WHERE (deleted_at IS NULL); diff --git a/crates/collab/migrations/20240226164505_unique_channel_names.sql b/crates/collab/migrations/20240226164505_unique_channel_names.sql deleted file mode 100644 index c9d9f0a1cbf55249ee1fc80c5338bdc45381b46f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240226164505_unique_channel_names.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add migration script here - -CREATE UNIQUE INDEX uix_channels_parent_path_name ON channels(parent_path, name) WHERE (parent_path IS NOT NULL AND parent_path != ''); diff --git a/crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql b/crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql deleted file mode 100644 index 69905d12f6d0e9945f291dc7b86f446bdaab08ac..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240227215556_hosted_projects_in_projects.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Add migration script here -ALTER TABLE projects ALTER COLUMN host_user_id DROP NOT NULL; -ALTER TABLE projects ADD COLUMN hosted_project_id INTEGER REFERENCES hosted_projects(id) UNIQUE NULL; diff --git a/crates/collab/migrations/20240307163119_denormalize_buffer_ops.sql b/crates/collab/migrations/20240307163119_denormalize_buffer_ops.sql deleted file mode 100644 index a332a20d52c4564ac90989a60fc0dd850d86034c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240307163119_denormalize_buffer_ops.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Add migration script here - -ALTER TABLE buffers ADD COLUMN latest_operation_epoch INTEGER; -ALTER TABLE buffers ADD COLUMN latest_operation_lamport_timestamp INTEGER; -ALTER TABLE buffers ADD COLUMN latest_operation_replica_id INTEGER; - -WITH ops AS ( - SELECT DISTINCT ON (buffer_id) buffer_id, epoch, lamport_timestamp, replica_id - FROM buffer_operations - ORDER BY buffer_id, epoch DESC, lamport_timestamp DESC, replica_id DESC -) -UPDATE buffers -SET latest_operation_epoch = ops.epoch, - latest_operation_lamport_timestamp = ops.lamport_timestamp, - latest_operation_replica_id = ops.replica_id -FROM ops -WHERE buffers.id = ops.buffer_id; diff --git a/crates/collab/migrations/20240315182903_non_null_channel_role.sql b/crates/collab/migrations/20240315182903_non_null_channel_role.sql deleted file mode 100644 index 2d359f8058f0591e47bc733f1c8b4c60fe3a56cd..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240315182903_non_null_channel_role.sql +++ /dev/null @@ -1,4 +0,0 @@ --- Add migration script here - -ALTER TABLE channel_members ALTER role SET NOT NULL; -ALTER TABLE channel_members DROP COLUMN admin; diff --git a/crates/collab/migrations/20240315183903_channel_parent_path_not_null.sql b/crates/collab/migrations/20240315183903_channel_parent_path_not_null.sql deleted file mode 100644 index 5703578b008817bacaa6e1956b28cdac7919e5fa..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240315183903_channel_parent_path_not_null.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add migration script here -ALTER TABLE channels ALTER parent_path SET NOT NULL; diff --git a/crates/collab/migrations/20240320124800_add_extension_schema_version.sql b/crates/collab/migrations/20240320124800_add_extension_schema_version.sql deleted file mode 100644 index 75fd0f40e4f9013f41e57cd094b3e93bf5f46101..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240320124800_add_extension_schema_version.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add migration script here -ALTER TABLE extension_versions ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 0; diff --git a/crates/collab/migrations/20240321162658_add_devservers.sql b/crates/collab/migrations/20240321162658_add_devservers.sql deleted file mode 100644 index cb1ff4df405f9f1deb9d2c9e86f4234b9ba6d2b2..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240321162658_add_devservers.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE dev_servers ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - channel_id INT NOT NULL REFERENCES channels(id), - name TEXT NOT NULL, - hashed_token TEXT NOT NULL -); -CREATE INDEX idx_dev_servers_on_channel_id ON dev_servers (channel_id); diff --git a/crates/collab/migrations/20240335123500_add_extension_wasm_api_version.sql b/crates/collab/migrations/20240335123500_add_extension_wasm_api_version.sql deleted file mode 100644 index 3b95323d262f1666dfbe8696a780dd5e8b674c99..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240335123500_add_extension_wasm_api_version.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE extension_versions ADD COLUMN wasm_api_version TEXT; diff --git a/crates/collab/migrations/20240402155003_add_dev_server_projects.sql b/crates/collab/migrations/20240402155003_add_dev_server_projects.sql deleted file mode 100644 index 003c43f4e27f4f7fb4cbfbb84b4be11d3a42ecc0..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240402155003_add_dev_server_projects.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE remote_projects ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - channel_id INT NOT NULL REFERENCES channels(id), - dev_server_id INT NOT NULL REFERENCES dev_servers(id), - name TEXT NOT NULL, - path TEXT NOT NULL -); - -ALTER TABLE projects ADD COLUMN remote_project_id INTEGER REFERENCES remote_projects(id); diff --git a/crates/collab/migrations/20240409082755_create_embeddings.sql b/crates/collab/migrations/20240409082755_create_embeddings.sql deleted file mode 100644 index ae4b4bcb61c049ea75726fb92eaf2c795891370e..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240409082755_create_embeddings.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS "embeddings" ( - "model" TEXT, - "digest" BYTEA, - "dimensions" FLOAT4[1536], - "retrieved_at" TIMESTAMP NOT NULL DEFAULT now(), - PRIMARY KEY ("model", "digest") -); - -CREATE INDEX IF NOT EXISTS "idx_retrieved_at_on_embeddings" ON "embeddings" ("retrieved_at"); diff --git a/crates/collab/migrations/20240412165156_dev_servers_per_user.sql b/crates/collab/migrations/20240412165156_dev_servers_per_user.sql deleted file mode 100644 index 7ef9e2fde0530aeca9598a0a1030eabdba3036f0..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240412165156_dev_servers_per_user.sql +++ /dev/null @@ -1,7 +0,0 @@ -DELETE FROM remote_projects; -DELETE FROM dev_servers; - -ALTER TABLE dev_servers DROP COLUMN channel_id; -ALTER TABLE dev_servers ADD COLUMN user_id INT NOT NULL REFERENCES users(id); - -ALTER TABLE remote_projects DROP COLUMN channel_id; diff --git a/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql b/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql deleted file mode 100644 index 923b948ceeb3ebe27502cb773a5232bc6cc39fc4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE remote_projects DROP COLUMN name; -ALTER TABLE remote_projects -ADD CONSTRAINT unique_path_constraint UNIQUE(dev_server_id, path); diff --git a/crates/collab/migrations/20240502150229_rename_to_dev_server_projects.sql b/crates/collab/migrations/20240502150229_rename_to_dev_server_projects.sql deleted file mode 100644 index 0d8e9de5e6ada47c617401b53dcca5d18b643aa6..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240502150229_rename_to_dev_server_projects.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE dev_server_projects ( - id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY (START WITH 100), - dev_server_id INT NOT NULL REFERENCES dev_servers(id) ON DELETE CASCADE, - path TEXT NOT NULL -); -INSERT INTO dev_server_projects OVERRIDING SYSTEM VALUE SELECT * FROM remote_projects; - -ALTER TABLE dev_server_projects ADD CONSTRAINT uix_dev_server_projects_dev_server_id_path UNIQUE(dev_server_id, path); - -ALTER TABLE projects ADD COLUMN dev_server_project_id INTEGER REFERENCES dev_server_projects(id); -UPDATE projects SET dev_server_project_id = remote_project_id; diff --git a/crates/collab/migrations/20240502180204_remove_old_remote_projects.sql b/crates/collab/migrations/20240502180204_remove_old_remote_projects.sql deleted file mode 100644 index 01ace43fab08bfeee9b7e665ccd28ebc50f27134..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240502180204_remove_old_remote_projects.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE projects DROP COLUMN remote_project_id; -DROP TABLE remote_projects; diff --git a/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql b/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql deleted file mode 100644 index 5085ca271bd130ab25d173391daae8542930ae27..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240514164510_store_ssh_connect_string.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE dev_servers ADD COLUMN ssh_connection_string TEXT; diff --git a/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql b/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql deleted file mode 100644 index 675df4885bb531722cf80a76bf93eac58add5b8c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE dev_server_projects ADD COLUMN paths JSONB NULL; -UPDATE dev_server_projects SET paths = to_json(ARRAY[path]); -ALTER TABLE dev_server_projects ALTER COLUMN paths SET NOT NULL; -ALTER TABLE dev_server_projects ALTER COLUMN path DROP NOT NULL; diff --git a/crates/collab/migrations/20240729170526_add_billing_subscription.sql b/crates/collab/migrations/20240729170526_add_billing_subscription.sql deleted file mode 100644 index acec4b3ddb43c3a59e221daabad4b3161c867d09..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240729170526_add_billing_subscription.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS billing_subscriptions ( - id SERIAL PRIMARY KEY, - created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - stripe_customer_id TEXT NOT NULL, - stripe_subscription_id TEXT NOT NULL, - stripe_subscription_status TEXT NOT NULL -); - -CREATE INDEX "ix_billing_subscriptions_on_user_id" ON billing_subscriptions (user_id); -CREATE INDEX "ix_billing_subscriptions_on_stripe_customer_id" ON billing_subscriptions (stripe_customer_id); -CREATE UNIQUE INDEX "uix_billing_subscriptions_on_stripe_subscription_id" ON billing_subscriptions (stripe_subscription_id); diff --git a/crates/collab/migrations/20240730014107_add_billing_customer.sql b/crates/collab/migrations/20240730014107_add_billing_customer.sql deleted file mode 100644 index 7f7d4a0f85608ba07595b81d90dd617a8acd4e0c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240730014107_add_billing_customer.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE IF NOT EXISTS billing_customers ( - id SERIAL PRIMARY KEY, - created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now(), - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - stripe_customer_id TEXT NOT NULL -); - -CREATE UNIQUE INDEX "uix_billing_customers_on_user_id" ON billing_customers (user_id); -CREATE UNIQUE INDEX "uix_billing_customers_on_stripe_customer_id" ON billing_customers (stripe_customer_id); - --- Make `billing_subscriptions` reference `billing_customers` instead of having its --- own `user_id` and `stripe_customer_id`. -DROP INDEX IF EXISTS "ix_billing_subscriptions_on_user_id"; -DROP INDEX IF EXISTS "ix_billing_subscriptions_on_stripe_customer_id"; -ALTER TABLE billing_subscriptions DROP COLUMN user_id; -ALTER TABLE billing_subscriptions DROP COLUMN stripe_customer_id; -ALTER TABLE billing_subscriptions ADD COLUMN billing_customer_id INTEGER NOT NULL REFERENCES billing_customers (id) ON DELETE CASCADE; -CREATE INDEX "ix_billing_subscriptions_on_billing_customer_id" ON billing_subscriptions (billing_customer_id); diff --git a/crates/collab/migrations/20240730122654_add_last_stripe_event_id.sql b/crates/collab/migrations/20240730122654_add_last_stripe_event_id.sql deleted file mode 100644 index 477eadd742e3a128356fdeb75d5f35c1e8f77795..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240730122654_add_last_stripe_event_id.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE billing_customers ADD COLUMN last_stripe_event_id TEXT; -ALTER TABLE billing_subscriptions ADD COLUMN last_stripe_event_id TEXT; diff --git a/crates/collab/migrations/20240730182554_add_processed_stripe_events.sql b/crates/collab/migrations/20240730182554_add_processed_stripe_events.sql deleted file mode 100644 index baf1aa3122e8009a705708e9b8880f5eba5f36f5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240730182554_add_processed_stripe_events.sql +++ /dev/null @@ -1,11 +0,0 @@ -ALTER TABLE billing_customers DROP COLUMN last_stripe_event_id; -ALTER TABLE billing_subscriptions DROP COLUMN last_stripe_event_id; - -CREATE TABLE IF NOT EXISTS processed_stripe_events ( - stripe_event_id TEXT PRIMARY KEY, - stripe_event_type TEXT NOT NULL, - stripe_event_created_timestamp BIGINT NOT NULL, - processed_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT now() -); - -CREATE INDEX "ix_processed_stripe_events_on_stripe_event_created_timestamp" ON processed_stripe_events (stripe_event_created_timestamp); diff --git a/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql b/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql deleted file mode 100644 index b09640bb1eabafda7b9eef9d0763db31d78d1e96..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240731120800_add_stripe_cancel_at_to_billing_subscriptions.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE billing_subscriptions ADD COLUMN stripe_cancel_at TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/migrations/20240812073542_add_accepted_tos_at.sql b/crates/collab/migrations/20240812073542_add_accepted_tos_at.sql deleted file mode 100644 index 43fa0e7bbdcbfe1c0f05a5ae3a74966dcecd7f1b..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240812073542_add_accepted_tos_at.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD accepted_tos_at TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql b/crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql deleted file mode 100644 index a5f713ef7c489f9b87ea9c3b47345903f1e4b5b5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240812204045_add_github_user_created_at_to_users.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "users" ADD COLUMN "github_user_created_at" TIMESTAMP WITHOUT TIME ZONE; diff --git a/crates/collab/migrations/20240816181658_add_enabled_for_all_to_feature_flags.sql b/crates/collab/migrations/20240816181658_add_enabled_for_all_to_feature_flags.sql deleted file mode 100644 index a56c87b97a41de260ccb4aa7d44fd35d2c026293..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240816181658_add_enabled_for_all_to_feature_flags.sql +++ /dev/null @@ -1 +0,0 @@ -alter table feature_flags add column enabled_for_all boolean not null default false; diff --git a/crates/collab/migrations/20240822215737_add_unique_constraint_on_github_user_id_on_users.sql b/crates/collab/migrations/20240822215737_add_unique_constraint_on_github_user_id_on_users.sql deleted file mode 100644 index 3b418f7e2669adfed83919605387d2c613b7f01a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240822215737_add_unique_constraint_on_github_user_id_on_users.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table users alter column github_user_id set not null; - -drop index index_users_on_github_user_id; -create unique index uix_users_on_github_user_id on users (github_user_id); diff --git a/crates/collab/migrations/20240823155956_add_is_fifo_to_worktree_entries.sql b/crates/collab/migrations/20240823155956_add_is_fifo_to_worktree_entries.sql deleted file mode 100644 index af6fdac19d2498e990605d9851cb690dff41e830..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20240823155956_add_is_fifo_to_worktree_entries.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "is_fifo" BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20241002120231_add_local_settings_kind.sql b/crates/collab/migrations/20241002120231_add_local_settings_kind.sql deleted file mode 100644 index aec4ffb8f8519b3fb30c90db4d9bd1221237d7c7..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241002120231_add_local_settings_kind.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "worktree_settings_files" ADD COLUMN "kind" VARCHAR; diff --git a/crates/collab/migrations/20241009190639_add_billing_preferences.sql b/crates/collab/migrations/20241009190639_add_billing_preferences.sql deleted file mode 100644 index 9aa5a1a303668eac7032555f0ea04c6c34b1718f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241009190639_add_billing_preferences.sql +++ /dev/null @@ -1,8 +0,0 @@ -create table if not exists billing_preferences ( - id serial primary key, - created_at timestamp without time zone not null default now(), - user_id integer not null references users(id) on delete cascade, - max_monthly_llm_usage_spending_in_cents integer not null -); - -create unique index "uix_billing_preferences_on_user_id" on billing_preferences (user_id); diff --git a/crates/collab/migrations/20241019184824_adjust_symlink_data.sql b/crates/collab/migrations/20241019184824_adjust_symlink_data.sql deleted file mode 100644 index a38dd21cde85e6ac48c2e45afd5397a81952e712..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241019184824_adjust_symlink_data.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE worktree_entries ADD COLUMN canonical_path text; -ALTER TABLE worktree_entries ALTER COLUMN is_symlink SET DEFAULT false; diff --git a/crates/collab/migrations/20241021202606_add_custom_llm_monthly_allowance_in_cents_to_users.sql b/crates/collab/migrations/20241021202606_add_custom_llm_monthly_allowance_in_cents_to_users.sql deleted file mode 100644 index 60a9bfa91074b4bff08e2cd686e6203beb6b2cf4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241021202606_add_custom_llm_monthly_allowance_in_cents_to_users.sql +++ /dev/null @@ -1 +0,0 @@ -alter table users add column custom_llm_monthly_allowance_in_cents integer; diff --git a/crates/collab/migrations/20241023201725_remove_dev_servers.sql b/crates/collab/migrations/20241023201725_remove_dev_servers.sql deleted file mode 100644 index c5da673a29b1e08b371c712c2786676829f3c25f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241023201725_remove_dev_servers.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE projects DROP COLUMN dev_server_project_id; -ALTER TABLE projects DROP COLUMN hosted_project_id; - -DROP TABLE hosted_projects; -DROP TABLE dev_server_projects; -DROP TABLE dev_servers; diff --git a/crates/collab/migrations/20241121185750_add_breakpoints.sql b/crates/collab/migrations/20241121185750_add_breakpoints.sql deleted file mode 100644 index 4b3071457392f433959ce8270b4dd91f6b99bb78..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20241121185750_add_breakpoints.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS "breakpoints" ( - "id" SERIAL PRIMARY KEY, - "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - "position" INTEGER NOT NULL, - "log_message" TEXT NULL, - "worktree_id" BIGINT NOT NULL, - "path" TEXT NOT NULL, - "kind" VARCHAR NOT NULL -); - -CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id"); diff --git a/crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql b/crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql deleted file mode 100644 index 31686f56bbc06e21797e5389664bf2b2850a6263..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250108184547_add_stripe_cancellation_reason_to_billing_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_subscriptions -add column stripe_cancellation_reason text; diff --git a/crates/collab/migrations/20250113230049_expand_git_status_information.sql b/crates/collab/migrations/20250113230049_expand_git_status_information.sql deleted file mode 100644 index eada39fe304020556bd39dedf640a890144ae5dd..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250113230049_expand_git_status_information.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE worktree_repository_statuses -ADD COLUMN status_kind INTEGER, -ADD COLUMN first_status INTEGER, -ADD COLUMN second_status INTEGER; - -UPDATE worktree_repository_statuses -SET - status_kind = 0; - -ALTER TABLE worktree_repository_statuses -ALTER COLUMN status_kind -SET - NOT NULL; diff --git a/crates/collab/migrations/20250117100620_add_user_name.sql b/crates/collab/migrations/20250117100620_add_user_name.sql deleted file mode 100644 index fff7f95b6052c54c47bcc0c40ef49c65e35b95b4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250117100620_add_user_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN name TEXT; diff --git a/crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql b/crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql deleted file mode 100644 index 07c40303994395e8f43c33df130955e0d82ab627..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250204224004_add_has_overdue_invoices_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers -add column has_overdue_invoices bool not null default false; diff --git a/crates/collab/migrations/20250205192813_add_provides_fields_to_extension_versions.sql b/crates/collab/migrations/20250205192813_add_provides_fields_to_extension_versions.sql deleted file mode 100644 index 50dcb0508f35cc69a87bbcbf83fed9809f539b41..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250205192813_add_provides_fields_to_extension_versions.sql +++ /dev/null @@ -1,10 +0,0 @@ -alter table extension_versions -add column provides_themes bool not null default false, -add column provides_icon_themes bool not null default false, -add column provides_languages bool not null default false, -add column provides_grammars bool not null default false, -add column provides_language_servers bool not null default false, -add column provides_context_servers bool not null default false, -add column provides_slash_commands bool not null default false, -add column provides_indexed_docs_providers bool not null default false, -add column provides_snippets bool not null default false; diff --git a/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql b/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql deleted file mode 100644 index e6e0770bba8cbbb7649689705c526ead9629518d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250205232017_add_conflicts_to_repositories.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE worktree_repositories -ADD COLUMN current_merge_conflicts VARCHAR NULL; diff --git a/crates/collab/migrations/20250210223746_add_branch_summary.sql b/crates/collab/migrations/20250210223746_add_branch_summary.sql deleted file mode 100644 index 3294f38b94114a73713b6282d401b97fcdc383e5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250210223746_add_branch_summary.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE worktree_repositories -ADD COLUMN worktree_repositories VARCHAR NULL; diff --git a/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql b/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql deleted file mode 100644 index d7e3c04e2ff7844ed8d47907b0d21b64ae7d9a1c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250212060936_add_worktree_branch_summary.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE worktree_repositories ADD COLUMN branch_summary TEXT NULL; diff --git a/crates/collab/migrations/20250319182812_create_project_repositories.sql b/crates/collab/migrations/20250319182812_create_project_repositories.sql deleted file mode 100644 index 8ca8c3444e60ccc4105e01e7a0d035930d57da4d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250319182812_create_project_repositories.sql +++ /dev/null @@ -1,32 +0,0 @@ -CREATE TABLE "project_repositories" ( - "project_id" INTEGER NOT NULL, - "abs_path" VARCHAR, - "id" INT8 NOT NULL, - "legacy_worktree_id" INT8, - "entry_ids" VARCHAR, - "branch" VARCHAR, - "scan_id" INT8 NOT NULL, - "is_deleted" BOOL NOT NULL, - "current_merge_conflicts" VARCHAR, - "branch_summary" VARCHAR, - PRIMARY KEY (project_id, id) -); - -CREATE INDEX "index_project_repositories_on_project_id" ON "project_repositories" ("project_id"); - -CREATE TABLE "project_repository_statuses" ( - "project_id" INTEGER NOT NULL, - "repository_id" INT8 NOT NULL, - "repo_path" VARCHAR NOT NULL, - "status" INT8 NOT NULL, - "status_kind" INT4 NOT NULL, - "first_status" INT4 NULL, - "second_status" INT4 NULL, - "scan_id" INT8 NOT NULL, - "is_deleted" BOOL NOT NULL, - PRIMARY KEY (project_id, repository_id, repo_path) -); - -CREATE INDEX "index_project_repos_statuses_on_project_id" ON "project_repository_statuses" ("project_id"); - -CREATE INDEX "index_project_repos_statuses_on_project_id_and_repo_id" ON "project_repository_statuses" ("project_id", "repository_id"); diff --git a/crates/collab/migrations/20250415164141_add_kind_and_period_to_billing_subscriptions.sql b/crates/collab/migrations/20250415164141_add_kind_and_period_to_billing_subscriptions.sql deleted file mode 100644 index b91431b28ba2ed2ce9fcf13a72938aad46330f04..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250415164141_add_kind_and_period_to_billing_subscriptions.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table billing_subscriptions - add column kind text, - add column stripe_current_period_start bigint, - add column stripe_current_period_end bigint; diff --git a/crates/collab/migrations/20250422194500_add_trial_started_at_to_billing_customers.sql b/crates/collab/migrations/20250422194500_add_trial_started_at_to_billing_customers.sql deleted file mode 100644 index 34a159cf65a2a92aa21b75daa62c9a0d91023bcc..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250422194500_add_trial_started_at_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers - add column trial_started_at timestamp without time zone; diff --git a/crates/collab/migrations/20250423150129_add_head_commit_details_to_project_repositories.sql b/crates/collab/migrations/20250423150129_add_head_commit_details_to_project_repositories.sql deleted file mode 100644 index c37fed224229d99403868f462a23fe811b27fda6..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250423150129_add_head_commit_details_to_project_repositories.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table project_repositories - add column head_commit_details varchar; diff --git a/crates/collab/migrations/20250425201930_add_model_request_overages_to_billing_preferences.sql b/crates/collab/migrations/20250425201930_add_model_request_overages_to_billing_preferences.sql deleted file mode 100644 index 86e35c9202b9184796a8c3d2463f20f89766ec8c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250425201930_add_model_request_overages_to_billing_preferences.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table billing_preferences - add column model_request_overages_enabled bool not null default false, - add column model_request_overages_spend_limit_in_cents integer not null default 0; diff --git a/crates/collab/migrations/20250530175450_add_channel_order.sql b/crates/collab/migrations/20250530175450_add_channel_order.sql deleted file mode 100644 index 977a4611cdb75d0e53c8d1c132290f9da7469dc5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250530175450_add_channel_order.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Add channel_order column to channels table with default value -ALTER TABLE channels ADD COLUMN channel_order INTEGER NOT NULL DEFAULT 1; - --- Update channel_order for existing channels using ROW_NUMBER for deterministic ordering -UPDATE channels -SET channel_order = ( - SELECT ROW_NUMBER() OVER ( - PARTITION BY parent_path - ORDER BY name, id - ) - FROM channels c2 - WHERE c2.id = channels.id -); - --- Create index for efficient ordering queries -CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order"); diff --git a/crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql b/crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql deleted file mode 100644 index 73876e89652deea8bbebf354b99cb2d792894130..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table project_collaborators - add column committer_name varchar; -alter table project_collaborators - add column committer_email varchar; diff --git a/crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql b/crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql deleted file mode 100644 index 8455a82f9ee6b5fdf8cfba3da08c880018061a43..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250617082236_add_debug_adapter_provides_field_to_extensions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table extension_versions -add column provides_debug_adapters bool not null default false diff --git a/crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql b/crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql deleted file mode 100644 index 3c399924b96891d490792fb36b61a034f8dce97f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table extension_versions -add column provides_agent_servers bool not null default false diff --git a/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql b/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql deleted file mode 100644 index 6d898c481199f4770ab7df5ce66c08e2fdf42423..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250702185129_add_cascading_delete_to_repository_entries.sql +++ /dev/null @@ -1,25 +0,0 @@ -DELETE FROM project_repositories -WHERE project_id NOT IN (SELECT id FROM projects); - -ALTER TABLE project_repositories - ADD CONSTRAINT fk_project_repositories_project_id - FOREIGN KEY (project_id) - REFERENCES projects (id) - ON DELETE CASCADE - NOT VALID; - -ALTER TABLE project_repositories - VALIDATE CONSTRAINT fk_project_repositories_project_id; - -DELETE FROM project_repository_statuses -WHERE project_id NOT IN (SELECT id FROM projects); - -ALTER TABLE project_repository_statuses - ADD CONSTRAINT fk_project_repository_statuses_project_id - FOREIGN KEY (project_id) - REFERENCES projects (id) - ON DELETE CASCADE - NOT VALID; - -ALTER TABLE project_repository_statuses - VALIDATE CONSTRAINT fk_project_repository_statuses_project_id; diff --git a/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql b/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql deleted file mode 100644 index ae0ffe24f6322196358225ff4159df9d1cfa6298..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250707182700_add_access_tokens_cascade_delete_on_user.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE access_tokens DROP CONSTRAINT access_tokens_user_id_fkey; -ALTER TABLE access_tokens ADD CONSTRAINT access_tokens_user_id_fkey - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/crates/collab/migrations/20250804080620_language_server_capabilities.sql b/crates/collab/migrations/20250804080620_language_server_capabilities.sql deleted file mode 100644 index f74f094ed25d488720f2f85f30b6762f83647b02..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250804080620_language_server_capabilities.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE language_servers - ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}'; - -ALTER TABLE language_servers - ALTER COLUMN capabilities DROP DEFAULT; diff --git a/crates/collab/migrations/20250816124707_make_admin_required_on_users.sql b/crates/collab/migrations/20250816124707_make_admin_required_on_users.sql deleted file mode 100644 index e372723d6d5f5e822a2e437cfac4b95bc2023998..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250816124707_make_admin_required_on_users.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table users -alter column admin set not null; diff --git a/crates/collab/migrations/20250816133027_add_orb_customer_id_to_billing_customers.sql b/crates/collab/migrations/20250816133027_add_orb_customer_id_to_billing_customers.sql deleted file mode 100644 index ea5e4de52a829413030bb5e206f5c7401381adcf..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250816133027_add_orb_customer_id_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers - add column orb_customer_id text; diff --git a/crates/collab/migrations/20250816135346_drop_rate_buckets_table.sql b/crates/collab/migrations/20250816135346_drop_rate_buckets_table.sql deleted file mode 100644 index f51a33ed30d7fb88bc9dc6c82e7217c7e4634b28..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250816135346_drop_rate_buckets_table.sql +++ /dev/null @@ -1 +0,0 @@ -drop table rate_buckets; diff --git a/crates/collab/migrations/20250818192156_add_git_merge_message.sql b/crates/collab/migrations/20250818192156_add_git_merge_message.sql deleted file mode 100644 index 335ea2f82493082e0e20d7762b5282696dc50224..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250818192156_add_git_merge_message.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "project_repositories" ADD COLUMN "merge_message" VARCHAR; diff --git a/crates/collab/migrations/20250819022421_add_orb_subscription_id_to_billing_subscriptions.sql b/crates/collab/migrations/20250819022421_add_orb_subscription_id_to_billing_subscriptions.sql deleted file mode 100644 index 317f6a7653e3d1762f74e795a17d2f99b3831201..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250819022421_add_orb_subscription_id_to_billing_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_subscriptions - add column orb_subscription_id text; diff --git a/crates/collab/migrations/20250819225916_make_stripe_fields_optional_on_billing_subscription.sql b/crates/collab/migrations/20250819225916_make_stripe_fields_optional_on_billing_subscription.sql deleted file mode 100644 index cf3b79da60be98da8dd78a2bcb01f7532be7fc59..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250819225916_make_stripe_fields_optional_on_billing_subscription.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table billing_subscriptions - alter column stripe_subscription_id drop not null, - alter column stripe_subscription_status drop not null; diff --git a/crates/collab/migrations/20250821133754_add_orb_subscription_status_and_period_to_billing_subscriptions.sql b/crates/collab/migrations/20250821133754_add_orb_subscription_status_and_period_to_billing_subscriptions.sql deleted file mode 100644 index 89a42ab82bd97f487a426ef1fa0a08aa5b0c8396..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250821133754_add_orb_subscription_status_and_period_to_billing_subscriptions.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table billing_subscriptions - add column orb_subscription_status text, - add column orb_current_billing_period_start_date timestamp without time zone, - add column orb_current_billing_period_end_date timestamp without time zone; diff --git a/crates/collab/migrations/20250827084812_worktree_in_servers.sql b/crates/collab/migrations/20250827084812_worktree_in_servers.sql deleted file mode 100644 index d4c6ffbbcccb2d2f23654cfc287b45bb8ea20508..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250827084812_worktree_in_servers.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE language_servers - ADD COLUMN worktree_id BIGINT; diff --git a/crates/collab/migrations/20250913035238_add_orb_cancellation_date_to_billing_subscriptions.sql b/crates/collab/migrations/20250913035238_add_orb_cancellation_date_to_billing_subscriptions.sql deleted file mode 100644 index 56144237421d49fa68545f9689bdb1688603739a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250913035238_add_orb_cancellation_date_to_billing_subscriptions.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_subscriptions - add column orb_cancellation_date timestamp without time zone; diff --git a/crates/collab/migrations/20250914022147_add_orb_portal_url_to_billing_customers.sql b/crates/collab/migrations/20250914022147_add_orb_portal_url_to_billing_customers.sql deleted file mode 100644 index 2de05740410f5d13f7cd510a85af24dd2ff171b6..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250914022147_add_orb_portal_url_to_billing_customers.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table billing_customers - add column orb_portal_url text; diff --git a/crates/collab/migrations/20250916173002_add_path_style_to_project.sql b/crates/collab/migrations/20250916173002_add_path_style_to_project.sql deleted file mode 100644 index b1244818f14403d38af577be4b14b1a8a765e07b..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20250916173002_add_path_style_to_project.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE projects ADD COLUMN windows_paths BOOLEAN DEFAULT FALSE; diff --git a/crates/collab/migrations/20251002214229_add_token_spend_in_cents_to_billing_subscriptions.sql b/crates/collab/migrations/20251002214229_add_token_spend_in_cents_to_billing_subscriptions.sql deleted file mode 100644 index ccae01e2833fedd530f290c55d2852c33de6957c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251002214229_add_token_spend_in_cents_to_billing_subscriptions.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table billing_subscriptions - add column token_spend_in_cents integer, - add column token_spend_in_cents_updated_at timestamp without time zone; diff --git a/crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql b/crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql deleted file mode 100644 index 5b4207aeea500595c66508fa88a20662bc5693c1..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251008120000_add_is_hidden_to_worktree_entries.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "worktree_entries" -ADD "is_hidden" BOOL NOT NULL DEFAULT FALSE; diff --git a/crates/collab/migrations/20251110214057_drop_channel_messages.sql b/crates/collab/migrations/20251110214057_drop_channel_messages.sql deleted file mode 100644 index 468534542fbb7cee04aee985bfe2143f30d219ad..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251110214057_drop_channel_messages.sql +++ /dev/null @@ -1,3 +0,0 @@ -drop table observed_channel_messages; -drop table channel_message_mentions; -drop table channel_messages; diff --git a/crates/collab/migrations/20251111161644_drop_embeddings.sql b/crates/collab/migrations/20251111161644_drop_embeddings.sql deleted file mode 100644 index 80f42c7d2c88b258ef8cc63757694a7e229643c7..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251111161644_drop_embeddings.sql +++ /dev/null @@ -1 +0,0 @@ -drop table embeddings; diff --git a/crates/collab/migrations/20251117215316_add_external_id_to_billing_customers.sql b/crates/collab/migrations/20251117215316_add_external_id_to_billing_customers.sql deleted file mode 100644 index 6add45d4ad5d83cea6d86d2edc1e06613bd25560..0000000000000000000000000000000000000000 --- a/crates/collab/migrations/20251117215316_add_external_id_to_billing_customers.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table billing_customers - add column external_id text; - -create unique index uix_billing_customers_on_external_id on billing_customers (external_id); diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..ed9c9d16dbdf2fbe2e69134f407ee5365236161b --- /dev/null +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -0,0 +1,899 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; + +CREATE TABLE public.access_tokens ( + id integer NOT NULL, + user_id integer, + hash character varying(128), + impersonated_user_id integer +); + +CREATE SEQUENCE public.access_tokens_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.access_tokens_id_seq OWNED BY public.access_tokens.id; + +CREATE TABLE public.breakpoints ( + id integer NOT NULL, + project_id integer NOT NULL, + "position" integer NOT NULL, + log_message text, + worktree_id bigint NOT NULL, + path text NOT NULL, + kind character varying NOT NULL +); + +CREATE SEQUENCE public.breakpoints_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.breakpoints_id_seq OWNED BY public.breakpoints.id; + +CREATE TABLE public.buffer_operations ( + buffer_id integer NOT NULL, + epoch integer NOT NULL, + replica_id integer NOT NULL, + lamport_timestamp integer NOT NULL, + value bytea NOT NULL +); + +CREATE TABLE public.buffer_snapshots ( + buffer_id integer NOT NULL, + epoch integer NOT NULL, + text text NOT NULL, + operation_serialization_version integer NOT NULL +); + +CREATE TABLE public.buffers ( + id integer NOT NULL, + channel_id integer NOT NULL, + epoch integer DEFAULT 0 NOT NULL, + latest_operation_epoch integer, + latest_operation_lamport_timestamp integer, + latest_operation_replica_id integer +); + +CREATE SEQUENCE public.buffers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.buffers_id_seq OWNED BY public.buffers.id; + +CREATE TABLE public.channel_buffer_collaborators ( + id integer NOT NULL, + channel_id integer NOT NULL, + connection_id integer NOT NULL, + connection_server_id integer NOT NULL, + connection_lost boolean DEFAULT false NOT NULL, + user_id integer NOT NULL, + replica_id integer NOT NULL +); + +CREATE SEQUENCE public.channel_buffer_collaborators_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channel_buffer_collaborators_id_seq OWNED BY public.channel_buffer_collaborators.id; + +CREATE TABLE public.channel_chat_participants ( + id integer NOT NULL, + user_id integer NOT NULL, + channel_id integer NOT NULL, + connection_id integer NOT NULL, + connection_server_id integer NOT NULL +); + +CREATE SEQUENCE public.channel_chat_participants_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channel_chat_participants_id_seq OWNED BY public.channel_chat_participants.id; + +CREATE TABLE public.channel_members ( + id integer NOT NULL, + channel_id integer NOT NULL, + user_id integer NOT NULL, + accepted boolean DEFAULT false NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + role text NOT NULL +); + +CREATE SEQUENCE public.channel_members_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channel_members_id_seq OWNED BY public.channel_members.id; + +CREATE TABLE public.channels ( + id integer NOT NULL, + name character varying NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + visibility text DEFAULT 'members'::text NOT NULL, + parent_path text NOT NULL, + requires_zed_cla boolean DEFAULT false NOT NULL, + channel_order integer DEFAULT 1 NOT NULL +); + +CREATE SEQUENCE public.channels_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.channels_id_seq OWNED BY public.channels.id; + +CREATE TABLE public.contacts ( + id integer NOT NULL, + user_id_a integer NOT NULL, + user_id_b integer NOT NULL, + a_to_b boolean NOT NULL, + should_notify boolean NOT NULL, + accepted boolean NOT NULL +); + +CREATE SEQUENCE public.contacts_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.contacts_id_seq OWNED BY public.contacts.id; + +CREATE TABLE public.contributors ( + user_id integer NOT NULL, + signed_at timestamp without time zone DEFAULT now() NOT NULL +); + +CREATE TABLE public.extension_versions ( + extension_id integer NOT NULL, + version text NOT NULL, + published_at timestamp without time zone DEFAULT now() NOT NULL, + authors text NOT NULL, + repository text NOT NULL, + description text NOT NULL, + download_count bigint DEFAULT 0 NOT NULL, + schema_version integer DEFAULT 0 NOT NULL, + wasm_api_version text, + provides_themes boolean DEFAULT false NOT NULL, + provides_icon_themes boolean DEFAULT false NOT NULL, + provides_languages boolean DEFAULT false NOT NULL, + provides_grammars boolean DEFAULT false NOT NULL, + provides_language_servers boolean DEFAULT false NOT NULL, + provides_context_servers boolean DEFAULT false NOT NULL, + provides_slash_commands boolean DEFAULT false NOT NULL, + provides_indexed_docs_providers boolean DEFAULT false NOT NULL, + provides_snippets boolean DEFAULT false NOT NULL, + provides_debug_adapters boolean DEFAULT false NOT NULL, + provides_agent_servers boolean DEFAULT false NOT NULL +); + +CREATE TABLE public.extensions ( + id integer NOT NULL, + name text NOT NULL, + external_id text NOT NULL, + latest_version text NOT NULL, + total_download_count bigint DEFAULT 0 NOT NULL +); + +CREATE SEQUENCE public.extensions_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.extensions_id_seq OWNED BY public.extensions.id; + +CREATE TABLE public.feature_flags ( + id integer NOT NULL, + flag character varying(255) NOT NULL, + enabled_for_all boolean DEFAULT false NOT NULL +); + +CREATE SEQUENCE public.feature_flags_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.feature_flags_id_seq OWNED BY public.feature_flags.id; + +CREATE TABLE public.followers ( + id integer NOT NULL, + room_id integer NOT NULL, + project_id integer NOT NULL, + leader_connection_server_id integer NOT NULL, + leader_connection_id integer NOT NULL, + follower_connection_server_id integer NOT NULL, + follower_connection_id integer NOT NULL +); + +CREATE SEQUENCE public.followers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.followers_id_seq OWNED BY public.followers.id; + +CREATE TABLE public.language_servers ( + project_id integer NOT NULL, + id bigint NOT NULL, + name character varying NOT NULL, + capabilities text NOT NULL, + worktree_id bigint +); + +CREATE TABLE public.notification_kinds ( + id integer NOT NULL, + name character varying NOT NULL +); + +CREATE SEQUENCE public.notification_kinds_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.notification_kinds_id_seq OWNED BY public.notification_kinds.id; + +CREATE TABLE public.notifications ( + id integer NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + recipient_id integer NOT NULL, + kind integer NOT NULL, + entity_id integer, + content text, + is_read boolean DEFAULT false NOT NULL, + response boolean +); + +CREATE SEQUENCE public.notifications_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.notifications_id_seq OWNED BY public.notifications.id; + +CREATE TABLE public.observed_buffer_edits ( + user_id integer NOT NULL, + buffer_id integer NOT NULL, + epoch integer NOT NULL, + lamport_timestamp integer NOT NULL, + replica_id integer NOT NULL +); + +CREATE TABLE public.project_collaborators ( + id integer NOT NULL, + project_id integer NOT NULL, + connection_id integer NOT NULL, + user_id integer NOT NULL, + replica_id integer NOT NULL, + is_host boolean NOT NULL, + connection_server_id integer NOT NULL, + committer_name character varying, + committer_email character varying +); + +CREATE SEQUENCE public.project_collaborators_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.project_collaborators_id_seq OWNED BY public.project_collaborators.id; + +CREATE TABLE public.project_repositories ( + project_id integer NOT NULL, + abs_path character varying, + id bigint NOT NULL, + legacy_worktree_id bigint, + entry_ids character varying, + branch character varying, + scan_id bigint NOT NULL, + is_deleted boolean NOT NULL, + current_merge_conflicts character varying, + branch_summary character varying, + head_commit_details character varying, + merge_message character varying +); + +CREATE TABLE public.project_repository_statuses ( + project_id integer NOT NULL, + repository_id bigint NOT NULL, + repo_path character varying NOT NULL, + status bigint NOT NULL, + status_kind integer NOT NULL, + first_status integer, + second_status integer, + scan_id bigint NOT NULL, + is_deleted boolean NOT NULL +); + +CREATE TABLE public.projects ( + id integer NOT NULL, + host_user_id integer, + unregistered boolean DEFAULT false NOT NULL, + room_id integer, + host_connection_id integer, + host_connection_server_id integer, + windows_paths boolean DEFAULT false +); + +CREATE SEQUENCE public.projects_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.projects_id_seq OWNED BY public.projects.id; + +CREATE TABLE public.room_participants ( + id integer NOT NULL, + room_id integer NOT NULL, + user_id integer NOT NULL, + answering_connection_id integer, + location_kind integer, + location_project_id integer, + initial_project_id integer, + calling_user_id integer NOT NULL, + calling_connection_id integer NOT NULL, + answering_connection_lost boolean DEFAULT false NOT NULL, + answering_connection_server_id integer, + calling_connection_server_id integer, + participant_index integer, + role text +); + +CREATE SEQUENCE public.room_participants_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.room_participants_id_seq OWNED BY public.room_participants.id; + +CREATE TABLE public.rooms ( + id integer NOT NULL, + live_kit_room character varying NOT NULL, + channel_id integer +); + +CREATE SEQUENCE public.rooms_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.rooms_id_seq OWNED BY public.rooms.id; + +CREATE TABLE public.servers ( + id integer NOT NULL, + environment character varying NOT NULL +); + +CREATE SEQUENCE public.servers_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.servers_id_seq OWNED BY public.servers.id; + +CREATE TABLE public.user_features ( + user_id integer NOT NULL, + feature_id integer NOT NULL +); + +CREATE TABLE public.users ( + id integer NOT NULL, + github_login character varying, + admin boolean NOT NULL, + email_address character varying(255) DEFAULT NULL::character varying, + invite_code character varying(64), + invite_count integer DEFAULT 0 NOT NULL, + inviter_id integer, + connected_once boolean DEFAULT false NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + github_user_id integer NOT NULL, + metrics_id uuid DEFAULT gen_random_uuid() NOT NULL, + accepted_tos_at timestamp without time zone, + github_user_created_at timestamp without time zone, + custom_llm_monthly_allowance_in_cents integer, + name text +); + +CREATE SEQUENCE public.users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; + +CREATE TABLE public.worktree_diagnostic_summaries ( + project_id integer NOT NULL, + worktree_id bigint NOT NULL, + path character varying NOT NULL, + language_server_id bigint NOT NULL, + error_count integer NOT NULL, + warning_count integer NOT NULL +); + +CREATE TABLE public.worktree_entries ( + project_id integer NOT NULL, + worktree_id bigint NOT NULL, + id bigint NOT NULL, + is_dir boolean NOT NULL, + path character varying NOT NULL, + inode bigint NOT NULL, + mtime_seconds bigint NOT NULL, + mtime_nanos integer NOT NULL, + is_symlink boolean DEFAULT false NOT NULL, + is_ignored boolean NOT NULL, + scan_id bigint, + is_deleted boolean, + git_status bigint, + is_external boolean DEFAULT false NOT NULL, + is_fifo boolean DEFAULT false NOT NULL, + canonical_path text, + is_hidden boolean DEFAULT false NOT NULL +); + +CREATE TABLE public.worktree_settings_files ( + project_id integer NOT NULL, + worktree_id bigint NOT NULL, + path character varying NOT NULL, + content text NOT NULL, + kind character varying +); + +CREATE TABLE public.worktrees ( + project_id integer NOT NULL, + id bigint NOT NULL, + root_name character varying NOT NULL, + abs_path character varying NOT NULL, + visible boolean NOT NULL, + scan_id bigint NOT NULL, + is_complete boolean DEFAULT false NOT NULL, + completed_scan_id bigint +); + +ALTER TABLE ONLY public.access_tokens ALTER COLUMN id SET DEFAULT nextval('public.access_tokens_id_seq'::regclass); + +ALTER TABLE ONLY public.breakpoints ALTER COLUMN id SET DEFAULT nextval('public.breakpoints_id_seq'::regclass); + +ALTER TABLE ONLY public.buffers ALTER COLUMN id SET DEFAULT nextval('public.buffers_id_seq'::regclass); + +ALTER TABLE ONLY public.channel_buffer_collaborators ALTER COLUMN id SET DEFAULT nextval('public.channel_buffer_collaborators_id_seq'::regclass); + +ALTER TABLE ONLY public.channel_chat_participants ALTER COLUMN id SET DEFAULT nextval('public.channel_chat_participants_id_seq'::regclass); + +ALTER TABLE ONLY public.channel_members ALTER COLUMN id SET DEFAULT nextval('public.channel_members_id_seq'::regclass); + +ALTER TABLE ONLY public.channels ALTER COLUMN id SET DEFAULT nextval('public.channels_id_seq'::regclass); + +ALTER TABLE ONLY public.contacts ALTER COLUMN id SET DEFAULT nextval('public.contacts_id_seq'::regclass); + +ALTER TABLE ONLY public.extensions ALTER COLUMN id SET DEFAULT nextval('public.extensions_id_seq'::regclass); + +ALTER TABLE ONLY public.feature_flags ALTER COLUMN id SET DEFAULT nextval('public.feature_flags_id_seq'::regclass); + +ALTER TABLE ONLY public.followers ALTER COLUMN id SET DEFAULT nextval('public.followers_id_seq'::regclass); + +ALTER TABLE ONLY public.notification_kinds ALTER COLUMN id SET DEFAULT nextval('public.notification_kinds_id_seq'::regclass); + +ALTER TABLE ONLY public.notifications ALTER COLUMN id SET DEFAULT nextval('public.notifications_id_seq'::regclass); + +ALTER TABLE ONLY public.project_collaborators ALTER COLUMN id SET DEFAULT nextval('public.project_collaborators_id_seq'::regclass); + +ALTER TABLE ONLY public.projects ALTER COLUMN id SET DEFAULT nextval('public.projects_id_seq'::regclass); + +ALTER TABLE ONLY public.room_participants ALTER COLUMN id SET DEFAULT nextval('public.room_participants_id_seq'::regclass); + +ALTER TABLE ONLY public.rooms ALTER COLUMN id SET DEFAULT nextval('public.rooms_id_seq'::regclass); + +ALTER TABLE ONLY public.servers ALTER COLUMN id SET DEFAULT nextval('public.servers_id_seq'::regclass); + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + +ALTER TABLE ONLY public.access_tokens + ADD CONSTRAINT access_tokens_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.breakpoints + ADD CONSTRAINT breakpoints_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.buffer_operations + ADD CONSTRAINT buffer_operations_pkey PRIMARY KEY (buffer_id, epoch, lamport_timestamp, replica_id); + +ALTER TABLE ONLY public.buffer_snapshots + ADD CONSTRAINT buffer_snapshots_pkey PRIMARY KEY (buffer_id, epoch); + +ALTER TABLE ONLY public.buffers + ADD CONSTRAINT buffers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.channels + ADD CONSTRAINT channels_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.contributors + ADD CONSTRAINT contributors_pkey PRIMARY KEY (user_id); + +ALTER TABLE ONLY public.extension_versions + ADD CONSTRAINT extension_versions_pkey PRIMARY KEY (extension_id, version); + +ALTER TABLE ONLY public.extensions + ADD CONSTRAINT extensions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.feature_flags + ADD CONSTRAINT feature_flags_flag_key UNIQUE (flag); + +ALTER TABLE ONLY public.feature_flags + ADD CONSTRAINT feature_flags_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.language_servers + ADD CONSTRAINT language_servers_pkey PRIMARY KEY (project_id, id); + +ALTER TABLE ONLY public.notification_kinds + ADD CONSTRAINT notification_kinds_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.observed_buffer_edits + ADD CONSTRAINT observed_buffer_edits_pkey PRIMARY KEY (user_id, buffer_id); + +ALTER TABLE ONLY public.project_collaborators + ADD CONSTRAINT project_collaborators_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.project_repositories + ADD CONSTRAINT project_repositories_pkey PRIMARY KEY (project_id, id); + +ALTER TABLE ONLY public.project_repository_statuses + ADD CONSTRAINT project_repository_statuses_pkey PRIMARY KEY (project_id, repository_id, repo_path); + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.rooms + ADD CONSTRAINT rooms_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.servers + ADD CONSTRAINT servers_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.user_features + ADD CONSTRAINT user_features_pkey PRIMARY KEY (user_id, feature_id); + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.worktree_diagnostic_summaries + ADD CONSTRAINT worktree_diagnostic_summaries_pkey PRIMARY KEY (project_id, worktree_id, path); + +ALTER TABLE ONLY public.worktree_entries + ADD CONSTRAINT worktree_entries_pkey PRIMARY KEY (project_id, worktree_id, id); + +ALTER TABLE ONLY public.worktree_settings_files + ADD CONSTRAINT worktree_settings_files_pkey PRIMARY KEY (project_id, worktree_id, path); + +ALTER TABLE ONLY public.worktrees + ADD CONSTRAINT worktrees_pkey PRIMARY KEY (project_id, id); + +CREATE INDEX index_access_tokens_user_id ON public.access_tokens USING btree (user_id); + +CREATE INDEX index_breakpoints_on_project_id ON public.breakpoints USING btree (project_id); + +CREATE INDEX index_buffers_on_channel_id ON public.buffers USING btree (channel_id); + +CREATE INDEX index_channel_buffer_collaborators_on_channel_id ON public.channel_buffer_collaborators USING btree (channel_id); + +CREATE UNIQUE INDEX index_channel_buffer_collaborators_on_channel_id_and_replica_id ON public.channel_buffer_collaborators USING btree (channel_id, replica_id); + +CREATE UNIQUE INDEX index_channel_buffer_collaborators_on_channel_id_connection_id_ ON public.channel_buffer_collaborators USING btree (channel_id, connection_id, connection_server_id); + +CREATE INDEX index_channel_buffer_collaborators_on_connection_id ON public.channel_buffer_collaborators USING btree (connection_id); + +CREATE INDEX index_channel_buffer_collaborators_on_connection_server_id ON public.channel_buffer_collaborators USING btree (connection_server_id); + +CREATE INDEX index_channel_chat_participants_on_channel_id ON public.channel_chat_participants USING btree (channel_id); + +CREATE UNIQUE INDEX index_channel_members_on_channel_id_and_user_id ON public.channel_members USING btree (channel_id, user_id); + +CREATE INDEX index_channels_on_parent_path ON public.channels USING btree (parent_path text_pattern_ops); + +CREATE INDEX index_channels_on_parent_path_and_order ON public.channels USING btree (parent_path, channel_order); + +CREATE INDEX index_contacts_user_id_b ON public.contacts USING btree (user_id_b); + +CREATE UNIQUE INDEX index_contacts_user_ids ON public.contacts USING btree (user_id_a, user_id_b); + +CREATE UNIQUE INDEX index_extensions_external_id ON public.extensions USING btree (external_id); + +CREATE INDEX index_extensions_total_download_count ON public.extensions USING btree (total_download_count); + +CREATE UNIQUE INDEX index_feature_flags ON public.feature_flags USING btree (id); + +CREATE UNIQUE INDEX index_followers_on_project_id_and_leader_connection_server_id_a ON public.followers USING btree (project_id, leader_connection_server_id, leader_connection_id, follower_connection_server_id, follower_connection_id); + +CREATE INDEX index_followers_on_room_id ON public.followers USING btree (room_id); + +CREATE UNIQUE INDEX index_invite_code_users ON public.users USING btree (invite_code); + +CREATE INDEX index_language_servers_on_project_id ON public.language_servers USING btree (project_id); + +CREATE UNIQUE INDEX index_notification_kinds_on_name ON public.notification_kinds USING btree (name); + +CREATE INDEX index_notifications_on_recipient_id_is_read_kind_entity_id ON public.notifications USING btree (recipient_id, is_read, kind, entity_id); + +CREATE UNIQUE INDEX index_observed_buffer_user_and_buffer_id ON public.observed_buffer_edits USING btree (user_id, buffer_id); + +CREATE INDEX index_project_collaborators_on_connection_id ON public.project_collaborators USING btree (connection_id); + +CREATE INDEX index_project_collaborators_on_connection_server_id ON public.project_collaborators USING btree (connection_server_id); + +CREATE INDEX index_project_collaborators_on_project_id ON public.project_collaborators USING btree (project_id); + +CREATE UNIQUE INDEX index_project_collaborators_on_project_id_and_replica_id ON public.project_collaborators USING btree (project_id, replica_id); + +CREATE UNIQUE INDEX index_project_collaborators_on_project_id_connection_id_and_ser ON public.project_collaborators USING btree (project_id, connection_id, connection_server_id); + +CREATE INDEX index_project_repos_statuses_on_project_id ON public.project_repository_statuses USING btree (project_id); + +CREATE INDEX index_project_repos_statuses_on_project_id_and_repo_id ON public.project_repository_statuses USING btree (project_id, repository_id); + +CREATE INDEX index_project_repositories_on_project_id ON public.project_repositories USING btree (project_id); + +CREATE INDEX index_projects_on_host_connection_id_and_host_connection_server ON public.projects USING btree (host_connection_id, host_connection_server_id); + +CREATE INDEX index_projects_on_host_connection_server_id ON public.projects USING btree (host_connection_server_id); + +CREATE INDEX index_room_participants_on_answering_connection_id ON public.room_participants USING btree (answering_connection_id); + +CREATE UNIQUE INDEX index_room_participants_on_answering_connection_id_and_answerin ON public.room_participants USING btree (answering_connection_id, answering_connection_server_id); + +CREATE INDEX index_room_participants_on_answering_connection_server_id ON public.room_participants USING btree (answering_connection_server_id); + +CREATE INDEX index_room_participants_on_calling_connection_server_id ON public.room_participants USING btree (calling_connection_server_id); + +CREATE INDEX index_room_participants_on_room_id ON public.room_participants USING btree (room_id); + +CREATE UNIQUE INDEX index_room_participants_on_user_id ON public.room_participants USING btree (user_id); + +CREATE UNIQUE INDEX index_rooms_on_channel_id ON public.rooms USING btree (channel_id); + +CREATE INDEX index_settings_files_on_project_id ON public.worktree_settings_files USING btree (project_id); + +CREATE INDEX index_settings_files_on_project_id_and_wt_id ON public.worktree_settings_files USING btree (project_id, worktree_id); + +CREATE INDEX index_user_features_on_feature_id ON public.user_features USING btree (feature_id); + +CREATE INDEX index_user_features_on_user_id ON public.user_features USING btree (user_id); + +CREATE UNIQUE INDEX index_user_features_user_id_and_feature_id ON public.user_features USING btree (user_id, feature_id); + +CREATE UNIQUE INDEX index_users_github_login ON public.users USING btree (github_login); + +CREATE INDEX index_users_on_email_address ON public.users USING btree (email_address); + +CREATE INDEX index_worktree_diagnostic_summaries_on_project_id ON public.worktree_diagnostic_summaries USING btree (project_id); + +CREATE INDEX index_worktree_diagnostic_summaries_on_project_id_and_worktree_ ON public.worktree_diagnostic_summaries USING btree (project_id, worktree_id); + +CREATE INDEX index_worktree_entries_on_project_id ON public.worktree_entries USING btree (project_id); + +CREATE INDEX index_worktree_entries_on_project_id_and_worktree_id ON public.worktree_entries USING btree (project_id, worktree_id); + +CREATE INDEX index_worktrees_on_project_id ON public.worktrees USING btree (project_id); + +CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name public.gin_trgm_ops); + +CREATE INDEX trigram_index_users_on_github_login ON public.users USING gin (github_login public.gin_trgm_ops); + +CREATE UNIQUE INDEX uix_channels_parent_path_name ON public.channels USING btree (parent_path, name) WHERE ((parent_path IS NOT NULL) AND (parent_path <> ''::text)); + +CREATE UNIQUE INDEX uix_users_on_github_user_id ON public.users USING btree (github_user_id); + +ALTER TABLE ONLY public.access_tokens + ADD CONSTRAINT access_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.breakpoints + ADD CONSTRAINT breakpoints_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.buffer_operations + ADD CONSTRAINT buffer_operations_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.buffer_snapshots + ADD CONSTRAINT buffer_snapshots_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.buffers + ADD CONSTRAINT buffers_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_buffer_collaborators + ADD CONSTRAINT channel_buffer_collaborators_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_chat_participants + ADD CONSTRAINT channel_chat_participants_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_user_id_a_fkey FOREIGN KEY (user_id_a) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.contributors + ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.extension_versions + ADD CONSTRAINT extension_versions_extension_id_fkey FOREIGN KEY (extension_id) REFERENCES public.extensions(id); + +ALTER TABLE ONLY public.project_repositories + ADD CONSTRAINT fk_project_repositories_project_id FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.project_repository_statuses + ADD CONSTRAINT fk_project_repository_statuses_project_id FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_follower_connection_server_id_fkey FOREIGN KEY (follower_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_leader_connection_server_id_fkey FOREIGN KEY (leader_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.followers + ADD CONSTRAINT followers_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.language_servers + ADD CONSTRAINT language_servers_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_kind_fkey FOREIGN KEY (kind) REFERENCES public.notification_kinds(id); + +ALTER TABLE ONLY public.notifications + ADD CONSTRAINT notifications_recipient_id_fkey FOREIGN KEY (recipient_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.observed_buffer_edits + ADD CONSTRAINT observed_buffer_edits_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.observed_buffer_edits + ADD CONSTRAINT observed_buffer_edits_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.project_collaborators + ADD CONSTRAINT project_collaborators_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.project_collaborators + ADD CONSTRAINT project_collaborators_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_host_connection_server_id_fkey FOREIGN KEY (host_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_host_user_id_fkey FOREIGN KEY (host_user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.projects + ADD CONSTRAINT projects_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_answering_connection_server_id_fkey FOREIGN KEY (answering_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_calling_connection_server_id_fkey FOREIGN KEY (calling_connection_server_id) REFERENCES public.servers(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_calling_user_id_fkey FOREIGN KEY (calling_user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id); + +ALTER TABLE ONLY public.room_participants + ADD CONSTRAINT room_participants_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + +ALTER TABLE ONLY public.rooms + ADD CONSTRAINT rooms_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_features + ADD CONSTRAINT user_features_feature_id_fkey FOREIGN KEY (feature_id) REFERENCES public.feature_flags(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.user_features + ADD CONSTRAINT user_features_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_inviter_id_fkey FOREIGN KEY (inviter_id) REFERENCES public.users(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.worktree_diagnostic_summaries + ADD CONSTRAINT worktree_diagnostic_summaries_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.worktree_entries + ADD CONSTRAINT worktree_entries_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.worktree_settings_files + ADD CONSTRAINT worktree_settings_files_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.worktrees + ADD CONSTRAINT worktrees_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; diff --git a/crates/collab/migrations_llm/20240806182921_create_providers_and_models.sql b/crates/collab/migrations_llm/20240806182921_create_providers_and_models.sql deleted file mode 100644 index b81ab7567f22ae750c9aad6357cbf995a75c47c4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240806182921_create_providers_and_models.sql +++ /dev/null @@ -1,19 +0,0 @@ -create table if not exists providers ( - id serial primary key, - name text not null -); - -create unique index uix_providers_on_name on providers (name); - -create table if not exists models ( - id serial primary key, - provider_id integer not null references providers (id) on delete cascade, - name text not null, - max_requests_per_minute integer not null, - max_tokens_per_minute integer not null, - max_tokens_per_day integer not null -); - -create unique index uix_models_on_provider_id_name on models (provider_id, name); -create index ix_models_on_provider_id on models (provider_id); -create index ix_models_on_name on models (name); diff --git a/crates/collab/migrations_llm/20240806213401_create_usages.sql b/crates/collab/migrations_llm/20240806213401_create_usages.sql deleted file mode 100644 index da2245d4b9ac20fb6dddcb892221d1ff773cd156..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240806213401_create_usages.sql +++ /dev/null @@ -1,19 +0,0 @@ -create table usage_measures ( - id serial primary key, - name text not null -); - -create unique index uix_usage_measures_on_name on usage_measures (name); - -create table if not exists usages ( - id serial primary key, - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - measure_id integer not null references usage_measures (id) on delete cascade, - timestamp timestamp without time zone not null, - buckets bigint[] not null -); - -create index ix_usages_on_user_id on usages (user_id); -create index ix_usages_on_model_id on usages (model_id); -create unique index uix_usages_on_user_id_model_id_measure_id on usages (user_id, model_id, measure_id); diff --git a/crates/collab/migrations_llm/20240809130000_change_rate_limit_columns_to_bigint.sql b/crates/collab/migrations_llm/20240809130000_change_rate_limit_columns_to_bigint.sql deleted file mode 100644 index f1def8209a742743a6c708365afc05f7ab911e18..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240809130000_change_rate_limit_columns_to_bigint.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE models - ALTER COLUMN max_requests_per_minute TYPE bigint, - ALTER COLUMN max_tokens_per_minute TYPE bigint, - ALTER COLUMN max_tokens_per_day TYPE bigint; diff --git a/crates/collab/migrations_llm/20240809160000_add_pricing_columns_to_models.sql b/crates/collab/migrations_llm/20240809160000_add_pricing_columns_to_models.sql deleted file mode 100644 index d9ffe2f9f29c10acc9aaa6dc48389f92298c404f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240809160000_add_pricing_columns_to_models.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE models - ADD COLUMN price_per_million_input_tokens integer NOT NULL DEFAULT 0, - ADD COLUMN price_per_million_output_tokens integer NOT NULL DEFAULT 0; diff --git a/crates/collab/migrations_llm/20240812184444_add_is_staff_to_usages.sql b/crates/collab/migrations_llm/20240812184444_add_is_staff_to_usages.sql deleted file mode 100644 index a50feb2e3f41cd8e25265049a09140bdb04ea0d3..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240812184444_add_is_staff_to_usages.sql +++ /dev/null @@ -1 +0,0 @@ -alter table usages add column is_staff boolean not null default false; diff --git a/crates/collab/migrations_llm/20240812225346_create_lifetime_usages.sql b/crates/collab/migrations_llm/20240812225346_create_lifetime_usages.sql deleted file mode 100644 index 42047433e564d0f463a11f119ab4950d1d2d1254..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240812225346_create_lifetime_usages.sql +++ /dev/null @@ -1,9 +0,0 @@ -create table lifetime_usages ( - id serial primary key, - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - input_tokens bigint not null default 0, - output_tokens bigint not null default 0 -); - -create unique index uix_lifetime_usages_on_user_id_model_id on lifetime_usages (user_id, model_id); diff --git a/crates/collab/migrations_llm/20240813002237_add_revoked_access_tokens_table.sql b/crates/collab/migrations_llm/20240813002237_add_revoked_access_tokens_table.sql deleted file mode 100644 index c30e58a6dd8bccd882a97b55b91e190950890aac..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20240813002237_add_revoked_access_tokens_table.sql +++ /dev/null @@ -1,7 +0,0 @@ -create table revoked_access_tokens ( - id serial primary key, - jti text not null, - revoked_at timestamp without time zone not null default now() -); - -create unique index uix_revoked_access_tokens_on_jti on revoked_access_tokens (jti); diff --git a/crates/collab/migrations_llm/20241007173634_add_cache_token_counts.sql b/crates/collab/migrations_llm/20241007173634_add_cache_token_counts.sql deleted file mode 100644 index 855e46ab0224dc4e20e0fb8634a3e678d584433e..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20241007173634_add_cache_token_counts.sql +++ /dev/null @@ -1,11 +0,0 @@ -alter table models - add column price_per_million_cache_creation_input_tokens integer not null default 0, - add column price_per_million_cache_read_input_tokens integer not null default 0; - -alter table usages - add column cache_creation_input_tokens_this_month bigint not null default 0, - add column cache_read_input_tokens_this_month bigint not null default 0; - -alter table lifetime_usages - add column cache_creation_input_tokens bigint not null default 0, - add column cache_read_input_tokens bigint not null default 0; diff --git a/crates/collab/migrations_llm/20241007220716_drop_incorrect_usages_columns.sql b/crates/collab/migrations_llm/20241007220716_drop_incorrect_usages_columns.sql deleted file mode 100644 index c204451b7538d8fefb41e3f2962433b552b91229..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20241007220716_drop_incorrect_usages_columns.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table usages - drop column cache_creation_input_tokens_this_month, - drop column cache_read_input_tokens_this_month; diff --git a/crates/collab/migrations_llm/20241008155620_create_monthly_usages.sql b/crates/collab/migrations_llm/20241008155620_create_monthly_usages.sql deleted file mode 100644 index 2733552a3a16f2754ba1c191e42ab4548f67848c..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20241008155620_create_monthly_usages.sql +++ /dev/null @@ -1,13 +0,0 @@ -create table monthly_usages ( - id serial primary key, - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - month integer not null, - year integer not null, - input_tokens bigint not null default 0, - cache_creation_input_tokens bigint not null default 0, - cache_read_input_tokens bigint not null default 0, - output_tokens bigint not null default 0 -); - -create unique index uix_monthly_usages_on_user_id_model_id_month_year on monthly_usages (user_id, model_id, month, year); diff --git a/crates/collab/migrations_llm/20241010151249_create_billing_events.sql b/crates/collab/migrations_llm/20241010151249_create_billing_events.sql deleted file mode 100644 index 74a270872e5f664096d90179359e78a5a2298812..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20241010151249_create_billing_events.sql +++ /dev/null @@ -1,12 +0,0 @@ -create table billing_events ( - id serial primary key, - idempotency_key uuid not null default gen_random_uuid(), - user_id integer not null, - model_id integer not null references models (id) on delete cascade, - input_tokens bigint not null default 0, - input_cache_creation_tokens bigint not null default 0, - input_cache_read_tokens bigint not null default 0, - output_tokens bigint not null default 0 -); - -create index uix_billing_events_on_user_id_model_id on billing_events (user_id, model_id); diff --git a/crates/collab/migrations_llm/20250404141155_add_granular_token_limits_to_models.sql b/crates/collab/migrations_llm/20250404141155_add_granular_token_limits_to_models.sql deleted file mode 100644 index e5c50d8385745c602f01b2bb7daa9ba8d3580eaa..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250404141155_add_granular_token_limits_to_models.sql +++ /dev/null @@ -1,3 +0,0 @@ -alter table models - add column max_input_tokens_per_minute bigint not null default 0, - add column max_output_tokens_per_minute bigint not null default 0; diff --git a/crates/collab/migrations_llm/20250415213005_add_subscription_usages.sql b/crates/collab/migrations_llm/20250415213005_add_subscription_usages.sql deleted file mode 100644 index b3873710580b39cdb9c8fe8a2bb176839b5163b4..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250415213005_add_subscription_usages.sql +++ /dev/null @@ -1,10 +0,0 @@ -create table subscription_usages ( - id serial primary key, - user_id integer not null, - period_start_at timestamp without time zone not null, - period_end_at timestamp without time zone not null, - model_requests int not null default 0, - edit_predictions int not null default 0 -); - -create unique index uix_subscription_usages_on_user_id_start_at_end_at on subscription_usages (user_id, period_start_at, period_end_at); diff --git a/crates/collab/migrations_llm/20250416181354_add_plan_to_subscription_usages.sql b/crates/collab/migrations_llm/20250416181354_add_plan_to_subscription_usages.sql deleted file mode 100644 index 8d54c8b87ca820bd8aa46c7bf18ccd50ccf52807..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250416181354_add_plan_to_subscription_usages.sql +++ /dev/null @@ -1,4 +0,0 @@ -alter table subscription_usages - add column plan text not null; - -create index ix_subscription_usages_on_plan on subscription_usages (plan); diff --git a/crates/collab/migrations_llm/20250425171838_add_subscription_usage_meters.sql b/crates/collab/migrations_llm/20250425171838_add_subscription_usage_meters.sql deleted file mode 100644 index ded918e18385dbfe9f916bc0bdb49a3a6bde153f..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250425171838_add_subscription_usage_meters.sql +++ /dev/null @@ -1,8 +0,0 @@ -create table subscription_usage_meters ( - id serial primary key, - subscription_usage_id integer not null references subscription_usages (id) on delete cascade, - model_id integer not null references models (id) on delete cascade, - requests integer not null default 0 -); - -create unique index uix_subscription_usage_meters_on_subscription_usage_model on subscription_usage_meters (subscription_usage_id, model_id); diff --git a/crates/collab/migrations_llm/20250429143553_add_mode_to_subscription_usage_meters.sql b/crates/collab/migrations_llm/20250429143553_add_mode_to_subscription_usage_meters.sql deleted file mode 100644 index 9d63e299f56e0475a4016b2a69a0779f09cc8b4d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250429143553_add_mode_to_subscription_usage_meters.sql +++ /dev/null @@ -1,6 +0,0 @@ -alter table subscription_usage_meters - add column mode text not null default 'normal'; - -drop index uix_subscription_usage_meters_on_subscription_usage_model; - -create unique index uix_subscription_usage_meters_on_subscription_usage_model_mode on subscription_usage_meters (subscription_usage_id, model_id, mode); diff --git a/crates/collab/migrations_llm/20250503162708_add_v2_subscription_usage_and_meter_tables.sql b/crates/collab/migrations_llm/20250503162708_add_v2_subscription_usage_and_meter_tables.sql deleted file mode 100644 index 59169d3c3ec722fd7dfcdde97f1ed3f34e5c51fd..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250503162708_add_v2_subscription_usage_and_meter_tables.sql +++ /dev/null @@ -1,23 +0,0 @@ -create table subscription_usages_v2 ( - id uuid primary key, - user_id integer not null, - period_start_at timestamp without time zone not null, - period_end_at timestamp without time zone not null, - plan text not null, - model_requests int not null default 0, - edit_predictions int not null default 0 -); - -create unique index uix_subscription_usages_v2_on_user_id_start_at_end_at on subscription_usages_v2 (user_id, period_start_at, period_end_at); - -create index ix_subscription_usages_v2_on_plan on subscription_usages_v2 (plan); - -create table subscription_usage_meters_v2 ( - id uuid primary key, - subscription_usage_id uuid not null references subscription_usages_v2 (id) on delete cascade, - model_id integer not null references models (id) on delete cascade, - mode text not null, - requests integer not null default 0 -); - -create unique index uix_subscription_usage_meters_v2_on_usage_model_mode on subscription_usage_meters_v2 (subscription_usage_id, model_id, mode); diff --git a/crates/collab/migrations_llm/20250504132836_drop_legacy_subscription_usage_and_meter_tables.sql b/crates/collab/migrations_llm/20250504132836_drop_legacy_subscription_usage_and_meter_tables.sql deleted file mode 100644 index f06b152d7ba9a38419facb18fd620899a63b083d..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250504132836_drop_legacy_subscription_usage_and_meter_tables.sql +++ /dev/null @@ -1,2 +0,0 @@ -drop table subscription_usage_meters; -drop table subscription_usages; diff --git a/crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql b/crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql deleted file mode 100644 index 5f03f50d0b3e17acf3aabd433df9ef317172039a..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250521211721_drop_monthly_and_lifetime_usages_tables.sql +++ /dev/null @@ -1,2 +0,0 @@ -drop table monthly_usages; -drop table lifetime_usages; diff --git a/crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql b/crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql deleted file mode 100644 index 36b79266b6adc448e99d6bb3fa1c88b9ee9604f5..0000000000000000000000000000000000000000 --- a/crates/collab/migrations_llm/20250521222416_drop_billing_events_table.sql +++ /dev/null @@ -1 +0,0 @@ -drop table billing_events; diff --git a/crates/collab/src/api/contributors.rs b/crates/collab/src/api/contributors.rs index 574667c723dce62b905e3d2a0b34de1ca4c88c8e..4758adb2d71760a52c9ce5e4f2bc44f7069778fb 100644 --- a/crates/collab/src/api/contributors.rs +++ b/crates/collab/src/api/contributors.rs @@ -54,6 +54,26 @@ async fn check_is_contributor( ) -> Result> { let params = params.into_contributor_selector()?; + if CopilotSweAgentBot::is_copilot_bot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + CopilotSweAgentBot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + + if Dependabot::is_dependabot(¶ms) { + return Ok(Json(CheckIsContributorResponse { + signed_at: Some( + Dependabot::created_at() + .and_utc() + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + })); + } + if RenovateBot::is_renovate_bot(¶ms) { return Ok(Json(CheckIsContributorResponse { signed_at: Some( @@ -83,6 +103,71 @@ async fn check_is_contributor( })) } +/// The Copilot bot GitHub user (`copilot-swe-agent[bot]`). +/// +/// https://api.github.com/users/copilot-swe-agent[bot] +struct CopilotSweAgentBot; + +impl CopilotSweAgentBot { + const LOGIN: &'static str = "copilot-swe-agent[bot]"; + const USER_ID: i32 = 198982749; + /// The alias of the GitHub copilot user. Although https://api.github.com/users/copilot + /// yields a 404, GitHub still refers to the copilot bot user as @Copilot in some cases. + const NAME_ALIAS: &'static str = "Copilot"; + + /// Returns the `created_at` timestamp for the Dependabot bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2025-02-12T20:26:08Z") + .expect("failed to parse 'created_at' for 'copilot-swe-agent[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Copilot bot user. + fn is_copilot_bot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => { + github_login == Self::LOGIN || github_login == Self::NAME_ALIAS + } + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + +/// The Dependabot bot GitHub user (`dependabot[bot]`). +/// +/// https://api.github.com/users/dependabot[bot] +struct Dependabot; + +impl Dependabot { + const LOGIN: &'static str = "dependabot[bot]"; + const USER_ID: i32 = 49699333; + + /// Returns the `created_at` timestamp for the Dependabot bot user. + fn created_at() -> &'static NaiveDateTime { + static CREATED_AT: OnceLock = OnceLock::new(); + CREATED_AT.get_or_init(|| { + chrono::DateTime::parse_from_rfc3339("2019-04-16T22:34:25Z") + .expect("failed to parse 'created_at' for 'dependabot[bot]'") + .naive_utc() + }) + } + + /// Returns whether the given contributor selector corresponds to the Dependabot bot user. + fn is_dependabot(contributor: &ContributorSelector) -> bool { + match contributor { + ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN, + ContributorSelector::GitHubUserId { github_user_id } => { + github_user_id == &Self::USER_ID + } + } + } +} + /// The Renovate bot GitHub user (`renovate[bot]`). /// /// https://api.github.com/users/renovate[bot] diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 51a0ef83323ec70675283d2fdec7ca1ad791b12d..6f1d8b884d15041eadaa9073a5bd99e5ed352502 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -362,6 +362,8 @@ impl Database { entry_ids: ActiveValue::set("[]".into()), head_commit_details: ActiveValue::set(None), merge_message: ActiveValue::set(None), + remote_upstream_url: ActiveValue::set(None), + remote_origin_url: ActiveValue::set(None), } }), ) @@ -511,6 +513,8 @@ impl Database { serde_json::to_string(&update.current_merge_conflicts).unwrap(), )), merge_message: ActiveValue::set(update.merge_message.clone()), + remote_upstream_url: ActiveValue::set(update.remote_upstream_url.clone()), + remote_origin_url: ActiveValue::set(update.remote_origin_url.clone()), }) .on_conflict( OnConflict::columns([ @@ -1005,6 +1009,8 @@ impl Database { is_last_update: true, merge_message: db_repository_entry.merge_message, stash_entries: Vec::new(), + remote_upstream_url: db_repository_entry.remote_upstream_url.clone(), + remote_origin_url: db_repository_entry.remote_origin_url.clone(), }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index f020b99b5f1030cfe9391498512258e6db249bac..eafb5cac44a510bf4ced0434a9b4adfadff0ebbc 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -796,6 +796,8 @@ impl Database { is_last_update: true, merge_message: db_repository.merge_message, stash_entries: Vec::new(), + remote_upstream_url: db_repository.remote_upstream_url.clone(), + remote_origin_url: db_repository.remote_origin_url.clone(), }); } } diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index 0220955824af30f489afe32f9695af3dbb52cdc9..c179539e4bbc07fe070f3089f912b4c0b4fbf167 100644 --- a/crates/collab/src/db/tables.rs +++ b/crates/collab/src/db/tables.rs @@ -22,7 +22,6 @@ pub mod project_repository_statuses; pub mod room; pub mod room_participant; pub mod server; -pub mod signup; pub mod user; pub mod worktree; pub mod worktree_diagnostic_summary; diff --git a/crates/collab/src/db/tables/project_repository.rs b/crates/collab/src/db/tables/project_repository.rs index eb653ecee37d48ce79e26450eb85d87dec411c1e..190ae8d79c54bb78daef4a1568ec75683eb0b0f2 100644 --- a/crates/collab/src/db/tables/project_repository.rs +++ b/crates/collab/src/db/tables/project_repository.rs @@ -22,6 +22,8 @@ pub struct Model { pub branch_summary: Option, // A JSON object representing the current Head commit values pub head_commit_details: Option, + pub remote_upstream_url: Option, + pub remote_origin_url: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/db/tables/signup.rs b/crates/collab/src/db/tables/signup.rs deleted file mode 100644 index 79d9f0580c13f981daf30283b1a9e8902ea0b995..0000000000000000000000000000000000000000 --- a/crates/collab/src/db/tables/signup.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::db::{SignupId, UserId}; -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "signups")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: SignupId, - pub email_address: String, - pub email_confirmation_code: String, - pub email_confirmation_sent: bool, - pub created_at: DateTime, - pub device_id: Option, - pub user_id: Option, - pub inviting_user_id: Option, - pub platform_mac: bool, - pub platform_linux: bool, - pub platform_windows: bool, - pub platform_unknown: bool, - pub editor_features: Option>, - pub programming_languages: Option>, - pub added_to_mailing_list: bool, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 7aed2ebc2dd16f31cde4116a70377b40b1cb8b2f..10a32691ed36b4db9502c63bd510df6ffe1fe5b6 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -3,22 +3,21 @@ mod channel_tests; mod contributor_tests; mod db_tests; mod extension_tests; +mod migrations; -use crate::migrations::run_database_migrations; +use std::sync::Arc; +use std::sync::atomic::{AtomicI32, Ordering::SeqCst}; +use std::time::Duration; -use super::*; use gpui::BackgroundExecutor; use parking_lot::Mutex; use rand::prelude::*; use sea_orm::ConnectionTrait; use sqlx::migrate::MigrateDatabase; -use std::{ - sync::{ - Arc, - atomic::{AtomicI32, Ordering::SeqCst}, - }, - time::Duration, -}; + +use self::migrations::run_database_migrations; + +use super::*; pub struct TestDb { pub db: Option>, diff --git a/crates/collab/src/migrations.rs b/crates/collab/src/db/tests/migrations.rs similarity index 100% rename from crates/collab/src/migrations.rs rename to crates/collab/src/db/tests/migrations.rs diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 14573e94b0b535b1644510e28dfc906b1a2c420e..08f7e61c020ca9ea23be62636e381f9abedf7cf0 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -3,8 +3,6 @@ pub mod auth; pub mod db; pub mod env; pub mod executor; -pub mod llm; -pub mod migrations; pub mod rpc; pub mod seed; diff --git a/crates/collab/src/llm.rs b/crates/collab/src/llm.rs deleted file mode 100644 index dec10232bdb000acef9def25cad519ceb213956b..0000000000000000000000000000000000000000 --- a/crates/collab/src/llm.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod db; diff --git a/crates/collab/src/llm/db.rs b/crates/collab/src/llm/db.rs deleted file mode 100644 index b15d5a42b5f183831b34552beba3f616d3a7c3f0..0000000000000000000000000000000000000000 --- a/crates/collab/src/llm/db.rs +++ /dev/null @@ -1,98 +0,0 @@ -use std::future::Future; -use std::sync::Arc; - -use anyhow::Context; -pub use sea_orm::ConnectOptions; -use sea_orm::{DatabaseConnection, DatabaseTransaction, IsolationLevel, TransactionTrait}; - -use crate::Result; -use crate::db::TransactionHandle; -use crate::executor::Executor; - -/// The database for the LLM service. -pub struct LlmDatabase { - options: ConnectOptions, - pool: DatabaseConnection, - #[allow(unused)] - executor: Executor, - #[cfg(test)] - runtime: Option, -} - -impl LlmDatabase { - /// Connects to the database with the given options - pub async fn new(options: ConnectOptions, executor: Executor) -> Result { - sqlx::any::install_default_drivers(); - Ok(Self { - options: options.clone(), - pool: sea_orm::Database::connect(options).await?, - executor, - #[cfg(test)] - runtime: None, - }) - } - - pub fn options(&self) -> &ConnectOptions { - &self.options - } - - pub async fn transaction(&self, f: F) -> Result - where - F: Send + Fn(TransactionHandle) -> Fut, - Fut: Send + Future>, - { - let body = async { - let (tx, result) = self.with_transaction(&f).await?; - match result { - Ok(result) => match tx.commit().await.map_err(Into::into) { - Ok(()) => Ok(result), - Err(error) => Err(error), - }, - Err(error) => { - tx.rollback().await?; - Err(error) - } - } - }; - - self.run(body).await - } - - async fn with_transaction(&self, f: &F) -> Result<(DatabaseTransaction, Result)> - where - F: Send + Fn(TransactionHandle) -> Fut, - Fut: Send + Future>, - { - let tx = self - .pool - .begin_with_config(Some(IsolationLevel::ReadCommitted), None) - .await?; - - let mut tx = Arc::new(Some(tx)); - let result = f(TransactionHandle(tx.clone())).await; - let tx = Arc::get_mut(&mut tx) - .and_then(|tx| tx.take()) - .context("couldn't complete transaction because it's still in use")?; - - Ok((tx, result)) - } - - async fn run(&self, future: F) -> Result - where - F: Future>, - { - #[cfg(test)] - { - if let Executor::Deterministic(executor) = &self.executor { - executor.simulate_random_delay().await; - } - - self.runtime.as_ref().unwrap().block_on(future) - } - - #[cfg(not(test))] - { - future.await - } - } -} diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 08047c56e55c016f3fd2b34d0935fb33a61b5dad..030158c94d640ef8a9024a8b783685bac7d0dcdb 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -1,4 +1,4 @@ -use anyhow::{Context as _, anyhow}; +use anyhow::anyhow; use axum::headers::HeaderMapExt; use axum::{ Extension, Router, @@ -9,8 +9,6 @@ use axum::{ use collab::ServiceMode; use collab::api::CloudflareIpCountryHeader; -use collab::llm::db::LlmDatabase; -use collab::migrations::run_database_migrations; use collab::{ AppState, Config, Result, api::fetch_extensions_from_blob_store_periodically, db, env, executor::Executor, @@ -19,7 +17,6 @@ use db::Database; use std::{ env::args, net::{SocketAddr, TcpListener}, - path::Path, sync::Arc, time::Duration, }; @@ -49,10 +46,6 @@ async fn main() -> Result<()> { Some("version") => { println!("collab v{} ({})", VERSION, REVISION.unwrap_or("unknown")); } - Some("migrate") => { - let config = envy::from_env::().expect("error loading config"); - setup_app_database(&config).await?; - } Some("seed") => { let config = envy::from_env::().expect("error loading config"); let db_options = db::ConnectOptions::new(config.database_url.clone()); @@ -69,7 +62,7 @@ async fn main() -> Result<()> { Some("all") => ServiceMode::All, _ => { return Err(anyhow!( - "usage: collab >" + "usage: collab >" ))?; } }; @@ -90,7 +83,6 @@ async fn main() -> Result<()> { if mode.is_collab() || mode.is_api() { setup_app_database(&config).await?; - setup_llm_database(&config).await?; let state = AppState::new(config, Executor::Production).await?; @@ -211,25 +203,6 @@ async fn setup_app_database(config: &Config) -> Result<()> { let db_options = db::ConnectOptions::new(config.database_url.clone()); let mut db = Database::new(db_options).await?; - let migrations_path = config.migrations_path.as_deref().unwrap_or_else(|| { - #[cfg(feature = "sqlite")] - let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations.sqlite"); - #[cfg(not(feature = "sqlite"))] - let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); - - Path::new(default_migrations) - }); - - let migrations = run_database_migrations(db.options(), migrations_path).await?; - for (migration, duration) in migrations { - log::info!( - "Migrated {} {} {:?}", - migration.version, - migration.description, - duration - ); - } - db.initialize_notification_kinds().await?; if config.seed_path.is_some() { @@ -239,37 +212,6 @@ async fn setup_app_database(config: &Config) -> Result<()> { Ok(()) } -async fn setup_llm_database(config: &Config) -> Result<()> { - let database_url = config - .llm_database_url - .as_ref() - .context("missing LLM_DATABASE_URL")?; - - let db_options = db::ConnectOptions::new(database_url.clone()); - let db = LlmDatabase::new(db_options, Executor::Production).await?; - - let migrations_path = config - .llm_database_migrations_path - .as_deref() - .unwrap_or_else(|| { - let default_migrations = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations_llm"); - - Path::new(default_migrations) - }); - - let migrations = run_database_migrations(db.options(), migrations_path).await?; - for (migration, duration) in migrations { - log::info!( - "Migrated {} {} {:?}", - migration.version, - migration.description, - duration - ); - } - - Ok(()) -} - async fn handle_root(Extension(mode): Extension) -> String { format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown")) } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index aa77ba25bfb687b6c5cb0da84e14c843f8a2a3bc..9511087af8887a3c799357d06050ce48431b38a6 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -469,6 +469,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_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 7d07360b8042ed54a9f19a82a2876e448e8a14a4..3785ee0b7abaeddeac5c9acb1718407ab5bd54f2 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use call::Room; use client::ChannelId; use gpui::{Entity, TestAppContext}; @@ -18,7 +16,6 @@ mod randomized_test_helpers; mod remote_editing_collaboration_tests; mod test_server; -use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; pub use randomized_test_helpers::{ RandomizedTest, TestError, UserTestPlan, run_randomized_test, save_randomized_test_plan, }; @@ -51,17 +48,3 @@ fn room_participants(room: &Entity, cx: &mut TestAppContext) -> RoomPartic fn channel_id(room: &Entity, cx: &mut TestAppContext) -> Option { cx.read(|cx| room.read(cx).channel_id()) } - -fn rust_lang() -> Arc { - Arc::new(Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )) -} diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index e5d3661aaf1aa0c74a4204e0989018121f5eb64a..4e6cdb0e79aba494bd01137cc262a097a084217e 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -1,7 +1,4 @@ -use crate::{ - rpc::RECONNECT_TIMEOUT, - tests::{TestServer, rust_lang}, -}; +use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; use call::ActiveCall; use editor::{ DocumentColorsRenderMode, Editor, FETCH_COLORS_DEBOUNCE_TIMEOUT, MultiBufferOffset, RowInfo, @@ -23,8 +20,9 @@ use gpui::{ App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext, }; use indoc::indoc; -use language::FakeLspAdapter; +use language::{FakeLspAdapter, rust_lang}; use lsp::LSP_REQUEST_TIMEOUT; +use pretty_assertions::assert_eq; use project::{ ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT, lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro}, @@ -314,6 +312,49 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu "Rust", FakeLspAdapter { capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.text_document_position.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + assert_eq!( + params.text_document_position.position, + lsp::Position::new(0, 14), + ); + + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first_method(…)".into(), + detail: Some("fn(&mut self, B) -> C".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "first_method($1)".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + lsp::CompletionItem { + label: "second_method(…)".into(), + detail: Some("fn(&mut self, C) -> D".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "second_method()".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + ]))) + }, + ); + })), ..FakeLspAdapter::default() }, ), @@ -322,6 +363,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu FakeLspAdapter { name: "fake-analyzer", capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |_, _| async move { Ok(None) }, + ); + })), ..FakeLspAdapter::default() }, ), @@ -375,6 +421,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu let fake_language_server = fake_language_servers[0].next().await.unwrap(); let second_fake_language_server = fake_language_servers[1].next().await.unwrap(); cx_a.background_executor.run_until_parked(); + cx_b.background_executor.run_until_parked(); buffer_b.read_with(cx_b, |buffer, _| { assert!(!buffer.completion_triggers().is_empty()) @@ -389,58 +436,9 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu }); cx_b.focus(&editor_b); - // Receive a completion request as the host's language server. - // Return some completions from the host's language server. - cx_a.executor().start_waiting(); - fake_language_server - .set_request_handler::(|params, _| async move { - assert_eq!( - params.text_document_position.text_document.uri, - lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), - ); - assert_eq!( - params.text_document_position.position, - lsp::Position::new(0, 14), - ); - - Ok(Some(lsp::CompletionResponse::Array(vec![ - lsp::CompletionItem { - label: "first_method(…)".into(), - detail: Some("fn(&mut self, B) -> C".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "first_method($1)".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - lsp::CompletionItem { - label: "second_method(…)".into(), - detail: Some("fn(&mut self, C) -> D".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "second_method()".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - ]))) - }) - .next() - .await - .unwrap(); - second_fake_language_server - .set_request_handler::(|_, _| async move { Ok(None) }) - .next() - .await - .unwrap(); - cx_a.executor().finish_waiting(); + // Allow the completion request to propagate from guest to host to LSP. + cx_b.background_executor.run_until_parked(); + cx_a.background_executor.run_until_parked(); // Open the buffer on the host. let buffer_a = project_a @@ -486,6 +484,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu // The additional edit is applied. cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); buffer_a.read_with(cx_a, |buffer, _| { assert_eq!( @@ -643,13 +642,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu ), })), insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }); - cx_b.executor().run_until_parked(); - // Await both language server responses first_lsp_completion.next().await.unwrap(); second_lsp_completion.next().await.unwrap(); @@ -3192,13 +3189,12 @@ async fn test_lsp_pull_diagnostics( .collect::>(); let expected_messages = [ expected_pull_diagnostic_lib_message, - // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer. - // expected_push_diagnostic_lib_message, + expected_push_diagnostic_lib_message, ]; assert_eq!( all_diagnostics.len(), - 1, - "Expected pull diagnostics, but got: {all_diagnostics:?}" + 2, + "Expected pull and push diagnostics, but got: {all_diagnostics:?}" ); for diagnostic in all_diagnostics { assert!( @@ -3258,14 +3254,15 @@ async fn test_lsp_pull_diagnostics( .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); let expected_messages = [ - expected_workspace_pull_diagnostics_lib_message, - // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer. - // expected_push_diagnostic_lib_message, + // Despite workspace diagnostics provided, + // the currently open file's diagnostics should be preferred, as LSP suggests. + expected_pull_diagnostic_lib_message, + expected_push_diagnostic_lib_message, ]; assert_eq!( all_diagnostics.len(), - 1, - "Expected pull diagnostics, but got: {all_diagnostics:?}" + 2, + "Expected pull and push diagnostics, but got: {all_diagnostics:?}" ); for diagnostic in all_diagnostics { assert!( @@ -3378,8 +3375,9 @@ async fn test_lsp_pull_diagnostics( "Another workspace diagnostics pull should happen after the diagnostics refresh server request" ); { - assert!( - diagnostics_pulls_result_ids.lock().await.len() == diagnostic_pulls_result_ids, + assert_eq!( + diagnostics_pulls_result_ids.lock().await.len(), + diagnostic_pulls_result_ids, "Pulls should not happen hence no extra ids should appear" ); assert!( @@ -3397,7 +3395,7 @@ async fn test_lsp_pull_diagnostics( expected_pull_diagnostic_lib_message, expected_push_diagnostic_lib_message, ]; - assert_eq!(all_diagnostics.len(), 1); + assert_eq!(all_diagnostics.len(), 2); for diagnostic in &all_diagnostics { assert!( expected_messages.contains(&diagnostic.diagnostic.message.as_str()), @@ -3516,7 +3514,6 @@ 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(), - remote_url: Some("git@github.com:zed-industries/zed.git".to_string()), }; client_a.fs().set_blame_for_repo( Path::new(path!("/my-repo/.git")), @@ -3601,10 +3598,6 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA for (idx, (buffer, entry)) in entries.iter().flatten().enumerate() { let details = blame.details_for_entry(*buffer, entry).unwrap(); assert_eq!(details.message, format!("message for idx-{}", idx)); - assert_eq!( - details.permalink.unwrap().to_string(), - format!("https://github.com/zed-industries/zed/commit/{}", entry.sha) - ); } }); }); diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 67a082f6cfdd56c74e758f7e8b12009f5c8fb3ac..6f6f22fd5ead885f7edc493e1e5a98d04b479e00 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2,7 +2,7 @@ use crate::{ rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, tests::{ RoomParticipants, TestClient, TestServer, channel_id, following_tests::join_channel, - room_participants, rust_lang, + room_participants, }, }; use anyhow::{Result, anyhow}; @@ -26,7 +26,7 @@ use language::{ Diagnostic, DiagnosticEntry, DiagnosticSourceKind, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope, language_settings::{Formatter, FormatterList}, - tree_sitter_rust, tree_sitter_typescript, + rust_lang, tree_sitter_rust, tree_sitter_typescript, }; use lsp::{LanguageServerId, OneOf}; use parking_lot::Mutex; @@ -6745,8 +6745,13 @@ async fn test_preview_tabs(cx: &mut TestAppContext) { }); // Split pane to the right - pane.update(cx, |pane, cx| { - pane.split(workspace::SplitDirection::Right, cx); + pane.update_in(cx, |pane, window, cx| { + pane.split( + workspace::SplitDirection::Right, + workspace::SplitMode::default(), + window, + cx, + ); }); cx.run_until_parked(); let right_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index 04403de9fa0883e9d738f3d96b9b2acdf1d66967..5342b0bbd4b11afb24ccbaa6d4bf17df036cec76 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -4,6 +4,7 @@ use collections::{HashMap, HashSet}; use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling}; use debugger_ui::debugger_panel::DebugPanel; +use editor::{Editor, EditorMode, MultiBuffer}; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs as _, RemoveOptions}; use futures::StreamExt as _; @@ -12,22 +13,30 @@ use http_client::BlockedHttpClient; use language::{ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, language_settings::{Formatter, FormatterList, language_settings}, - tree_sitter_typescript, + rust_lang, tree_sitter_typescript, }; use node_runtime::NodeRuntime; use project::{ ProjectPath, debugger::session::ThreadId, lsp_store::{FormatTrigger, LspFormatTarget}, + trusted_worktrees::{PathTrust, TrustedWorktrees}, }; use remote::RemoteClient; use remote_server::{HeadlessAppState, HeadlessProject}; use rpc::proto; use serde_json::json; -use settings::{LanguageServerFormatterSpecifier, PrettierSettingsContent, SettingsStore}; +use settings::{ + InlayHintSettingsContent, LanguageServerFormatterSpecifier, PrettierSettingsContent, + SettingsStore, +}; use std::{ path::Path, - sync::{Arc, atomic::AtomicUsize}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, }; use task::TcpArgumentsTemplate; use util::{path, rel_path::rel_path}; @@ -90,13 +99,14 @@ async fn test_sharing_an_ssh_remote_project( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, worktree_id) = client_a - .build_ssh_project(path!("/code/project1"), client_ssh, cx_a) + .build_ssh_project(path!("/code/project1"), client_ssh, false, cx_a) .await; // While the SSH worktree is being scanned, user A shares the remote project. @@ -250,13 +260,14 @@ async fn test_ssh_collaboration_git_branches( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, _) = client_a - .build_ssh_project("/project", client_ssh, cx_a) + .build_ssh_project("/project", client_ssh, false, cx_a) .await; // While the SSH worktree is being scanned, user A shares the remote project. @@ -454,13 +465,14 @@ async fn test_ssh_collaboration_formatting_with_prettier( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); let client_ssh = RemoteClient::fake_client(opts, cx_a).await; let (project_a, worktree_id) = client_a - .build_ssh_project(path!("/project"), client_ssh, cx_a) + .build_ssh_project(path!("/project"), client_ssh, false, cx_a) .await; // While the SSH worktree is being scanned, user A shares the remote project. @@ -615,6 +627,7 @@ async fn test_remote_server_debugger( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); @@ -627,7 +640,7 @@ async fn test_remote_server_debugger( command_palette_hooks::init(cx); }); let (project_a, _) = client_a - .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a) + .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a) .await; let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a); @@ -723,6 +736,7 @@ async fn test_slow_adapter_startup_retries( languages, extension_host_proxy: Arc::new(ExtensionHostProxy::new()), }, + false, cx, ) }); @@ -735,7 +749,7 @@ async fn test_slow_adapter_startup_retries( command_palette_hooks::init(cx); }); let (project_a, _) = client_a - .build_ssh_project(path!("/code"), client_ssh.clone(), cx_a) + .build_ssh_project(path!("/code"), client_ssh.clone(), false, cx_a) .await; let (workspace, cx_a) = client_a.build_workspace(&project_a, cx_a); @@ -838,3 +852,261 @@ async fn test_slow_adapter_startup_retries( shutdown_session.await.unwrap(); } + +#[gpui::test] +async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &mut TestAppContext) { + use project::trusted_worktrees::RemoteHostLocation; + + cx_a.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + project::trusted_worktrees::init(HashMap::default(), None, None, cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + project::trusted_worktrees::init(HashMap::default(), None, None, cx); + }); + + let mut server = TestServer::start(cx_a.executor().clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + + let server_name = "override-rust-analyzer"; + let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0)); + + let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx); + let remote_fs = FakeFs::new(server_cx.executor()); + remote_fs + .insert_tree( + path!("/projects"), + json!({ + "project_a": { + ".zed": { + "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"# + }, + "main.rs": "fn main() {}" + }, + "project_b": { "lib.rs": "pub fn lib() {}" } + }), + ) + .await; + + server_cx.update(HeadlessProject::init); + let remote_http_client = Arc::new(BlockedHttpClient); + let node = NodeRuntime::unavailable(); + let languages = Arc::new(LanguageRegistry::new(server_cx.executor())); + languages.add(rust_lang()); + + let capabilities = lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + let mut fake_language_servers = languages.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: server_name, + capabilities: capabilities.clone(), + initializer: Some(Box::new({ + let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); + move |fake_server| { + let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); + fake_server.set_request_handler::( + move |_params, _| { + lsp_inlay_hint_request_count.fetch_add(1, Ordering::Release); + async move { + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, 0), + label: lsp::InlayHintLabel::String("hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + + let _headless_project = server_cx.new(|cx| { + HeadlessProject::new( + HeadlessAppState { + session: server_ssh, + fs: remote_fs.clone(), + http_client: remote_http_client, + node_runtime: node, + languages, + extension_host_proxy: Arc::new(ExtensionHostProxy::new()), + }, + true, + cx, + ) + }); + + let client_ssh = RemoteClient::fake_client(opts, cx_a).await; + let (project_a, worktree_id_a) = client_a + .build_ssh_project(path!("/projects/project_a"), client_ssh.clone(), true, cx_a) + .await; + + cx_a.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + let language_settings = &mut settings.project.all_languages.defaults; + language_settings.inlay_hints = Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }) + }); + }); + }); + + project_a + .update(cx_a, |project, cx| { + project.languages().add(rust_lang()); + project.languages().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + name: server_name, + capabilities, + ..FakeLspAdapter::default() + }, + ); + project.find_or_create_worktree(path!("/projects/project_b"), true, cx) + }) + .await + .unwrap(); + + cx_a.run_until_parked(); + + let worktree_ids = project_a.read_with(cx_a, |project, cx| { + project + .worktrees(cx) + .map(|wt| wt.read(cx).id()) + .collect::>() + }); + assert_eq!(worktree_ids.len(), 2); + + let remote_host = project_a.read_with(cx_a, |project, cx| { + project + .remote_connection_options(cx) + .map(RemoteHostLocation::from) + }); + + let trusted_worktrees = + cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist")); + + let can_trust_a = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_b = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx)); + assert!(!can_trust_a, "project_a should be restricted initially"); + assert!(!can_trust_b, "project_b should be restricted initially"); + + let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store()); + let has_restricted = trusted_worktrees.read_with(cx_a, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!(has_restricted, "should have restricted worktrees"); + + let buffer_before_approval = project_a + .update(cx_a, |project, cx| { + project.open_buffer((worktree_id_a, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (editor, cx_a) = cx_a.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)), + Some(project_a.clone()), + window, + cx, + ) + }); + cx_a.run_until_parked(); + let fake_language_server = fake_language_servers.next(); + + cx_a.read(|cx| { + let file = buffer_before_approval.read(cx).file(); + assert_eq!( + language_settings(Some("Rust".into()), file, cx).language_servers, + ["...".to_string()], + "remote .zed/settings.json must not sync before trust approval" + ) + }); + + editor.update_in(cx_a, |editor, window, cx| { + editor.handle_input("1", window, cx); + }); + cx_a.run_until_parked(); + cx_a.executor().advance_clock(Duration::from_secs(1)); + assert_eq!( + lsp_inlay_hint_request_count.load(Ordering::Acquire), + 0, + "inlay hints must not be queried before trust approval" + ); + + trusted_worktrees.update(cx_a, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_ids[0])]), + remote_host.clone(), + cx, + ); + }); + cx_a.run_until_parked(); + + cx_a.read(|cx| { + let file = buffer_before_approval.read(cx).file(); + assert_eq!( + language_settings(Some("Rust".into()), file, cx).language_servers, + ["override-rust-analyzer".to_string()], + "remote .zed/settings.json should sync after trust approval" + ) + }); + let _fake_language_server = fake_language_server.await.unwrap(); + editor.update_in(cx_a, |editor, window, cx| { + editor.handle_input("1", window, cx); + }); + cx_a.run_until_parked(); + cx_a.executor().advance_clock(Duration::from_secs(1)); + assert!( + lsp_inlay_hint_request_count.load(Ordering::Acquire) > 0, + "inlay hints should be queried after trust approval" + ); + + let can_trust_a = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_b = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx)); + assert!(can_trust_a, "project_a should be trusted after trust()"); + assert!(!can_trust_b, "project_b should still be restricted"); + + trusted_worktrees.update(cx_a, |store, cx| { + store.trust( + HashSet::from_iter([PathTrust::Worktree(worktree_ids[1])]), + remote_host.clone(), + cx, + ); + }); + + let can_trust_a = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[0], cx)); + let can_trust_b = + trusted_worktrees.update(cx_a, |store, cx| store.can_trust(worktree_ids[1], cx)); + assert!(can_trust_a, "project_a should remain trusted"); + assert!(can_trust_b, "project_b should now be trusted"); + + let has_restricted_after = trusted_worktrees.read_with(cx_a, |store, cx| { + store.has_restricted_worktrees(&worktree_store, cx) + }); + assert!( + !has_restricted_after, + "should have no restricted worktrees after trusting both" + ); +} diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 959d54cf0864ccddf7273cca0276d18d4f59308b..3abbd1a014b556db02e70b42c239729100f17eb8 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -761,6 +761,7 @@ impl TestClient { &self, root_path: impl AsRef, ssh: Entity, + init_worktree_trust: bool, cx: &mut TestAppContext, ) -> (Entity, WorktreeId) { let project = cx.update(|cx| { @@ -771,6 +772,7 @@ impl TestClient { self.app_state.user_store.clone(), self.app_state.languages.clone(), self.app_state.fs.clone(), + init_worktree_trust, cx, ) }); @@ -839,6 +841,7 @@ impl TestClient { self.app_state.languages.clone(), self.app_state.fs.clone(), None, + false, cx, ) }) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 7137af21d315391383d3007c148807a7604a1155..0ae4ff270bd672ca028d638484b9a23f5981de1a 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -109,22 +109,8 @@ pub fn init(cx: &mut App) { }); // TODO: make it possible to bind this one to a held key for push to talk? // how to make "toggle_on_modifiers_press" contextual? - workspace.register_action(|_, _: &Mute, window, cx| { - let room = ActiveCall::global(cx).read(cx).room().cloned(); - if let Some(room) = room { - window.defer(cx, move |_window, cx| { - room.update(cx, |room, cx| room.toggle_mute(cx)) - }); - } - }); - workspace.register_action(|_, _: &Deafen, window, cx| { - let room = ActiveCall::global(cx).read(cx).room().cloned(); - if let Some(room) = room { - window.defer(cx, move |_window, cx| { - room.update(cx, |room, cx| room.toggle_deafen(cx)) - }); - } - }); + workspace.register_action(|_, _: &Mute, _, cx| title_bar::collab::toggle_mute(cx)); + workspace.register_action(|_, _: &Deafen, _, cx| title_bar::collab::toggle_deafen(cx)); workspace.register_action(|_, _: &LeaveCall, window, cx| { CollabPanel::leave_call(window, cx); }); @@ -1266,7 +1252,7 @@ impl CollabPanel { context_menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -1438,7 +1424,7 @@ impl CollabPanel { context_menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -1501,7 +1487,7 @@ impl CollabPanel { }) }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -1535,9 +1521,9 @@ impl CollabPanel { if cx.stop_active_drag(window) { return; } else if self.take_editing_state(window, cx) { - window.focus(&self.filter_editor.focus_handle(cx)); + window.focus(&self.filter_editor.focus_handle(cx), cx); } else if !self.reset_filter_editor_text(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } if self.context_menu.is_some() { @@ -1840,7 +1826,7 @@ impl CollabPanel { }); self.update_entries(false, cx); self.select_channel_editor(); - window.focus(&self.channel_name_editor.focus_handle(cx)); + window.focus(&self.channel_name_editor.focus_handle(cx), cx); cx.notify(); } @@ -1865,7 +1851,7 @@ impl CollabPanel { }); self.update_entries(false, cx); self.select_channel_editor(); - window.focus(&self.channel_name_editor.focus_handle(cx)); + window.focus(&self.channel_name_editor.focus_handle(cx), cx); cx.notify(); } @@ -1914,7 +1900,7 @@ impl CollabPanel { editor.set_text(channel.name.clone(), window, cx); editor.select_all(&Default::default(), window, cx); }); - window.focus(&self.channel_name_editor.focus_handle(cx)); + window.focus(&self.channel_name_editor.focus_handle(cx), cx); self.update_entries(false, cx); self.select_channel_editor(); } diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 9d882562cab710f562145087e5c38474fda4808b..ae5b537f2c66dc273d504a70f2b75cb8bec0be20 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -642,7 +642,7 @@ impl ChannelModalDelegate { }); menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index d971bca1f01e878d7517c1d13f525dfbf8e47afa..038b58ac5f4e90544232ccc8da55d0ca71ec28df 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -2,7 +2,7 @@ mod persistence; use std::{ cmp::{self, Reverse}, - collections::HashMap, + collections::{HashMap, VecDeque}, sync::Arc, time::Duration, }; @@ -19,6 +19,7 @@ use gpui::{ ParentElement, Render, Styled, Task, WeakEntity, Window, }; use persistence::COMMAND_PALETTE_HISTORY; +use picker::Direction; use picker::{Picker, PickerDelegate}; use postage::{sink::Sink, stream::Stream}; use settings::Settings; @@ -163,6 +164,7 @@ pub struct CommandPaletteDelegate { Task<()>, postage::dispatch::Receiver<(Vec, Vec, CommandInterceptResult)>, )>, + query_history: QueryHistory, } struct Command { @@ -170,6 +172,91 @@ struct Command { action: Box, } +#[derive(Default)] +struct QueryHistory { + history: Option>, + cursor: Option, + prefix: Option, +} + +impl QueryHistory { + fn history(&mut self) -> &mut VecDeque { + self.history.get_or_insert_with(|| { + COMMAND_PALETTE_HISTORY + .list_recent_queries() + .unwrap_or_default() + .into_iter() + .collect() + }) + } + + fn add(&mut self, query: String) { + if let Some(pos) = self.history().iter().position(|h| h == &query) { + self.history().remove(pos); + } + self.history().push_back(query); + self.cursor = None; + self.prefix = None; + } + + fn validate_cursor(&mut self, current_query: &str) -> Option { + if let Some(pos) = self.cursor { + if self.history().get(pos).map(|s| s.as_str()) != Some(current_query) { + self.cursor = None; + self.prefix = None; + } + } + self.cursor + } + + fn previous(&mut self, current_query: &str) -> Option<&str> { + if self.validate_cursor(current_query).is_none() { + self.prefix = Some(current_query.to_string()); + } + + let prefix = self.prefix.clone().unwrap_or_default(); + let start_index = self.cursor.unwrap_or(self.history().len()); + + for i in (0..start_index).rev() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn next(&mut self, current_query: &str) -> Option<&str> { + let selected = self.validate_cursor(current_query)?; + let prefix = self.prefix.clone().unwrap_or_default(); + + for i in (selected + 1)..self.history().len() { + if self + .history() + .get(i) + .is_some_and(|e| e.starts_with(&prefix)) + { + self.cursor = Some(i); + return self.history().get(i).map(|s| s.as_str()); + } + } + None + } + + fn reset_cursor(&mut self) { + self.cursor = None; + self.prefix = None; + } + + fn is_navigating(&self) -> bool { + self.cursor.is_some() + } +} + impl Clone for Command { fn clone(&self) -> Self { Self { @@ -196,6 +283,7 @@ impl CommandPaletteDelegate { previous_focus_handle, latest_query: String::new(), updating_matches: None, + query_history: Default::default(), } } @@ -271,6 +359,11 @@ impl CommandPaletteDelegate { // so we need to return an Option here self.commands.get(action_ix) } + + #[cfg(any(test, feature = "test-support"))] + pub fn seed_history(&mut self, queries: &[&str]) { + self.query_history.history = Some(queries.iter().map(|s| s.to_string()).collect()); + } } impl PickerDelegate for CommandPaletteDelegate { @@ -280,6 +373,38 @@ impl PickerDelegate for CommandPaletteDelegate { "Execute a command...".into() } + fn select_history( + &mut self, + direction: Direction, + query: &str, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + match direction { + Direction::Up => { + let should_use_history = + self.selected_ix == 0 || self.query_history.is_navigating(); + if should_use_history { + if let Some(query) = self.query_history.previous(query).map(|s| s.to_string()) { + return Some(query); + } + } + } + Direction::Down => { + if self.query_history.is_navigating() { + if let Some(query) = self.query_history.next(query).map(|s| s.to_string()) { + return Some(query); + } else { + let prefix = self.query_history.prefix.take().unwrap_or_default(); + self.query_history.reset_cursor(); + return Some(prefix); + } + } + } + } + None + } + fn match_count(&self) -> usize { self.matches.len() } @@ -439,6 +564,12 @@ impl PickerDelegate for CommandPaletteDelegate { self.dismissed(window, cx); return; } + + if !self.latest_query.is_empty() { + self.query_history.add(self.latest_query.clone()); + self.query_history.reset_cursor(); + } + let action_ix = self.matches[self.selected_ix].candidate_id; let command = self.commands.swap_remove(action_ix); telemetry::event!( @@ -457,7 +588,7 @@ impl PickerDelegate for CommandPaletteDelegate { }) .detach_and_log_err(cx); let action = command.action; - window.focus(&self.previous_focus_handle); + window.focus(&self.previous_focus_handle, cx); self.dismissed(window, cx); window.dispatch_action(action, cx); } @@ -588,7 +719,7 @@ mod tests { use super::*; use editor::Editor; use go_to_line::GoToLine; - use gpui::TestAppContext; + use gpui::{TestAppContext, VisualTestContext}; use language::Point; use project::Project; use settings::KeymapFile; @@ -653,7 +784,7 @@ mod tests { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))) + editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) }); cx.simulate_keystrokes("cmd-shift-p"); @@ -724,7 +855,7 @@ mod tests { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx))) + editor.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx)) }); // Test normalize (trimming whitespace and double colons) @@ -799,7 +930,9 @@ mod tests { "bindings": { "cmd-n": "workspace::NewFile", "enter": "menu::Confirm", - "cmd-shift-p": "command_palette::Toggle" + "cmd-shift-p": "command_palette::Toggle", + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" } } ]"#, @@ -808,4 +941,264 @@ mod tests { app_state }) } + + fn open_palette_with_history( + workspace: &Entity, + history: &[&str], + cx: &mut VisualTestContext, + ) -> Entity> { + cx.simulate_keystrokes("cmd-shift-p"); + cx.run_until_parked(); + + let palette = workspace.update(cx, |workspace, cx| { + workspace + .active_modal::(cx) + .unwrap() + .read(cx) + .picker + .clone() + }); + + palette.update(cx, |palette, _cx| { + palette.delegate.seed_history(history); + }); + + palette + } + + #[gpui::test] + async fn test_history_navigation_basic(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx); + + // Query should be empty initially + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + + // Press up - should load most recent query "select all" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should load "backspace" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Press down - should go back to "select all" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down again - should clear query (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } + + #[gpui::test] + async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["backspace"], cx); + + // Press up to enter history mode + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspace"); + }); + + // Type something - should append to the history query + cx.simulate_input("x"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "backspacex"); + }); + } + + #[gpui::test] + async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx); + + // Open palette with a query that has multiple matches + cx.simulate_input("editor"); + cx.background_executor.run_until_parked(); + + // Should have multiple matches, selected_ix should be 0 + palette.read_with(cx, |palette, _| { + assert!(palette.delegate.matches.len() > 1); + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press down - should navigate to next suggestion (not history) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 1); + }); + + // Press up - should go back to first suggestion + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, _| { + assert_eq!(palette.delegate.selected_ix, 0); + }); + + // Press up again at top - should enter history mode and show previous query + // that matches the "editor" prefix + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "editor: open"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history( + &workspace, + &["open file", "select all", "select line", "backspace"], + cx, + ); + + // Type "sel" as a prefix + cx.simulate_input("sel"); + cx.background_executor.run_until_parked(); + + // Press up - should get "select line" (most recent matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press up again - should get "select all" (next matching "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press up again - should stay at "select all" (no more matches for "sel") + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select all"); + }); + + // Press down - should go back to "select line" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "select line"); + }); + + // Press down again - should return to original prefix "sel" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "sel"); + }); + } + + #[gpui::test] + async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = + open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx); + + // Type "xyz" as a prefix that doesn't match anything + cx.simulate_input("xyz"); + cx.background_executor.run_until_parked(); + + // Press up - should stay at "xyz" (no matches) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "xyz"); + }); + } + + #[gpui::test] + async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx); + + // With empty query, press up - should get "gamma" (most recent) + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press up - should get "beta" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press up - should get "alpha" + cx.simulate_keystrokes("up"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "alpha"); + }); + + // Press down - should get "beta" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "beta"); + }); + + // Press down - should get "gamma" + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), "gamma"); + }); + + // Press down - should return to empty string (exit history mode) + cx.simulate_keystrokes("down"); + cx.background_executor.run_until_parked(); + palette.read_with(cx, |palette, cx| { + assert_eq!(palette.query(cx), ""); + }); + } } diff --git a/crates/command_palette/src/persistence.rs b/crates/command_palette/src/persistence.rs index feaed72570d56f4895ff05eef891fc81c2e5e0b6..4556079b4f9c8e7a989f3e32eac6f7d084e67a4e 100644 --- a/crates/command_palette/src/persistence.rs +++ b/crates/command_palette/src/persistence.rs @@ -123,6 +123,16 @@ impl CommandPaletteDB { ORDER BY COUNT(1) DESC } } + + query! { + pub fn list_recent_queries() -> Result> { + SELECT user_query + FROM command_invocations + WHERE user_query != "" + GROUP BY user_query + ORDER BY MAX(last_invoked) ASC + } + } } #[cfg(test)] diff --git a/crates/component_preview/Cargo.toml b/crates/component_preview/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..d7ca3bad47d74d86c701007da879ddf092a08b73 --- /dev/null +++ b/crates/component_preview/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "component_preview" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/component_preview.rs" + +[features] +default = [] +preview = [] +test-support = ["db/test-support"] + +[dependencies] +anyhow.workspace = true +client.workspace = true +collections.workspace = true +component.workspace = true +db.workspace = true +fs.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +node_runtime.workspace = true +notifications.workspace = true +project.workspace = true +release_channel.workspace = true +reqwest_client.workspace = true +session.workspace = true +settings.workspace = true +theme.workspace = true +ui.workspace = true +ui_input.workspace = true +uuid.workspace = true +workspace.workspace = true + +[[example]] +name = "component_preview" +path = "examples/component_preview.rs" +required-features = ["preview"] diff --git a/crates/component_preview/LICENSE-GPL b/crates/component_preview/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..e0f9dbd5d63fef1630c297edc4ceba4790be6f02 --- /dev/null +++ b/crates/component_preview/LICENSE-GPL @@ -0,0 +1 @@ +LICENSE-GPL \ No newline at end of file diff --git a/crates/component_preview/examples/component_preview.rs b/crates/component_preview/examples/component_preview.rs new file mode 100644 index 0000000000000000000000000000000000000000..a49110e91ee76fef8b6f69048153f47493e52097 --- /dev/null +++ b/crates/component_preview/examples/component_preview.rs @@ -0,0 +1,18 @@ +//! Component Preview Example +//! +//! Run with: `cargo run -p component_preview --example component_preview --features="preview"` +//! +//! To use this in other projects, add the following to your `Cargo.toml`: +//! +//! ```toml +//! [dependencies] +//! component_preview = { path = "../component_preview", features = ["preview"] } +//! +//! [[example]] +//! name = "component_preview" +//! path = "examples/component_preview.rs" +//! ``` + +fn main() { + component_preview::run_component_preview(); +} diff --git a/crates/zed/src/zed/component_preview.rs b/crates/component_preview/src/component_preview.rs similarity index 99% rename from crates/zed/src/zed/component_preview.rs rename to crates/component_preview/src/component_preview.rs index 14a46d8882d1d3d371c50e9886062a124917a48d..0f4a8d94baf9021f0d6911e693a435ee4ab5e524 100644 --- a/crates/zed/src/zed/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -1,7 +1,4 @@ -//! # Component Preview -//! -//! A view for exploring Zed components. - +mod component_preview_example; mod persistence; use client::UserStore; @@ -11,18 +8,21 @@ use gpui::{ App, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, list, prelude::*, }; use gpui::{ListState, ScrollHandle, ScrollStrategy, UniformListScrollHandle}; -use languages::LanguageRegistry; +use language::LanguageRegistry; use notifications::status_toast::{StatusToast, ToastIcon}; use persistence::COMPONENT_PREVIEW_DB; use project::Project; use std::{iter::Iterator, ops::Range, sync::Arc}; use ui::{ButtonLike, Divider, HighlightedLabel, ListItem, ListSubHeader, Tooltip, prelude::*}; use ui_input::InputField; +use workspace::AppState; use workspace::{ - AppState, Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, - item::ItemEvent, + Item, ItemId, SerializableItem, Workspace, WorkspaceId, delete_unloaded_items, item::ItemEvent, }; +#[allow(unused_imports)] +pub use component_preview_example::*; + pub fn init(app_state: Arc, cx: &mut App) { workspace::register_serializable_item::(cx); @@ -161,7 +161,7 @@ impl ComponentPreview { component_preview.update_component_list(cx); let focus_handle = component_preview.filter_editor.read(cx).focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Ok(component_preview) } @@ -770,7 +770,7 @@ impl Item for ComponentPreview { self.workspace_id = workspace.database_id(); let focus_handle = self.filter_editor.read(cx).focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); } } diff --git a/crates/component_preview/src/component_preview_example.rs b/crates/component_preview/src/component_preview_example.rs new file mode 100644 index 0000000000000000000000000000000000000000..a5efd015566a6bafdf333346c33810032ec08284 --- /dev/null +++ b/crates/component_preview/src/component_preview_example.rs @@ -0,0 +1,145 @@ +/// Run the component preview application. +/// +/// This initializes the application with minimal required infrastructure +/// and opens a workspace with the ComponentPreview item. +#[cfg(feature = "preview")] +pub fn run_component_preview() { + use fs::RealFs; + use gpui::{ + AppContext as _, Application, Bounds, KeyBinding, WindowBounds, WindowOptions, actions, + size, + }; + + use client::{Client, UserStore}; + use language::LanguageRegistry; + use node_runtime::NodeRuntime; + use project::Project; + use reqwest_client::ReqwestClient; + use session::{AppSession, Session}; + use std::sync::Arc; + use ui::{App, px}; + use workspace::{AppState, Workspace, WorkspaceStore}; + + use crate::{ComponentPreview, init}; + + actions!(zed, [Quit]); + + fn quit(_: &Quit, cx: &mut App) { + cx.quit(); + } + + Application::new().run(|cx| { + component::init(); + + cx.on_action(quit); + cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); + let version = release_channel::AppVersion::load(env!("CARGO_PKG_VERSION"), None, None); + release_channel::init(version, cx); + + let http_client = + ReqwestClient::user_agent("component_preview").expect("Failed to create HTTP client"); + cx.set_http_client(Arc::new(http_client)); + + let fs = Arc::new(RealFs::new(None, cx.background_executor().clone())); + ::set_global(fs.clone(), cx); + + settings::init(cx); + theme::init(theme::LoadThemes::JustBase, cx); + + let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); + let client = Client::production(cx); + client::init(&client, cx); + + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); + let session_id = uuid::Uuid::new_v4().to_string(); + let session = cx.background_executor().block(Session::new(session_id)); + let session = cx.new(|cx| AppSession::new(session, cx)); + let node_runtime = NodeRuntime::unavailable(); + + let app_state = Arc::new(AppState { + languages, + client, + user_store, + workspace_store, + fs, + build_window_options: |_, _| Default::default(), + node_runtime, + session, + }); + AppState::set_global(Arc::downgrade(&app_state), cx); + + workspace::init(app_state.clone(), cx); + init(app_state.clone(), cx); + + let size = size(px(1200.), px(800.)); + let bounds = Bounds::centered(None, size, cx); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + { + move |window, cx| { + let app_state = app_state; + theme::setup_ui_font(window, cx); + + let project = Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + false, + cx, + ); + + let workspace = cx.new(|cx| { + Workspace::new( + Default::default(), + project.clone(), + app_state.clone(), + window, + cx, + ) + }); + + workspace.update(cx, |workspace, cx| { + let weak_workspace = cx.entity().downgrade(); + let language_registry = app_state.languages.clone(); + let user_store = app_state.user_store.clone(); + + let component_preview = cx.new(|cx| { + ComponentPreview::new( + weak_workspace, + project, + language_registry, + user_store, + None, + None, + window, + cx, + ) + .expect("Failed to create component preview") + }); + + workspace.add_item_to_active_pane( + Box::new(component_preview), + None, + true, + window, + cx, + ); + }); + + workspace + } + }, + ) + .expect("Failed to open component preview window"); + + cx.activate(true); + }); +} diff --git a/crates/zed/src/zed/component_preview/persistence.rs b/crates/component_preview/src/persistence.rs similarity index 100% rename from crates/zed/src/zed/component_preview/persistence.rs rename to crates/component_preview/src/persistence.rs diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index f73e6a9bab011c5d675040d1ee3a05dfa708dc45..539b873c3527b5a01f1dfcf7b768f0758dc869b5 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -29,10 +29,12 @@ schemars.workspace = true serde_json.workspace = true serde.workspace = true settings.workspace = true +slotmap.workspace = true smol.workspace = true tempfile.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true +terminal.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index f891e96250f3334540aa859fe438c87297fc0100..605f24178916faa5173c32c28be6c80ee625cb6c 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -6,6 +6,7 @@ use parking_lot::Mutex; use postage::barrier; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, value::RawValue}; +use slotmap::SlotMap; use smol::channel; use std::{ fmt, @@ -50,7 +51,7 @@ pub(crate) struct Client { next_id: AtomicI32, outbound_tx: channel::Sender, name: Arc, - notification_handlers: Arc>>, + subscription_set: Arc>, response_handlers: Arc>>>, #[allow(clippy::type_complexity)] #[allow(dead_code)] @@ -191,21 +192,20 @@ impl Client { let (outbound_tx, outbound_rx) = channel::unbounded::(); let (output_done_tx, output_done_rx) = barrier::channel(); - let notification_handlers = - Arc::new(Mutex::new(HashMap::<_, NotificationHandler>::default())); + let subscription_set = Arc::new(Mutex::new(NotificationSubscriptionSet::default())); let response_handlers = Arc::new(Mutex::new(Some(HashMap::<_, ResponseHandler>::default()))); let request_handlers = Arc::new(Mutex::new(HashMap::<_, RequestHandler>::default())); let receive_input_task = cx.spawn({ - let notification_handlers = notification_handlers.clone(); + let subscription_set = subscription_set.clone(); let response_handlers = response_handlers.clone(); let request_handlers = request_handlers.clone(); let transport = transport.clone(); async move |cx| { Self::handle_input( transport, - notification_handlers, + subscription_set, request_handlers, response_handlers, cx, @@ -236,7 +236,7 @@ impl Client { Ok(Self { server_id, - notification_handlers, + subscription_set, response_handlers, name: server_name, next_id: Default::default(), @@ -257,7 +257,7 @@ impl Client { /// to pending requests) and notifications (which trigger registered handlers). async fn handle_input( transport: Arc, - notification_handlers: Arc>>, + subscription_set: Arc>, request_handlers: Arc>>, response_handlers: Arc>>>, cx: &mut AsyncApp, @@ -282,10 +282,11 @@ impl Client { handler(Ok(message.to_string())); } } else if let Ok(notification) = serde_json::from_str::(&message) { - let mut notification_handlers = notification_handlers.lock(); - if let Some(handler) = notification_handlers.get_mut(notification.method.as_str()) { - handler(notification.params.unwrap_or(Value::Null), cx.clone()); - } + subscription_set.lock().notify( + ¬ification.method, + notification.params.unwrap_or(Value::Null), + cx, + ) } else { log::error!("Unhandled JSON from context_server: {}", message); } @@ -451,12 +452,18 @@ impl Client { Ok(()) } + #[must_use] pub fn on_notification( &self, method: &'static str, f: Box, - ) { - self.notification_handlers.lock().insert(method, f); + ) -> NotificationSubscription { + let mut notification_subscriptions = self.subscription_set.lock(); + + NotificationSubscription { + id: notification_subscriptions.add_handler(method, f), + set: self.subscription_set.clone(), + } } } @@ -485,3 +492,73 @@ impl fmt::Debug for Client { .finish_non_exhaustive() } } + +slotmap::new_key_type! { + struct NotificationSubscriptionId; +} + +#[derive(Default)] +pub struct NotificationSubscriptionSet { + // we have very few subscriptions at the moment + methods: Vec<(&'static str, Vec)>, + handlers: SlotMap, +} + +impl NotificationSubscriptionSet { + #[must_use] + fn add_handler( + &mut self, + method: &'static str, + handler: NotificationHandler, + ) -> NotificationSubscriptionId { + let id = self.handlers.insert(handler); + if let Some((_, handler_ids)) = self + .methods + .iter_mut() + .find(|(probe_method, _)| method == *probe_method) + { + debug_assert!( + handler_ids.len() < 20, + "Too many MCP handlers for {}. Consider using a different data structure.", + method + ); + + handler_ids.push(id); + } else { + self.methods.push((method, vec![id])); + }; + id + } + + fn notify(&mut self, method: &str, payload: Value, cx: &mut AsyncApp) { + let Some((_, handler_ids)) = self + .methods + .iter_mut() + .find(|(probe_method, _)| method == *probe_method) + else { + return; + }; + + for handler_id in handler_ids { + if let Some(handler) = self.handlers.get_mut(*handler_id) { + handler(payload.clone(), cx.clone()); + } + } + } +} + +pub struct NotificationSubscription { + id: NotificationSubscriptionId, + set: Arc>, +} + +impl Drop for NotificationSubscription { + fn drop(&mut self) { + let mut set = self.set.lock(); + set.handlers.remove(self.id); + set.methods.retain_mut(|(_, handler_ids)| { + handler_ids.retain(|id| *id != self.id); + !handler_ids.is_empty() + }); + } +} diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index 553e845df87a2fec30b1afbffa05b970d5d672f6..92804549c69b01dd3729efb3a0b47905cd73d813 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -96,22 +96,6 @@ impl ContextServer { self.initialize(self.new_client(cx)?).await } - /// Starts the context server, making sure handlers are registered before initialization happens - pub async fn start_with_handlers( - &self, - notification_handlers: Vec<( - &'static str, - Box, - )>, - cx: &AsyncApp, - ) -> Result<()> { - let client = self.new_client(cx)?; - for (method, handler) in notification_handlers { - client.on_notification(method, handler); - } - self.initialize(client).await - } - fn new_client(&self, cx: &AsyncApp) -> Result { Ok(match &self.configuration { ContextServerTransport::Stdio(command, working_directory) => Client::stdio( diff --git a/crates/context_server/src/protocol.rs b/crates/context_server/src/protocol.rs index 5355f20f620b5bed76bf945e863fdb5cbcc2ff43..a218a8a3e0e6352997e4152214077cb3851317b3 100644 --- a/crates/context_server/src/protocol.rs +++ b/crates/context_server/src/protocol.rs @@ -12,7 +12,7 @@ use futures::channel::oneshot; use gpui::AsyncApp; use serde_json::Value; -use crate::client::Client; +use crate::client::{Client, NotificationSubscription}; use crate::types::{self, Notification, Request}; pub struct ModelContextProtocol { @@ -119,7 +119,7 @@ impl InitializedContextServerProtocol { &self, method: &'static str, f: Box, - ) { - self.inner.on_notification(method, f); + ) -> NotificationSubscription { + self.inner.on_notification(method, f) } } diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 83908b46829c4cfe3b536ecca1155c909ee424dd..e675770e9ee50df9993076e6d71c70befa118c4b 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -8,9 +8,12 @@ use futures::{ AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _, }; use gpui::AsyncApp; +use settings::Settings as _; use smol::channel; use smol::process::Child; +use terminal::terminal_settings::TerminalSettings; use util::TryFutureExt as _; +use util::shell_builder::ShellBuilder; use crate::client::ModelContextServerBinary; use crate::transport::Transport; @@ -28,9 +31,12 @@ impl StdioTransport { working_directory: &Option, cx: &AsyncApp, ) -> Result { - let mut command = util::command::new_smol_command(&binary.executable); + let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); + let mut command = + builder.build_command(Some(binary.executable.display().to_string()), &binary.args); + command - .args(&binary.args) .envs(binary.env.unwrap_or_default()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) diff --git a/crates/context_server/src/types.rs b/crates/context_server/src/types.rs index 03aca4f3caf7995091bbc8e049494b324674a9d3..81a427a289347ad50bf6a11674c4c5867073a274 100644 --- a/crates/context_server/src/types.rs +++ b/crates/context_server/src/types.rs @@ -330,7 +330,7 @@ pub struct PromptMessage { pub content: MessageContent, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Role { User, diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 0d3b19c0c7bd264f8ed10e53289376055f833307..3a1706a7a679fbc14eafbeac953d842cda9f65c8 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -33,7 +33,7 @@ fs.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true -edit_prediction.workspace = true +edit_prediction_types.workspace = true language.workspace = true log.workspace = true lsp.workspace = true @@ -52,6 +52,7 @@ ui.workspace = true util.workspace = true workspace.workspace = true itertools.workspace = true +url.workspace = true [target.'cfg(windows)'.dependencies] async-std = { version = "1.12.0", features = ["unstable"] } diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index ed18a199bf2c08c8c046a8ad3e7f945b1340643e..a6963296f5c0ce0395698d2952618123c103ff55 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1,10 +1,11 @@ pub mod copilot_chat; -mod copilot_completion_provider; +mod copilot_edit_prediction_delegate; pub mod copilot_responses; pub mod request; mod sign_in; -use crate::sign_in::initiate_sign_in_within_workspace; +use crate::request::NextEditSuggestions; +use crate::sign_in::initiate_sign_out; use ::fs::Fs; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; @@ -18,7 +19,7 @@ use http_client::HttpClient; use language::language_settings::CopilotSettings; use language::{ Anchor, Bias, Buffer, BufferSnapshot, Language, PointUtf16, ToPointUtf16, - language_settings::{EditPredictionProvider, all_language_settings, language_settings}, + language_settings::{EditPredictionProvider, all_language_settings}, point_from_lsp, point_to_lsp, }; use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServerName}; @@ -28,12 +29,10 @@ use project::DisableAiSettings; use request::StatusNotification; use semver::Version; use serde_json::json; -use settings::Settings; -use settings::SettingsStore; -use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace}; -use std::collections::hash_map::Entry; +use settings::{Settings, SettingsStore}; use std::{ any::TypeId, + collections::hash_map::Entry, env, ffi::OsString, mem, @@ -42,12 +41,14 @@ use std::{ sync::Arc, }; use sum_tree::Dimensions; -use util::rel_path::RelPath; use util::{ResultExt, fs::remove_matching}; use workspace::Workspace; -pub use crate::copilot_completion_provider::CopilotCompletionProvider; -pub use crate::sign_in::{CopilotCodeVerification, initiate_sign_in, reinstall_and_sign_in}; +pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate; +pub use crate::sign_in::{ + ConfigurationMode, ConfigurationView, CopilotCodeVerification, initiate_sign_in, + reinstall_and_sign_in, +}; actions!( copilot, @@ -98,21 +99,14 @@ pub fn init( .detach(); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { - workspace.register_action(|workspace, _: &SignIn, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); - } + workspace.register_action(|_, _: &SignIn, window, cx| { + initiate_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &Reinstall, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); - } + workspace.register_action(|_, _: &Reinstall, window, cx| { + reinstall_and_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &SignOut, _window, cx| { - if let Some(copilot) = Copilot::global(cx) { - sign_out_within_workspace(workspace, copilot, cx); - } + workspace.register_action(|_, _: &SignOut, window, cx| { + initiate_sign_out(window, cx); }); }) .detach(); @@ -322,6 +316,15 @@ struct GlobalCopilot(Entity); impl Global for GlobalCopilot {} +/// Copilot's NextEditSuggestion response, with coordinates converted to Anchors. +struct CopilotEditPrediction { + buffer: Entity, + range: Range, + text: String, + command: Option, + snapshot: BufferSnapshot, +} + impl Copilot { pub fn global(cx: &App) -> Option> { cx.try_global::() @@ -375,7 +378,7 @@ impl Copilot { } } - fn start_copilot( + pub fn start_copilot( &mut self, check_edit_prediction_provider: bool, awaiting_sign_in_after_start: bool, @@ -563,6 +566,14 @@ impl Copilot { let server = start_language_server.await; this.update(cx, |this, cx| { cx.notify(); + + if env::var("ZED_FORCE_COPILOT_ERROR").is_ok() { + this.server = CopilotServer::Error( + "Forced error for testing (ZED_FORCE_COPILOT_ERROR)".into(), + ); + return; + } + match server { Ok((server, status)) => { this.server = CopilotServer::Running(RunningCopilotServer { @@ -584,7 +595,17 @@ impl Copilot { .ok(); } - pub(crate) fn sign_in(&mut self, cx: &mut Context) -> Task> { + pub fn is_authenticated(&self) -> bool { + return matches!( + self.server, + CopilotServer::Running(RunningCopilotServer { + sign_in_status: SignInStatus::Authorized, + .. + }) + ); + } + + pub fn sign_in(&mut self, cx: &mut Context) -> Task> { if let CopilotServer::Running(server) = &mut self.server { let task = match &server.sign_in_status { SignInStatus::Authorized => Task::ready(Ok(())).shared(), @@ -807,7 +828,7 @@ impl Copilot { .ok(); } language::BufferEvent::FileHandleChanged - | language::BufferEvent::LanguageChanged => { + | language::BufferEvent::LanguageChanged(_) => { let new_language_id = id_for_language(buffer.read(cx).language()); let Ok(new_uri) = uri_for_buffer(&buffer, cx) else { return Ok(()); @@ -862,101 +883,19 @@ impl Copilot { } } - pub fn completions( - &mut self, - buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>> - where - T: ToPointUtf16, - { - self.request_completions::(buffer, position, cx) - } - - pub fn completions_cycling( + pub(crate) fn completions( &mut self, buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>> - where - T: ToPointUtf16, - { - self.request_completions::(buffer, position, cx) - } - - pub fn accept_completion( - &mut self, - completion: &Completion, + position: Anchor, cx: &mut Context, - ) -> Task> { - let server = match self.server.as_authenticated() { - Ok(server) => server, - Err(error) => return Task::ready(Err(error)), - }; - let request = - server - .lsp - .request::(request::NotifyAcceptedParams { - uuid: completion.uuid.clone(), - }); - cx.background_spawn(async move { - request - .await - .into_response() - .context("copilot: notify accepted")?; - Ok(()) - }) - } - - pub fn discard_completions( - &mut self, - completions: &[Completion], - cx: &mut Context, - ) -> Task> { - let server = match self.server.as_authenticated() { - Ok(server) => server, - Err(_) => return Task::ready(Ok(())), - }; - let request = - server - .lsp - .request::(request::NotifyRejectedParams { - uuids: completions - .iter() - .map(|completion| completion.uuid.clone()) - .collect(), - }); - cx.background_spawn(async move { - request - .await - .into_response() - .context("copilot: notify rejected")?; - Ok(()) - }) - } - - fn request_completions( - &mut self, - buffer: &Entity, - position: T, - cx: &mut Context, - ) -> Task>> - where - R: 'static - + lsp::request::Request< - Params = request::GetCompletionsParams, - Result = request::GetCompletionsResult, - >, - T: ToPointUtf16, - { + ) -> Task>> { self.register_buffer(buffer, cx); let server = match self.server.as_authenticated() { Ok(server) => server, Err(error) => return Task::ready(Err(error)), }; + let buffer_entity = buffer.clone(); let lsp = server.lsp.clone(); let registered_buffer = server .registered_buffers @@ -966,46 +905,31 @@ impl Copilot { let buffer = buffer.read(cx); let uri = registered_buffer.uri.clone(); let position = position.to_point_utf16(buffer); - let settings = language_settings( - buffer.language_at(position).map(|l| l.name()), - buffer.file(), - cx, - ); - let tab_size = settings.tab_size; - let hard_tabs = settings.hard_tabs; - let relative_path = buffer - .file() - .map_or(RelPath::empty().into(), |file| file.path().clone()); cx.background_spawn(async move { let (version, snapshot) = snapshot.await?; let result = lsp - .request::(request::GetCompletionsParams { - doc: request::GetCompletionsDocument { - uri, - tab_size: tab_size.into(), - indent_size: 1, - insert_spaces: !hard_tabs, - relative_path: relative_path.to_proto(), - position: point_to_lsp(position), - version: version.try_into().unwrap(), - }, + .request::(request::NextEditSuggestionsParams { + text_document: lsp::VersionedTextDocumentIdentifier { uri, version }, + position: point_to_lsp(position), }) .await .into_response() .context("copilot: get completions")?; let completions = result - .completions + .edits .into_iter() .map(|completion| { let start = snapshot .clip_point_utf16(point_from_lsp(completion.range.start), Bias::Left); let end = snapshot.clip_point_utf16(point_from_lsp(completion.range.end), Bias::Left); - Completion { - uuid: completion.uuid, + CopilotEditPrediction { + buffer: buffer_entity.clone(), range: snapshot.anchor_before(start)..snapshot.anchor_after(end), text: completion.text, + command: completion.command, + snapshot: snapshot.clone(), } }) .collect(); @@ -1013,6 +937,35 @@ impl Copilot { }) } + pub(crate) fn accept_completion( + &mut self, + completion: &CopilotEditPrediction, + cx: &mut Context, + ) -> Task> { + let server = match self.server.as_authenticated() { + Ok(server) => server, + Err(error) => return Task::ready(Err(error)), + }; + if let Some(command) = &completion.command { + let request = server + .lsp + .request::(lsp::ExecuteCommandParams { + command: command.command.clone(), + arguments: command.arguments.clone().unwrap_or_default(), + ..Default::default() + }); + cx.background_spawn(async move { + request + .await + .into_response() + .context("copilot: notify accepted")?; + Ok(()) + }) + } else { + Task::ready(Ok(())) + } + } + pub fn status(&self) -> Status { match &self.server { CopilotServer::Starting { task } => Status::Starting { task: task.clone() }, @@ -1235,7 +1188,10 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow:: .await; if should_install { node_runtime - .npm_install_packages(paths::copilot_dir(), &[(PACKAGE_NAME, &latest_version)]) + .npm_install_packages( + paths::copilot_dir(), + &[(PACKAGE_NAME, &latest_version.to_string())], + ) .await?; } @@ -1246,7 +1202,11 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow:: mod tests { use super::*; use gpui::TestAppContext; - use util::{path, paths::PathStyle, rel_path::rel_path}; + use util::{ + path, + paths::PathStyle, + rel_path::{RelPath, rel_path}, + }; #[gpui::test(iterations = 10)] async fn test_buffer_management(cx: &mut TestAppContext) { diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs similarity index 74% rename from crates/copilot/src/copilot_completion_provider.rs rename to crates/copilot/src/copilot_edit_prediction_delegate.rs index e92f0c7d7dd7e51c4a8fdc19f34bd6eb4189c097..514e135cb4c34f6a1f49687fcd413113f78f9eae 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -1,53 +1,33 @@ -use crate::{Completion, Copilot}; +use crate::{Copilot, CopilotEditPrediction}; use anyhow::Result; -use edit_prediction::{Direction, EditPrediction, EditPredictionProvider}; -use gpui::{App, Context, Entity, EntityId, Task}; -use language::{Buffer, OffsetRangeExt, ToOffset, language_settings::AllLanguageSettings}; -use settings::Settings; -use std::{path::Path, time::Duration}; +use edit_prediction_types::{EditPrediction, EditPredictionDelegate, interpolate_edits}; +use gpui::{App, Context, Entity, Task}; +use language::{Anchor, Buffer, EditPreview, OffsetRangeExt}; +use std::{ops::Range, sync::Arc, time::Duration}; pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); -pub struct CopilotCompletionProvider { - cycled: bool, - buffer_id: Option, - completions: Vec, - active_completion_index: usize, - file_extension: Option, +pub struct CopilotEditPredictionDelegate { + completion: Option<(CopilotEditPrediction, EditPreview)>, pending_refresh: Option>>, - pending_cycling_refresh: Option>>, copilot: Entity, } -impl CopilotCompletionProvider { +impl CopilotEditPredictionDelegate { pub fn new(copilot: Entity) -> Self { Self { - cycled: false, - buffer_id: None, - completions: Vec::new(), - active_completion_index: 0, - file_extension: None, + completion: None, pending_refresh: None, - pending_cycling_refresh: None, copilot, } } - fn active_completion(&self) -> Option<&Completion> { - self.completions.get(self.active_completion_index) - } - - fn push_completion(&mut self, new_completion: Completion) { - for completion in &self.completions { - if completion.text == new_completion.text && completion.range == new_completion.range { - return; - } - } - self.completions.push(new_completion); + fn active_completion(&self) -> Option<&(CopilotEditPrediction, EditPreview)> { + self.completion.as_ref() } } -impl EditPredictionProvider for CopilotCompletionProvider { +impl EditPredictionDelegate for CopilotEditPredictionDelegate { fn name() -> &'static str { "copilot" } @@ -56,7 +36,7 @@ impl EditPredictionProvider for CopilotCompletionProvider { "Copilot" } - fn show_completions_in_menu() -> bool { + fn show_predictions_in_menu() -> bool { true } @@ -64,12 +44,8 @@ impl EditPredictionProvider for CopilotCompletionProvider { true } - fn supports_jump_to_edit() -> bool { - false - } - fn is_refreshing(&self, _cx: &App) -> bool { - self.pending_refresh.is_some() && self.completions.is_empty() + self.pending_refresh.is_some() && self.completion.is_none() } fn is_enabled( @@ -102,160 +78,96 @@ impl EditPredictionProvider for CopilotCompletionProvider { })? .await?; - this.update(cx, |this, cx| { - if !completions.is_empty() { - this.cycled = false; + if let Some(mut completion) = completions.into_iter().next() + && let Some(trimmed_completion) = cx + .update(|cx| trim_completion(&completion, cx)) + .ok() + .flatten() + { + let preview = buffer + .update(cx, |this, cx| { + this.preview_edits(Arc::from(std::slice::from_ref(&trimmed_completion)), cx) + })? + .await; + this.update(cx, |this, cx| { this.pending_refresh = None; - this.pending_cycling_refresh = None; - this.completions.clear(); - this.active_completion_index = 0; - this.buffer_id = Some(buffer.entity_id()); - this.file_extension = buffer.read(cx).file().and_then(|file| { - Some( - Path::new(file.file_name(cx)) - .extension()? - .to_str()? - .to_string(), - ) - }); - - for completion in completions { - this.push_completion(completion); - } + completion.range = trimmed_completion.0; + completion.text = trimmed_completion.1.to_string(); + this.completion = Some((completion, preview)); + cx.notify(); - } - })?; + })?; + } Ok(()) })); } - fn cycle( - &mut self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut Context, - ) { - if self.cycled { - match direction { - Direction::Prev => { - self.active_completion_index = if self.active_completion_index == 0 { - self.completions.len().saturating_sub(1) - } else { - self.active_completion_index - 1 - }; - } - Direction::Next => { - if self.completions.is_empty() { - self.active_completion_index = 0 - } else { - self.active_completion_index = - (self.active_completion_index + 1) % self.completions.len(); - } - } - } - - cx.notify(); - } else { - let copilot = self.copilot.clone(); - self.pending_cycling_refresh = Some(cx.spawn(async move |this, cx| { - let completions = copilot - .update(cx, |copilot, cx| { - copilot.completions_cycling(&buffer, cursor_position, cx) - })? - .await?; - - this.update(cx, |this, cx| { - this.cycled = true; - this.file_extension = buffer.read(cx).file().and_then(|file| { - Some( - Path::new(file.file_name(cx)) - .extension()? - .to_str()? - .to_string(), - ) - }); - for completion in completions { - this.push_completion(completion); - } - this.cycle(buffer, cursor_position, direction, cx); - })?; - - Ok(()) - })); - } - } - fn accept(&mut self, cx: &mut Context) { - if let Some(completion) = self.active_completion() { + if let Some((completion, _)) = self.active_completion() { self.copilot .update(cx, |copilot, cx| copilot.accept_completion(completion, cx)) .detach_and_log_err(cx); } } - fn discard(&mut self, cx: &mut Context) { - let settings = AllLanguageSettings::get_global(cx); - - let copilot_enabled = settings.show_edit_predictions(None, cx); - - if !copilot_enabled { - return; - } - - self.copilot - .update(cx, |copilot, cx| { - copilot.discard_completions(&self.completions, cx) - }) - .detach_and_log_err(cx); - } + fn discard(&mut self, _: &mut Context) {} fn suggest( &mut self, buffer: &Entity, - cursor_position: language::Anchor, + _: language::Anchor, cx: &mut Context, ) -> Option { let buffer_id = buffer.entity_id(); let buffer = buffer.read(cx); - let completion = self.active_completion()?; - if Some(buffer_id) != self.buffer_id + let (completion, edit_preview) = self.active_completion()?; + + if Some(buffer_id) != Some(completion.buffer.entity_id()) || !completion.range.start.is_valid(buffer) || !completion.range.end.is_valid(buffer) { return None; } + let edits = vec![( + completion.range.clone(), + Arc::from(completion.text.as_ref()), + )]; + let edits = interpolate_edits(&completion.snapshot, &buffer.snapshot(), &edits) + .filter(|edits| !edits.is_empty())?; + + Some(EditPrediction::Local { + id: None, + edits, + edit_preview: Some(edit_preview.clone()), + }) + } +} - let mut completion_range = completion.range.to_offset(buffer); - let prefix_len = common_prefix( - buffer.chars_for_range(completion_range.clone()), - completion.text.chars(), - ); - completion_range.start += prefix_len; - let suffix_len = common_prefix( - buffer.reversed_chars_for_range(completion_range.clone()), - completion.text[prefix_len..].chars().rev(), - ); - completion_range.end = completion_range.end.saturating_sub(suffix_len); - - if completion_range.is_empty() - && completion_range.start == cursor_position.to_offset(buffer) - { - let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len]; - if completion_text.trim().is_empty() { - None - } else { - let position = cursor_position.bias_right(buffer); - Some(EditPrediction::Local { - id: None, - edits: vec![(position..position, completion_text.into())], - edit_preview: None, - }) - } - } else { - None - } +fn trim_completion( + completion: &CopilotEditPrediction, + cx: &mut App, +) -> Option<(Range, Arc)> { + let buffer = completion.buffer.read(cx); + let mut completion_range = completion.range.to_offset(buffer); + let prefix_len = common_prefix( + buffer.chars_for_range(completion_range.clone()), + completion.text.chars(), + ); + completion_range.start += prefix_len; + let suffix_len = common_prefix( + buffer.reversed_chars_for_range(completion_range.clone()), + completion.text[prefix_len..].chars().rev(), + ); + completion_range.end = completion_range.end.saturating_sub(suffix_len); + let completion_text = &completion.text[prefix_len..completion.text.len() - suffix_len]; + if completion_text.trim().is_empty() { + None + } else { + let completion_range = + buffer.anchor_after(completion_range.start)..buffer.anchor_after(completion_range.end); + + Some((completion_range, Arc::from(completion_text))) } } @@ -269,6 +181,7 @@ fn common_prefix, T2: Iterator>(a: T1, b: #[cfg(test)] mod tests { use super::*; + use edit_prediction_types::EditPredictionGranularity; use editor::{ Editor, ExcerptRange, MultiBuffer, MultiBufferOffset, SelectionEffects, test::editor_lsp_test_context::EditorLspTestContext, @@ -281,6 +194,7 @@ mod tests { Point, language_settings::{CompletionSettingsContent, LspInsertMode, WordsCompletionMode}, }; + use lsp::Uri; use project::Project; use serde_json::json; use settings::{AllLanguageSettingsContent, SettingsStore}; @@ -314,7 +228,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -336,12 +250,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot1".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { @@ -382,12 +299,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot1".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -411,12 +331,15 @@ mod tests { // After debouncing, new Copilot completions should be requested. handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot2".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { @@ -478,45 +401,6 @@ mod tests { assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n"); }); - - // Reset the editor to verify how suggestions behave when tabbing on leading indentation. - cx.update_editor(|editor, window, cx| { - editor.set_text("fn foo() {\n \n}", window, cx); - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_ranges([Point::new(1, 2)..Point::new(1, 2)]) - }); - }); - handle_copilot_completion_request( - &copilot_lsp, - vec![crate::request::Completion { - text: " let x = 4;".into(), - range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() - }], - vec![], - ); - - cx.update_editor(|editor, window, cx| { - editor.next_edit_prediction(&Default::default(), window, cx) - }); - executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); - cx.update_editor(|editor, window, cx| { - assert!(editor.has_active_edit_prediction()); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - assert_eq!(editor.text(cx), "fn foo() {\n \n}"); - - // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion. - editor.tab(&Default::default(), window, cx); - assert!(editor.has_active_edit_prediction()); - assert_eq!(editor.text(cx), "fn foo() {\n \n}"); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - - // Using AcceptEditPrediction again accepts the suggestion. - editor.accept_edit_prediction(&Default::default(), window, cx); - assert!(!editor.has_active_edit_prediction()); - assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}"); - assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - }); } #[gpui::test(iterations = 10)] @@ -546,7 +430,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -569,25 +453,30 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.copilot1".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); // Accepting the first word of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); // Accepting next word should accept the non-word and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); @@ -611,19 +500,22 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "one.123. copilot\n 456".into(), range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); // Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest. - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n"); assert_eq!( @@ -632,7 +524,7 @@ mod tests { ); // Accepting next word should accept the next word and copilot suggestion should still exist - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n"); assert_eq!( @@ -641,7 +533,7 @@ mod tests { ); // Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone - editor.accept_partial_edit_prediction(&Default::default(), window, cx); + editor.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); assert!(!editor.has_active_edit_prediction()); assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n"); assert_eq!( @@ -670,7 +562,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -683,15 +575,18 @@ mod tests { handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); cx.update_editor(|editor, window, cx| { - editor.next_edit_prediction(&Default::default(), window, cx) + editor.show_edit_prediction(&Default::default(), window, cx) }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, window, cx| { @@ -700,15 +595,22 @@ mod tests { assert_eq!(editor.text(cx), "one\ntw\nthree\n"); editor.backspace(&Default::default(), window, cx); + }); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\nt\nthree\n"); editor.backspace(&Default::default(), window, cx); + }); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { assert!(editor.has_active_edit_prediction()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\n\nthree\n"); - // Deleting across the original suggestion range invalidates it. editor.backspace(&Default::default(), window, cx); assert!(!editor.has_active_edit_prediction()); @@ -750,10 +652,10 @@ mod tests { editor .update(cx, |editor, window, cx| { use gpui::Focusable; - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); }) .unwrap(); - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor .update(cx, |editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) @@ -762,19 +664,22 @@ mod tests { handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "b = 2 + a".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); _ = editor.update(cx, |editor, window, cx| { // Ensure copilot suggestions are shown for the first excerpt. editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(1, 5)..Point::new(1, 5)]) }); - editor.next_edit_prediction(&Default::default(), window, cx); + editor.show_edit_prediction(&Default::default(), window, cx); }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); _ = editor.update(cx, |editor, _, cx| { @@ -788,12 +693,15 @@ mod tests { handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "d = 4 + c".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); _ = editor.update(cx, |editor, window, cx| { // Move to another excerpt, ensuring the suggestion gets cleared. @@ -848,7 +756,7 @@ mod tests { cx, ) .await; - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) }); @@ -870,15 +778,18 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); cx.update_editor(|editor, window, cx| { - editor.next_edit_prediction(&Default::default(), window, cx) + editor.show_edit_prediction(&Default::default(), window, cx) }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -900,12 +811,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 3)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -927,12 +841,15 @@ mod tests { )); handle_copilot_completion_request( &copilot_lsp, - vec![crate::request::Completion { + vec![crate::request::NextEditSuggestion { text: "two.foo()".into(), range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 4)), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], - vec![], ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, _, cx| { @@ -997,10 +914,10 @@ mod tests { editor .update(cx, |editor, window, cx| { use gpui::Focusable; - window.focus(&editor.focus_handle(cx)) + window.focus(&editor.focus_handle(cx), cx) }) .unwrap(); - let copilot_provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let copilot_provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor .update(cx, |editor, window, cx| { editor.set_edit_prediction_provider(Some(copilot_provider), window, cx) @@ -1008,16 +925,20 @@ mod tests { .unwrap(); let mut copilot_requests = copilot_lsp - .set_request_handler::( + .set_request_handler::( move |_params, _cx| async move { - Ok(crate::request::GetCompletionsResult { - completions: vec![crate::request::Completion { + Ok(crate::request::NextEditSuggestionsResult { + edits: vec![crate::request::NextEditSuggestion { text: "next line".into(), range: lsp::Range::new( lsp::Position::new(1, 0), lsp::Position::new(1, 0), ), - ..Default::default() + command: None, + text_document: lsp::VersionedTextDocumentIdentifier { + uri: Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: 0, + }, }], }) }, @@ -1046,23 +967,14 @@ mod tests { fn handle_copilot_completion_request( lsp: &lsp::FakeLanguageServer, - completions: Vec, - completions_cycling: Vec, + completions: Vec, ) { - lsp.set_request_handler::(move |_params, _cx| { - let completions = completions.clone(); - async move { - Ok(crate::request::GetCompletionsResult { - completions: completions.clone(), - }) - } - }); - lsp.set_request_handler::( + lsp.set_request_handler::( move |_params, _cx| { - let completions_cycling = completions_cycling.clone(); + let completions = completions.clone(); async move { - Ok(crate::request::GetCompletionsResult { - completions: completions_cycling.clone(), + Ok(crate::request::NextEditSuggestionsResult { + edits: completions.clone(), }) } }, diff --git a/crates/copilot/src/request.rs b/crates/copilot/src/request.rs index 85d6254dc060824a9b2686e8f53090fccb39980e..2f97fb72a42904b1fefdd3999f680fca12559ecd 100644 --- a/crates/copilot/src/request.rs +++ b/crates/copilot/src/request.rs @@ -1,3 +1,4 @@ +use lsp::VersionedTextDocumentIdentifier; use serde::{Deserialize, Serialize}; pub enum CheckStatus {} @@ -88,72 +89,6 @@ impl lsp::request::Request for SignOut { const METHOD: &'static str = "signOut"; } -pub enum GetCompletions {} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetCompletionsParams { - pub doc: GetCompletionsDocument, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetCompletionsDocument { - pub tab_size: u32, - pub indent_size: u32, - pub insert_spaces: bool, - pub uri: lsp::Uri, - pub relative_path: String, - pub position: lsp::Position, - pub version: usize, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetCompletionsResult { - pub completions: Vec, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Completion { - pub text: String, - pub position: lsp::Position, - pub uuid: String, - pub range: lsp::Range, - pub display_text: String, -} - -impl lsp::request::Request for GetCompletions { - type Params = GetCompletionsParams; - type Result = GetCompletionsResult; - const METHOD: &'static str = "getCompletions"; -} - -pub enum GetCompletionsCycling {} - -impl lsp::request::Request for GetCompletionsCycling { - type Params = GetCompletionsParams; - type Result = GetCompletionsResult; - const METHOD: &'static str = "getCompletionsCycling"; -} - -pub enum LogMessage {} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LogMessageParams { - pub level: u8, - pub message: String, - pub metadata_str: String, - pub extra: Vec, -} - -impl lsp::notification::Notification for LogMessage { - type Params = LogMessageParams; - const METHOD: &'static str = "LogMessage"; -} - pub enum StatusNotification {} #[derive(Debug, Serialize, Deserialize)] @@ -223,3 +158,36 @@ impl lsp::request::Request for NotifyRejected { type Result = String; const METHOD: &'static str = "notifyRejected"; } + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestions; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestionsParams { + pub(crate) text_document: VersionedTextDocumentIdentifier, + pub(crate) position: lsp::Position, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestion { + pub text: String, + pub text_document: VersionedTextDocumentIdentifier, + pub range: lsp::Range, + pub command: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NextEditSuggestionsResult { + pub edits: Vec, +} + +impl lsp::request::Request for NextEditSuggestions { + type Params = NextEditSuggestionsParams; + type Result = NextEditSuggestionsResult; + + const METHOD: &'static str = "textDocument/copilotInlineEdit"; +} diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs index 464a114d4ea11bca5597a6a91fd831ade050baaa..4f71a34408e23f099d4d3c145d86af24e607e3c3 100644 --- a/crates/copilot/src/sign_in.rs +++ b/crates/copilot/src/sign_in.rs @@ -1,166 +1,159 @@ use crate::{Copilot, Status, request::PromptUserDeviceFlow}; +use anyhow::Context as _; use gpui::{ - Animation, AnimationExt, App, ClipboardItem, Context, DismissEvent, Element, Entity, - EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent, - ParentElement, Render, Styled, Subscription, Transformation, Window, div, percentage, svg, + App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled, + Subscription, Window, WindowBounds, WindowOptions, div, point, }; -use std::time::Duration; -use ui::{Button, Label, Vector, VectorName, prelude::*}; +use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*}; +use url::Url; use util::ResultExt as _; -use workspace::notifications::NotificationId; -use workspace::{ModalView, Toast, Workspace}; +use workspace::{Toast, Workspace, notifications::NotificationId}; const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot"; +const ERROR_LABEL: &str = + "Copilot had issues starting. You can try reinstalling it and signing in again."; struct CopilotStatusToast; pub fn initiate_sign_in(window: &mut Window, cx: &mut App) { + let is_reinstall = false; + initiate_sign_in_impl(is_reinstall, window, cx) +} + +pub fn initiate_sign_out(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; - let Some(workspace) = window.root::().flatten() else { - return; - }; - workspace.update(cx, |workspace, cx| { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx) - }); + + copilot_toast(Some("Signing out of Copilot…"), window, cx); + + let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); + window + .spawn(cx, async move |cx| match sign_out_task.await { + Ok(()) => { + cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx)) + } + Err(err) => cx.update(|window, cx| { + if let Some(workspace) = window.root::().flatten() { + workspace.update(cx, |workspace, cx| { + workspace.show_error(&err, cx); + }) + } else { + log::error!("{:?}", err); + } + }), + }) + .detach(); } pub fn reinstall_and_sign_in(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; + let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); + let is_reinstall = true; + initiate_sign_in_impl(is_reinstall, window, cx); +} + +fn open_copilot_code_verification_window(copilot: &Entity, window: &Window, cx: &mut App) { + let current_window_center = window.bounds().center(); + let height = px(450.); + let width = px(350.); + let window_bounds = WindowBounds::Windowed(gpui::bounds( + current_window_center - point(height / 2.0, width / 2.0), + gpui::size(height, width), + )); + cx.open_window( + WindowOptions { + kind: gpui::WindowKind::PopUp, + window_bounds: Some(window_bounds), + is_resizable: false, + is_movable: true, + titlebar: Some(gpui::TitlebarOptions { + appears_transparent: true, + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)), + ) + .context("Failed to open Copilot code verification window") + .log_err(); +} + +fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) { + const NOTIFICATION_ID: NotificationId = NotificationId::unique::(); + let Some(workspace) = window.root::().flatten() else { return; }; - workspace.update(cx, |workspace, cx| { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); - }); -} -pub fn reinstall_and_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - window: &mut Window, - cx: &mut Context, -) { - let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); - let is_reinstall = true; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); + workspace.update(cx, |workspace, cx| match message { + Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx), + None => workspace.dismiss_toast(&NOTIFICATION_ID, cx), + }); } -pub fn initiate_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - is_reinstall: bool, - window: &mut Window, - cx: &mut Context, -) { +pub fn initiate_sign_in_impl(is_reinstall: bool, window: &mut Window, cx: &mut App) { + let Some(copilot) = Copilot::global(cx) else { + return; + }; if matches!(copilot.read(cx).status(), Status::Disabled) { copilot.update(cx, |copilot, cx| copilot.start_copilot(false, true, cx)); } match copilot.read(cx).status() { Status::Starting { task } => { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - if is_reinstall { - "Copilot is reinstalling..." - } else { - "Copilot is starting..." - }, - ), + copilot_toast( + Some(if is_reinstall { + "Copilot is reinstalling…" + } else { + "Copilot is starting…" + }), + window, cx, ); - cx.spawn_in(window, async move |workspace, cx| { - task.await; - if let Some(copilot) = cx.update(|_window, cx| Copilot::global(cx)).ok().flatten() { - workspace - .update_in(cx, |workspace, window, cx| { - match copilot.read(cx).status() { - Status::Authorized => workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Copilot has started.", - ), - cx, - ), - _ => { - workspace.dismiss_toast( - &NotificationId::unique::(), - cx, - ); - copilot - .update(cx, |copilot, cx| copilot.sign_in(cx)) - .detach_and_log_err(cx); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); - } + window + .spawn(cx, async move |cx| { + task.await; + cx.update(|window, cx| { + let Some(copilot) = Copilot::global(cx) else { + return; + }; + match copilot.read(cx).status() { + Status::Authorized => { + copilot_toast(Some("Copilot has started."), window, cx) } - }) - .log_err(); - } - }) - .detach(); + _ => { + copilot_toast(None, window, cx); + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + open_copilot_code_verification_window(&copilot, window, cx); + } + } + }) + .log_err(); + }) + .detach(); } _ => { copilot .update(cx, |copilot, cx| copilot.sign_in(cx)) .detach(); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); + open_copilot_code_verification_window(&copilot, window, cx); } } } -pub fn sign_out_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - cx: &mut Context, -) { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signing out of Copilot...", - ), - cx, - ); - let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); - cx.spawn(async move |workspace, cx| match sign_out_task.await { - Ok(()) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signed out of Copilot.", - ), - cx, - ) - }) - .ok(); - } - Err(err) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_error(&err, cx); - }) - .ok(); - } - }) - .detach(); -} - pub struct CopilotCodeVerification { status: Status, connect_clicked: bool, focus_handle: FocusHandle, copilot: Entity, _subscription: Subscription, + sign_up_url: Option, } impl Focusable for CopilotCodeVerification { @@ -170,29 +163,44 @@ impl Focusable for CopilotCodeVerification { } impl EventEmitter for CopilotCodeVerification {} -impl ModalView for CopilotCodeVerification { - fn on_before_dismiss( - &mut self, - _: &mut Window, - cx: &mut Context, - ) -> workspace::DismissDecision { - self.copilot.update(cx, |copilot, cx| { - if matches!(copilot.status(), Status::SigningIn { .. }) { - copilot.sign_out(cx).detach_and_log_err(cx); + +impl CopilotCodeVerification { + pub fn new(copilot: &Entity, window: &mut Window, cx: &mut Context) -> Self { + window.on_window_should_close(cx, |window, cx| { + if let Some(this) = window.root::().flatten() { + this.update(cx, |this, cx| { + this.before_dismiss(cx); + }); } + true }); - workspace::DismissDecision::Dismiss(true) - } -} + cx.subscribe_in( + &cx.entity(), + window, + |this, _, _: &DismissEvent, window, cx| { + window.remove_window(); + this.before_dismiss(cx); + }, + ) + .detach(); -impl CopilotCodeVerification { - pub fn new(copilot: &Entity, cx: &mut Context) -> Self { let status = copilot.read(cx).status(); + // Determine sign-up URL based on verification_uri domain if available + let sign_up_url = if let Status::SigningIn { + prompt: Some(ref prompt), + } = status + { + // Extract domain from verification_uri to construct sign-up URL + Self::get_sign_up_url_from_verification(&prompt.verification_uri) + } else { + None + }; Self { status, connect_clicked: false, focus_handle: cx.focus_handle(), copilot: copilot.clone(), + sign_up_url, _subscription: cx.observe(copilot, |this, copilot, cx| { let status = copilot.read(cx).status(); match status { @@ -206,54 +214,74 @@ impl CopilotCodeVerification { } pub fn set_status(&mut self, status: Status, cx: &mut Context) { + // Update sign-up URL if we have a new verification URI + if let Status::SigningIn { + prompt: Some(ref prompt), + } = status + { + self.sign_up_url = Self::get_sign_up_url_from_verification(&prompt.verification_uri); + } self.status = status; cx.notify(); } + fn get_sign_up_url_from_verification(verification_uri: &str) -> Option { + // Extract domain from verification URI using url crate + if let Ok(url) = Url::parse(verification_uri) + && let Some(host) = url.host_str() + && !host.contains("github.com") + { + // For GHE, construct URL from domain + Some(format!("https://{}/features/copilot", host)) + } else { + None + } + } + fn render_device_code(data: &PromptUserDeviceFlow, cx: &mut Context) -> impl IntoElement { let copied = cx .read_from_clipboard() .map(|item| item.text().as_ref() == Some(&data.user_code)) .unwrap_or(false); - h_flex() - .w_full() - .p_1() - .border_1() - .border_muted(cx) - .rounded_sm() - .cursor_pointer() - .justify_between() - .on_mouse_down(gpui::MouseButton::Left, { + + ButtonLike::new("copy-button") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .size(ButtonSize::Medium) + .child( + h_flex() + .w_full() + .p_1() + .justify_between() + .child(Label::new(data.user_code.clone())) + .child(Label::new(if copied { "Copied!" } else { "Copy" })), + ) + .on_click({ let user_code = data.user_code.clone(); move |_, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone())); window.refresh(); } }) - .child(div().flex_1().child(Label::new(data.user_code.clone()))) - .child(div().flex_none().px_1().child(Label::new(if copied { - "Copied!" - } else { - "Copy" - }))) } fn render_prompting_modal( connect_clicked: bool, data: &PromptUserDeviceFlow, - cx: &mut Context, ) -> impl Element { let connect_button_label = if connect_clicked { - "Waiting for connection..." + "Waiting for connection…" } else { "Connect to GitHub" }; + v_flex() .flex_1() - .gap_2() + .gap_2p5() .items_center() - .child(Headline::new("Use GitHub Copilot in Zed.").size(HeadlineSize::Large)) + .text_center() + .child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large)) .child( Label::new("Using Copilot requires an active subscription on GitHub.") .color(Color::Muted), @@ -261,110 +289,154 @@ impl CopilotCodeVerification { .child(Self::render_device_code(data, cx)) .child( Label::new("Paste this code into GitHub after clicking the button below.") - .size(ui::LabelSize::Small), - ) - .child( - Button::new("connect-button", connect_button_label) - .on_click({ - let verification_uri = data.verification_uri.clone(); - cx.listener(move |this, _, _window, cx| { - cx.open_url(&verification_uri); - this.connect_clicked = true; - }) - }) - .full_width() - .style(ButtonStyle::Filled), + .color(Color::Muted), ) .child( - Button::new("copilot-enable-cancel-button", "Cancel") - .full_width() - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(DismissEvent); - })), + v_flex() + .w_full() + .gap_1() + .child( + Button::new("connect-button", connect_button_label) + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click({ + let verification_uri = data.verification_uri.clone(); + cx.listener(move |this, _, _window, cx| { + cx.open_url(&verification_uri); + this.connect_clicked = true; + }) + }), + ) + .child( + Button::new("copilot-enable-cancel-button", "Cancel") + .full_width() + .size(ButtonSize::Medium) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(DismissEvent); + })), + ), ) } fn render_enabled_modal(cx: &mut Context) -> impl Element { v_flex() .gap_2() + .text_center() + .justify_center() .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large)) - .child(Label::new( - "You can update your settings or sign out from the Copilot menu in the status bar.", - )) + .child(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted)) .child( Button::new("copilot-enabled-done-button", "Done") .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } - fn render_unauthorized_modal(cx: &mut Context) -> impl Element { - v_flex() - .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large)) + fn render_unauthorized_modal(&self, cx: &mut Context) -> impl Element { + let sign_up_url = self + .sign_up_url + .as_deref() + .unwrap_or(COPILOT_SIGN_UP_URL) + .to_owned(); + let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription."; - .child(Label::new( - "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.", - ).color(Color::Warning)) + v_flex() + .gap_2() + .text_center() + .justify_center() + .child( + Headline::new("You must have an active GitHub Copilot subscription.") + .size(HeadlineSize::Large), + ) + .child(Label::new(description).color(Color::Warning)) .child( Button::new("copilot-subscribe-button", "Subscribe on GitHub") .full_width() - .on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)), + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click(move |_, _, cx| cx.open_url(&sign_up_url)), ) .child( Button::new("copilot-subscribe-cancel-button", "Cancel") .full_width() + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } - fn render_loading(window: &mut Window, _: &mut Context) -> impl Element { - let loading_icon = svg() - .size_8() - .path(IconName::ArrowCircle.path()) - .text_color(window.text_style().color) - .with_animation( - "icon_circle_arrow", - Animation::new(Duration::from_secs(2)).repeat(), - |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))), - ); + fn render_error_modal(_cx: &mut Context) -> impl Element { + v_flex() + .gap_2() + .text_center() + .justify_center() + .child(Headline::new("An Error Happened").size(HeadlineSize::Large)) + .child(Label::new(ERROR_LABEL).color(Color::Muted)) + .child( + Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In") + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)), + ) + } - h_flex().justify_center().child(loading_icon) + fn before_dismiss( + &mut self, + cx: &mut Context<'_, CopilotCodeVerification>, + ) -> workspace::DismissDecision { + self.copilot.update(cx, |copilot, cx| { + if matches!(copilot.status(), Status::SigningIn { .. }) { + copilot.sign_out(cx).detach_and_log_err(cx); + } + }); + workspace::DismissDecision::Dismiss(true) } } impl Render for CopilotCodeVerification { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let prompt = match &self.status { - Status::SigningIn { prompt: None } => { - Self::render_loading(window, cx).into_any_element() - } + Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element(), Status::SigningIn { prompt: Some(prompt), } => Self::render_prompting_modal(self.connect_clicked, prompt, cx).into_any_element(), Status::Unauthorized => { self.connect_clicked = false; - Self::render_unauthorized_modal(cx).into_any_element() + self.render_unauthorized_modal(cx).into_any_element() } Status::Authorized => { self.connect_clicked = false; Self::render_enabled_modal(cx).into_any_element() } + Status::Error(..) => Self::render_error_modal(cx).into_any_element(), _ => div().into_any_element(), }; v_flex() - .id("copilot code verification") + .id("copilot_code_verification") .track_focus(&self.focus_handle(cx)) - .elevation_3(cx) - .w_96() - .items_center() - .p_4() + .size_full() + .px_4() + .py_8() .gap_2() + .items_center() + .justify_center() + .elevation_3(cx) .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _| { - window.focus(&this.focus_handle); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + window.focus(&this.focus_handle, cx); })) .child( Vector::new(VectorName::ZedXCopilot, rems(8.), rems(4.)) @@ -373,3 +445,243 @@ impl Render for CopilotCodeVerification { .child(prompt) } } + +pub struct ConfigurationView { + copilot_status: Option, + is_authenticated: fn(cx: &App) -> bool, + edit_prediction: bool, + _subscription: Option, +} + +pub enum ConfigurationMode { + Chat, + EditPrediction, +} + +impl ConfigurationView { + pub fn new( + is_authenticated: fn(cx: &App) -> bool, + mode: ConfigurationMode, + cx: &mut Context, + ) -> Self { + let copilot = Copilot::global(cx); + + Self { + copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()), + is_authenticated, + edit_prediction: matches!(mode, ConfigurationMode::EditPrediction), + _subscription: copilot.as_ref().map(|copilot| { + cx.observe(copilot, |this, model, cx| { + this.copilot_status = Some(model.read(cx).status()); + cx.notify(); + }) + }), + } + } +} + +impl ConfigurationView { + fn is_starting(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Starting { .. })) + } + + fn is_signing_in(&self) -> bool { + matches!( + &self.copilot_status, + Some(Status::SigningIn { .. }) + | Some(Status::SignedOut { + awaiting_signing_in: true + }) + ) + } + + fn is_error(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Error(_))) + } + + fn has_no_status(&self) -> bool { + self.copilot_status.is_none() + } + + fn loading_message(&self) -> Option { + if self.is_starting() { + Some("Starting Copilot…".into()) + } else if self.is_signing_in() { + Some("Signing into Copilot…".into()) + } else { + None + } + } + + fn render_loading_button( + &self, + label: impl Into, + edit_prediction: bool, + ) -> impl IntoElement { + ButtonLike::new("loading_button") + .disabled(true) + .style(ButtonStyle::Outlined) + .when(edit_prediction, |this| this.size(ButtonSize::Medium)) + .child( + h_flex() + .w_full() + .gap_1() + .justify_center() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(4), + ) + .child(Label::new(label)), + ) + } + + fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Sign in to GitHub" + } else { + "Sign in to use GitHub Copilot" + }; + + Button::new("sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Github) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| initiate_sign_in(window, cx)) + } + + fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Reinstall and Sign in" + } else { + "Reinstall Copilot and Sign in" + }; + + Button::new("reinstall_and_sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)) + } + + fn render_for_edit_prediction(&self) -> impl IntoElement { + let container = |description: SharedString, action: AnyElement| { + h_flex() + .pt_2p5() + .w_full() + .justify_between() + .child( + v_flex() + .w_full() + .max_w_1_2() + .child(Label::new("Authenticate To Use")) + .child( + Label::new(description) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child(action) + }; + + let start_label = "To use Copilot for edit predictions, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot subscription.".into(); + let no_status_label = "Copilot requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different edit predictions provider.".into(); + + if let Some(msg) = self.loading_message() { + container( + start_label, + self.render_loading_button(msg, true).into_any_element(), + ) + .into_any_element() + } else if self.is_error() { + container( + ERROR_LABEL.into(), + self.render_reinstall_button(true).into_any_element(), + ) + .into_any_element() + } else if self.has_no_status() { + container( + no_status_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } else { + container( + start_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } + } + + fn render_for_chat(&self) -> impl IntoElement { + let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; + let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider."; + + if let Some(msg) = self.loading_message() { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_loading_button(msg, false)) + .into_any_element() + } else if self.is_error() { + v_flex() + .gap_2() + .child(Label::new(ERROR_LABEL)) + .child(self.render_reinstall_button(false)) + .into_any_element() + } else if self.has_no_status() { + v_flex() + .gap_2() + .child(Label::new(no_status_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } else { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } + } +} + +impl Render for ConfigurationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_authenticated = self.is_authenticated; + + if is_authenticated(cx) { + return ConfiguredApiCard::new("Authorized") + .button_label("Sign Out") + .on_click(|_, window, cx| { + initiate_sign_out(window, cx); + }) + .into_any_element(); + } + + if self.edit_prediction { + self.render_for_edit_prediction().into_any_element() + } else { + self.render_for_chat().into_any_element() + } + } +} diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index 3f85039e9ea3bce8e702991461adec4a931d3e4a..bd1c1121848e34349b5cd58c0fa033d380fa791b 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -23,6 +23,9 @@ zstd.workspace = true [target.'cfg(target_os = "macos")'.dependencies] mach2.workspace = true +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true + [lints] workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index baf0bcde3b0769c4fc6cf958c86e181cda615683..4c601c393004beca1d5e550e1eeae7f126751448 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -3,6 +3,8 @@ use log::info; use minidumper::{Client, LoopAction, MinidumpBinary}; use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; use serde::{Deserialize, Serialize}; + +#[cfg(not(target_os = "windows"))] use smol::process::Command; #[cfg(target_os = "macos")] @@ -70,11 +72,16 @@ pub async fn init(crash_init: InitCrashHandler) { // used by the crash handler isn't destroyed correctly which causes it to stay on the file // system and block further attempts to initialize crash handlers with that socket path. let socket_name = paths::temp_dir().join(format!("zed-crash-handler-{zed_pid}")); + #[cfg(not(target_os = "windows"))] let _crash_handler = Command::new(exe) .arg("--crash-handler") .arg(&socket_name) .spawn() .expect("unable to spawn server process"); + + #[cfg(target_os = "windows")] + spawn_crash_handler_windows(&exe, &socket_name); + #[cfg(target_os = "linux")] let server_pid = _crash_handler.id(); info!("spawning crash handler process"); @@ -342,6 +349,57 @@ pub fn panic_hook(info: &PanicHookInfo) { } } +#[cfg(target_os = "windows")] +fn spawn_crash_handler_windows(exe: &Path, socket_name: &Path) { + use std::ffi::OsStr; + use std::iter::once; + use std::os::windows::ffi::OsStrExt; + use windows::Win32::System::Threading::{ + CreateProcessW, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTF_FORCEOFFFEEDBACK, + STARTUPINFOW, + }; + use windows::core::PWSTR; + + let mut command_line: Vec = OsStr::new(&format!( + "\"{}\" --crash-handler \"{}\"", + exe.display(), + socket_name.display() + )) + .encode_wide() + .chain(once(0)) + .collect(); + + let mut startup_info = STARTUPINFOW::default(); + startup_info.cb = std::mem::size_of::() as u32; + + // By default, Windows enables a "busy" cursor when a GUI application is launched. + // This cursor is disabled once the application starts processing window messages. + // Since the crash handler process doesn't process messages, this "busy" cursor stays enabled for a long time. + // Disable the cursor feedback to prevent this from happening. + startup_info.dwFlags = STARTF_FORCEOFFFEEDBACK; + + let mut process_info = PROCESS_INFORMATION::default(); + + unsafe { + CreateProcessW( + None, + Some(PWSTR(command_line.as_mut_ptr())), + None, + None, + false, + PROCESS_CREATION_FLAGS(0), + None, + None, + &startup_info, + &mut process_info, + ) + .expect("unable to spawn server process"); + + windows::Win32::Foundation::CloseHandle(process_info.hProcess).ok(); + windows::Win32::Foundation::CloseHandle(process_info.hThread).ok(); + } +} + pub fn crash_server(socket: &Path) { let Ok(mut server) = minidumper::Server::with_name(socket) else { log::info!("Couldn't create socket, there may already be a running crash server"); diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index 2f84193ac9343cac9ff1cf52eb648bad1cd77896..a45e16dc32180e1e4ed3b2ec01c92ad07ffc5e93 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -870,7 +870,7 @@ impl DebugAdapter for PythonDebugAdapter { .active_toolchain( delegate.worktree_id(), base_path.into_arc(), - language::LanguageName::new(Self::LANGUAGE_NAME), + language::LanguageName::new_static(Self::LANGUAGE_NAME), cx, ) .await diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index 8841a3744a4452355e2b02c9dca969cab493796e..317ce8b4c65e441f1fc4041706989532aa150204 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -1017,11 +1017,13 @@ impl SearchableItem for DapLogView { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ) { - self.editor - .update(cx, |e, cx| e.update_matches(matches, window, cx)) + self.editor.update(cx, |e, cx| { + e.update_matches(matches, active_match_index, window, cx) + }) } fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index 325bcc300ae637ab46c36b7a3e7875e197f7d3d2..fb79b1b0790b28d7204774720bf9c413cfed64e6 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -37,6 +37,7 @@ dap_adapters = { workspace = true, optional = true } db.workspace = true debugger_tools.workspace = true editor.workspace = true +feature_flags.workspace = true file_icons.workspace = true futures.workspace = true fuzzy.workspace = true @@ -82,6 +83,7 @@ dap_adapters = { workspace = true, features = ["test-support"] } debugger_tools = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } tree-sitter-go.workspace = true unindent.workspace = true diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs index 64146169f53cfe44c3bdcb59b93e78d0f9223abd..6e537ae0c6e1db7418596cf48b51ca22df30be57 100644 --- a/crates/debugger_ui/src/attach_modal.rs +++ b/crates/debugger_ui/src/attach_modal.rs @@ -317,7 +317,7 @@ impl PickerDelegate for AttachModalDelegate { let candidate = self.candidates.get(hit.candidate_id)?; Some( - ListItem::new(SharedString::from(format!("process-entry-{ix}"))) + ListItem::new(format!("process-entry-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) @@ -327,7 +327,7 @@ impl PickerDelegate for AttachModalDelegate { .child(Label::new(format!("{} {}", candidate.name, candidate.pid))) .child( div() - .id(SharedString::from(format!("process-entry-{ix}-command"))) + .id(format!("process-entry-{ix}-command")) .tooltip(Tooltip::text( candidate .command diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 3890fa6326329d0d72aa6f81c6b94e7c2f364d34..0b91b9f28559ac6d7f991b7a0b9822820004148d 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -15,10 +15,11 @@ use dap::adapters::DebugAdapterName; use dap::{DapRegistry, StartDebuggingRequestArguments}; use dap::{client::SessionId, debugger_settings::DebuggerSettings}; use editor::{Editor, MultiBufferOffset, ToPoint}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; use gpui::{ - Action, App, AsyncWindowContext, ClipboardItem, Context, DismissEvent, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, Subscription, Task, - WeakEntity, anchored, deferred, + Action, App, AsyncWindowContext, ClipboardItem, Context, Corner, DismissEvent, Entity, + EntityId, EventEmitter, FocusHandle, Focusable, MouseButton, MouseDownEvent, Point, + Subscription, Task, WeakEntity, anchored, deferred, }; use itertools::Itertools as _; @@ -31,7 +32,9 @@ use settings::Settings; use std::sync::{Arc, LazyLock}; use task::{DebugScenario, TaskContext}; use tree_sitter::{Query, StreamingIterator as _}; -use ui::{ContextMenu, Divider, PopoverMenuHandle, Tab, Tooltip, prelude::*}; +use ui::{ + ContextMenu, Divider, PopoverMenu, PopoverMenuHandle, SplitButton, Tab, Tooltip, prelude::*, +}; use util::rel_path::RelPath; use util::{ResultExt, debug_panic, maybe}; use workspace::SplitDirection; @@ -42,6 +45,12 @@ use workspace::{ }; use zed_actions::ToggleFocus; +pub struct DebuggerHistoryFeatureFlag; + +impl FeatureFlag for DebuggerHistoryFeatureFlag { + const NAME: &'static str = "debugger-history"; +} + const DEBUG_PANEL_KEY: &str = "DebugPanel"; pub struct DebugPanel { @@ -284,7 +293,7 @@ impl DebugPanel { } }); - session.update(cx, |session, _| match &mut session.mode { + session.update(cx, |session, _| match &mut session.state { SessionState::Booting(state_task) => { *state_task = Some(boot_task); } @@ -568,7 +577,7 @@ impl DebugPanel { menu }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| { this.context_menu.take(); cx.notify(); @@ -662,6 +671,12 @@ impl DebugPanel { ) }; + let thread_status = active_session + .as_ref() + .map(|session| session.read(cx).running_state()) + .and_then(|state| state.read(cx).thread_status(cx)) + .unwrap_or(project::debugger::session::ThreadStatus::Exited); + Some( div.w_full() .py_1() @@ -679,10 +694,6 @@ impl DebugPanel { .as_ref() .map(|session| session.read(cx).running_state()), |this, running_state| { - let thread_status = - running_state.read(cx).thread_status(cx).unwrap_or( - project::debugger::session::ThreadStatus::Exited, - ); let capabilities = running_state.read(cx).capabilities(cx); let supports_detach = running_state.read(cx).session().read(cx).is_attached(); @@ -740,7 +751,7 @@ impl DebugPanel { } }) .child( - IconButton::new("debug-step-over", IconName::ArrowRight) + IconButton::new("step-over", IconName::DebugStepOver) .icon_size(IconSize::Small) .on_click(window.listener_for( running_state, @@ -762,32 +773,29 @@ impl DebugPanel { }), ) .child( - IconButton::new( - "debug-step-into", - IconName::ArrowDownRight, - ) - .icon_size(IconSize::Small) - .on_click(window.listener_for( - running_state, - |this, _, _window, cx| { - this.step_in(cx); - }, - )) - .disabled(thread_status != ThreadStatus::Stopped) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Step In", - &StepInto, - &focus_handle, - cx, - ) - } - }), + IconButton::new("step-into", IconName::DebugStepInto) + .icon_size(IconSize::Small) + .on_click(window.listener_for( + running_state, + |this, _, _window, cx| { + this.step_in(cx); + }, + )) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Step In", + &StepInto, + &focus_handle, + cx, + ) + } + }), ) .child( - IconButton::new("debug-step-out", IconName::ArrowUpRight) + IconButton::new("step-out", IconName::DebugStepOut) .icon_size(IconSize::Small) .on_click(window.listener_for( running_state, @@ -874,36 +882,53 @@ impl DebugPanel { } }), ) + .when(supports_detach, |div| { + div.child( + IconButton::new( + "debug-disconnect", + IconName::DebugDetach, + ) + .disabled( + thread_status != ThreadStatus::Stopped + && thread_status != ThreadStatus::Running, + ) + .icon_size(IconSize::Small) + .on_click(window.listener_for( + running_state, + |this, _, _, cx| { + this.detach_client(cx); + }, + )) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Detach", + &Detach, + &focus_handle, + cx, + ) + } + }), + ) + }) .when( - supports_detach, - |div| { - div.child( - IconButton::new( - "debug-disconnect", - IconName::DebugDetach, - ) - .disabled( - thread_status != ThreadStatus::Stopped - && thread_status != ThreadStatus::Running, + cx.has_flag::(), + |this| { + this.child(Divider::vertical()).child( + SplitButton::new( + self.render_history_button( + &running_state, + thread_status, + window, + ), + self.render_history_toggle_button( + thread_status, + &running_state, + ) + .into_any_element(), ) - .icon_size(IconSize::Small) - .on_click(window.listener_for( - running_state, - |this, _, _, cx| { - this.detach_client(cx); - }, - )) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Detach", - &Detach, - &focus_handle, - cx, - ) - } - }), + .style(ui::SplitButtonStyle::Outlined), ) }, ) @@ -1027,7 +1052,7 @@ impl DebugPanel { cx: &mut Context, ) { debug_assert!(self.sessions_with_children.contains_key(&session_item)); - session_item.focus_handle(cx).focus(window); + session_item.focus_handle(cx).focus(window, cx); session_item.update(cx, |this, cx| { this.running_state().update(cx, |this, cx| { this.go_to_selected_stack_frame(window, cx); @@ -1320,6 +1345,97 @@ impl DebugPanel { }); } } + + fn render_history_button( + &self, + running_state: &Entity, + thread_status: ThreadStatus, + window: &mut Window, + ) -> IconButton { + IconButton::new("debug-back-in-history", IconName::HistoryRerun) + .icon_size(IconSize::Small) + .on_click(window.listener_for(running_state, |this, _, _window, cx| { + this.session().update(cx, |session, cx| { + let ix = session + .active_snapshot_index() + .unwrap_or_else(|| session.historic_snapshots().len()); + + session.select_historic_snapshot(Some(ix.saturating_sub(1)), cx); + }) + })) + .disabled( + thread_status == ThreadStatus::Running || thread_status == ThreadStatus::Stepping, + ) + } + + fn render_history_toggle_button( + &self, + thread_status: ThreadStatus, + running_state: &Entity, + ) -> impl IntoElement { + 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)), + ) + .disabled( + thread_status == ThreadStatus::Running + || thread_status == ThreadStatus::Stepping, + ), + ) + .menu({ + let running_state = running_state.clone(); + move |window, cx| { + let handler = + |ix: Option, running_state: Entity, cx: &mut App| { + running_state.update(cx, |state, cx| { + state.session().update(cx, |session, cx| { + session.select_historic_snapshot(ix, cx); + }) + }) + }; + + let running_state = running_state.clone(); + Some(ContextMenu::build( + window, + cx, + move |mut context_menu, _window, cx| { + let history = running_state + .read(cx) + .session() + .read(cx) + .historic_snapshots(); + + context_menu = context_menu.entry("Current State", None, { + let running_state = running_state.clone(); + move |_window, cx| { + handler(None, running_state.clone(), cx); + } + }); + context_menu = context_menu.separator(); + + for (ix, _) in history.iter().enumerate().rev() { + context_menu = + context_menu.entry(format!("history-{}", ix + 1), None, { + let running_state = running_state.clone(); + move |_window, cx| { + handler(Some(ix), running_state.clone(), cx); + } + }); + } + + context_menu + }, + )) + } + }) + .anchor(Corner::TopRight) + } } async fn register_session_inner( @@ -1441,7 +1557,7 @@ impl Panel for DebugPanel { self.sessions_with_children.keys().for_each(|session_item| { session_item.update(cx, |item, cx| { item.running_state() - .update(cx, |state, _| state.invert_axies()) + .update(cx, |state, cx| state.invert_axies(cx)) }) }); } @@ -1463,8 +1579,10 @@ impl Panel for DebugPanel { Some(proto::PanelId::DebugPanel) } - fn icon(&self, _window: &Window, _cx: &App) -> Option { - Some(IconName::Debug) + fn icon(&self, _window: &Window, cx: &App) -> Option { + DebuggerSettings::get_global(cx) + .button + .then_some(IconName::Debug) } fn icon_tooltip(&self, _window: &Window, cx: &App) -> Option<&'static str> { diff --git a/crates/debugger_ui/src/debugger_ui.rs b/crates/debugger_ui/src/debugger_ui.rs index a9abb50bb68851334285b05064176e0347474014..bd5a7cda4a21a3d3fd0ac132d6ba2e7aace68722 100644 --- a/crates/debugger_ui/src/debugger_ui.rs +++ b/crates/debugger_ui/src/debugger_ui.rs @@ -387,7 +387,7 @@ pub fn init(cx: &mut App) { window.on_action( TypeId::of::(), move |_, _, window, cx| { - maybe!({ + let status = maybe!({ let text = editor .update(cx, |editor, cx| { let range = editor @@ -411,7 +411,13 @@ pub fn init(cx: &mut App) { state.session().update(cx, |session, cx| { session - .evaluate(text, None, stack_id, None, cx) + .evaluate( + text, + Some(dap::EvaluateArgumentsContext::Repl), + stack_id, + None, + cx, + ) .detach(); }); }); @@ -419,6 +425,9 @@ pub fn init(cx: &mut App) { Some(()) }); + if status.is_some() { + cx.stop_propagation(); + } }, ); }) diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 40187cef9cc55cb4192a3cea773f42dca15a2571..68e391562b57d530a21624b0626173eeb7a67c16 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -574,7 +574,7 @@ impl Render for NewProcessModal { NewProcessMode::Launch => NewProcessMode::Task, }; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); })) .on_action( cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| { @@ -585,7 +585,7 @@ impl Render for NewProcessModal { NewProcessMode::Launch => NewProcessMode::Attach, }; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); }), ) .child( @@ -602,7 +602,7 @@ impl Render for NewProcessModal { NewProcessMode::Task.to_string(), cx.listener(|this, _, window, cx| { this.mode = NewProcessMode::Task; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -611,7 +611,7 @@ impl Render for NewProcessModal { NewProcessMode::Debug.to_string(), cx.listener(|this, _, window, cx| { this.mode = NewProcessMode::Debug; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -629,7 +629,7 @@ impl Render for NewProcessModal { cx, ); } - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -638,7 +638,7 @@ impl Render for NewProcessModal { NewProcessMode::Launch.to_string(), cx.listener(|this, _, window, cx| { this.mode = NewProcessMode::Launch; - this.mode_focus_handle(cx).focus(window); + this.mode_focus_handle(cx).focus(window, cx); cx.notify(); }), ) @@ -840,17 +840,17 @@ impl ConfigureMode { } } - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); } fn on_tab_prev( &mut self, _: &menu::SelectPrevious, window: &mut Window, - _: &mut Context, + cx: &mut Context, ) { - window.focus_prev(); + window.focus_prev(cx); } fn render( @@ -881,7 +881,6 @@ impl ConfigureMode { .label("Stop on Entry") .label_position(SwitchLabelPosition::Start) .label_size(LabelSize::Default) - .color(ui::SwitchColor::Accent) .on_click({ let this = cx.weak_entity(); move |state, _, cx| { @@ -924,7 +923,7 @@ impl AttachMode { window, cx, ); - window.focus(&modal.focus_handle(cx)); + window.focus(&modal.focus_handle(cx), cx); modal }); @@ -1023,7 +1022,7 @@ impl DebugDelegate { Some(TaskSourceKind::Lsp { language_name, .. }) => { Some(format!("LSP: {language_name}")) } - Some(TaskSourceKind::Language { name }) => Some(format!("Lang: {name}")), + Some(TaskSourceKind::Language { name }) => Some(format!("Language: {name}")), _ => context.clone().and_then(|ctx| { ctx.task_context .task_variables @@ -1520,7 +1519,7 @@ impl PickerDelegate for DebugDelegate { }); Some( - ListItem::new(SharedString::from(format!("debug-scenario-selection-{ix}"))) + ListItem::new(format!("debug-scenario-selection-{ix}")) .inset(true) .start_slot::(icon) .spacing(ListItemSpacing::Sparse) diff --git a/crates/debugger_ui/src/onboarding_modal.rs b/crates/debugger_ui/src/onboarding_modal.rs index 18205209983421691046e8a9d93eb6de32cd4563..b6f1ab944183c4f44d2bc5f6855731abb65ce1f7 100644 --- a/crates/debugger_ui/src/onboarding_modal.rs +++ b/crates/debugger_ui/src/onboarding_modal.rs @@ -83,8 +83,8 @@ impl Render for DebuggerOnboardingModal { debugger_onboarding_event!("Canceled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div() diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index b82f839edee82f884c1419d44a2344c39c8abd0d..422207d3cbf4880e0c8e3c02e01dbe373800ea62 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -286,10 +286,10 @@ impl Item for SubView { impl Render for SubView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .id(SharedString::from(format!( + .id(format!( "subview-container-{}", self.kind.to_shared_string() - ))) + )) .on_hover(cx.listener(|this, hovered, _, cx| { this.hovered = *hovered; cx.notify(); @@ -348,7 +348,7 @@ pub(crate) fn new_debugger_pane( debug_assert!(_previous_subscription.is_none()); running .panes - .split(&this_pane, &new_pane, split_direction)?; + .split(&this_pane, &new_pane, split_direction, cx)?; anyhow::Ok(new_pane) }) }) @@ -484,10 +484,7 @@ pub(crate) fn new_debugger_pane( let deemphasized = !pane.has_focus(window, cx); let item_ = item.boxed_clone(); div() - .id(SharedString::from(format!( - "debugger_tab_{}", - item.item_id().as_u64() - ))) + .id(format!("debugger_tab_{}", item.item_id().as_u64())) .p_1() .rounded_md() .cursor_pointer() @@ -607,7 +604,7 @@ impl DebugTerminal { let focus_handle = cx.focus_handle(); let focus_subscription = cx.on_focus(&focus_handle, window, |this, window, cx| { if let Some(terminal) = this.terminal.as_ref() { - terminal.focus_handle(cx).focus(window); + terminal.focus_handle(cx).focus(window, cx); } }); @@ -1465,7 +1462,7 @@ impl RunningState { this.serialize_layout(window, cx); match event { Event::Remove { .. } => { - let _did_find_pane = this.panes.remove(source_pane).is_ok(); + let _did_find_pane = this.panes.remove(source_pane, cx).is_ok(); debug_assert!(_did_find_pane); cx.notify(); } @@ -1743,7 +1740,7 @@ impl RunningState { let is_building = self.session.update(cx, |session, cx| { session.shutdown(cx).detach(); - matches!(session.mode, session::SessionState::Booting(_)) + matches!(session.state, session::SessionState::Booting(_)) }); if is_building { @@ -1892,9 +1889,9 @@ impl RunningState { Member::Axis(group_root) } - pub(crate) fn invert_axies(&mut self) { + pub(crate) fn invert_axies(&mut self, cx: &mut App) { self.dock_axis = self.dock_axis.invert(); - self.panes.invert_axies(); + self.panes.invert_axies(cx); } } diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index 2c7e2074678290356b7669228dcf29008f1cc36b..f154757429a2bbfe153ee40c2c513dd06f05aa03 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -310,7 +310,7 @@ impl BreakpointList { fn dismiss(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { if self.input.focus_handle(cx).contains_focused(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else if self.strip_mode.is_some() { self.strip_mode.take(); cx.notify(); @@ -364,9 +364,9 @@ impl BreakpointList { } } } - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else { - handle.focus(window); + handle.focus(window, cx); } return; @@ -627,7 +627,7 @@ impl BreakpointList { .on_click({ let focus_handle = focus_handle.clone(); move |_, window, cx| { - focus_handle.focus(window); + focus_handle.focus(window, cx); window.dispatch_action(ToggleEnableBreakpoint.boxed_clone(), cx) } }), @@ -654,7 +654,7 @@ impl BreakpointList { ) .on_click({ move |_, window, cx| { - focus_handle.focus(window); + focus_handle.focus(window, cx); window.dispatch_action(UnsetBreakpoint.boxed_clone(), cx) } }), diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 717169ff5ad1d0f479075ca996c550a774a4307a..040953bff6e8f0efa6045c1629c964ac98929547 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -105,7 +105,7 @@ impl Console { cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events), cx.on_focus(&focus_handle, window, |console, window, cx| { if console.is_running(cx) { - console.query_bar.focus_handle(cx).focus(window); + console.query_bar.focus_handle(cx).focus(window, cx); } }), ]; @@ -252,10 +252,11 @@ impl Console { let start_offset = range.start; let range = buffer.anchor_after(MultiBufferOffset(range.start)) ..buffer.anchor_before(MultiBufferOffset(range.end)); + let color_fn = color_fetcher(color); console.highlight_background_key::( start_offset, &[range], - color_fetcher(color), + move |_, theme| color_fn(theme), cx, ); } @@ -559,7 +560,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { position: language::Anchor, text: &str, trigger_in_words: bool, - menu_is_open: bool, cx: &mut Context, ) -> bool { let mut chars = text.chars(); @@ -570,9 +570,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { }; let snapshot = buffer.read(cx).snapshot(); - if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input { - return false; - } let classifier = snapshot .char_classifier_at(position) diff --git a/crates/debugger_ui/src/session/running/loaded_source_list.rs b/crates/debugger_ui/src/session/running/loaded_source_list.rs index 921ebd8b5f5bdfe8a3c8a8f7bb1625bd1ffad7fb..e55fad336b5ee6dfbee1cb0c90ea3d19f561a2ba 100644 --- a/crates/debugger_ui/src/session/running/loaded_source_list.rs +++ b/crates/debugger_ui/src/session/running/loaded_source_list.rs @@ -17,7 +17,9 @@ impl LoadedSourceList { let list = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); let _subscription = cx.subscribe(&session, |this, _, event, cx| match event { - SessionEvent::Stopped(_) | SessionEvent::LoadedSources => { + SessionEvent::Stopped(_) + | SessionEvent::HistoricSnapshotSelected + | SessionEvent::LoadedSources => { this.invalidate = true; cx.notify(); } diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 55a8e8429eb23cd0bfcaa7d592d16797c061d2ae..f10e5179e37f87be0e27985b557fcb63cf089a42 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -403,7 +403,7 @@ impl MemoryView { this.set_placeholder_text("Write to Selected Memory Range", window, cx); }); self.is_writing_memory = true; - self.query_editor.focus_handle(cx).focus(window); + self.query_editor.focus_handle(cx).focus(window, cx); } else { self.query_editor.update(cx, |this, cx| { this.clear(window, cx); diff --git a/crates/debugger_ui/src/session/running/module_list.rs b/crates/debugger_ui/src/session/running/module_list.rs index 19f407eb23f8acf0aa665f5119ecfd2156eb685f..7d0228fc6851185d10a3a237257d6244d5a90c76 100644 --- a/crates/debugger_ui/src/session/running/module_list.rs +++ b/crates/debugger_ui/src/session/running/module_list.rs @@ -32,7 +32,9 @@ impl ModuleList { let focus_handle = cx.focus_handle(); let _subscription = cx.subscribe(&session, |this, _, event, cx| match event { - SessionEvent::Stopped(_) | SessionEvent::Modules => { + SessionEvent::Stopped(_) + | SessionEvent::HistoricSnapshotSelected + | SessionEvent::Modules => { if this._rebuild_task.is_some() { this.schedule_rebuild(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 96a910af4dd0ac901c6802c139ddd5b8b3d728bc..4dffb57a792cb5a5ed6bcc8003a8fa6f3b9af9de 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -4,6 +4,7 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow}; use dap::StackFrameId; +use dap::adapters::DebugAdapterName; use db::kvp::KEY_VALUE_STORE; use gpui::{ Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, @@ -20,7 +21,7 @@ use project::debugger::breakpoint_store::ActiveStackFrame; use project::debugger::session::{Session, SessionEvent, StackFrame, ThreadStatus}; use project::{ProjectItem, ProjectPath}; use ui::{Tooltip, WithScrollbar, prelude::*}; -use workspace::{ItemHandle, Workspace}; +use workspace::{ItemHandle, Workspace, WorkspaceId}; use super::RunningState; @@ -58,6 +59,14 @@ impl From for String { } } +pub(crate) fn stack_frame_filter_key( + adapter_name: &DebugAdapterName, + workspace_id: WorkspaceId, +) -> String { + let database_id: i64 = workspace_id.into(); + format!("stack-frame-list-filter-{}-{}", adapter_name.0, database_id) +} + pub struct StackFrameList { focus_handle: FocusHandle, _subscription: Subscription, @@ -97,7 +106,9 @@ impl StackFrameList { SessionEvent::Threads => { this.schedule_refresh(false, window, cx); } - SessionEvent::Stopped(..) | SessionEvent::StackTrace => { + SessionEvent::Stopped(..) + | SessionEvent::StackTrace + | SessionEvent::HistoricSnapshotSelected => { this.schedule_refresh(true, window, cx); } _ => {} @@ -105,14 +116,18 @@ impl StackFrameList { let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); - let list_filter = KEY_VALUE_STORE - .read_kvp(&format!( - "stack-frame-list-filter-{}", - session.read(cx).adapter().0 - )) + let list_filter = workspace + .read_with(cx, |workspace, _| workspace.database_id()) .ok() .flatten() - .map(StackFrameFilter::from_str_or_default) + .and_then(|database_id| { + let key = stack_frame_filter_key(&session.read(cx).adapter(), database_id); + KEY_VALUE_STORE + .read_kvp(&key) + .ok() + .flatten() + .map(StackFrameFilter::from_str_or_default) + }) .unwrap_or(StackFrameFilter::All); let mut this = Self { @@ -225,7 +240,6 @@ impl StackFrameList { } this.update_in(cx, |this, window, cx| { this.build_entries(select_first, window, cx); - cx.notify(); }) .ok(); }) @@ -806,15 +820,8 @@ impl StackFrameList { .ok() .flatten() { - let database_id: i64 = database_id.into(); - let save_task = KEY_VALUE_STORE.write_kvp( - format!( - "stack-frame-list-filter-{}-{}", - self.session.read(cx).adapter().0, - database_id, - ), - self.list_filter.into(), - ); + let key = stack_frame_filter_key(&self.session.read(cx).adapter(), database_id); + let save_task = KEY_VALUE_STORE.write_kvp(key, self.list_filter.into()); cx.background_spawn(save_task).detach(); } diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 1b455b59d7d12712a3d4adc713a6ed15e8166c6e..8329a6baf04061cc33e8130a4e6b3a33b35267b6 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -217,6 +217,12 @@ impl VariableList { let _subscriptions = vec![ cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events), cx.subscribe(&session, |this, _, event, cx| match event { + SessionEvent::HistoricSnapshotSelected => { + this.selection.take(); + this.edited_path.take(); + this.selected_stack_frame_id.take(); + this.build_entries(cx); + } SessionEvent::Stopped(_) => { this.selection.take(); this.edited_path.take(); @@ -225,7 +231,6 @@ impl VariableList { SessionEvent::Variables | SessionEvent::Watchers => { this.build_entries(cx); } - _ => {} }), cx.on_focus_out(&focus_handle, window, |this, _, _, cx| { @@ -524,7 +529,7 @@ impl VariableList { fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { self.edited_path.take(); - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); cx.notify(); } @@ -1062,7 +1067,7 @@ impl VariableList { editor.select_all(&editor::actions::SelectAll, window, cx); editor }); - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); editor } diff --git a/crates/debugger_ui/src/tests/inline_values.rs b/crates/debugger_ui/src/tests/inline_values.rs index 801e6d43623b50d69ea3ce297c274c2d7e5a8b14..379bc4c98f5341b089b5936ed8571da5a6280723 100644 --- a/crates/debugger_ui/src/tests/inline_values.rs +++ b/crates/debugger_ui/src/tests/inline_values.rs @@ -4,7 +4,7 @@ use dap::{Scope, StackFrame, Variable, requests::Variables}; use editor::{Editor, EditorMode, MultiBuffer}; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; use language::{ - Language, LanguageConfig, LanguageMatcher, tree_sitter_python, tree_sitter_rust, + Language, LanguageConfig, LanguageMatcher, rust_lang, tree_sitter_python, tree_sitter_typescript, }; use project::{FakeFs, Project}; @@ -224,7 +224,7 @@ fn main() { .unwrap(); buffer.update(cx, |buffer, cx| { - buffer.set_language(Some(Arc::new(rust_lang())), cx); + buffer.set_language(Some(rust_lang()), cx); }); let (editor, cx) = cx.add_window_view(|window, cx| { @@ -1521,23 +1521,6 @@ fn main() { }); } -fn rust_lang() -> Language { - let debug_variables_query = include_str!("../../../languages/src/rust/debugger.scm"); - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_debug_variables_query(debug_variables_query) - .unwrap() -} - #[gpui::test] async fn test_python_inline_values(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx); @@ -1859,21 +1842,23 @@ fn python_lang() -> Language { .unwrap() } -fn go_lang() -> Language { +fn go_lang() -> Arc { let debug_variables_query = include_str!("../../../languages/src/go/debugger.scm"); - Language::new( - LanguageConfig { - name: "Go".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["go".to_string()], + Arc::new( + Language::new( + LanguageConfig { + name: "Go".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["go".to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - Some(tree_sitter_go::LANGUAGE.into()), + Some(tree_sitter_go::LANGUAGE.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), ) - .with_debug_variables_query(debug_variables_query) - .unwrap() } /// Test utility function for inline values testing @@ -1891,7 +1876,7 @@ async fn test_inline_values_util( before: &str, after: &str, active_debug_line: Option, - language: Language, + language: Arc, executor: BackgroundExecutor, cx: &mut TestAppContext, ) { @@ -2091,7 +2076,7 @@ async fn test_inline_values_util( .unwrap(); buffer.update(cx, |buffer, cx| { - buffer.set_language(Some(Arc::new(language)), cx); + buffer.set_language(Some(language), cx); }); let (editor, cx) = cx.add_window_view(|window, cx| { @@ -2276,55 +2261,61 @@ fn main() { .await; } -fn javascript_lang() -> Language { +fn javascript_lang() -> Arc { let debug_variables_query = include_str!("../../../languages/src/javascript/debugger.scm"); - Language::new( - LanguageConfig { - name: "JavaScript".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["js".to_string()], + Arc::new( + Language::new( + LanguageConfig { + name: "JavaScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["js".to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), ) - .with_debug_variables_query(debug_variables_query) - .unwrap() } -fn typescript_lang() -> Language { +fn typescript_lang() -> Arc { let debug_variables_query = include_str!("../../../languages/src/typescript/debugger.scm"); - Language::new( - LanguageConfig { - name: "TypeScript".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["ts".to_string()], + Arc::new( + Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), ) - .with_debug_variables_query(debug_variables_query) - .unwrap() } -fn tsx_lang() -> Language { +fn tsx_lang() -> Arc { let debug_variables_query = include_str!("../../../languages/src/tsx/debugger.scm"); - Language::new( - LanguageConfig { - name: "TSX".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["tsx".to_string()], + Arc::new( + Language::new( + LanguageConfig { + name: "TSX".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["tsx".to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + Some(tree_sitter_typescript::LANGUAGE_TSX.into()), + ) + .with_debug_variables_query(debug_variables_query) + .unwrap(), ) - .with_debug_variables_query(debug_variables_query) - .unwrap() } #[gpui::test] diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 05e638e2321bb6fcb4504a8bc8c81123f1b09a33..445d5a01d9f062501c889a9d5aa920de6f037914 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1,12 +1,15 @@ use crate::{ debugger_panel::DebugPanel, - session::running::stack_frame_list::{StackFrameEntry, StackFrameFilter}, + session::running::stack_frame_list::{ + StackFrameEntry, StackFrameFilter, stack_frame_filter_key, + }, tests::{active_debug_session_panel, init_test, init_test_workspace, start_debug_session}, }; use dap::{ StackFrame, requests::{Scopes, StackTrace, Threads}, }; +use db::kvp::KEY_VALUE_STORE; use editor::{Editor, ToPoint as _}; use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; use project::{FakeFs, Project}; @@ -1085,3 +1088,180 @@ async fn test_stack_frame_filter(executor: BackgroundExecutor, cx: &mut TestAppC ); }); } + +#[gpui::test] +async fn test_stack_frame_filter_persistence( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": "function main() { console.log('hello'); }", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + workspace + .update(cx, |workspace, _, _| { + workspace.set_random_database_id(); + }) + .unwrap(); + + let threads_response = dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }; + + let stack_trace_response = dap::StackTraceResponse { + stack_frames: vec![StackFrame { + id: 1, + name: "main".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, + }], + total_frames: None, + }; + + let stopped_event = 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, + }; + + let session = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + let adapter_name = session.update(cx, |session, _| session.adapter()); + + client.on_request::({ + let threads_response = threads_response.clone(); + move |_, _| Ok(threads_response.clone()) + }); + + client.on_request::(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] })); + + client.on_request::({ + let stack_trace_response = stack_trace_response.clone(); + move |_, _| Ok(stack_trace_response.clone()) + }); + + client + .fake_event(dap::messages::Events::Stopped(stopped_event.clone())) + .await; + + cx.run_until_parked(); + + let stack_frame_list = + active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()) + }); + + stack_frame_list.update(cx, |stack_frame_list, _cx| { + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::All, + "Initial filter should be All" + ); + }); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list + .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx); + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::OnlyUserFrames, + "Filter should be OnlyUserFrames after toggle" + ); + }); + + cx.run_until_parked(); + + let workspace_id = workspace + .update(cx, |workspace, _window, _cx| workspace.database_id()) + .ok() + .flatten() + .expect("workspace id has to be some for this test to work properly"); + + let key = stack_frame_filter_key(&adapter_name, workspace_id); + let stored_value = KEY_VALUE_STORE.read_kvp(&key).unwrap(); + assert_eq!( + stored_value, + Some(StackFrameFilter::OnlyUserFrames.into()), + "Filter should be persisted in KVP store with key: {}", + key + ); + + client + .fake_event(dap::messages::Events::Terminated(None)) + .await; + cx.run_until_parked(); + + let session2 = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client2 = session2.update(cx, |session, _| session.adapter_client().unwrap()); + + client2.on_request::({ + let threads_response = threads_response.clone(); + move |_, _| Ok(threads_response.clone()) + }); + + client2.on_request::(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] })); + + client2.on_request::({ + let stack_trace_response = stack_trace_response.clone(); + move |_, _| Ok(stack_trace_response.clone()) + }); + + client2 + .fake_event(dap::messages::Events::Stopped(stopped_event.clone())) + .await; + + cx.run_until_parked(); + + let stack_frame_list2 = + active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { + debug_panel_item + .running_state() + .update(cx, |state, _| state.stack_frame_list().clone()) + }); + + stack_frame_list2.update(cx, |stack_frame_list, _cx| { + assert_eq!( + stack_frame_list.list_filter(), + StackFrameFilter::OnlyUserFrames, + "Filter should be restored from KVP store in new session" + ); + }); +} diff --git a/crates/deepseek/src/deepseek.rs b/crates/deepseek/src/deepseek.rs index 64a1cbe5d96354260c2bf84a43ed70be7336aa7a..636258a5a132ce79cb5d15b1aaa25d6e4d3af643 100644 --- a/crates/deepseek/src/deepseek.rs +++ b/crates/deepseek/src/deepseek.rs @@ -103,8 +103,9 @@ impl Model { pub fn max_output_tokens(&self) -> Option { match self { - Self::Chat => Some(8_192), - Self::Reasoner => Some(64_000), + // Their API treats this max against the context window, which means we hit the limit a lot + // Using the default value of None in the API instead + Self::Chat | Self::Reasoner => None, Self::Custom { max_output_tokens, .. } => *max_output_tokens, @@ -155,6 +156,8 @@ pub enum RequestMessage { content: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_content: Option, }, User { content: String, diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index 0fd8783dd514f8da3c53d41dcb6f8e9004ae501c..ba10f6fbdabf05a095a7fed7c6ae682d4dc177c7 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -6,7 +6,7 @@ use crate::{ use anyhow::Result; use collections::HashMap; use editor::{ - Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, multibuffer_context_lines, }; @@ -175,7 +175,7 @@ impl BufferDiagnosticsEditor { // `BufferDiagnosticsEditor` instance. EditorEvent::Focused => { if buffer_diagnostics_editor.multibuffer.read(cx).is_empty() { - window.focus(&buffer_diagnostics_editor.focus_handle); + window.focus(&buffer_diagnostics_editor.focus_handle, cx); } } EditorEvent::Blurred => { @@ -517,7 +517,7 @@ impl BufferDiagnosticsEditor { .editor .read(cx) .focus_handle(cx) - .focus(window); + .focus(window, cx); } } } @@ -617,7 +617,7 @@ impl BufferDiagnosticsEditor { // not empty, focus on the editor instead, which will allow the user to // start interacting and editing the buffer's contents. if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { - self.editor.focus_handle(cx).focus(window) + self.editor.focus_handle(cx).focus(window, cx) } } @@ -701,8 +701,12 @@ impl Item for BufferDiagnosticsEditor { }); } - fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + if EditorSettings::get_global(cx).toolbar.breadcrumbs { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } } fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 72ad7b591413832183bb85d58d188e692d46ffad..521752ff1959fccc12b74857e342ff33a0444f3f 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -315,6 +315,6 @@ impl DiagnosticBlock { editor.change_selections(Default::default(), window, cx, |s| { s.select_ranges([range.start..range.start]); }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } } diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 58babbd251416118947362fae0a47a80cc277695..d85eb07f68619e15bfe44d26282db3a3e49df4f3 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -12,7 +12,7 @@ use buffer_diagnostics::BufferDiagnosticsEditor; use collections::{BTreeSet, HashMap, HashSet}; use diagnostic_renderer::DiagnosticBlock; use editor::{ - Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey, + Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey, display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, multibuffer_context_lines, }; @@ -243,7 +243,7 @@ impl ProjectDiagnosticsEditor { match event { EditorEvent::Focused => { if this.multibuffer.read(cx).is_empty() { - window.focus(&this.focus_handle); + window.focus(&this.focus_handle, cx); } } EditorEvent::Blurred => this.close_diagnosticless_buffers(cx, false), @@ -434,7 +434,7 @@ impl ProjectDiagnosticsEditor { fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { - self.editor.focus_handle(cx).focus(window) + self.editor.focus_handle(cx).focus(window, cx) } } @@ -650,7 +650,7 @@ impl ProjectDiagnosticsEditor { }) }); if this.focus_handle.is_focused(window) { - this.editor.read(cx).focus_handle(cx).focus(window); + this.editor.read(cx).focus_handle(cx).focus(window, cx); } } @@ -894,8 +894,12 @@ impl Item for ProjectDiagnosticsEditor { Some(Box::new(self.editor.clone())) } - fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft + fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { + if EditorSettings::get_global(cx).toolbar.breadcrumbs { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } } fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option> { @@ -1045,54 +1049,47 @@ async fn heuristic_syntactic_expand( let node_range = node_start..node_end; let row_count = node_end.row - node_start.row + 1; let mut ancestor_range = None; - let reached_outline_node = cx.background_executor().scoped({ - let node_range = node_range.clone(); - let outline_range = outline_range.clone(); - let ancestor_range = &mut ancestor_range; - |scope| { - scope.spawn(async move { - // Stop if we've exceeded the row count or reached an outline node. Then, find the interval - // of node children which contains the query range. For example, this allows just returning - // the header of a declaration rather than the entire declaration. - if row_count > max_row_count || outline_range == Some(node_range.clone()) { - let mut cursor = node.walk(); - let mut included_child_start = None; - let mut included_child_end = None; - let mut previous_end = node_start; - if cursor.goto_first_child() { - loop { - let child_node = cursor.node(); - let child_range = - previous_end..Point::from_ts_point(child_node.end_position()); - if included_child_start.is_none() - && child_range.contains(&input_range.start) - { - included_child_start = Some(child_range.start); - } - if child_range.contains(&input_range.end) { - included_child_end = Some(child_range.end); - } - previous_end = child_range.end; - if !cursor.goto_next_sibling() { - break; - } + cx.background_executor() + .await_on_background(async { + // Stop if we've exceeded the row count or reached an outline node. Then, find the interval + // of node children which contains the query range. For example, this allows just returning + // the header of a declaration rather than the entire declaration. + if row_count > max_row_count || outline_range == Some(node_range.clone()) { + let mut cursor = node.walk(); + let mut included_child_start = None; + let mut included_child_end = None; + let mut previous_end = node_start; + if cursor.goto_first_child() { + loop { + let child_node = cursor.node(); + let child_range = + previous_end..Point::from_ts_point(child_node.end_position()); + if included_child_start.is_none() + && child_range.contains(&input_range.start) + { + included_child_start = Some(child_range.start); } - } - let end = included_child_end.unwrap_or(node_range.end); - if let Some(start) = included_child_start { - let row_count = end.row - start.row; - if row_count < max_row_count { - *ancestor_range = - Some(Some(RangeInclusive::new(start.row, end.row))); - return; + if child_range.contains(&input_range.end) { + included_child_end = Some(child_range.end); + } + previous_end = child_range.end; + if !cursor.goto_next_sibling() { + break; } } - *ancestor_range = Some(None); } - }) - } - }); - reached_outline_node.await; + let end = included_child_end.unwrap_or(node_range.end); + if let Some(start) = included_child_start { + let row_count = end.row - start.row; + if row_count < max_row_count { + ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row))); + return; + } + } + ancestor_range = Some(None); + } + }) + .await; if let Some(node) = ancestor_range { return node; } diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index e71f9ae3f3f6fcff790db27fb1e377f0d1c20e40..07da23899956822f7577118ae85b6338b4cefae7 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -7,8 +7,6 @@ license = "GPL-3.0-or-later" [dependencies] anyhow.workspace = true -command_palette.workspace = true -gpui.workspace = true # We are specifically pinning this version of mdbook, as later versions introduce issues with double-nested subdirectories. # Ask @maxdeviant about this before bumping. mdbook = "= 0.4.40" @@ -17,7 +15,6 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true util.workspace = true -zed.workspace = true zlog.workspace = true task.workspace = true theme.workspace = true @@ -27,4 +24,4 @@ workspace = true [[bin]] name = "docs_preprocessor" -path = "src/main.rs" +path = "src/main.rs" \ No newline at end of file diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index b614a8251139413f4b316937db1d4e3c0d551df6..d90dcc10db9fbd8d27a968094ea8d733a79b7e80 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -22,16 +22,13 @@ static KEYMAP_WINDOWS: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap") }); -static ALL_ACTIONS: LazyLock> = LazyLock::new(dump_all_gpui_actions); +static ALL_ACTIONS: LazyLock> = LazyLock::new(load_all_actions); const FRONT_MATTER_COMMENT: &str = ""; fn main() -> Result<()> { zlog::init(); zlog::init_output_stderr(); - // call a zed:: function so everything in `zed` crate is linked and - // all actions in the actual app are registered - zed::stdout_is_a_pty(); let args = std::env::args().skip(1).collect::>(); match args.get(0).map(String::as_str) { @@ -72,8 +69,8 @@ enum PreprocessorError { impl PreprocessorError { fn new_for_not_found_action(action_name: String) -> Self { for action in &*ALL_ACTIONS { - for alias in action.deprecated_aliases { - if alias == &action_name { + for alias in &action.deprecated_aliases { + if alias == action_name.as_str() { return PreprocessorError::DeprecatedActionUsed { used: action_name, should_be: action.name.to_string(), @@ -214,7 +211,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet{}", name); }; format!("{}", &action.human_name) }) @@ -257,11 +256,19 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet Option<&ActionDef> { ALL_ACTIONS - .binary_search_by(|action| action.name.cmp(name)) + .binary_search_by(|action| action.name.as_str().cmp(name)) .ok() .map(|index| &ALL_ACTIONS[index]) } +fn actions_available() -> bool { + !ALL_ACTIONS.is_empty() +} + +fn is_missing_action(name: &str) -> bool { + actions_available() && find_action_by_name(name).is_none() +} + fn find_binding(os: &str, action: &str) -> Option { let keymap = match os { "macos" => &KEYMAP_MACOS, @@ -384,18 +391,13 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
, _>>()
-                            .context("Failed to parse keystroke")?;
+                    for (_keystrokes, action) in section.bindings() {
                         if let Some((action_name, _)) = settings::KeymapFile::parse_action(action)
                             .map_err(|err| anyhow::format_err!(err))
                             .context("Failed to parse action")?
                         {
                             anyhow::ensure!(
-                                find_action_by_name(action_name).is_some(),
+                                !is_missing_action(action_name),
                                 "Action not found: {}",
                                 action_name
                             );
@@ -491,27 +493,35 @@ where
     });
 }
 
-#[derive(Debug, serde::Serialize)]
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
 struct ActionDef {
-    name: &'static str,
+    name: String,
     human_name: String,
-    deprecated_aliases: &'static [&'static str],
-    docs: Option<&'static str>,
+    deprecated_aliases: Vec,
+    #[serde(rename = "documentation")]
+    docs: Option,
 }
 
-fn dump_all_gpui_actions() -> Vec {
-    let mut actions = gpui::generate_list_of_all_registered_actions()
-        .map(|action| ActionDef {
-            name: action.name,
-            human_name: command_palette::humanize_action_name(action.name),
-            deprecated_aliases: action.deprecated_aliases,
-            docs: action.documentation,
-        })
-        .collect::>();
-
-    actions.sort_by_key(|a| a.name);
-
-    actions
+fn load_all_actions() -> Vec {
+    let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
+    match std::fs::read_to_string(asset_path) {
+        Ok(content) => {
+            let mut actions: Vec =
+                serde_json::from_str(&content).expect("Failed to parse actions.json");
+            actions.sort_by(|a, b| a.name.cmp(&b.name));
+            actions
+        }
+        Err(err) => {
+            if std::env::var("CI").is_ok() {
+                panic!("actions.json not found at {}: {}", asset_path, err);
+            }
+            eprintln!(
+                "Warning: actions.json not found, action validation will be skipped: {}",
+                err
+            );
+            Vec::new()
+        }
+    }
 }
 
 fn handle_postprocessing() -> Result<()> {
@@ -647,7 +657,7 @@ fn generate_big_table_of_actions() -> String {
     let mut output = String::new();
 
     let mut actions_sorted = actions.iter().collect::>();
-    actions_sorted.sort_by_key(|a| a.name);
+    actions_sorted.sort_by_key(|a| a.name.as_str());
 
     // Start the definition list with custom styling for better spacing
     output.push_str("
\n"); @@ -664,7 +674,7 @@ fn generate_big_table_of_actions() -> String { output.push_str("
\n"); // Add the description, escaping HTML if needed - if let Some(description) = action.docs { + if let Some(description) = action.docs.as_ref() { output.push_str( &description .replace("&", "&") @@ -674,7 +684,7 @@ fn generate_big_table_of_actions() -> String { output.push_str("
\n"); } output.push_str("Keymap Name: "); - output.push_str(action.name); + output.push_str(&action.name); output.push_str("
\n"); if !action.deprecated_aliases.is_empty() { output.push_str("Deprecated Alias(es): "); diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 2c6888d14be49c857e7805fb63f9f9335ac32c8e..5145777ff0733d674f33a597db6682e611e4d0fc 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -11,7 +11,70 @@ workspace = true [lib] path = "src/edit_prediction.rs" +[features] +cli-support = [] + [dependencies] +ai_onboarding.workspace = true +anyhow.workspace = true +arrayvec.workspace = true +brotli.workspace = true +buffer_diff.workspace = true client.workspace = true +cloud_llm_client.workspace = true +collections.workspace = true +copilot.workspace = true +db.workspace = true +edit_prediction_types.workspace = true +edit_prediction_context.workspace = true +feature_flags.workspace = true +fs.workspace = true +futures.workspace = true gpui.workspace = true +indoc.workspace = true +itertools.workspace = true language.workspace = true +language_model.workspace = true +log.workspace = true +lsp.workspace = true +menu.workspace = true +open_ai.workspace = true +postage.workspace = true +pretty_assertions.workspace = true +project.workspace = true +pulldown-cmark.workspace = true +rand.workspace = true +regex.workspace = true +release_channel.workspace = true +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +strum.workspace = true +telemetry.workspace = true +telemetry_events.workspace = true +text.workspace = true +thiserror.workspace = true +time.workspace = true +ui.workspace = true +util.workspace = true +uuid.workspace = true +workspace.workspace = true +worktree.workspace = true +zed_actions.workspace = true +zeta_prompt.workspace = true + +[dev-dependencies] +clock = { workspace = true, features = ["test-support"] } +cloud_api_types.workspace = true +cloud_llm_client = { workspace = true, features = ["test-support"] } +ctor.workspace = true +gpui = { workspace = true, features = ["test-support"] } +indoc.workspace = true +language = { workspace = true, features = ["test-support"] } +language_model = { workspace = true, features = ["test-support"] } +lsp.workspace = true +parking_lot.workspace = true +project = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/zeta/license_examples/0bsd.txt b/crates/edit_prediction/license_examples/0bsd.txt similarity index 100% rename from crates/zeta/license_examples/0bsd.txt rename to crates/edit_prediction/license_examples/0bsd.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex0.txt b/crates/edit_prediction/license_examples/apache-2.0-ex0.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex0.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex0.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex1.txt b/crates/edit_prediction/license_examples/apache-2.0-ex1.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex1.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex1.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex2.txt b/crates/edit_prediction/license_examples/apache-2.0-ex2.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex2.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex2.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex3.txt b/crates/edit_prediction/license_examples/apache-2.0-ex3.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex3.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex3.txt diff --git a/crates/zeta/license_examples/apache-2.0-ex4.txt b/crates/edit_prediction/license_examples/apache-2.0-ex4.txt similarity index 100% rename from crates/zeta/license_examples/apache-2.0-ex4.txt rename to crates/edit_prediction/license_examples/apache-2.0-ex4.txt diff --git a/crates/zeta/license_examples/bsd-1-clause.txt b/crates/edit_prediction/license_examples/bsd-1-clause.txt similarity index 100% rename from crates/zeta/license_examples/bsd-1-clause.txt rename to crates/edit_prediction/license_examples/bsd-1-clause.txt diff --git a/crates/zeta/license_examples/bsd-2-clause-ex0.txt b/crates/edit_prediction/license_examples/bsd-2-clause-ex0.txt similarity index 100% rename from crates/zeta/license_examples/bsd-2-clause-ex0.txt rename to crates/edit_prediction/license_examples/bsd-2-clause-ex0.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex0.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex0.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex0.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex0.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex1.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex1.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex1.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex1.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex2.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex2.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex2.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex2.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex3.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex3.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex3.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex3.txt diff --git a/crates/zeta/license_examples/bsd-3-clause-ex4.txt b/crates/edit_prediction/license_examples/bsd-3-clause-ex4.txt similarity index 100% rename from crates/zeta/license_examples/bsd-3-clause-ex4.txt rename to crates/edit_prediction/license_examples/bsd-3-clause-ex4.txt diff --git a/crates/zeta/license_examples/isc.txt b/crates/edit_prediction/license_examples/isc.txt similarity index 100% rename from crates/zeta/license_examples/isc.txt rename to crates/edit_prediction/license_examples/isc.txt diff --git a/crates/zeta/license_examples/mit-ex0.txt b/crates/edit_prediction/license_examples/mit-ex0.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex0.txt rename to crates/edit_prediction/license_examples/mit-ex0.txt diff --git a/crates/zeta/license_examples/mit-ex1.txt b/crates/edit_prediction/license_examples/mit-ex1.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex1.txt rename to crates/edit_prediction/license_examples/mit-ex1.txt diff --git a/crates/zeta/license_examples/mit-ex2.txt b/crates/edit_prediction/license_examples/mit-ex2.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex2.txt rename to crates/edit_prediction/license_examples/mit-ex2.txt diff --git a/crates/zeta/license_examples/mit-ex3.txt b/crates/edit_prediction/license_examples/mit-ex3.txt similarity index 100% rename from crates/zeta/license_examples/mit-ex3.txt rename to crates/edit_prediction/license_examples/mit-ex3.txt diff --git a/crates/zeta/license_examples/upl-1.0.txt b/crates/edit_prediction/license_examples/upl-1.0.txt similarity index 100% rename from crates/zeta/license_examples/upl-1.0.txt rename to crates/edit_prediction/license_examples/upl-1.0.txt diff --git a/crates/zeta/license_examples/zlib-ex0.txt b/crates/edit_prediction/license_examples/zlib-ex0.txt similarity index 100% rename from crates/zeta/license_examples/zlib-ex0.txt rename to crates/edit_prediction/license_examples/zlib-ex0.txt diff --git a/crates/zeta/license_patterns/0bsd-pattern b/crates/edit_prediction/license_patterns/0bsd-pattern similarity index 100% rename from crates/zeta/license_patterns/0bsd-pattern rename to crates/edit_prediction/license_patterns/0bsd-pattern diff --git a/crates/zeta/license_patterns/apache-2.0-pattern b/crates/edit_prediction/license_patterns/apache-2.0-pattern similarity index 100% rename from crates/zeta/license_patterns/apache-2.0-pattern rename to crates/edit_prediction/license_patterns/apache-2.0-pattern diff --git a/crates/zeta/license_patterns/apache-2.0-reference-pattern b/crates/edit_prediction/license_patterns/apache-2.0-reference-pattern similarity index 100% rename from crates/zeta/license_patterns/apache-2.0-reference-pattern rename to crates/edit_prediction/license_patterns/apache-2.0-reference-pattern diff --git a/crates/zeta/license_patterns/bsd-pattern b/crates/edit_prediction/license_patterns/bsd-pattern similarity index 100% rename from crates/zeta/license_patterns/bsd-pattern rename to crates/edit_prediction/license_patterns/bsd-pattern diff --git a/crates/zeta/license_patterns/isc-pattern b/crates/edit_prediction/license_patterns/isc-pattern similarity index 100% rename from crates/zeta/license_patterns/isc-pattern rename to crates/edit_prediction/license_patterns/isc-pattern diff --git a/crates/zeta/license_patterns/mit-pattern b/crates/edit_prediction/license_patterns/mit-pattern similarity index 100% rename from crates/zeta/license_patterns/mit-pattern rename to crates/edit_prediction/license_patterns/mit-pattern diff --git a/crates/zeta/license_patterns/upl-1.0-pattern b/crates/edit_prediction/license_patterns/upl-1.0-pattern similarity index 100% rename from crates/zeta/license_patterns/upl-1.0-pattern rename to crates/edit_prediction/license_patterns/upl-1.0-pattern diff --git a/crates/zeta/license_patterns/zlib-pattern b/crates/edit_prediction/license_patterns/zlib-pattern similarity index 100% rename from crates/zeta/license_patterns/zlib-pattern rename to crates/edit_prediction/license_patterns/zlib-pattern diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs new file mode 100644 index 0000000000000000000000000000000000000000..0171b711dd24384e1588c076d4b4f678723d7459 --- /dev/null +++ b/crates/edit_prediction/src/capture_example.rs @@ -0,0 +1,375 @@ +use crate::{ + EditPredictionStore, StoredEvent, + cursor_excerpt::editable_and_context_ranges_for_cursor_position, example_spec::ExampleSpec, +}; +use anyhow::Result; +use buffer_diff::BufferDiffSnapshot; +use collections::HashMap; +use gpui::{App, Entity, Task}; +use language::{Buffer, ToPoint as _}; +use project::Project; +use std::{collections::hash_map, fmt::Write as _, path::Path, sync::Arc}; +use text::{BufferSnapshot as TextBufferSnapshot, ToOffset as _}; + +pub fn capture_example( + project: Entity, + buffer: Entity, + cursor_anchor: language::Anchor, + last_event_is_expected_patch: bool, + cx: &mut App, +) -> Option>> { + let ep_store = EditPredictionStore::try_global(cx)?; + let snapshot = buffer.read(cx).snapshot(); + let file = snapshot.file()?; + let worktree_id = file.worktree_id(cx); + let repository = project.read(cx).active_repository(cx)?; + let repository_snapshot = repository.read(cx).snapshot(); + let worktree = project.read(cx).worktree_for_id(worktree_id, cx)?; + let cursor_path = worktree.read(cx).root_name().join(file.path()); + if worktree.read(cx).abs_path() != repository_snapshot.work_directory_abs_path { + return None; + } + + let repository_url = repository_snapshot + .remote_origin_url + .clone() + .or_else(|| repository_snapshot.remote_upstream_url.clone())?; + let revision = repository_snapshot.head_commit.as_ref()?.sha.to_string(); + + let mut events = ep_store.update(cx, |store, cx| { + store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + + let git_store = project.read(cx).git_store().clone(); + + Some(cx.spawn(async move |mut cx| { + let snapshots_by_path = collect_snapshots(&project, &git_store, &events, &mut cx).await?; + let cursor_excerpt = cx + .background_executor() + .spawn(async move { compute_cursor_excerpt(&snapshot, cursor_anchor) }) + .await; + let uncommitted_diff = cx + .background_executor() + .spawn(async move { compute_uncommitted_diff(snapshots_by_path) }) + .await; + + let mut edit_history = String::new(); + let mut expected_patch = String::new(); + if last_event_is_expected_patch { + if let Some(stored_event) = events.pop() { + zeta_prompt::write_event(&mut expected_patch, &stored_event.event); + } + } + + for stored_event in &events { + zeta_prompt::write_event(&mut edit_history, &stored_event.event); + if !edit_history.ends_with('\n') { + edit_history.push('\n'); + } + } + + let name = generate_timestamp_name(); + + Ok(ExampleSpec { + name, + repository_url, + revision, + uncommitted_diff, + cursor_path: cursor_path.as_std_path().into(), + cursor_position: cursor_excerpt, + edit_history, + expected_patch, + }) + })) +} + +fn compute_cursor_excerpt( + snapshot: &language::BufferSnapshot, + cursor_anchor: language::Anchor, +) -> String { + let cursor_point = cursor_anchor.to_point(snapshot); + let (_editable_range, context_range) = + editable_and_context_ranges_for_cursor_position(cursor_point, snapshot, 100, 50); + + let context_start_offset = context_range.start.to_offset(snapshot); + let cursor_offset = cursor_anchor.to_offset(snapshot); + let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset); + let mut excerpt = snapshot.text_for_range(context_range).collect::(); + if cursor_offset_in_excerpt <= excerpt.len() { + excerpt.insert_str(cursor_offset_in_excerpt, zeta_prompt::CURSOR_MARKER); + } + excerpt +} + +async fn collect_snapshots( + project: &Entity, + git_store: &Entity, + events: &[StoredEvent], + cx: &mut gpui::AsyncApp, +) -> Result, (TextBufferSnapshot, BufferDiffSnapshot)>> { + let mut snapshots_by_path = HashMap::default(); + for stored_event in events { + let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref(); + if let Some((project_path, full_path)) = project.read_with(cx, |project, cx| { + let project_path = project.find_project_path(path, cx)?; + let full_path = project + .worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .root_name() + .join(&project_path.path) + .as_std_path() + .into(); + Some((project_path, full_path)) + })? { + if let hash_map::Entry::Vacant(entry) = snapshots_by_path.entry(full_path) { + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + })? + .await?; + let diff = git_store + .update(cx, |git_store, cx| { + git_store.open_uncommitted_diff(buffer.clone(), cx) + })? + .await?; + let diff_snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx))?; + entry.insert((stored_event.old_snapshot.clone(), diff_snapshot)); + } + } + } + Ok(snapshots_by_path) +} + +fn compute_uncommitted_diff( + snapshots_by_path: HashMap, (TextBufferSnapshot, BufferDiffSnapshot)>, +) -> String { + let mut uncommitted_diff = String::new(); + for (full_path, (before_text, diff_snapshot)) in snapshots_by_path { + if let Some(head_text) = &diff_snapshot.base_text_string() { + let file_diff = language::unified_diff(head_text, &before_text.text()); + if !file_diff.is_empty() { + let path_str = full_path.to_string_lossy(); + writeln!(uncommitted_diff, "--- a/{path_str}").ok(); + writeln!(uncommitted_diff, "+++ b/{path_str}").ok(); + uncommitted_diff.push_str(&file_diff); + if !uncommitted_diff.ends_with('\n') { + uncommitted_diff.push('\n'); + } + } + } + } + uncommitted_diff +} + +fn generate_timestamp_name() -> String { + let format = time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]"); + match format { + Ok(format) => { + let now = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + now.format(&format) + .unwrap_or_else(|_| "unknown-time".to_string()) + } + Err(_) => "unknown-time".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use client::{Client, UserStore}; + use clock::FakeSystemClock; + use gpui::{AppContext as _, TestAppContext, http_client::FakeHttpClient}; + use indoc::indoc; + use language::{Anchor, Point}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use std::path::Path; + + #[gpui::test] + async fn test_capture_example(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let committed_contents = indoc! {" + fn main() { + one(); + two(); + three(); + four(); + five(); + six(); + seven(); + eight(); + nine(); + } + "}; + + let disk_contents = indoc! {" + fn main() { + // comment 1 + one(); + two(); + three(); + four(); + five(); + six(); + seven(); + eight(); + // comment 2 + nine(); + } + "}; + + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": disk_contents, + } + }), + ) + .await; + + fs.set_head_for_repo( + Path::new("/project/.git"), + &[("src/main.rs", committed_contents.to_string())], + "abc123def456", + ); + fs.set_remote_for_repo( + Path::new("/project/.git"), + "origin", + "https://github.com/test/repo.git", + ); + + let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/project/src/main.rs", cx) + }) + .await + .unwrap(); + + let ep_store = cx.read(|cx| EditPredictionStore::try_global(cx).unwrap()); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.run_until_parked(); + + buffer.update(cx, |buffer, cx| { + let point = Point::new(6, 0); + buffer.edit([(point..point, " // comment 3\n")], None, cx); + let point = Point::new(4, 0); + buffer.edit([(point..point, " // comment 4\n")], None, cx); + + pretty_assertions::assert_eq!( + buffer.text(), + indoc! {" + fn main() { + // comment 1 + one(); + two(); + // comment 4 + three(); + four(); + // comment 3 + five(); + six(); + seven(); + eight(); + // comment 2 + nine(); + } + "} + ); + }); + cx.run_until_parked(); + + let mut example = cx + .update(|cx| { + capture_example(project.clone(), buffer.clone(), Anchor::MIN, false, cx).unwrap() + }) + .await + .unwrap(); + example.name = "test".to_string(); + + pretty_assertions::assert_eq!( + example, + ExampleSpec { + name: "test".to_string(), + repository_url: "https://github.com/test/repo.git".to_string(), + revision: "abc123def456".to_string(), + uncommitted_diff: indoc! {" + --- a/project/src/main.rs + +++ b/project/src/main.rs + @@ -1,4 +1,5 @@ + fn main() { + + // comment 1 + one(); + two(); + three(); + @@ -7,5 +8,6 @@ + six(); + seven(); + eight(); + + // comment 2 + nine(); + } + "} + .to_string(), + cursor_path: Path::new("project/src/main.rs").into(), + cursor_position: indoc! {" + <|user_cursor|>fn main() { + // comment 1 + one(); + two(); + // comment 4 + three(); + four(); + // comment 3 + five(); + six(); + seven(); + eight(); + // comment 2 + nine(); + } + "} + .to_string(), + edit_history: indoc! {" + --- a/project/src/main.rs + +++ b/project/src/main.rs + @@ -2,8 +2,10 @@ + // comment 1 + one(); + two(); + + // comment 4 + three(); + four(); + + // comment 3 + five(); + six(); + seven(); + "} + .to_string(), + expected_patch: "".to_string(), + } + ); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + zlog::init_test(); + let http_client = FakeHttpClient::with_404_response(); + let client = Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); + language_model::init(client.clone(), cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + EditPredictionStore::global(&client, &user_store, cx); + }) + } +} diff --git a/crates/edit_prediction/src/cursor_excerpt.rs b/crates/edit_prediction/src/cursor_excerpt.rs new file mode 100644 index 0000000000000000000000000000000000000000..1f2f1d32ebcb2eaa151433bd49d275e0e2a3b817 --- /dev/null +++ b/crates/edit_prediction/src/cursor_excerpt.rs @@ -0,0 +1,78 @@ +use language::{BufferSnapshot, Point}; +use std::ops::Range; + +pub fn editable_and_context_ranges_for_cursor_position( + position: Point, + snapshot: &BufferSnapshot, + editable_region_token_limit: usize, + context_token_limit: usize, +) -> (Range, Range) { + let mut scope_range = position..position; + let mut remaining_edit_tokens = editable_region_token_limit; + + while let Some(parent) = snapshot.syntax_ancestor(scope_range.clone()) { + let parent_tokens = guess_token_count(parent.byte_range().len()); + let parent_point_range = Point::new( + parent.start_position().row as u32, + parent.start_position().column as u32, + ) + ..Point::new( + parent.end_position().row as u32, + parent.end_position().column as u32, + ); + if parent_point_range == scope_range { + break; + } else if parent_tokens <= editable_region_token_limit { + scope_range = parent_point_range; + remaining_edit_tokens = editable_region_token_limit - parent_tokens; + } else { + break; + } + } + + let editable_range = expand_range(snapshot, scope_range, remaining_edit_tokens); + let context_range = expand_range(snapshot, editable_range.clone(), context_token_limit); + (editable_range, context_range) +} + +fn expand_range( + snapshot: &BufferSnapshot, + range: Range, + mut remaining_tokens: usize, +) -> Range { + let mut expanded_range = range; + expanded_range.start.column = 0; + expanded_range.end.column = snapshot.line_len(expanded_range.end.row); + loop { + let mut expanded = false; + + if remaining_tokens > 0 && expanded_range.start.row > 0 { + expanded_range.start.row -= 1; + let line_tokens = + guess_token_count(snapshot.line_len(expanded_range.start.row) as usize); + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + + if remaining_tokens > 0 && expanded_range.end.row < snapshot.max_point().row { + expanded_range.end.row += 1; + expanded_range.end.column = snapshot.line_len(expanded_range.end.row); + let line_tokens = guess_token_count(expanded_range.end.column as usize); + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + + if !expanded { + break; + } + } + expanded_range +} + +/// Typical number of string bytes per token for the purposes of limiting model input. This is +/// intentionally low to err on the side of underestimating limits. +pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3; + +pub fn guess_token_count(bytes: usize) -> usize { + bytes / BYTES_PER_TOKEN_GUESS +} diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 1984383a9691ae9373973a3eb9f00db4e7e795f2..c47c1a70e843a17cc98083dec664c7fc05933426 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,298 +1,2192 @@ -use std::{ops::Range, sync::Arc}; +use anyhow::Result; +use arrayvec::ArrayVec; +use client::{Client, EditPredictionUsage, UserStore}; +use cloud_llm_client::predict_edits_v3::{self, PromptFormat}; +use cloud_llm_client::{ + AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, EditPredictionRejectReason, + EditPredictionRejection, MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST, + MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsRequestTrigger, RejectEditPredictionsBodyRef, + ZED_VERSION_HEADER_NAME, +}; +use collections::{HashMap, HashSet}; +use db::kvp::{Dismissable, KEY_VALUE_STORE}; +use edit_prediction_context::EditPredictionExcerptOptions; +use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use futures::{ + AsyncReadExt as _, FutureExt as _, StreamExt as _, + channel::mpsc::{self, UnboundedReceiver}, + select_biased, +}; +use gpui::BackgroundExecutor; +use gpui::http_client::Url; +use gpui::{ + App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, + http_client::{self, AsyncBody, Method}, + prelude::*, +}; +use language::language_settings::all_language_settings; +use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToPoint}; +use language::{BufferSnapshot, OffsetRangeExt}; +use language_model::{LlmApiToken, RefreshLlmTokenListener}; +use project::{Project, ProjectPath, WorktreeId}; +use release_channel::AppVersion; +use semver::Version; +use serde::de::DeserializeOwned; +use settings::{EditPredictionProvider, SettingsStore, update_settings_file}; +use std::collections::{VecDeque, hash_map}; +use text::Edit; +use workspace::Workspace; -use client::EditPredictionUsage; -use gpui::{App, Context, Entity, SharedString}; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt}; +use std::ops::Range; +use std::path::Path; +use std::rc::Rc; +use std::str::FromStr as _; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; +use std::{env, mem}; +use thiserror::Error; +use util::{RangeExt as _, ResultExt as _}; +use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; -// TODO: Find a better home for `Direction`. -// -// This should live in an ancestor crate of `editor` and `edit_prediction`, -// but at time of writing there isn't an obvious spot. -#[derive(Copy, Clone, PartialEq, Eq)] -pub enum Direction { - Prev, - Next, +pub mod cursor_excerpt; +pub mod example_spec; +mod license_detection; +pub mod mercury; +mod onboarding_modal; +pub mod open_ai_response; +mod prediction; +pub mod sweep_ai; + +pub mod udiff; + +mod capture_example; +mod zed_edit_prediction_delegate; +pub mod zeta1; +pub mod zeta2; + +#[cfg(test)] +mod edit_prediction_tests; + +use crate::license_detection::LicenseDetectionWatcher; +use crate::mercury::Mercury; +use crate::onboarding_modal::ZedPredictModal; +pub use crate::prediction::EditPrediction; +pub use crate::prediction::EditPredictionId; +use crate::prediction::EditPredictionResult; +pub use crate::sweep_ai::SweepAi; +pub use capture_example::capture_example; +pub use language_model::ApiKeyState; +pub use telemetry_events::EditPredictionRating; +pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate; + +actions!( + edit_prediction, + [ + /// Resets the edit prediction onboarding state. + ResetOnboarding, + /// Clears the edit prediction history. + ClearHistory, + ] +); + +/// Maximum number of events to track. +const EVENT_COUNT_MAX: usize = 6; +const CHANGE_GROUPING_LINE_SPAN: u32 = 8; +const LAST_CHANGE_GROUPING_TIME: Duration = Duration::from_secs(1); +const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; +const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); + +pub struct SweepFeatureFlag; + +impl FeatureFlag for SweepFeatureFlag { + const NAME: &str = "sweep-ai"; } -#[derive(Clone)] -pub enum EditPrediction { - /// Edits within the buffer that requested the prediction - Local { - id: Option, - edits: Vec<(Range, Arc)>, - edit_preview: Option, - }, - /// Jump to a different file from the one that requested the prediction - Jump { - id: Option, - snapshot: language::BufferSnapshot, - target: language::Anchor, +pub struct MercuryFeatureFlag; + +impl FeatureFlag for MercuryFeatureFlag { + const NAME: &str = "mercury"; +} + +pub const DEFAULT_OPTIONS: ZetaOptions = ZetaOptions { + context: EditPredictionExcerptOptions { + max_bytes: 512, + min_bytes: 128, + target_before_cursor_over_total_bytes: 0.5, }, + prompt_format: PromptFormat::DEFAULT, +}; + +static USE_OLLAMA: LazyLock = + LazyLock::new(|| env::var("ZED_ZETA2_OLLAMA").is_ok_and(|var| !var.is_empty())); + +static EDIT_PREDICTIONS_MODEL_ID: LazyLock = LazyLock::new(|| { + match env::var("ZED_ZETA2_MODEL").as_deref() { + Ok("zeta2-exp") => "4w5n28vw", // Fine-tuned model @ Baseten + Ok(model) => model, + Err(_) if *USE_OLLAMA => "qwen3-coder:30b", + Err(_) => "yqvev8r3", // Vanilla qwen3-coder @ Baseten + } + .to_string() +}); + +pub struct Zeta2FeatureFlag; + +impl FeatureFlag for Zeta2FeatureFlag { + const NAME: &'static str = "zeta2"; + + fn enabled_for_staff() -> bool { + true + } +} + +#[derive(Clone)] +struct EditPredictionStoreGlobal(Entity); + +impl Global for EditPredictionStoreGlobal {} + +pub struct EditPredictionStore { + client: Arc, + user_store: Entity, + llm_token: LlmApiToken, + _llm_token_subscription: Subscription, + projects: HashMap, + use_context: bool, + options: ZetaOptions, + update_required: bool, + #[cfg(feature = "cli-support")] + eval_cache: Option>, + edit_prediction_model: EditPredictionModel, + pub sweep_ai: SweepAi, + pub mercury: Mercury, + data_collection_choice: DataCollectionChoice, + reject_predictions_tx: mpsc::UnboundedSender, + shown_predictions: VecDeque, + rated_predictions: HashSet, + custom_predict_edits_url: Option>, +} + +#[derive(Copy, Clone, Default, PartialEq, Eq)] +pub enum EditPredictionModel { + #[default] + Zeta1, + Zeta2, + Sweep, + Mercury, +} + +pub struct EditPredictionModelInput { + project: Entity, + buffer: Entity, + snapshot: BufferSnapshot, + position: Anchor, + events: Vec>, + related_files: Arc<[RelatedFile]>, + recent_paths: VecDeque, + trigger: PredictEditsRequestTrigger, + diagnostic_search_range: Range, + debug_tx: Option>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ZetaOptions { + pub context: EditPredictionExcerptOptions, + pub prompt_format: predict_edits_v3::PromptFormat, } -pub enum DataCollectionState { - /// The provider doesn't support data collection. - Unsupported, - /// Data collection is enabled. - Enabled { is_project_open_source: bool }, - /// Data collection is disabled or unanswered. - Disabled { is_project_open_source: bool }, +#[derive(Debug)] +pub enum DebugEvent { + ContextRetrievalStarted(ContextRetrievalStartedDebugEvent), + ContextRetrievalFinished(ContextRetrievalFinishedDebugEvent), + EditPredictionStarted(EditPredictionStartedDebugEvent), + EditPredictionFinished(EditPredictionFinishedDebugEvent), } -impl DataCollectionState { - pub fn is_supported(&self) -> bool { - !matches!(self, DataCollectionState::Unsupported) +#[derive(Debug)] +pub struct ContextRetrievalStartedDebugEvent { + pub project_entity_id: EntityId, + pub timestamp: Instant, + pub search_prompt: String, +} + +#[derive(Debug)] +pub struct ContextRetrievalFinishedDebugEvent { + pub project_entity_id: EntityId, + pub timestamp: Instant, + pub metadata: Vec<(&'static str, SharedString)>, +} + +#[derive(Debug)] +pub struct EditPredictionStartedDebugEvent { + pub buffer: WeakEntity, + pub position: Anchor, + pub prompt: Option, +} + +#[derive(Debug)] +pub struct EditPredictionFinishedDebugEvent { + pub buffer: WeakEntity, + pub position: Anchor, + pub model_output: Option, +} + +pub type RequestDebugInfo = predict_edits_v3::DebugInfo; + +/// An event with associated metadata for reconstructing buffer state. +#[derive(Clone)] +pub struct StoredEvent { + pub event: Arc, + pub old_snapshot: TextBufferSnapshot, +} + +struct ProjectState { + events: VecDeque, + last_event: Option, + recent_paths: VecDeque, + registered_buffers: HashMap, + current_prediction: Option, + next_pending_prediction_id: usize, + pending_predictions: ArrayVec, + debug_tx: Option>, + last_prediction_refresh: Option<(EntityId, Instant)>, + cancelled_predictions: HashSet, + context: Entity, + license_detection_watchers: HashMap>, + _subscription: gpui::Subscription, +} + +impl ProjectState { + pub fn events(&self, cx: &App) -> Vec { + self.events + .iter() + .cloned() + .chain( + self.last_event + .as_ref() + .and_then(|event| event.finalize(&self.license_detection_watchers, cx)), + ) + .collect() } - pub fn is_enabled(&self) -> bool { - matches!(self, DataCollectionState::Enabled { .. }) + pub fn events_split_by_pause(&self, cx: &App) -> Vec { + self.events + .iter() + .cloned() + .chain(self.last_event.as_ref().iter().flat_map(|event| { + let (one, two) = event.split_by_pause(); + let one = one.finalize(&self.license_detection_watchers, cx); + let two = two.and_then(|two| two.finalize(&self.license_detection_watchers, cx)); + one.into_iter().chain(two) + })) + .collect() } - pub fn is_project_open_source(&self) -> bool { + fn cancel_pending_prediction( + &mut self, + pending_prediction: PendingPrediction, + cx: &mut Context, + ) { + self.cancelled_predictions.insert(pending_prediction.id); + + cx.spawn(async move |this, cx| { + let Some(prediction_id) = pending_prediction.task.await else { + return; + }; + + this.update(cx, |this, _cx| { + this.reject_prediction(prediction_id, EditPredictionRejectReason::Canceled, false); + }) + .ok(); + }) + .detach() + } + + fn active_buffer( + &self, + project: &Entity, + cx: &App, + ) -> Option<(Entity, Option)> { + let project = project.read(cx); + let active_path = project.path_for_entry(project.active_entry()?, cx)?; + let active_buffer = project.buffer_store().read(cx).get_by_path(&active_path)?; + let registered_buffer = self.registered_buffers.get(&active_buffer.entity_id())?; + Some((active_buffer, registered_buffer.last_position)) + } +} + +#[derive(Debug, Clone)] +struct CurrentEditPrediction { + pub requested_by: PredictionRequestedBy, + pub prediction: EditPrediction, + pub was_shown: bool, +} + +impl CurrentEditPrediction { + fn should_replace_prediction(&self, old_prediction: &Self, cx: &App) -> bool { + let Some(new_edits) = self + .prediction + .interpolate(&self.prediction.buffer.read(cx)) + else { + return false; + }; + + if self.prediction.buffer != old_prediction.prediction.buffer { + return true; + } + + let Some(old_edits) = old_prediction + .prediction + .interpolate(&old_prediction.prediction.buffer.read(cx)) + else { + return true; + }; + + let requested_by_buffer_id = self.requested_by.buffer_id(); + + // This reduces the occurrence of UI thrash from replacing edits + // + // TODO: This is fairly arbitrary - should have a more general heuristic that handles multiple edits. + if requested_by_buffer_id == Some(self.prediction.buffer.entity_id()) + && requested_by_buffer_id == Some(old_prediction.prediction.buffer.entity_id()) + && old_edits.len() == 1 + && new_edits.len() == 1 + { + let (old_range, old_text) = &old_edits[0]; + let (new_range, new_text) = &new_edits[0]; + new_range == old_range && new_text.starts_with(old_text.as_ref()) + } else { + true + } + } +} + +#[derive(Debug, Clone)] +enum PredictionRequestedBy { + DiagnosticsUpdate, + Buffer(EntityId), +} + +impl PredictionRequestedBy { + pub fn buffer_id(&self) -> Option { match self { - Self::Enabled { - is_project_open_source, - } - | Self::Disabled { - is_project_open_source, - } => *is_project_open_source, - _ => false, + PredictionRequestedBy::DiagnosticsUpdate => None, + PredictionRequestedBy::Buffer(buffer_id) => Some(*buffer_id), } } } -pub trait EditPredictionProvider: 'static + Sized { - fn name() -> &'static str; - fn display_name() -> &'static str; - fn show_completions_in_menu() -> bool; - fn show_tab_accept_marker() -> bool { - false +#[derive(Debug)] +struct PendingPrediction { + id: usize, + task: Task>, +} + +/// A prediction from the perspective of a buffer. +#[derive(Debug)] +enum BufferEditPrediction<'a> { + Local { prediction: &'a EditPrediction }, + Jump { prediction: &'a EditPrediction }, +} + +#[cfg(test)] +impl std::ops::Deref for BufferEditPrediction<'_> { + type Target = EditPrediction; + + fn deref(&self) -> &Self::Target { + match self { + BufferEditPrediction::Local { prediction } => prediction, + BufferEditPrediction::Jump { prediction } => prediction, + } } - fn supports_jump_to_edit() -> bool { - true +} + +struct RegisteredBuffer { + file: Option>, + snapshot: TextBufferSnapshot, + last_position: Option, + _subscriptions: [gpui::Subscription; 2], +} + +#[derive(Clone)] +struct LastEvent { + old_snapshot: TextBufferSnapshot, + new_snapshot: TextBufferSnapshot, + old_file: Option>, + new_file: Option>, + end_edit_anchor: Option, + snapshot_after_last_editing_pause: Option, + last_edit_time: Option, +} + +impl LastEvent { + pub fn finalize( + &self, + license_detection_watchers: &HashMap>, + cx: &App, + ) -> Option { + let path = buffer_path_with_id_fallback(self.new_file.as_ref(), &self.new_snapshot, cx); + let old_path = buffer_path_with_id_fallback(self.old_file.as_ref(), &self.old_snapshot, cx); + + let in_open_source_repo = + [self.new_file.as_ref(), self.old_file.as_ref()] + .iter() + .all(|file| { + file.is_some_and(|file| { + license_detection_watchers + .get(&file.worktree_id(cx)) + .is_some_and(|watcher| watcher.is_project_open_source()) + }) + }); + + let diff = compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?; + + if path == old_path && diff.is_empty() { + None + } else { + Some(StoredEvent { + event: Arc::new(zeta_prompt::Event::BufferChange { + old_path, + path, + diff, + in_open_source_repo, + // TODO: Actually detect if this edit was predicted or not + predicted: false, + }), + old_snapshot: self.old_snapshot.clone(), + }) + } + } + + pub fn split_by_pause(&self) -> (LastEvent, Option) { + let Some(boundary_snapshot) = self.snapshot_after_last_editing_pause.as_ref() else { + return (self.clone(), None); + }; + + let before = LastEvent { + old_snapshot: self.old_snapshot.clone(), + new_snapshot: boundary_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + let after = LastEvent { + old_snapshot: boundary_snapshot.clone(), + new_snapshot: self.new_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + (before, Some(after)) } +} + +pub(crate) fn compute_diff_between_snapshots( + old_snapshot: &TextBufferSnapshot, + new_snapshot: &TextBufferSnapshot, +) -> Option { + let edits: Vec> = new_snapshot + .edits_since::(&old_snapshot.version) + .collect(); + + let (first_edit, last_edit) = edits.first().zip(edits.last())?; + + let old_start_point = old_snapshot.offset_to_point(first_edit.old.start); + let old_end_point = old_snapshot.offset_to_point(last_edit.old.end); + let new_start_point = new_snapshot.offset_to_point(first_edit.new.start); + let new_end_point = new_snapshot.offset_to_point(last_edit.new.end); + + const CONTEXT_LINES: u32 = 3; + + let old_context_start_row = old_start_point.row.saturating_sub(CONTEXT_LINES); + let new_context_start_row = new_start_point.row.saturating_sub(CONTEXT_LINES); + let old_context_end_row = + (old_end_point.row + 1 + CONTEXT_LINES).min(old_snapshot.max_point().row); + let new_context_end_row = + (new_end_point.row + 1 + CONTEXT_LINES).min(new_snapshot.max_point().row); + + let old_start_line_offset = old_snapshot.point_to_offset(Point::new(old_context_start_row, 0)); + let new_start_line_offset = new_snapshot.point_to_offset(Point::new(new_context_start_row, 0)); + let old_end_line_offset = old_snapshot + .point_to_offset(Point::new(old_context_end_row + 1, 0).min(old_snapshot.max_point())); + let new_end_line_offset = new_snapshot + .point_to_offset(Point::new(new_context_end_row + 1, 0).min(new_snapshot.max_point())); + let old_edit_range = old_start_line_offset..old_end_line_offset; + let new_edit_range = new_start_line_offset..new_end_line_offset; + + let old_region_text: String = old_snapshot.text_for_range(old_edit_range).collect(); + let new_region_text: String = new_snapshot.text_for_range(new_edit_range).collect(); + + let diff = language::unified_diff_with_offsets( + &old_region_text, + &new_region_text, + old_context_start_row, + new_context_start_row, + ); + + Some(diff) +} - fn data_collection_state(&self, _cx: &App) -> DataCollectionState { - DataCollectionState::Unsupported +fn buffer_path_with_id_fallback( + file: Option<&Arc>, + snapshot: &TextBufferSnapshot, + cx: &App, +) -> Arc { + if let Some(file) = file { + file.full_path(cx).into() + } else { + Path::new(&format!("untitled-{}", snapshot.remote_id())).into() + } +} + +impl EditPredictionStore { + pub fn try_global(cx: &App) -> Option> { + cx.try_global::() + .map(|global| global.0.clone()) } - fn usage(&self, _cx: &App) -> Option { - None + pub fn global( + client: &Arc, + user_store: &Entity, + cx: &mut App, + ) -> Entity { + cx.try_global::() + .map(|global| global.0.clone()) + .unwrap_or_else(|| { + let ep_store = cx.new(|cx| Self::new(client.clone(), user_store.clone(), cx)); + cx.set_global(EditPredictionStoreGlobal(ep_store.clone())); + ep_store + }) } - fn toggle_data_collection(&mut self, _cx: &mut App) {} - fn is_enabled( + pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { + let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let data_collection_choice = Self::load_data_collection_choice(); + + let llm_token = LlmApiToken::default(); + + let (reject_tx, reject_rx) = mpsc::unbounded(); + cx.background_spawn({ + let client = client.clone(); + let llm_token = llm_token.clone(); + let app_version = AppVersion::global(cx); + let background_executor = cx.background_executor().clone(); + async move { + Self::handle_rejected_predictions( + reject_rx, + client, + llm_token, + app_version, + background_executor, + ) + .await + } + }) + .detach(); + + let mut this = Self { + projects: HashMap::default(), + client, + user_store, + options: DEFAULT_OPTIONS, + use_context: false, + llm_token, + _llm_token_subscription: cx.subscribe( + &refresh_llm_token_listener, + |this, _listener, _event, cx| { + let client = this.client.clone(); + let llm_token = this.llm_token.clone(); + cx.spawn(async move |_this, _cx| { + llm_token.refresh(&client).await?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + }, + ), + update_required: false, + #[cfg(feature = "cli-support")] + eval_cache: None, + edit_prediction_model: EditPredictionModel::Zeta2, + sweep_ai: SweepAi::new(cx), + mercury: Mercury::new(cx), + data_collection_choice, + reject_predictions_tx: reject_tx, + rated_predictions: Default::default(), + shown_predictions: Default::default(), + custom_predict_edits_url: match env::var("ZED_PREDICT_EDITS_URL") { + Ok(custom_url) => Url::parse(&custom_url).log_err().map(Into::into), + Err(_) => { + if *USE_OLLAMA { + Some( + Url::parse("http://localhost:11434/v1/chat/completions") + .unwrap() + .into(), + ) + } else { + None + } + } + }, + }; + + this.configure_context_retrieval(cx); + let weak_this = cx.weak_entity(); + cx.on_flags_ready(move |_, cx| { + weak_this + .update(cx, |this, cx| this.configure_context_retrieval(cx)) + .ok(); + }) + .detach(); + cx.observe_global::(|this, cx| { + this.configure_context_retrieval(cx); + }) + .detach(); + + this + } + + #[cfg(test)] + pub fn set_custom_predict_edits_url(&mut self, url: Url) { + self.custom_predict_edits_url = Some(url.into()); + } + + pub fn set_edit_prediction_model(&mut self, model: EditPredictionModel) { + self.edit_prediction_model = model; + } + + pub fn has_sweep_api_token(&self, cx: &App) -> bool { + self.sweep_ai.api_token.read(cx).has_key() + } + + pub fn has_mercury_api_token(&self, cx: &App) -> bool { + self.mercury.api_token.read(cx).has_key() + } + + #[cfg(feature = "cli-support")] + pub fn with_eval_cache(&mut self, cache: Arc) { + self.eval_cache = Some(cache); + } + + pub fn options(&self) -> &ZetaOptions { + &self.options + } + + pub fn set_options(&mut self, options: ZetaOptions) { + self.options = options; + } + + pub fn set_use_context(&mut self, use_context: bool) { + self.use_context = use_context; + } + + pub fn clear_history(&mut self) { + for project_state in self.projects.values_mut() { + project_state.events.clear(); + } + } + + pub fn clear_history_for_project(&mut self, project: &Entity) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + project_state.events.clear(); + } + } + + pub fn edit_history_for_project( &self, - buffer: &Entity, - cursor_position: language::Anchor, + project: &Entity, cx: &App, - ) -> bool; - fn is_refreshing(&self, cx: &App) -> bool; - fn refresh( + ) -> Vec { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events(cx)) + .unwrap_or_default() + } + + pub fn edit_history_for_project_with_pause_split_last_event( + &self, + project: &Entity, + cx: &App, + ) -> Vec { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events_split_by_pause(cx)) + .unwrap_or_default() + } + + pub fn context_for_project<'a>( + &'a self, + project: &Entity, + cx: &'a App, + ) -> Arc<[RelatedFile]> { + self.projects + .get(&project.entity_id()) + .map(|project| project.context.read(cx).related_files()) + .unwrap_or_else(|| vec![].into()) + } + + pub fn context_for_project_with_buffers<'a>( + &'a self, + project: &Entity, + cx: &'a App, + ) -> Option)>> { + self.projects + .get(&project.entity_id()) + .map(|project| project.context.read(cx).related_files_with_buffers()) + } + + pub fn usage(&self, cx: &App) -> Option { + if self.edit_prediction_model == EditPredictionModel::Zeta2 { + self.user_store.read(cx).edit_prediction_usage() + } else { + None + } + } + + pub fn register_project(&mut self, project: &Entity, cx: &mut Context) { + self.get_or_init_project(project, cx); + } + + pub fn register_buffer( &mut self, - buffer: Entity, - cursor_position: language::Anchor, - debounce: bool, + buffer: &Entity, + project: &Entity, cx: &mut Context, - ); - fn cycle( + ) { + let project_state = self.get_or_init_project(project, cx); + Self::register_buffer_impl(project_state, buffer, project, cx); + } + + fn get_or_init_project( &mut self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, + project: &Entity, cx: &mut Context, - ); - fn accept(&mut self, cx: &mut Context); - fn discard(&mut self, cx: &mut Context); - fn did_show(&mut self, _cx: &mut Context) {} - fn suggest( + ) -> &mut ProjectState { + let entity_id = project.entity_id(); + self.projects + .entry(entity_id) + .or_insert_with(|| ProjectState { + context: { + let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(project, cx)); + cx.subscribe(&related_excerpt_store, move |this, _, event, _| { + this.handle_excerpt_store_event(entity_id, event); + }) + .detach(); + related_excerpt_store + }, + events: VecDeque::new(), + last_event: None, + recent_paths: VecDeque::new(), + debug_tx: None, + registered_buffers: HashMap::default(), + current_prediction: None, + cancelled_predictions: HashSet::default(), + pending_predictions: ArrayVec::new(), + next_pending_prediction_id: 0, + last_prediction_refresh: None, + license_detection_watchers: HashMap::default(), + _subscription: cx.subscribe(&project, Self::handle_project_event), + }) + } + + pub fn remove_project(&mut self, project: &Entity) { + self.projects.remove(&project.entity_id()); + } + + fn handle_excerpt_store_event( + &mut self, + project_entity_id: EntityId, + event: &RelatedExcerptStoreEvent, + ) { + if let Some(project_state) = self.projects.get(&project_entity_id) { + if let Some(debug_tx) = project_state.debug_tx.clone() { + match event { + RelatedExcerptStoreEvent::StartedRefresh => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalStarted( + ContextRetrievalStartedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + search_prompt: String::new(), + }, + )) + .ok(); + } + RelatedExcerptStoreEvent::FinishedRefresh { + cache_hit_count, + cache_miss_count, + mean_definition_latency, + max_definition_latency, + } => { + debug_tx + .unbounded_send(DebugEvent::ContextRetrievalFinished( + ContextRetrievalFinishedDebugEvent { + project_entity_id: project_entity_id, + timestamp: Instant::now(), + metadata: vec![ + ( + "Cache Hits", + format!( + "{}/{}", + cache_hit_count, + cache_hit_count + cache_miss_count + ) + .into(), + ), + ( + "Max LSP Time", + format!("{} ms", max_definition_latency.as_millis()) + .into(), + ), + ( + "Mean LSP Time", + format!("{} ms", mean_definition_latency.as_millis()) + .into(), + ), + ], + }, + )) + .ok(); + } + } + } + } + } + + pub fn debug_info( + &mut self, + project: &Entity, + cx: &mut Context, + ) -> mpsc::UnboundedReceiver { + let project_state = self.get_or_init_project(project, cx); + let (debug_watch_tx, debug_watch_rx) = mpsc::unbounded(); + project_state.debug_tx = Some(debug_watch_tx); + debug_watch_rx + } + + fn handle_project_event( &mut self, + project: Entity, + event: &project::Event, + cx: &mut Context, + ) { + // TODO [zeta2] init with recent paths + match event { + project::Event::ActiveEntryChanged(Some(active_entry_id)) => { + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + return; + }; + let path = project.read(cx).path_for_entry(*active_entry_id, cx); + if let Some(path) = path { + if let Some(ix) = project_state + .recent_paths + .iter() + .position(|probe| probe == &path) + { + project_state.recent_paths.remove(ix); + } + project_state.recent_paths.push_front(path); + } + } + project::Event::DiagnosticsUpdated { .. } => { + if cx.has_flag::() { + self.refresh_prediction_from_diagnostics(project, cx); + } + } + _ => (), + } + } + + fn register_buffer_impl<'a>( + project_state: &'a mut ProjectState, buffer: &Entity, - cursor_position: language::Anchor, + project: &Entity, cx: &mut Context, - ) -> Option; -} + ) -> &'a mut RegisteredBuffer { + let buffer_id = buffer.entity_id(); -pub trait EditPredictionProviderHandle { - fn name(&self) -> &'static str; - fn display_name(&self) -> &'static str; - fn is_enabled( - &self, + if let Some(file) = buffer.read(cx).file() { + let worktree_id = file.worktree_id(cx); + if let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) { + project_state + .license_detection_watchers + .entry(worktree_id) + .or_insert_with(|| { + let project_entity_id = project.entity_id(); + cx.observe_release(&worktree, move |this, _worktree, _cx| { + let Some(project_state) = this.projects.get_mut(&project_entity_id) + else { + return; + }; + project_state + .license_detection_watchers + .remove(&worktree_id); + }) + .detach(); + Rc::new(LicenseDetectionWatcher::new(&worktree, cx)) + }); + } + } + + match project_state.registered_buffers.entry(buffer_id) { + hash_map::Entry::Occupied(entry) => entry.into_mut(), + hash_map::Entry::Vacant(entry) => { + let buf = buffer.read(cx); + let snapshot = buf.text_snapshot(); + let file = buf.file().cloned(); + let project_entity_id = project.entity_id(); + entry.insert(RegisteredBuffer { + snapshot, + file, + last_position: None, + _subscriptions: [ + cx.subscribe(buffer, { + let project = project.downgrade(); + move |this, buffer, event, cx| { + if let language::BufferEvent::Edited = event + && let Some(project) = project.upgrade() + { + this.report_changes_for_buffer(&buffer, &project, cx); + } + } + }), + cx.observe_release(buffer, move |this, _buffer, _cx| { + let Some(project_state) = this.projects.get_mut(&project_entity_id) + else { + return; + }; + project_state.registered_buffers.remove(&buffer_id); + }), + ], + }) + } + } + } + + fn report_changes_for_buffer( + &mut self, buffer: &Entity, - cursor_position: language::Anchor, - cx: &App, - ) -> bool; - fn show_completions_in_menu(&self) -> bool; - fn show_tab_accept_marker(&self) -> bool; - fn supports_jump_to_edit(&self) -> bool; - fn data_collection_state(&self, cx: &App) -> DataCollectionState; - fn usage(&self, cx: &App) -> Option; - fn toggle_data_collection(&self, cx: &mut App); - fn is_refreshing(&self, cx: &App) -> bool; - fn refresh( - &self, - buffer: Entity, - cursor_position: language::Anchor, - debounce: bool, - cx: &mut App, - ); - fn cycle( - &self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut App, - ); - fn did_show(&self, cx: &mut App); - fn accept(&self, cx: &mut App); - fn discard(&self, cx: &mut App); - fn suggest( - &self, + project: &Entity, + cx: &mut Context, + ) { + let project_state = self.get_or_init_project(project, cx); + let registered_buffer = Self::register_buffer_impl(project_state, buffer, project, cx); + + let buf = buffer.read(cx); + let new_file = buf.file().cloned(); + let new_snapshot = buf.text_snapshot(); + if new_snapshot.version == registered_buffer.snapshot.version { + return; + } + + let old_file = mem::replace(&mut registered_buffer.file, new_file.clone()); + let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); + let end_edit_anchor = new_snapshot + .anchored_edits_since::(&old_snapshot.version) + .last() + .map(|(_, range)| range.end); + let events = &mut project_state.events; + + let now = cx.background_executor().now(); + if let Some(last_event) = project_state.last_event.as_mut() { + let is_next_snapshot_of_same_buffer = old_snapshot.remote_id() + == last_event.new_snapshot.remote_id() + && old_snapshot.version == last_event.new_snapshot.version; + + let should_coalesce = is_next_snapshot_of_same_buffer + && end_edit_anchor + .as_ref() + .zip(last_event.end_edit_anchor.as_ref()) + .is_some_and(|(a, b)| { + let a = a.to_point(&new_snapshot); + let b = b.to_point(&new_snapshot); + a.row.abs_diff(b.row) <= CHANGE_GROUPING_LINE_SPAN + }); + + if should_coalesce { + let pause_elapsed = last_event + .last_edit_time + .map(|t| now.duration_since(t) >= LAST_CHANGE_GROUPING_TIME) + .unwrap_or(false); + if pause_elapsed { + last_event.snapshot_after_last_editing_pause = + Some(last_event.new_snapshot.clone()); + } + + last_event.end_edit_anchor = end_edit_anchor; + last_event.new_snapshot = new_snapshot; + last_event.last_edit_time = Some(now); + return; + } + } + + if events.len() + 1 >= EVENT_COUNT_MAX { + events.pop_front(); + } + + if let Some(event) = project_state.last_event.take() { + events.extend(event.finalize(&project_state.license_detection_watchers, cx)); + } + + project_state.last_event = Some(LastEvent { + old_file, + new_file, + old_snapshot, + new_snapshot, + end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: Some(now), + }); + } + + fn prediction_at( + &mut self, buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut App, - ) -> Option; -} + position: Option, + project: &Entity, + cx: &App, + ) -> Option> { + let project_state = self.projects.get_mut(&project.entity_id())?; + if let Some(position) = position + && let Some(buffer) = project_state + .registered_buffers + .get_mut(&buffer.entity_id()) + { + buffer.last_position = Some(position); + } + + let CurrentEditPrediction { + requested_by, + prediction, + .. + } = project_state.current_prediction.as_ref()?; -impl EditPredictionProviderHandle for Entity -where - T: EditPredictionProvider, -{ - fn name(&self) -> &'static str { - T::name() + if prediction.targets_buffer(buffer.read(cx)) { + Some(BufferEditPrediction::Local { prediction }) + } else { + let show_jump = match requested_by { + PredictionRequestedBy::Buffer(requested_by_buffer_id) => { + requested_by_buffer_id == &buffer.entity_id() + } + PredictionRequestedBy::DiagnosticsUpdate => true, + }; + + if show_jump { + Some(BufferEditPrediction::Jump { prediction }) + } else { + None + } + } } - fn display_name(&self) -> &'static str { - T::display_name() + fn accept_current_prediction(&mut self, project: &Entity, cx: &mut Context) { + let custom_accept_url = env::var("ZED_ACCEPT_PREDICTION_URL").ok(); + match self.edit_prediction_model { + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() && custom_accept_url.is_none() { + return; + } + } + EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, + } + + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + return; + }; + + let Some(prediction) = project_state.current_prediction.take() else { + return; + }; + let request_id = prediction.prediction.id.to_string(); + for pending_prediction in mem::take(&mut project_state.pending_predictions) { + project_state.cancel_pending_prediction(pending_prediction, cx); + } + + let client = self.client.clone(); + let llm_token = self.llm_token.clone(); + let app_version = AppVersion::global(cx); + cx.spawn(async move |this, cx| { + let (url, require_auth) = if let Some(accept_edits_url) = custom_accept_url { + (http_client::Url::parse(&accept_edits_url)?, false) + } else { + ( + client + .http_client() + .build_zed_llm_url("/predict_edits/accept", &[])?, + true, + ) + }; + + let response = cx + .background_spawn(Self::send_api_request::<()>( + move |builder| { + let req = builder.uri(url.as_ref()).body( + serde_json::to_string(&AcceptEditPredictionBody { + request_id: request_id.clone(), + })? + .into(), + ); + Ok(req?) + }, + client, + llm_token, + app_version, + require_auth, + )) + .await; + + Self::handle_api_response(&this, response, cx)?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); } - fn show_completions_in_menu(&self) -> bool { - T::show_completions_in_menu() + async fn handle_rejected_predictions( + rx: UnboundedReceiver, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + background_executor: BackgroundExecutor, + ) { + let mut rx = std::pin::pin!(rx.peekable()); + let mut batched = Vec::new(); + + while let Some(rejection) = rx.next().await { + batched.push(rejection); + + if batched.len() < MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST / 2 { + select_biased! { + next = rx.as_mut().peek().fuse() => { + if next.is_some() { + continue; + } + } + () = background_executor.timer(REJECT_REQUEST_DEBOUNCE).fuse() => {}, + } + } + + let url = client + .http_client() + .build_zed_llm_url("/predict_edits/reject", &[]) + .unwrap(); + + let flush_count = batched + .len() + // in case items have accumulated after failure + .min(MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST); + let start = batched.len() - flush_count; + + let body = RejectEditPredictionsBodyRef { + rejections: &batched[start..], + }; + + let result = Self::send_api_request::<()>( + |builder| { + let req = builder + .uri(url.as_ref()) + .body(serde_json::to_string(&body)?.into()); + anyhow::Ok(req?) + }, + client.clone(), + llm_token.clone(), + app_version.clone(), + true, + ) + .await; + + if result.log_err().is_some() { + batched.drain(start..); + } + } } - fn show_tab_accept_marker(&self) -> bool { - T::show_tab_accept_marker() + fn reject_current_prediction( + &mut self, + reason: EditPredictionRejectReason, + project: &Entity, + ) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + project_state.pending_predictions.clear(); + if let Some(prediction) = project_state.current_prediction.take() { + self.reject_prediction(prediction.prediction.id, reason, prediction.was_shown); + } + }; } - fn supports_jump_to_edit(&self) -> bool { - T::supports_jump_to_edit() + fn did_show_current_prediction(&mut self, project: &Entity, _cx: &mut Context) { + if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { + if let Some(current_prediction) = project_state.current_prediction.as_mut() { + if !current_prediction.was_shown { + current_prediction.was_shown = true; + self.shown_predictions + .push_front(current_prediction.prediction.clone()); + if self.shown_predictions.len() > 50 { + let completion = self.shown_predictions.pop_back().unwrap(); + self.rated_predictions.remove(&completion.id); + } + } + } + } } - fn data_collection_state(&self, cx: &App) -> DataCollectionState { - self.read(cx).data_collection_state(cx) + fn reject_prediction( + &mut self, + prediction_id: EditPredictionId, + reason: EditPredictionRejectReason, + was_shown: bool, + ) { + match self.edit_prediction_model { + EditPredictionModel::Zeta1 | EditPredictionModel::Zeta2 => { + if self.custom_predict_edits_url.is_some() { + return; + } + } + EditPredictionModel::Sweep | EditPredictionModel::Mercury => return, + } + + self.reject_predictions_tx + .unbounded_send(EditPredictionRejection { + request_id: prediction_id.to_string(), + reason, + was_shown, + }) + .log_err(); } - fn usage(&self, cx: &App) -> Option { - self.read(cx).usage(cx) + fn is_refreshing(&self, project: &Entity) -> bool { + self.projects + .get(&project.entity_id()) + .is_some_and(|project_state| !project_state.pending_predictions.is_empty()) } - fn toggle_data_collection(&self, cx: &mut App) { - self.update(cx, |this, cx| this.toggle_data_collection(cx)) + pub fn refresh_prediction_from_buffer( + &mut self, + project: Entity, + buffer: Entity, + position: language::Anchor, + cx: &mut Context, + ) { + self.queue_prediction_refresh(project.clone(), buffer.entity_id(), cx, move |this, cx| { + let Some(request_task) = this + .update(cx, |this, cx| { + this.request_prediction( + &project, + &buffer, + position, + PredictEditsRequestTrigger::Other, + cx, + ) + }) + .log_err() + else { + return Task::ready(anyhow::Ok(None)); + }; + + cx.spawn(async move |_cx| { + request_task.await.map(|prediction_result| { + prediction_result.map(|prediction_result| { + ( + prediction_result, + PredictionRequestedBy::Buffer(buffer.entity_id()), + ) + }) + }) + }) + }) } - fn is_enabled( - &self, - buffer: &Entity, - cursor_position: language::Anchor, + pub fn refresh_prediction_from_diagnostics( + &mut self, + project: Entity, + cx: &mut Context, + ) { + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { + return; + }; + + // Prefer predictions from buffer + if project_state.current_prediction.is_some() { + return; + }; + + self.queue_prediction_refresh(project.clone(), project.entity_id(), cx, move |this, cx| { + let Some((active_buffer, snapshot, cursor_point)) = this + .read_with(cx, |this, cx| { + let project_state = this.projects.get(&project.entity_id())?; + let (buffer, position) = project_state.active_buffer(&project, cx)?; + let snapshot = buffer.read(cx).snapshot(); + + if !Self::predictions_enabled_at(&snapshot, position, cx) { + return None; + } + + let cursor_point = position + .map(|pos| pos.to_point(&snapshot)) + .unwrap_or_default(); + + Some((buffer, snapshot, cursor_point)) + }) + .log_err() + .flatten() + else { + return Task::ready(anyhow::Ok(None)); + }; + + cx.spawn(async move |cx| { + let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( + active_buffer, + &snapshot, + Default::default(), + cursor_point, + &project, + cx, + ) + .await? + else { + return anyhow::Ok(None); + }; + + let Some(prediction_result) = this + .update(cx, |this, cx| { + this.request_prediction( + &project, + &jump_buffer, + jump_position, + PredictEditsRequestTrigger::Diagnostics, + cx, + ) + })? + .await? + else { + return anyhow::Ok(None); + }; + + this.update(cx, |this, cx| { + Some(( + if this + .get_or_init_project(&project, cx) + .current_prediction + .is_none() + { + prediction_result + } else { + EditPredictionResult { + id: prediction_result.id, + prediction: Err(EditPredictionRejectReason::CurrentPreferred), + } + }, + PredictionRequestedBy::DiagnosticsUpdate, + )) + }) + }) + }); + } + + fn predictions_enabled_at( + snapshot: &BufferSnapshot, + position: Option, cx: &App, ) -> bool { - self.read(cx).is_enabled(buffer, cursor_position, cx) - } + let file = snapshot.file(); + let all_settings = all_language_settings(file, cx); + if !all_settings.show_edit_predictions(snapshot.language(), cx) + || file.is_some_and(|file| !all_settings.edit_predictions_enabled_for_file(file, cx)) + { + return false; + } + + if let Some(last_position) = position { + let settings = snapshot.settings_at(last_position, cx); + + if !settings.edit_predictions_disabled_in.is_empty() + && let Some(scope) = snapshot.language_scope_at(last_position) + && let Some(scope_name) = scope.override_name() + && settings + .edit_predictions_disabled_in + .iter() + .any(|s| s == scope_name) + { + return false; + } + } - fn is_refreshing(&self, cx: &App) -> bool { - self.read(cx).is_refreshing(cx) + true } - fn refresh( - &self, - buffer: Entity, - cursor_position: language::Anchor, - debounce: bool, - cx: &mut App, + #[cfg(not(test))] + pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); + #[cfg(test)] + pub const THROTTLE_TIMEOUT: Duration = Duration::ZERO; + + fn queue_prediction_refresh( + &mut self, + project: Entity, + throttle_entity: EntityId, + cx: &mut Context, + do_refresh: impl FnOnce( + WeakEntity, + &mut AsyncApp, + ) + -> Task>> + + 'static, ) { - self.update(cx, |this, cx| { - this.refresh(buffer, cursor_position, debounce, cx) - }) + let project_state = self.get_or_init_project(&project, cx); + let pending_prediction_id = project_state.next_pending_prediction_id; + project_state.next_pending_prediction_id += 1; + let last_request = project_state.last_prediction_refresh; + + let task = cx.spawn(async move |this, cx| { + if let Some((last_entity, last_timestamp)) = last_request + && throttle_entity == last_entity + && let Some(timeout) = + (last_timestamp + Self::THROTTLE_TIMEOUT).checked_duration_since(Instant::now()) + { + cx.background_executor().timer(timeout).await; + } + + // If this task was cancelled before the throttle timeout expired, + // do not perform a request. + let mut is_cancelled = true; + this.update(cx, |this, cx| { + let project_state = this.get_or_init_project(&project, cx); + if !project_state + .cancelled_predictions + .remove(&pending_prediction_id) + { + project_state.last_prediction_refresh = Some((throttle_entity, Instant::now())); + is_cancelled = false; + } + }) + .ok(); + if is_cancelled { + return None; + } + + let new_prediction_result = do_refresh(this.clone(), cx).await.log_err().flatten(); + let new_prediction_id = new_prediction_result + .as_ref() + .map(|(prediction, _)| prediction.id.clone()); + + // When a prediction completes, remove it from the pending list, and cancel + // any pending predictions that were enqueued before it. + this.update(cx, |this, cx| { + let project_state = this.get_or_init_project(&project, cx); + + let is_cancelled = project_state + .cancelled_predictions + .remove(&pending_prediction_id); + + let new_current_prediction = if !is_cancelled + && let Some((prediction_result, requested_by)) = new_prediction_result + { + match prediction_result.prediction { + Ok(prediction) => { + let new_prediction = CurrentEditPrediction { + requested_by, + prediction, + was_shown: false, + }; + + if let Some(current_prediction) = + project_state.current_prediction.as_ref() + { + if new_prediction.should_replace_prediction(¤t_prediction, cx) + { + this.reject_current_prediction( + EditPredictionRejectReason::Replaced, + &project, + ); + + Some(new_prediction) + } else { + this.reject_prediction( + new_prediction.prediction.id, + EditPredictionRejectReason::CurrentPreferred, + false, + ); + None + } + } else { + Some(new_prediction) + } + } + Err(reject_reason) => { + this.reject_prediction(prediction_result.id, reject_reason, false); + None + } + } + } else { + None + }; + + let project_state = this.get_or_init_project(&project, cx); + + if let Some(new_prediction) = new_current_prediction { + project_state.current_prediction = Some(new_prediction); + } + + let mut pending_predictions = mem::take(&mut project_state.pending_predictions); + for (ix, pending_prediction) in pending_predictions.iter().enumerate() { + if pending_prediction.id == pending_prediction_id { + pending_predictions.remove(ix); + for pending_prediction in pending_predictions.drain(0..ix) { + project_state.cancel_pending_prediction(pending_prediction, cx) + } + break; + } + } + this.get_or_init_project(&project, cx).pending_predictions = pending_predictions; + cx.notify(); + }) + .ok(); + + new_prediction_id + }); + + if project_state.pending_predictions.len() <= 1 { + project_state.pending_predictions.push(PendingPrediction { + id: pending_prediction_id, + task, + }); + } else if project_state.pending_predictions.len() == 2 { + let pending_prediction = project_state.pending_predictions.pop().unwrap(); + project_state.pending_predictions.push(PendingPrediction { + id: pending_prediction_id, + task, + }); + project_state.cancel_pending_prediction(pending_prediction, cx); + } } - fn cycle( - &self, - buffer: Entity, - cursor_position: language::Anchor, - direction: Direction, - cx: &mut App, - ) { - self.update(cx, |this, cx| { - this.cycle(buffer, cursor_position, direction, cx) + pub fn request_prediction( + &mut self, + project: &Entity, + active_buffer: &Entity, + position: language::Anchor, + trigger: PredictEditsRequestTrigger, + cx: &mut Context, + ) -> Task>> { + self.request_prediction_internal( + project.clone(), + active_buffer.clone(), + position, + trigger, + cx.has_flag::(), + cx, + ) + } + + fn request_prediction_internal( + &mut self, + project: Entity, + active_buffer: Entity, + position: language::Anchor, + trigger: PredictEditsRequestTrigger, + allow_jump: bool, + cx: &mut Context, + ) -> Task>> { + const DIAGNOSTIC_LINES_RANGE: u32 = 20; + + self.get_or_init_project(&project, cx); + let project_state = self.projects.get(&project.entity_id()).unwrap(); + let stored_events = project_state.events(cx); + let has_events = !stored_events.is_empty(); + let events: Vec> = + stored_events.into_iter().map(|e| e.event).collect(); + let debug_tx = project_state.debug_tx.clone(); + + let snapshot = active_buffer.read(cx).snapshot(); + let cursor_point = position.to_point(&snapshot); + let diagnostic_search_start = cursor_point.row.saturating_sub(DIAGNOSTIC_LINES_RANGE); + let diagnostic_search_end = cursor_point.row + DIAGNOSTIC_LINES_RANGE; + let diagnostic_search_range = + Point::new(diagnostic_search_start, 0)..Point::new(diagnostic_search_end, 0); + + let related_files = if self.use_context { + self.context_for_project(&project, cx) + } else { + Vec::new().into() + }; + + let inputs = EditPredictionModelInput { + project: project.clone(), + buffer: active_buffer.clone(), + snapshot: snapshot.clone(), + position, + events, + related_files, + recent_paths: project_state.recent_paths.clone(), + trigger, + diagnostic_search_range: diagnostic_search_range.clone(), + debug_tx, + }; + + let task = match self.edit_prediction_model { + EditPredictionModel::Zeta1 => zeta1::request_prediction_with_zeta1(self, inputs, cx), + EditPredictionModel::Zeta2 => zeta2::request_prediction_with_zeta2(self, inputs, cx), + EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx), + EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), + }; + + cx.spawn(async move |this, cx| { + let prediction = task.await?; + + if prediction.is_none() && allow_jump { + let cursor_point = position.to_point(&snapshot); + if has_events + && let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( + active_buffer.clone(), + &snapshot, + diagnostic_search_range, + cursor_point, + &project, + cx, + ) + .await? + { + return this + .update(cx, |this, cx| { + this.request_prediction_internal( + project, + jump_buffer, + jump_position, + trigger, + false, + cx, + ) + })? + .await; + } + + return anyhow::Ok(None); + } + + Ok(prediction) }) } - fn accept(&self, cx: &mut App) { - self.update(cx, |this, cx| this.accept(cx)) + async fn next_diagnostic_location( + active_buffer: Entity, + active_buffer_snapshot: &BufferSnapshot, + active_buffer_diagnostic_search_range: Range, + active_buffer_cursor_point: Point, + project: &Entity, + cx: &mut AsyncApp, + ) -> Result, language::Anchor)>> { + // find the closest diagnostic to the cursor that wasn't close enough to be included in the last request + let mut jump_location = active_buffer_snapshot + .diagnostic_groups(None) + .into_iter() + .filter_map(|(_, group)| { + let range = &group.entries[group.primary_ix] + .range + .to_point(&active_buffer_snapshot); + if range.overlaps(&active_buffer_diagnostic_search_range) { + None + } else { + Some(range.start) + } + }) + .min_by_key(|probe| probe.row.abs_diff(active_buffer_cursor_point.row)) + .map(|position| { + ( + active_buffer.clone(), + active_buffer_snapshot.anchor_before(position), + ) + }); + + if jump_location.is_none() { + let active_buffer_path = active_buffer.read_with(cx, |buffer, cx| { + let file = buffer.file()?; + + Some(ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }) + })?; + + let buffer_task = project.update(cx, |project, cx| { + let (path, _, _) = project + .diagnostic_summaries(false, cx) + .filter(|(path, _, _)| Some(path) != active_buffer_path.as_ref()) + .max_by_key(|(path, _, _)| { + // find the buffer with errors that shares most parent directories + path.path + .components() + .zip( + active_buffer_path + .as_ref() + .map(|p| p.path.components()) + .unwrap_or_default(), + ) + .take_while(|(a, b)| a == b) + .count() + })?; + + Some(project.open_buffer(path, cx)) + })?; + + if let Some(buffer_task) = buffer_task { + let closest_buffer = buffer_task.await?; + + jump_location = closest_buffer + .read_with(cx, |buffer, _cx| { + buffer + .buffer_diagnostics(None) + .into_iter() + .min_by_key(|entry| entry.diagnostic.severity) + .map(|entry| entry.range.start) + })? + .map(|position| (closest_buffer, position)); + } + } + + anyhow::Ok(jump_location) } - fn discard(&self, cx: &mut App) { - self.update(cx, |this, cx| this.discard(cx)) + async fn send_raw_llm_request( + request: open_ai::Request, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + #[cfg(feature = "cli-support")] eval_cache: Option>, + #[cfg(feature = "cli-support")] eval_cache_kind: EvalCacheEntryKind, + ) -> Result<(open_ai::Response, Option)> { + let url = client + .http_client() + .build_zed_llm_url("/predict_edits/raw", &[])?; + + #[cfg(feature = "cli-support")] + let cache_key = if let Some(cache) = eval_cache { + use collections::FxHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = FxHasher::default(); + url.hash(&mut hasher); + let request_str = serde_json::to_string_pretty(&request)?; + request_str.hash(&mut hasher); + let hash = hasher.finish(); + + let key = (eval_cache_kind, hash); + if let Some(response_str) = cache.read(key) { + return Ok((serde_json::from_str(&response_str)?, None)); + } + + Some((cache, request_str, key)) + } else { + None + }; + + let (response, usage) = Self::send_api_request( + |builder| { + let req = builder + .uri(url.as_ref()) + .body(serde_json::to_string(&request)?.into()); + Ok(req?) + }, + client, + llm_token, + app_version, + true, + ) + .await?; + + #[cfg(feature = "cli-support")] + if let Some((cache, request, key)) = cache_key { + cache.write(key, &request, &serde_json::to_string_pretty(&response)?); + } + + Ok((response, usage)) } - fn did_show(&self, cx: &mut App) { - self.update(cx, |this, cx| this.did_show(cx)) + fn handle_api_response( + this: &WeakEntity, + response: Result<(T, Option)>, + cx: &mut gpui::AsyncApp, + ) -> Result { + match response { + Ok((data, usage)) => { + if let Some(usage) = usage { + this.update(cx, |this, cx| { + this.user_store.update(cx, |user_store, cx| { + user_store.update_edit_prediction_usage(usage, cx); + }); + }) + .ok(); + } + Ok(data) + } + Err(err) => { + if err.is::() { + cx.update(|cx| { + this.update(cx, |this, _cx| { + this.update_required = true; + }) + .ok(); + + let error_message: SharedString = err.to_string().into(); + show_app_notification( + NotificationId::unique::(), + cx, + move |cx| { + cx.new(|cx| { + ErrorMessagePrompt::new(error_message.clone(), cx) + .with_link_button("Update Zed", "https://zed.dev/releases") + }) + }, + ); + }) + .ok(); + } + Err(err) + } + } } - fn suggest( - &self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut App, - ) -> Option { - self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx)) - } -} - -/// Returns edits updated based on user edits since the old snapshot. None is returned if any user -/// edit is not a prefix of a predicted insertion. -pub fn interpolate_edits( - old_snapshot: &BufferSnapshot, - new_snapshot: &BufferSnapshot, - current_edits: &[(Range, Arc)], -) -> Option, Arc)>> { - let mut edits = Vec::new(); - - let mut model_edits = current_edits.iter().peekable(); - for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { - while let Some((model_old_range, _)) = model_edits.peek() { - let model_old_range = model_old_range.to_offset(old_snapshot); - if model_old_range.end < user_edit.old.start { - let (model_old_range, model_new_text) = model_edits.next().unwrap(); - edits.push((model_old_range.clone(), model_new_text.clone())); + async fn send_api_request( + build: impl Fn(http_client::http::request::Builder) -> Result>, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + require_auth: bool, + ) -> Result<(Res, Option)> + where + Res: DeserializeOwned, + { + let http_client = client.http_client(); + + let mut token = if require_auth { + Some(llm_token.acquire(&client).await?) + } else { + llm_token.acquire(&client).await.ok() + }; + let mut did_retry = false; + + loop { + let request_builder = http_client::Request::builder().method(Method::POST); + + let mut request_builder = request_builder + .header("Content-Type", "application/json") + .header(ZED_VERSION_HEADER_NAME, app_version.to_string()); + + // Only add Authorization header if we have a token + if let Some(ref token_value) = token { + request_builder = + request_builder.header("Authorization", format!("Bearer {}", token_value)); + } + + let request = build(request_builder)?; + + let mut response = http_client.send(request).await?; + + if let Some(minimum_required_version) = response + .headers() + .get(MINIMUM_REQUIRED_VERSION_HEADER_NAME) + .and_then(|version| Version::from_str(version.to_str().ok()?).ok()) + { + anyhow::ensure!( + app_version >= minimum_required_version, + ZedUpdateRequiredError { + minimum_version: minimum_required_version + } + ); + } + + if response.status().is_success() { + let usage = EditPredictionUsage::from_headers(response.headers()).ok(); + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + return Ok((serde_json::from_slice(&body)?, usage)); + } else if !did_retry + && token.is_some() + && response + .headers() + .get(EXPIRED_LLM_TOKEN_HEADER_NAME) + .is_some() + { + did_retry = true; + token = Some(llm_token.refresh(&client).await?); } else { - break; + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + anyhow::bail!( + "Request failed with status: {:?}\nBody: {}", + response.status(), + body + ); } } + } - if let Some((model_old_range, model_new_text)) = model_edits.peek() { - let model_old_offset_range = model_old_range.to_offset(old_snapshot); - if user_edit.old == model_old_offset_range { - let user_new_text = new_snapshot - .text_for_range(user_edit.new.clone()) - .collect::(); + pub fn refresh_context( + &mut self, + project: &Entity, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut Context, + ) { + if self.use_context { + self.get_or_init_project(project, cx) + .context + .update(cx, |store, cx| { + store.refresh(buffer.clone(), cursor_position, cx); + }); + } + } - if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { - if !model_suffix.is_empty() { - let anchor = old_snapshot.anchor_after(user_edit.old.end); - edits.push((anchor..anchor, model_suffix.into())); - } + #[cfg(feature = "cli-support")] + pub fn set_context_for_buffer( + &mut self, + project: &Entity, + related_files: Vec, + cx: &mut Context, + ) { + self.get_or_init_project(project, cx) + .context + .update(cx, |store, _| { + store.set_related_files(related_files); + }); + } + + fn is_file_open_source( + &self, + project: &Entity, + file: &Arc, + cx: &App, + ) -> bool { + if !file.is_local() || file.is_private() { + return false; + } + let Some(project_state) = self.projects.get(&project.entity_id()) else { + return false; + }; + project_state + .license_detection_watchers + .get(&file.worktree_id(cx)) + .as_ref() + .is_some_and(|watcher| watcher.is_project_open_source()) + } - model_edits.next(); - continue; + fn can_collect_file(&self, project: &Entity, file: &Arc, cx: &App) -> bool { + self.data_collection_choice.is_enabled() && self.is_file_open_source(project, file, cx) + } + + fn can_collect_events(&self, events: &[Arc]) -> bool { + if !self.data_collection_choice.is_enabled() { + return false; + } + events.iter().all(|event| { + matches!( + event.as_ref(), + zeta_prompt::Event::BufferChange { + in_open_source_repo: true, + .. } + ) + }) + } + + fn load_data_collection_choice() -> DataCollectionChoice { + let choice = KEY_VALUE_STORE + .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) + .log_err() + .flatten(); + + match choice.as_deref() { + Some("true") => DataCollectionChoice::Enabled, + Some("false") => DataCollectionChoice::Disabled, + Some(_) => { + log::error!("unknown value in '{ZED_PREDICT_DATA_COLLECTION_CHOICE}'"); + DataCollectionChoice::NotAnswered } + None => DataCollectionChoice::NotAnswered, + } + } + + fn toggle_data_collection_choice(&mut self, cx: &mut Context) { + self.data_collection_choice = self.data_collection_choice.toggle(); + let new_choice = self.data_collection_choice; + db::write_and_log(cx, move || { + KEY_VALUE_STORE.write_kvp( + ZED_PREDICT_DATA_COLLECTION_CHOICE.into(), + new_choice.is_enabled().to_string(), + ) + }); + } + + pub fn shown_predictions(&self) -> impl DoubleEndedIterator { + self.shown_predictions.iter() + } + + pub fn shown_completions_len(&self) -> usize { + self.shown_predictions.len() + } + + pub fn is_prediction_rated(&self, id: &EditPredictionId) -> bool { + self.rated_predictions.contains(id) + } + + pub fn rate_prediction( + &mut self, + prediction: &EditPrediction, + rating: EditPredictionRating, + feedback: String, + cx: &mut Context, + ) { + self.rated_predictions.insert(prediction.id.clone()); + telemetry::event!( + "Edit Prediction Rated", + rating, + inputs = prediction.inputs, + output = prediction.edit_preview.as_unified_diff(&prediction.edits), + feedback + ); + self.client.telemetry().flush_events().detach(); + cx.notify(); + } + + fn configure_context_retrieval(&mut self, cx: &mut Context<'_, EditPredictionStore>) { + self.use_context = cx.has_flag::() + && all_language_settings(None, cx).edit_predictions.use_context; + } +} + +#[derive(Error, Debug)] +#[error( + "You must update to Zed version {minimum_version} or higher to continue using edit predictions." +)] +pub struct ZedUpdateRequiredError { + minimum_version: Version, +} + +#[cfg(feature = "cli-support")] +pub type EvalCacheKey = (EvalCacheEntryKind, u64); + +#[cfg(feature = "cli-support")] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum EvalCacheEntryKind { + Context, + Search, + Prediction, +} + +#[cfg(feature = "cli-support")] +impl std::fmt::Display for EvalCacheEntryKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EvalCacheEntryKind::Search => write!(f, "search"), + EvalCacheEntryKind::Context => write!(f, "context"), + EvalCacheEntryKind::Prediction => write!(f, "prediction"), + } + } +} + +#[cfg(feature = "cli-support")] +pub trait EvalCache: Send + Sync { + fn read(&self, key: EvalCacheKey) -> Option; + fn write(&self, key: EvalCacheKey, input: &str, value: &str); +} + +#[derive(Debug, Clone, Copy)] +pub enum DataCollectionChoice { + NotAnswered, + Enabled, + Disabled, +} + +impl DataCollectionChoice { + pub fn is_enabled(self) -> bool { + match self { + Self::Enabled => true, + Self::NotAnswered | Self::Disabled => false, } + } - return None; + pub fn is_answered(self) -> bool { + match self { + Self::Enabled | Self::Disabled => true, + Self::NotAnswered => false, + } } - edits.extend(model_edits.cloned()); + #[must_use] + pub fn toggle(&self) -> DataCollectionChoice { + match self { + Self::Enabled => Self::Disabled, + Self::Disabled => Self::Enabled, + Self::NotAnswered => Self::Enabled, + } + } +} + +impl From for DataCollectionChoice { + fn from(value: bool) -> Self { + match value { + true => DataCollectionChoice::Enabled, + false => DataCollectionChoice::Disabled, + } + } +} + +struct ZedPredictUpsell; + +impl Dismissable for ZedPredictUpsell { + const KEY: &'static str = "dismissed-edit-predict-upsell"; + + fn dismissed() -> bool { + // To make this backwards compatible with older versions of Zed, we + // check if the user has seen the previous Edit Prediction Onboarding + // before, by checking the data collection choice which was written to + // the database once the user clicked on "Accept and Enable" + if KEY_VALUE_STORE + .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) + .log_err() + .is_some_and(|s| s.is_some()) + { + return true; + } + + KEY_VALUE_STORE + .read_kvp(Self::KEY) + .log_err() + .is_some_and(|s| s.is_some()) + } +} + +pub fn should_show_upsell_modal() -> bool { + !ZedPredictUpsell::dismissed() +} + +pub fn init(cx: &mut App) { + cx.observe_new(move |workspace: &mut Workspace, _, _cx| { + workspace.register_action( + move |workspace, _: &zed_actions::OpenZedPredictOnboarding, window, cx| { + ZedPredictModal::toggle( + workspace, + workspace.user_store().clone(), + workspace.client().clone(), + window, + cx, + ) + }, + ); - if edits.is_empty() { None } else { Some(edits) } + workspace.register_action(|workspace, _: &ResetOnboarding, _window, cx| { + update_settings_file(workspace.app_state().fs.clone(), cx, move |settings, _| { + settings + .project + .all_languages + .features + .get_or_insert_default() + .edit_prediction_provider = Some(EditPredictionProvider::None) + }); + }); + }) + .detach(); } diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..40d729f1ebdcedf2c424d8c8df4bea1bb1829300 --- /dev/null +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -0,0 +1,2156 @@ +use super::*; +use crate::{compute_diff_between_snapshots, udiff::apply_diff_to_string, zeta1::MAX_EVENT_TOKENS}; +use client::{UserStore, test::FakeServer}; +use clock::{FakeSystemClock, ReplicaId}; +use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; +use cloud_llm_client::{ + EditPredictionRejectReason, EditPredictionRejection, PredictEditsBody, PredictEditsResponse, + RejectEditPredictionsBody, +}; +use futures::{ + AsyncReadExt, StreamExt, + channel::{mpsc, oneshot}, +}; +use gpui::{ + Entity, TestAppContext, + http_client::{FakeHttpClient, Response}, +}; +use indoc::indoc; +use language::{Point, ToOffset as _}; +use lsp::LanguageServerId; +use open_ai::Usage; +use parking_lot::Mutex; +use pretty_assertions::{assert_eq, assert_matches}; +use project::{FakeFs, Project}; +use serde_json::json; +use settings::SettingsStore; +use std::{path::Path, sync::Arc, time::Duration}; +use util::{path, rel_path::rel_path}; +use uuid::Uuid; +use zeta_prompt::ZetaPromptInput; + +use crate::{BufferEditPrediction, EditPredictionId, EditPredictionStore, REJECT_REQUEST_DEBOUNCE}; + +#[gpui::test] +async fn test_current_state(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!({ + "1.txt": "Hello!\nHow\nBye\n", + "2.txt": "Hola!\nComo\nAdios\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer1 = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("/root/1.txt"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot1 = buffer1.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot1.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_project(&project, cx); + ep_store.register_buffer(&buffer1, &project, cx); + }); + + // Prediction for current file + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx) + }); + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + respond_tx + .send(model_response( + request, + indoc! {r" + --- a/root/1.txt + +++ b/root/1.txt + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) + .unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer1, None, &project, cx) + .unwrap(); + assert_matches!(prediction, BufferEditPrediction::Local { .. }); + }); + + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project); + }); + + // Prediction for diagnostic in another file + + let diagnostic = lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: "Sentence is incomplete".to_string(), + ..Default::default() + }; + + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), + diagnostics: vec![diagnostic], + version: None, + }, + None, + language::DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx + .send(model_response( + request, + indoc! {r#" + --- a/root/2.txt + +++ b/root/2.txt + @@ ... @@ + Hola! + -Como + +Como estas? + Adios + "#}, + )) + .unwrap(); + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer1, None, &project, cx) + .unwrap(); + assert_matches!( + prediction, + BufferEditPrediction::Jump { prediction } if prediction.snapshot.file().unwrap().full_path(cx) == Path::new(path!("root/2.txt")) + ); + }); + + let buffer2 = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/2.txt"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer2, None, &project, cx) + .unwrap(); + assert_matches!(prediction, BufferEditPrediction::Local { .. }); + }); +} + +#[gpui::test] +async fn test_simple_request(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)); + + let prediction_task = ep_store.update(cx, |ep_store, cx| { + ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + // TODO Put back when we have a structured request again + // assert_eq!( + // request.excerpt_path.as_ref(), + // Path::new(path!("root/foo.md")) + // ); + // assert_eq!( + // request.cursor_point, + // Point { + // line: Line(1), + // column: 3 + // } + // ); + + respond_tx + .send(model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) + .unwrap(); + + let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); + + assert_eq!(prediction.edits.len(), 1); + assert_eq!( + prediction.edits[0].0.to_point(&snapshot).start, + language::Point::new(1, 3) + ); + assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); +} + +#[gpui::test] +async fn test_request_events(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!\n\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + }); + + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(7..7, "How")], None, cx); + }); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot.anchor_before(language::Point::new(1, 3)); + + let prediction_task = ep_store.update(cx, |ep_store, cx| { + ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + let prompt = prompt_from_request(&request); + assert!( + prompt.contains(indoc! {" + --- a/root/foo.md + +++ b/root/foo.md + @@ -1,3 +1,3 @@ + Hello! + - + +How + Bye + "}), + "{prompt}" + ); + + respond_tx + .send(model_response( + request, + indoc! {r#" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye + "#}, + )) + .unwrap(); + + let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); + + assert_eq!(prediction.edits.len(), 1); + assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); +} + +#[gpui::test] +async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\n\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + }); + + // First burst: insert "How" + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(7..7, "How")], None, cx); + }); + + // Simulate a pause longer than the grouping threshold (e.g. 500ms). + cx.executor().advance_clock(LAST_CHANGE_GROUPING_TIME * 2); + cx.run_until_parked(); + + // Second burst: append " are you?" immediately after "How" on the same line. + // + // Keeping both bursts on the same line ensures the existing line-span coalescing logic + // groups them into a single `LastEvent`, allowing the pause-split getter to return two diffs. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(10..10, " are you?")], None, cx); + }); + + // A second edit shortly after the first post-pause edit ensures the last edit timestamp is + // advanced after the pause boundary is recorded, making pause-splitting deterministic. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(19..19, "!")], None, cx); + }); + + // Without time-based splitting, there is one event. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!(events.len(), 1); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How are you?! + Bye + "} + ); + + // With time-based splitting, there are two distinct events. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + assert_eq!(events.len(), 2); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How + Bye + "} + ); + + let zeta_prompt::Event::BufferChange { diff, .. } = events[1].event.as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + -How + +How are you?! + Bye + "} + ); +} + +#[gpui::test] +async fn test_empty_prediction(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, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let response = model_response(request, ""); + let id = response.id.clone(); + respond_tx.send(response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .is_none() + ); + }); + + // prediction is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: id, + reason: EditPredictionRejectReason::Empty, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_interpolated_empty(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, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + + buffer.update(cx, |buffer, cx| { + buffer.set_text("Hello!\nHow are you?\nBye", cx); + }); + + let response = model_response(request, SIMPLE_DIFF); + let id = response.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() + ); + }); + + // prediction is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: id, + reason: EditPredictionRejectReason::InterpolatedEmpty, + was_shown: false + }] + ); +} + +const SIMPLE_DIFF: &str = indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are you? + Bye +"}; + +#[gpui::test] +async fn test_replace_current(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, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_tx.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + // a second request is triggered + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let second_response = model_response(request, SIMPLE_DIFF); + let second_id = second_response.id.clone(); + respond_tx.send(second_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // second replaces first + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + second_id + ); + }); + + // first is reported as replaced + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: first_id, + reason: EditPredictionRejectReason::Replaced, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_current_preferred(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, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + let first_response = model_response(request, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_tx.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + // a second request is triggered + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + // worse than current prediction + let second_response = model_response( + request, + indoc! { r" + --- a/root/foo.md + +++ b/root/foo.md + @@ ... @@ + Hello! + -How + +How are + Bye + "}, + ); + let second_id = second_response.id.clone(); + respond_tx.send(second_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // first is preferred over second + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + // second is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: second_id, + reason: EditPredictionRejectReason::CurrentPreferred, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_cancel_earlier_pending_requests(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)); + + // start two refresh tasks + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request1, respond_first) = requests.predict.next().await.unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request, respond_second) = requests.predict.next().await.unwrap(); + + // wait for throttle + cx.run_until_parked(); + + // second responds first + let second_response = model_response(request, SIMPLE_DIFF); + let second_id = second_response.id.clone(); + respond_second.send(second_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is second + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + second_id + ); + }); + + let first_response = model_response(request1, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_first.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is still second, since first was cancelled + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + second_id + ); + }); + + // first is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + cx.run_until_parked(); + + assert_eq!( + &reject_request.rejections, + &[EditPredictionRejection { + request_id: first_id, + reason: EditPredictionRejectReason::Canceled, + was_shown: false + }] + ); +} + +#[gpui::test] +async fn test_cancel_second_on_third_request(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)); + + // start two refresh tasks + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request1, respond_first) = requests.predict.next().await.unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (request2, respond_second) = requests.predict.next().await.unwrap(); + + // wait for throttle, so requests are sent + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // start a third request + ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + + // 2 are pending, so 2nd is cancelled + assert_eq!( + ep_store + .get_or_init_project(&project, cx) + .cancelled_predictions + .iter() + .copied() + .collect::>(), + [1] + ); + }); + + // wait for throttle + cx.run_until_parked(); + + let (request3, respond_third) = requests.predict.next().await.unwrap(); + + let first_response = model_response(request1, SIMPLE_DIFF); + let first_id = first_response.id.clone(); + respond_first.send(first_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is first + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + let cancelled_response = model_response(request2, SIMPLE_DIFF); + let cancelled_id = cancelled_response.id.clone(); + respond_second.send(cancelled_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // current prediction is still first, since second was cancelled + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + first_id + ); + }); + + let third_response = model_response(request3, SIMPLE_DIFF); + let third_response_id = third_response.id.clone(); + respond_third.send(third_response).unwrap(); + + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + // third completes and replaces first + assert_eq!( + ep_store + .prediction_at(&buffer, None, &project, cx) + .unwrap() + .id + .0, + third_response_id + ); + }); + + // second is reported as rejected + let (reject_request, _) = requests.reject.next().await.unwrap(); + + cx.run_until_parked(); + + assert_eq!( + &reject_request.rejections, + &[ + EditPredictionRejection { + request_id: cancelled_id, + reason: EditPredictionRejectReason::Canceled, + was_shown: false + }, + EditPredictionRejection { + request_id: first_id, + reason: EditPredictionRejectReason::Replaced, + was_shown: false + } + ] + ); +} + +#[gpui::test] +async fn test_rejections_flushing(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_prediction( + EditPredictionId("test-1".into()), + EditPredictionRejectReason::Discarded, + false, + ); + ep_store.reject_prediction( + EditPredictionId("test-2".into()), + EditPredictionRejectReason::Canceled, + true, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + // batched + assert_eq!(reject_request.rejections.len(), 2); + assert_eq!( + reject_request.rejections[0], + EditPredictionRejection { + request_id: "test-1".to_string(), + reason: EditPredictionRejectReason::Discarded, + was_shown: false + } + ); + assert_eq!( + reject_request.rejections[1], + EditPredictionRejection { + request_id: "test-2".to_string(), + reason: EditPredictionRejectReason::Canceled, + was_shown: true + } + ); + + // Reaching batch size limit sends without debounce + ep_store.update(cx, |ep_store, _cx| { + for i in 0..70 { + ep_store.reject_prediction( + EditPredictionId(format!("batch-{}", i).into()), + EditPredictionRejectReason::Discarded, + false, + ); + } + }); + + // First MAX/2 items are sent immediately + cx.run_until_parked(); + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 50); + assert_eq!(reject_request.rejections[0].request_id, "batch-0"); + assert_eq!(reject_request.rejections[49].request_id, "batch-49"); + + // Remaining items are debounced with the next batch + cx.executor().advance_clock(Duration::from_secs(15)); + cx.run_until_parked(); + + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 20); + assert_eq!(reject_request.rejections[0].request_id, "batch-50"); + assert_eq!(reject_request.rejections[19].request_id, "batch-69"); + + // Request failure + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_prediction( + EditPredictionId("retry-1".into()), + EditPredictionRejectReason::Discarded, + false, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + let (reject_request, _respond_tx) = requests.reject.next().await.unwrap(); + assert_eq!(reject_request.rejections.len(), 1); + assert_eq!(reject_request.rejections[0].request_id, "retry-1"); + // Simulate failure + drop(_respond_tx); + + // Add another rejection + ep_store.update(cx, |ep_store, _cx| { + ep_store.reject_prediction( + EditPredictionId("retry-2".into()), + EditPredictionRejectReason::Discarded, + false, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + // Retry should include both the failed item and the new one + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 2); + assert_eq!(reject_request.rejections[0].request_id, "retry-1"); + assert_eq!(reject_request.rejections[1].request_id, "retry-2"); +} + +// Skipped until we start including diagnostics in prompt +// #[gpui::test] +// async fn test_request_diagnostics(cx: &mut TestAppContext) { +// let (ep_store, mut req_rx) = init_test_with_fake_client(cx); +// let fs = FakeFs::new(cx.executor()); +// fs.insert_tree( +// "/root", +// json!({ +// "foo.md": "Hello!\nBye" +// }), +// ) +// .await; +// let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + +// let path_to_buffer_uri = lsp::Uri::from_file_path(path!("/root/foo.md")).unwrap(); +// let diagnostic = lsp::Diagnostic { +// range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), +// severity: Some(lsp::DiagnosticSeverity::ERROR), +// message: "\"Hello\" deprecated. Use \"Hi\" instead".to_string(), +// ..Default::default() +// }; + +// project.update(cx, |project, cx| { +// project.lsp_store().update(cx, |lsp_store, cx| { +// // Create some diagnostics +// lsp_store +// .update_diagnostics( +// LanguageServerId(0), +// lsp::PublishDiagnosticsParams { +// uri: path_to_buffer_uri.clone(), +// diagnostics: vec![diagnostic], +// version: None, +// }, +// None, +// language::DiagnosticSourceKind::Pushed, +// &[], +// cx, +// ) +// .unwrap(); +// }); +// }); + +// 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(0, 0)); + +// let _prediction_task = ep_store.update(cx, |ep_store, cx| { +// ep_store.request_prediction(&project, &buffer, position, cx) +// }); + +// let (request, _respond_tx) = req_rx.next().await.unwrap(); + +// assert_eq!(request.diagnostic_groups.len(), 1); +// let value = serde_json::from_str::(request.diagnostic_groups[0].0.get()) +// .unwrap(); +// // We probably don't need all of this. TODO define a specific diagnostic type in predict_edits_v3 +// assert_eq!( +// value, +// json!({ +// "entries": [{ +// "range": { +// "start": 8, +// "end": 10 +// }, +// "diagnostic": { +// "source": null, +// "code": null, +// "code_description": null, +// "severity": 1, +// "message": "\"Hello\" deprecated. Use \"Hi\" instead", +// "markdown": null, +// "group_id": 0, +// "is_primary": true, +// "is_disk_based": false, +// "is_unnecessary": false, +// "source_kind": "Pushed", +// "data": null, +// "underline": true +// } +// }], +// "primary_ix": 0 +// }) +// ); +// } + +// Generate a model response that would apply the given diff to the active file. +fn model_response(request: open_ai::Request, diff_to_apply: &str) -> open_ai::Response { + let prompt = match &request.messages[0] { + open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(content), + } => content, + _ => panic!("unexpected request {request:?}"), + }; + + let open = "\n"; + let close = ""; + let cursor = "<|user_cursor|>"; + + let start_ix = open.len() + prompt.find(open).unwrap(); + let end_ix = start_ix + &prompt[start_ix..].find(close).unwrap(); + let excerpt = prompt[start_ix..end_ix].replace(cursor, ""); + let new_excerpt = apply_diff_to_string(diff_to_apply, &excerpt).unwrap(); + + open_ai::Response { + id: Uuid::new_v4().to_string(), + object: "response".into(), + created: 0, + model: "model".into(), + choices: vec![open_ai::Choice { + index: 0, + message: open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain(new_excerpt)), + tool_calls: vec![], + }, + finish_reason: None, + }], + usage: Usage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + } +} + +fn prompt_from_request(request: &open_ai::Request) -> &str { + assert_eq!(request.messages.len(), 1); + let open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(content), + .. + } = &request.messages[0] + else { + panic!( + "Request does not have single user message of type Plain. {:#?}", + request + ); + }; + content +} + +struct RequestChannels { + predict: mpsc::UnboundedReceiver<(open_ai::Request, oneshot::Sender)>, + reject: mpsc::UnboundedReceiver<(RejectEditPredictionsBody, oneshot::Sender<()>)>, +} + +fn init_test_with_fake_client( + cx: &mut TestAppContext, +) -> (Entity, RequestChannels) { + cx.update(move |cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + zlog::init_test(); + + let (predict_req_tx, predict_req_rx) = mpsc::unbounded(); + let (reject_req_tx, reject_req_rx) = mpsc::unbounded(); + + let http_client = FakeHttpClient::create({ + move |req| { + let uri = req.uri().path().to_string(); + let mut body = req.into_body(); + let predict_req_tx = predict_req_tx.clone(); + let reject_req_tx = reject_req_tx.clone(); + async move { + let resp = match uri.as_str() { + "/client/llm_tokens" => serde_json::to_string(&json!({ + "token": "test" + })) + .unwrap(), + "/predict_edits/raw" => { + let mut buf = Vec::new(); + body.read_to_end(&mut buf).await.ok(); + let req = serde_json::from_slice(&buf).unwrap(); + + let (res_tx, res_rx) = oneshot::channel(); + predict_req_tx.unbounded_send((req, res_tx)).unwrap(); + serde_json::to_string(&res_rx.await?).unwrap() + } + "/predict_edits/reject" => { + let mut buf = Vec::new(); + body.read_to_end(&mut buf).await.ok(); + let req = serde_json::from_slice(&buf).unwrap(); + + let (res_tx, res_rx) = oneshot::channel(); + reject_req_tx.unbounded_send((req, res_tx)).unwrap(); + serde_json::to_string(&res_rx.await?).unwrap() + } + _ => { + panic!("Unexpected path: {}", uri) + } + }; + + Ok(Response::builder().body(resp.into()).unwrap()) + } + } + }); + + let client = client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); + client.cloud_client().set_credentials(1, "test".into()); + + language_model::init(client.clone(), cx); + + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let ep_store = EditPredictionStore::global(&client, &user_store, cx); + + ( + ep_store, + RequestChannels { + predict: predict_req_rx, + reject: reject_req_rx, + }, + ) + }) +} + +const BSD_0_TXT: &str = include_str!("../license_examples/0bsd.txt"); + +#[gpui::test] +async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); + let edits: Arc<[(Range, Arc)]> = cx.update(|cx| { + to_completion_edits([(2..5, "REM".into()), (9..11, "".into())], &buffer, cx).into() + }); + + let edit_preview = cx + .read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx)) + .await; + + let prediction = EditPrediction { + edits, + edit_preview, + buffer: buffer.clone(), + snapshot: cx.read(|cx| buffer.read(cx).snapshot()), + id: EditPredictionId("the-id".into()), + inputs: ZetaPromptInput { + events: Default::default(), + related_files: Default::default(), + cursor_path: Path::new("").into(), + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, + cursor_offset_in_excerpt: 0, + }, + buffer_snapshotted_at: Instant::now(), + response_received_at: Instant::now(), + }; + + cx.update(|cx| { + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".into()), (9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..2, "REM".into()), (6..8, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".into()), (9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(3..3, "EM".into()), (7..9, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into()), (8..10, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(9..11, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into()), (8..10, "".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); + assert_eq!( + from_completion_edits( + &prediction.interpolate(&buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".into())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); + assert_eq!(prediction.interpolate(&buffer.read(cx).snapshot()), None); + }) +} + +#[gpui::test] +async fn test_clean_up_diff(cx: &mut TestAppContext) { + init_test(cx); + + assert_eq!( + apply_edit_prediction( + indoc! {" + fn main() { + let word_1 = \"lorem\"; + let range = word.len()..word.len(); + } + "}, + indoc! {" + <|editable_region_start|> + fn main() { + let word_1 = \"lorem\"; + let range = word_1.len()..word_1.len(); + } + + <|editable_region_end|> + "}, + cx, + ) + .await, + indoc! {" + fn main() { + let word_1 = \"lorem\"; + let range = word_1.len()..word_1.len(); + } + "}, + ); + + assert_eq!( + apply_edit_prediction( + indoc! {" + fn main() { + let story = \"the quick\" + } + "}, + indoc! {" + <|editable_region_start|> + fn main() { + let story = \"the quick brown fox jumps over the lazy dog\"; + } + + <|editable_region_end|> + "}, + cx, + ) + .await, + indoc! {" + fn main() { + let story = \"the quick brown fox jumps over the lazy dog\"; + } + "}, + ); +} + +#[gpui::test] +async fn test_edit_prediction_end_of_buffer(cx: &mut TestAppContext) { + init_test(cx); + + let buffer_content = "lorem\n"; + let completion_response = indoc! {" + ```animals.js + <|start_of_file|> + <|editable_region_start|> + lorem + ipsum + <|editable_region_end|> + ```"}; + + assert_eq!( + apply_edit_prediction(buffer_content, completion_response, cx).await, + "lorem\nipsum" + ); +} + +#[gpui::test] +async fn test_can_collect_data(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({ "LICENSE": BSD_0_TXT })) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/src/main.rs"), cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); + + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Disabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_remote_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + + let buffer = cx.new(|_cx| { + Buffer::remote( + language::BufferId::new(1).unwrap(), + ReplicaId::new(1), + language::Capability::ReadWrite, + "fn main() {\n println!(\"Hello\");\n}", + ) + }); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_private_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "LICENSE": BSD_0_TXT, + ".env": "SECRET_KEY=secret" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/project/.env", cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_untitled_buffer(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + let buffer = cx.new(|cx| Buffer::local("", cx)); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_when_closed_source(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({ "main.rs": "fn main() {}" })) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/project/main.rs", cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_data_collection_status_changes_on_move(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/open_source_worktree"), + json!({ "LICENSE": BSD_0_TXT, "main.rs": "" }), + ) + .await; + fs.insert_tree(path!("/closed_source_worktree"), json!({ "main.rs": "" })) + .await; + + let project = Project::test( + fs.clone(), + [ + path!("/open_source_worktree").as_ref(), + path!("/closed_source_worktree").as_ref(), + ], + cx, + ) + .await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/open_source_worktree/main.rs"), cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); + + let closed_source_file = project + .update(cx, |project, cx| { + let worktree2 = project + .worktree_for_root_name("closed_source_worktree", cx) + .unwrap(); + worktree2.update(cx, |worktree2, cx| { + worktree2.load_file(rel_path("main.rs"), cx) + }) + }) + .await + .unwrap() + .file; + + buffer.update(cx, |buffer, cx| { + buffer.file_updated(closed_source_file, cx); + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); +} + +#[gpui::test] +async fn test_no_data_collection_for_events_in_uncollectable_buffers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/worktree1"), + json!({ "LICENSE": BSD_0_TXT, "main.rs": "", "other.rs": "" }), + ) + .await; + fs.insert_tree(path!("/worktree2"), json!({ "private.rs": "" })) + .await; + + let project = Project::test( + fs.clone(), + [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], + cx, + ) + .await; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/worktree1/main.rs"), cx) + }) + .await + .unwrap(); + let private_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/worktree2/file.rs"), cx) + }) + .await + .unwrap(); + + let (ep_store, captured_request, _) = make_test_ep_store(&project, cx).await; + ep_store.update(cx, |ep_store, _cx| { + ep_store.data_collection_choice = DataCollectionChoice::Enabled + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); + + // this has a side effect of registering the buffer to watch for edits + run_edit_prediction(&private_buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); + + private_buffer.update(cx, |private_buffer, cx| { + private_buffer.edit([(0..0, "An edit for the history!")], None, cx); + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + false + ); + + // make an edit that uses too many bytes, causing private_buffer edit to not be able to be + // included + buffer.update(cx, |buffer, cx| { + buffer.edit( + [( + 0..0, + " ".repeat(MAX_EVENT_TOKENS * cursor_excerpt::BYTES_PER_TOKEN_GUESS), + )], + None, + cx, + ); + }); + + run_edit_prediction(&buffer, &project, &ep_store, cx).await; + assert_eq!( + captured_request.lock().clone().unwrap().can_collect_data, + true + ); +} + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); +} + +async fn apply_edit_prediction( + buffer_content: &str, + completion_response: &str, + cx: &mut TestAppContext, +) -> String { + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); + let (ep_store, _, response) = make_test_ep_store(&project, cx).await; + *response.lock() = completion_response.to_string(); + let edit_prediction = run_edit_prediction(&buffer, &project, &ep_store, cx).await; + buffer.update(cx, |buffer, cx| { + buffer.edit(edit_prediction.edits.iter().cloned(), None, cx) + }); + buffer.read_with(cx, |buffer, _| buffer.text()) +} + +async fn run_edit_prediction( + buffer: &Entity, + project: &Entity, + ep_store: &Entity, + cx: &mut TestAppContext, +) -> EditPrediction { + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + let prediction_task = ep_store.update(cx, |ep_store, cx| { + ep_store.request_prediction(&project, buffer, cursor, Default::default(), cx) + }); + prediction_task.await.unwrap().unwrap().prediction.unwrap() +} + +async fn make_test_ep_store( + project: &Entity, + cx: &mut TestAppContext, +) -> ( + Entity, + Arc>>, + Arc>, +) { + let default_response = indoc! {" + ```main.rs + <|start_of_file|> + <|editable_region_start|> + hello world + <|editable_region_end|> + ```" + }; + let captured_request: Arc>> = Arc::new(Mutex::new(None)); + let completion_response: Arc> = + Arc::new(Mutex::new(default_response.to_string())); + let http_client = FakeHttpClient::create({ + let captured_request = captured_request.clone(); + let completion_response = completion_response.clone(); + let mut next_request_id = 0; + move |req| { + let captured_request = captured_request.clone(); + let completion_response = completion_response.clone(); + async move { + match (req.method(), req.uri().path()) { + (&Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&CreateLlmTokenResponse { + token: LlmToken("the-llm-token".to_string()), + }) + .unwrap() + .into(), + ) + .unwrap()), + (&Method::POST, "/predict_edits/v2") => { + let mut request_body = String::new(); + req.into_body().read_to_string(&mut request_body).await?; + *captured_request.lock() = + Some(serde_json::from_str(&request_body).unwrap()); + next_request_id += 1; + Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&PredictEditsResponse { + request_id: format!("request-{next_request_id}"), + output_excerpt: completion_response.lock().clone(), + }) + .unwrap() + .into(), + ) + .unwrap()) + } + _ => Ok(http_client::Response::builder() + .status(404) + .body("Not Found".into()) + .unwrap()), + } + } + } + }); + + let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + RefreshLlmTokenListener::register(client.clone(), cx); + }); + let _server = FakeServer::for_client(42, &client, cx).await; + + let ep_store = cx.new(|cx| { + let mut ep_store = EditPredictionStore::new(client, project.read(cx).user_store(), cx); + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + + let worktrees = project.read(cx).worktrees(cx).collect::>(); + for worktree in worktrees { + let worktree_id = worktree.read(cx).id(); + ep_store + .get_or_init_project(project, cx) + .license_detection_watchers + .entry(worktree_id) + .or_insert_with(|| Rc::new(LicenseDetectionWatcher::new(&worktree, cx))); + } + + ep_store + }); + + (ep_store, captured_request, completion_response) +} + +fn to_completion_edits( + iterator: impl IntoIterator, Arc)>, + buffer: &Entity, + cx: &App, +) -> Vec<(Range, Arc)> { + let buffer = buffer.read(cx); + iterator + .into_iter() + .map(|(range, text)| { + ( + buffer.anchor_after(range.start)..buffer.anchor_before(range.end), + text, + ) + }) + .collect() +} + +fn from_completion_edits( + editor_edits: &[(Range, Arc)], + buffer: &Entity, + cx: &App, +) -> Vec<(Range, Arc)> { + let buffer = buffer.read(cx); + editor_edits + .iter() + .map(|(range, text)| { + ( + range.start.to_offset(buffer)..range.end.to_offset(buffer), + text.clone(), + ) + }) + .collect() +} + +#[gpui::test] +async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let http_client = FakeHttpClient::create(|_req| async move { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let result = completion_task.await; + assert!( + result.is_err(), + "Without authentication and without custom URL, prediction should fail" + ); +} + +#[gpui::test] +async fn test_unauthenticated_with_custom_url_allows_prediction_impl(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + "main.rs": "fn main() {\n \n}\n" + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + + let predict_called = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let predict_called_clone = predict_called.clone(); + + let http_client = FakeHttpClient::create({ + move |req| { + let uri = req.uri().path().to_string(); + let predict_called = predict_called_clone.clone(); + async move { + if uri.contains("predict") { + predict_called.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(gpui::http_client::Response::builder() + .body( + serde_json::to_string(&open_ai::Response { + id: "test-123".to_string(), + object: "chat.completion".to_string(), + created: 0, + model: "test".to_string(), + usage: open_ai::Usage { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + choices: vec![open_ai::Choice { + index: 0, + message: open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain( + indoc! {" + ```main.rs + <|start_of_file|> + <|editable_region_start|> + fn main() { + println!(\"Hello, world!\"); + } + <|editable_region_end|> + ``` + "} + .to_string(), + )), + tool_calls: vec![], + }, + finish_reason: Some("stop".to_string()), + }], + }) + .unwrap() + .into(), + ) + .unwrap()) + } else { + Ok(gpui::http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()) + } + } + } + }); + + let client = + cx.update(|cx| client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + language_model::RefreshLlmTokenListener::register(client.clone(), cx); + }); + + let ep_store = cx.new(|cx| EditPredictionStore::new(client, project.read(cx).user_store(), cx)); + + let buffer = project + .update(cx, |project, cx| { + let path = project + .find_project_path(path!("/project/main.rs"), cx) + .unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 4))); + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx) + }); + cx.background_executor.run_until_parked(); + + let completion_task = ep_store.update(cx, |ep_store, cx| { + ep_store.set_custom_predict_edits_url(Url::parse("http://test/predict").unwrap()); + ep_store.set_edit_prediction_model(EditPredictionModel::Zeta1); + ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + }); + + let _ = completion_task.await; + + assert!( + predict_called.load(std::sync::atomic::Ordering::SeqCst), + "With custom URL, predict endpoint should be called even without authentication" + ); +} + +#[gpui::test] +fn test_compute_diff_between_snapshots(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| { + Buffer::local( + indoc! {" + zero + one + two + three + four + five + six + seven + eight + nine + ten + eleven + twelve + thirteen + fourteen + fifteen + sixteen + seventeen + eighteen + nineteen + twenty + twenty-one + twenty-two + twenty-three + twenty-four + "}, + cx, + ) + }); + + let old_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + + buffer.update(cx, |buffer, cx| { + let point = Point::new(12, 0); + buffer.edit([(point..point, "SECOND INSERTION\n")], None, cx); + let point = Point::new(8, 0); + buffer.edit([(point..point, "FIRST INSERTION\n")], None, cx); + }); + + let new_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); + + let diff = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap(); + + assert_eq!( + diff, + indoc! {" + @@ -6,10 +6,12 @@ + five + six + seven + +FIRST INSERTION + eight + nine + ten + eleven + +SECOND INSERTION + twelve + thirteen + fourteen + "} + ); +} + +#[ctor::ctor] +fn init_logger() { + zlog::init_test(); +} diff --git a/crates/edit_prediction/src/example_spec.rs b/crates/edit_prediction/src/example_spec.rs new file mode 100644 index 0000000000000000000000000000000000000000..8a30c7b85c494fcc7a0b32ece665f814e8452bc4 --- /dev/null +++ b/crates/edit_prediction/src/example_spec.rs @@ -0,0 +1,212 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt::Write as _, mem, path::Path, sync::Arc}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExampleSpec { + #[serde(default)] + pub name: String, + pub repository_url: String, + pub revision: String, + #[serde(default)] + pub uncommitted_diff: String, + pub cursor_path: Arc, + pub cursor_position: String, + pub edit_history: String, + pub expected_patch: String, +} + +const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; +const EDIT_HISTORY_HEADING: &str = "Edit History"; +const CURSOR_POSITION_HEADING: &str = "Cursor Position"; +const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; +const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; +const REPOSITORY_URL_FIELD: &str = "repository_url"; +const REVISION_FIELD: &str = "revision"; + +impl ExampleSpec { + /// Format this example spec as markdown. + pub fn to_markdown(&self) -> String { + let mut markdown = String::new(); + + _ = writeln!(markdown, "# {}", self.name); + markdown.push('\n'); + + _ = writeln!(markdown, "repository_url = {}", self.repository_url); + _ = writeln!(markdown, "revision = {}", self.revision); + markdown.push('\n'); + + if !self.uncommitted_diff.is_empty() { + _ = writeln!(markdown, "## {}", UNCOMMITTED_DIFF_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.uncommitted_diff); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", EDIT_HISTORY_HEADING); + _ = writeln!(markdown); + + if self.edit_history.is_empty() { + _ = writeln!(markdown, "(No edit history)"); + _ = writeln!(markdown); + } else { + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.edit_history); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", CURSOR_POSITION_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```{}", self.cursor_path.to_string_lossy()); + markdown.push_str(&self.cursor_position); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + _ = writeln!(markdown, "## {}", EXPECTED_PATCH_HEADING); + markdown.push('\n'); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.expected_patch); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + markdown + } + + /// Parse an example spec from markdown. + pub fn from_markdown(name: String, input: &str) -> anyhow::Result { + use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd}; + + let parser = Parser::new(input); + + let mut spec = ExampleSpec { + name, + repository_url: String::new(), + revision: String::new(), + uncommitted_diff: String::new(), + cursor_path: Path::new("").into(), + cursor_position: String::new(), + edit_history: String::new(), + expected_patch: String::new(), + }; + + let mut text = String::new(); + let mut block_info: CowStr = "".into(); + + #[derive(PartialEq)] + enum Section { + Start, + UncommittedDiff, + EditHistory, + CursorPosition, + ExpectedExcerpts, + ExpectedPatch, + Other, + } + + let mut current_section = Section::Start; + + for event in parser { + match event { + Event::Text(line) => { + text.push_str(&line); + + if let Section::Start = current_section + && let Some((field, value)) = line.split_once('=') + { + match field.trim() { + REPOSITORY_URL_FIELD => { + spec.repository_url = value.trim().to_string(); + } + REVISION_FIELD => { + spec.revision = value.trim().to_string(); + } + _ => {} + } + } + } + Event::End(TagEnd::Heading(HeadingLevel::H2)) => { + let title = mem::take(&mut text); + current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { + Section::UncommittedDiff + } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { + Section::EditHistory + } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { + Section::CursorPosition + } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { + Section::ExpectedPatch + } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { + Section::ExpectedExcerpts + } else { + Section::Other + }; + } + Event::End(TagEnd::Heading(HeadingLevel::H3)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(HeadingLevel::H4)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(level)) => { + anyhow::bail!("Unexpected heading level: {level}"); + } + Event::Start(Tag::CodeBlock(kind)) => { + match kind { + CodeBlockKind::Fenced(info) => { + block_info = info; + } + CodeBlockKind::Indented => { + anyhow::bail!("Unexpected indented codeblock"); + } + }; + } + Event::Start(_) => { + text.clear(); + block_info = "".into(); + } + Event::End(TagEnd::CodeBlock) => { + let block_info = block_info.trim(); + match current_section { + Section::UncommittedDiff => { + spec.uncommitted_diff = mem::take(&mut text); + } + Section::EditHistory => { + spec.edit_history.push_str(&mem::take(&mut text)); + } + Section::CursorPosition => { + spec.cursor_path = Path::new(block_info).into(); + spec.cursor_position = mem::take(&mut text); + } + Section::ExpectedExcerpts => { + mem::take(&mut text); + } + Section::ExpectedPatch => { + spec.expected_patch = mem::take(&mut text); + } + Section::Start | Section::Other => {} + } + } + _ => {} + } + } + + if spec.cursor_path.as_ref() == Path::new("") || spec.cursor_position.is_empty() { + anyhow::bail!("Missing cursor position codeblock"); + } + + Ok(spec) + } +} diff --git a/crates/zeta/src/license_detection.rs b/crates/edit_prediction/src/license_detection.rs similarity index 99% rename from crates/zeta/src/license_detection.rs rename to crates/edit_prediction/src/license_detection.rs index d4d4825615f19e5e5654f7bd78439d9eaa39e4c1..3ad34e7e6df6233cd4ff7462681d7b3588d36534 100644 --- a/crates/zeta/src/license_detection.rs +++ b/crates/edit_prediction/src/license_detection.rs @@ -735,6 +735,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -758,6 +759,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -816,6 +818,7 @@ mod tests { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs new file mode 100644 index 0000000000000000000000000000000000000000..8186fc5d8c609468be04c117eabac11c6c015efd --- /dev/null +++ b/crates/edit_prediction/src/mercury.rs @@ -0,0 +1,322 @@ +use crate::{ + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, open_ai_response::text_from_response, + prediction::EditPredictionResult, +}; +use anyhow::{Context as _, Result}; +use futures::AsyncReadExt as _; +use gpui::{ + App, AppContext as _, Entity, Global, SharedString, Task, + http_client::{self, AsyncBody, Method}, +}; +use language::{OffsetRangeExt as _, ToOffset, ToPoint as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; +use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; +use zeta_prompt::ZetaPromptInput; + +const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; +const MAX_CONTEXT_TOKENS: usize = 150; +const MAX_REWRITE_TOKENS: usize = 350; + +pub struct Mercury { + pub api_token: Entity, +} + +impl Mercury { + pub fn new(cx: &mut App) -> Self { + Mercury { + api_token: mercury_api_token(cx), + } + } + + pub(crate) fn request_prediction( + &self, + EditPredictionModelInput { + buffer, + snapshot, + position, + events, + related_files, + debug_tx, + .. + }: EditPredictionModelInput, + cx: &mut App, + ) -> Task>> { + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else { + return Task::ready(Ok(None)); + }; + let full_path: Arc = snapshot + .file() + .map(|file| file.full_path(cx)) + .unwrap_or_else(|| "untitled".into()) + .into(); + + let http_client = cx.http_client(); + let cursor_point = position.to_point(&snapshot); + let buffer_snapshotted_at = Instant::now(); + let active_buffer = buffer.clone(); + + let result = cx.background_spawn(async move { + let (editable_range, context_range) = + crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + &snapshot, + MAX_CONTEXT_TOKENS, + MAX_REWRITE_TOKENS, + ); + + let context_offset_range = context_range.to_offset(&snapshot); + + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = zeta_prompt::ZetaPromptInput { + events, + related_files, + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) + - context_range.start.to_offset(&snapshot), + cursor_path: full_path.clone(), + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_offset_range.start + - context_offset_range.start) + ..(editable_offset_range.end - context_offset_range.start), + }; + + let prompt = build_prompt(&inputs); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: active_buffer.downgrade(), + prompt: Some(prompt.clone()), + position, + }, + )) + .ok(); + } + + let request_body = open_ai::Request { + model: "mercury-coder".into(), + messages: vec![open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(prompt), + }], + stream: false, + max_completion_tokens: None, + stop: vec![], + temperature: None, + tool_choice: None, + parallel_tool_calls: None, + tools: vec![], + prompt_cache_key: None, + reasoning_effort: None, + }; + + let buf = serde_json::to_vec(&request_body)?; + let body: AsyncBody = buf.into(); + + let request = http_client::Request::builder() + .uri(MERCURY_API_URL) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_token)) + .header("Connection", "keep-alive") + .method(Method::POST) + .body(body) + .context("Failed to create request")?; + + let mut response = http_client + .send(request) + .await + .context("Failed to send request")?; + + let mut body: Vec = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("Failed to read response body")?; + + let response_received_at = Instant::now(); + if !response.status().is_success() { + anyhow::bail!( + "Request failed with status: {:?}\nBody: {}", + response.status(), + String::from_utf8_lossy(&body), + ); + }; + + let mut response: open_ai::Response = + serde_json::from_slice(&body).context("Failed to parse response")?; + + let id = mem::take(&mut response.id); + let response_str = text_from_response(response).unwrap_or_default(); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: active_buffer.downgrade(), + model_output: Some(response_str.clone()), + position, + }, + )) + .ok(); + } + + let response_str = response_str.strip_prefix("```\n").unwrap_or(&response_str); + let response_str = response_str.strip_suffix("\n```").unwrap_or(&response_str); + + let mut edits = Vec::new(); + const NO_PREDICTION_OUTPUT: &str = "None"; + + if response_str != NO_PREDICTION_OUTPUT { + let old_text = snapshot + .text_for_range(editable_offset_range.clone()) + .collect::(); + edits.extend( + language::text_diff(&old_text, &response_str) + .into_iter() + .map(|(range, text)| { + ( + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot + .anchor_before(editable_offset_range.start + range.end), + text, + ) + }), + ); + } + + anyhow::Ok((id, edits, snapshot, response_received_at, inputs)) + }); + + cx.spawn(async move |cx| { + let (id, edits, old_snapshot, response_received_at, inputs) = + result.await.context("Mercury edit prediction failed")?; + anyhow::Ok(Some( + EditPredictionResult::new( + EditPredictionId(id.into()), + &buffer, + &old_snapshot, + edits.into(), + buffer_snapshotted_at, + response_received_at, + inputs, + cx, + ) + .await, + )) + }) + } +} + +fn build_prompt(inputs: &ZetaPromptInput) -> String { + const RECENTLY_VIEWED_SNIPPETS_START: &str = "<|recently_viewed_code_snippets|>\n"; + const RECENTLY_VIEWED_SNIPPETS_END: &str = "<|/recently_viewed_code_snippets|>\n"; + const RECENTLY_VIEWED_SNIPPET_START: &str = "<|recently_viewed_code_snippet|>\n"; + const RECENTLY_VIEWED_SNIPPET_END: &str = "<|/recently_viewed_code_snippet|>\n"; + const CURRENT_FILE_CONTENT_START: &str = "<|current_file_content|>\n"; + const CURRENT_FILE_CONTENT_END: &str = "<|/current_file_content|>\n"; + const CODE_TO_EDIT_START: &str = "<|code_to_edit|>\n"; + const CODE_TO_EDIT_END: &str = "<|/code_to_edit|>\n"; + const EDIT_DIFF_HISTORY_START: &str = "<|edit_diff_history|>\n"; + const EDIT_DIFF_HISTORY_END: &str = "<|/edit_diff_history|>\n"; + const CURSOR_TAG: &str = "<|cursor|>"; + const CODE_SNIPPET_FILE_PATH_PREFIX: &str = "code_snippet_file_path: "; + const CURRENT_FILE_PATH_PREFIX: &str = "current_file_path: "; + + let mut prompt = String::new(); + + push_delimited( + &mut prompt, + RECENTLY_VIEWED_SNIPPETS_START..RECENTLY_VIEWED_SNIPPETS_END, + |prompt| { + for related_file in inputs.related_files.iter() { + for related_excerpt in &related_file.excerpts { + push_delimited( + prompt, + RECENTLY_VIEWED_SNIPPET_START..RECENTLY_VIEWED_SNIPPET_END, + |prompt| { + prompt.push_str(CODE_SNIPPET_FILE_PATH_PREFIX); + prompt.push_str(related_file.path.to_string_lossy().as_ref()); + prompt.push('\n'); + prompt.push_str(&related_excerpt.text.to_string()); + }, + ); + } + } + }, + ); + + push_delimited( + &mut prompt, + CURRENT_FILE_CONTENT_START..CURRENT_FILE_CONTENT_END, + |prompt| { + prompt.push_str(CURRENT_FILE_PATH_PREFIX); + prompt.push_str(inputs.cursor_path.as_os_str().to_string_lossy().as_ref()); + prompt.push('\n'); + + prompt.push_str(&inputs.cursor_excerpt[0..inputs.editable_range_in_excerpt.start]); + push_delimited(prompt, CODE_TO_EDIT_START..CODE_TO_EDIT_END, |prompt| { + prompt.push_str( + &inputs.cursor_excerpt + [inputs.editable_range_in_excerpt.start..inputs.cursor_offset_in_excerpt], + ); + prompt.push_str(CURSOR_TAG); + prompt.push_str( + &inputs.cursor_excerpt + [inputs.cursor_offset_in_excerpt..inputs.editable_range_in_excerpt.end], + ); + }); + prompt.push_str(&inputs.cursor_excerpt[inputs.editable_range_in_excerpt.end..]); + }, + ); + + push_delimited( + &mut prompt, + EDIT_DIFF_HISTORY_START..EDIT_DIFF_HISTORY_END, + |prompt| { + for event in inputs.events.iter() { + zeta_prompt::write_event(prompt, &event); + } + }, + ); + + prompt +} + +fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce(&mut String)) { + prompt.push_str(delimiters.start); + cb(prompt); + prompt.push_str(delimiters.end); +} + +pub const MERCURY_CREDENTIALS_URL: SharedString = + SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions"); +pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token"; +pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("MERCURY_AI_TOKEN"); + +struct GlobalMercuryApiKey(Entity); + +impl Global for GlobalMercuryApiKey {} + +pub fn mercury_api_token(cx: &mut App) -> Entity { + if let Some(global) = cx.try_global::() { + return global.0.clone(); + } + let entity = + cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone())); + cx.set_global(GlobalMercuryApiKey(entity.clone())); + entity +} + +pub fn load_mercury_api_token(cx: &mut App) -> Task> { + mercury_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx) + }) +} diff --git a/crates/zeta/src/onboarding_modal.rs b/crates/edit_prediction/src/onboarding_modal.rs similarity index 98% rename from crates/zeta/src/onboarding_modal.rs rename to crates/edit_prediction/src/onboarding_modal.rs index ed7adfc75476afb07f9c56b9c9c03abbbcef1134..97f529ae38df350ef21ffc04b79df6e8e6a7a501 100644 --- a/crates/zeta/src/onboarding_modal.rs +++ b/crates/edit_prediction/src/onboarding_modal.rs @@ -131,8 +131,8 @@ impl Render for ZedPredictModal { onboarding_event!("Cancelled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div() diff --git a/crates/edit_prediction/src/open_ai_response.rs b/crates/edit_prediction/src/open_ai_response.rs new file mode 100644 index 0000000000000000000000000000000000000000..c7e3350936dd89c89849130ba279ad2914dd2bd8 --- /dev/null +++ b/crates/edit_prediction/src/open_ai_response.rs @@ -0,0 +1,31 @@ +pub fn text_from_response(mut res: open_ai::Response) -> Option { + let choice = res.choices.pop()?; + let output_text = match choice.message { + open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Plain(content)), + .. + } => content, + open_ai::RequestMessage::Assistant { + content: Some(open_ai::MessageContent::Multipart(mut content)), + .. + } => { + if content.is_empty() { + log::error!("No output from Baseten completion response"); + return None; + } + + match content.remove(0) { + open_ai::MessagePart::Text { text } => text, + open_ai::MessagePart::Image { .. } => { + log::error!("Expected text, got an image"); + return None; + } + } + } + _ => { + log::error!("Invalid response message: {:?}", choice.message); + return None; + } + }; + Some(output_text) +} diff --git a/crates/zeta/src/prediction.rs b/crates/edit_prediction/src/prediction.rs similarity index 76% rename from crates/zeta/src/prediction.rs rename to crates/edit_prediction/src/prediction.rs index fd3241730030fe8bdd95e2cae9ee87b406ade735..c63640ccd0e1815b32f736e8a0fee8d75d124df1 100644 --- a/crates/zeta/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -1,14 +1,14 @@ use std::{ ops::Range, - path::Path, sync::Arc, time::{Duration, Instant}, }; use cloud_llm_client::EditPredictionRejectReason; +use edit_prediction_types::interpolate_edits; use gpui::{AsyncApp, Entity, SharedString}; -use language::{Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, TextBufferSnapshot}; -use serde::Serialize; +use language::{Anchor, Buffer, BufferSnapshot, EditPreview, TextBufferSnapshot}; +use zeta_prompt::ZetaPromptInput; #[derive(Clone, Default, Debug, PartialEq, Eq, Hash)] pub struct EditPredictionId(pub SharedString); @@ -39,7 +39,7 @@ impl EditPredictionResult { edits: Arc<[(Range, Arc)]>, buffer_snapshotted_at: Instant, response_received_at: Instant, - inputs: EditPredictionInputs, + inputs: ZetaPromptInput, cx: &mut AsyncApp, ) -> Self { if edits.is_empty() { @@ -53,7 +53,7 @@ impl EditPredictionResult { .read_with(cx, |buffer, cx| { let new_snapshot = buffer.snapshot(); let edits: Arc<[_]> = - interpolate_edits(&edited_buffer_snapshot, &new_snapshot, edits)?.into(); + interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits)?.into(); Some((edits.clone(), new_snapshot, buffer.preview_edits(edits, cx))) }) @@ -93,15 +93,7 @@ pub struct EditPrediction { pub buffer: Entity, pub buffer_snapshotted_at: Instant, pub response_received_at: Instant, - pub inputs: EditPredictionInputs, -} - -#[derive(Debug, Clone, Serialize)] -pub struct EditPredictionInputs { - pub events: Vec>, - pub included_files: Vec, - pub cursor_point: cloud_llm_client::predict_edits_v3::Point, - pub cursor_path: Arc, + pub inputs: zeta_prompt::ZetaPromptInput, } impl EditPrediction { @@ -109,7 +101,7 @@ impl EditPrediction { &self, new_snapshot: &TextBufferSnapshot, ) -> Option, Arc)>> { - interpolate_edits(&self.snapshot, new_snapshot, self.edits.clone()) + interpolate_edits(&self.snapshot, new_snapshot, &self.edits) } pub fn targets_buffer(&self, buffer: &Buffer) -> bool { @@ -130,57 +122,14 @@ impl std::fmt::Debug for EditPrediction { } } -pub fn interpolate_edits( - old_snapshot: &TextBufferSnapshot, - new_snapshot: &TextBufferSnapshot, - current_edits: Arc<[(Range, Arc)]>, -) -> Option, Arc)>> { - let mut edits = Vec::new(); - - let mut model_edits = current_edits.iter().peekable(); - for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { - while let Some((model_old_range, _)) = model_edits.peek() { - let model_old_range = model_old_range.to_offset(old_snapshot); - if model_old_range.end < user_edit.old.start { - let (model_old_range, model_new_text) = model_edits.next().unwrap(); - edits.push((model_old_range.clone(), model_new_text.clone())); - } else { - break; - } - } - - if let Some((model_old_range, model_new_text)) = model_edits.peek() { - let model_old_offset_range = model_old_range.to_offset(old_snapshot); - if user_edit.old == model_old_offset_range { - let user_new_text = new_snapshot - .text_for_range(user_edit.new.clone()) - .collect::(); - - if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { - if !model_suffix.is_empty() { - let anchor = old_snapshot.anchor_after(user_edit.old.end); - edits.push((anchor..anchor, model_suffix.into())); - } - - model_edits.next(); - continue; - } - } - } - - return None; - } - - edits.extend(model_edits.cloned()); - - if edits.is_empty() { None } else { Some(edits) } -} - #[cfg(test)] mod tests { + use std::path::Path; + use super::*; use gpui::{App, Entity, TestAppContext, prelude::*}; use language::{Buffer, ToOffset as _}; + use zeta_prompt::ZetaPromptInput; #[gpui::test] async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { @@ -199,14 +148,13 @@ mod tests { snapshot: cx.read(|cx| buffer.read(cx).snapshot()), buffer: buffer.clone(), edit_preview, - inputs: EditPredictionInputs { + inputs: ZetaPromptInput { events: vec![], - included_files: vec![], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - line: cloud_llm_client::predict_edits_v3::Line(0), - column: 0, - }, + related_files: vec![].into(), cursor_path: Path::new("path.txt").into(), + cursor_offset_in_excerpt: 0, + cursor_excerpt: "".into(), + editable_range_in_excerpt: 0..0, }, buffer_snapshotted_at: Instant::now(), response_received_at: Instant::now(), diff --git a/crates/zeta/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs similarity index 67% rename from crates/zeta/src/sweep_ai.rs rename to crates/edit_prediction/src/sweep_ai.rs index 8fd5398f3facc807d99951c48c749e9247fb5670..71f28c9213c3440a9267dab7d5a5416dc219f2f3 100644 --- a/crates/zeta/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -1,81 +1,70 @@ -use anyhow::{Context as _, Result}; -use cloud_llm_client::predict_edits_v3::Event; -use credentials_provider::CredentialsProvider; -use futures::{AsyncReadExt as _, FutureExt, future::Shared}; +use anyhow::Result; +use futures::AsyncReadExt as _; use gpui::{ - App, AppContext as _, Entity, Task, + App, AppContext as _, Entity, Global, SharedString, Task, http_client::{self, AsyncBody, Method}, }; -use language::{Buffer, BufferSnapshot, Point, ToOffset as _, ToPoint as _}; +use language::{Point, ToOffset as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; use lsp::DiagnosticSeverity; -use project::{Project, ProjectPath}; use serde::{Deserialize, Serialize}; use std::{ - collections::VecDeque, fmt::{self, Write as _}, - ops::Range, path::Path, sync::Arc, time::Instant, }; -use crate::{EditPredictionId, EditPredictionInputs, prediction::EditPredictionResult}; +use crate::{EditPredictionId, EditPredictionModelInput, prediction::EditPredictionResult}; const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; pub struct SweepAi { - pub api_token: Shared>>, + pub api_token: Entity, pub debug_info: Arc, } impl SweepAi { - pub fn new(cx: &App) -> Self { + pub fn new(cx: &mut App) -> Self { SweepAi { - api_token: load_api_token(cx).shared(), + api_token: sweep_api_token(cx), debug_info: debug_info(cx), } } - pub fn set_api_token(&mut self, api_token: Option, cx: &mut App) -> Task> { - self.api_token = Task::ready(api_token.clone()).shared(); - store_api_token_in_keychain(api_token, cx) - } - pub fn request_prediction_with_sweep( &self, - project: &Entity, - active_buffer: &Entity, - snapshot: BufferSnapshot, - position: language::Anchor, - events: Vec>, - recent_paths: &VecDeque, - diagnostic_search_range: Range, + inputs: EditPredictionModelInput, cx: &mut App, ) -> Task>> { let debug_info = self.debug_info.clone(); - let Some(api_token) = self.api_token.clone().now_or_never().flatten() else { + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else { return Task::ready(Ok(None)); }; - let full_path: Arc = snapshot + let full_path: Arc = inputs + .snapshot .file() .map(|file| file.full_path(cx)) .unwrap_or_else(|| "untitled".into()) .into(); - let project_file = project::File::from_dyn(snapshot.file()); + let project_file = project::File::from_dyn(inputs.snapshot.file()); let repo_name = project_file .map(|file| file.worktree.read(cx).root_name_str()) .unwrap_or("untitled") .into(); - let offset = position.to_offset(&snapshot); + let offset = inputs.position.to_offset(&inputs.snapshot); - let recent_buffers = recent_paths.iter().cloned(); + let recent_buffers = inputs.recent_paths.iter().cloned(); let http_client = cx.http_client(); let recent_buffer_snapshots = recent_buffers .filter_map(|project_path| { - let buffer = project.read(cx).get_open_buffer(&project_path, cx)?; - if active_buffer == &buffer { + let buffer = inputs.project.read(cx).get_open_buffer(&project_path, cx)?; + if inputs.buffer == buffer { None } else { Some(buffer.read(cx).snapshot()) @@ -84,14 +73,13 @@ impl SweepAi { .take(3) .collect::>(); - let cursor_point = position.to_point(&snapshot); let buffer_snapshotted_at = Instant::now(); let result = cx.background_spawn(async move { - let text = snapshot.text(); + let text = inputs.snapshot.text(); let mut recent_changes = String::new(); - for event in &events { + for event in &inputs.events { write_event(event.as_ref(), &mut recent_changes).unwrap(); } @@ -120,7 +108,23 @@ impl SweepAi { }) .collect::>(); - let diagnostic_entries = snapshot.diagnostics_in_range(diagnostic_search_range, false); + let retrieval_chunks = inputs + .related_files + .iter() + .flat_map(|related_file| { + related_file.excerpts.iter().map(|excerpt| FileChunk { + file_path: related_file.path.to_string_lossy().to_string(), + start_line: excerpt.row_range.start as usize, + end_line: excerpt.row_range.end as usize, + content: excerpt.text.to_string(), + timestamp: None, + }) + }) + .collect(); + + let diagnostic_entries = inputs + .snapshot + .diagnostics_in_range(inputs.diagnostic_search_range, false); let mut diagnostic_content = String::new(); let mut diagnostic_count = 0; @@ -168,7 +172,7 @@ impl SweepAi { multiple_suggestions: false, branch: None, file_chunks, - retrieval_chunks: vec![], + retrieval_chunks, recent_user_actions: vec![], use_bytes: true, // TODO @@ -180,21 +184,14 @@ impl SweepAi { serde_json::to_writer(writer, &request_body)?; let body: AsyncBody = buf.into(); - let inputs = EditPredictionInputs { - events, - included_files: vec![cloud_llm_client::predict_edits_v3::IncludedFile { - path: full_path.clone(), - max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row), - excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt { - start_line: cloud_llm_client::predict_edits_v3::Line(0), - text: request_body.file_contents.into(), - }], - }], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - column: cursor_point.column, - line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row), - }, + let ep_inputs = zeta_prompt::ZetaPromptInput { + events: inputs.events, + related_files: inputs.related_files.clone(), cursor_path: full_path.clone(), + cursor_excerpt: request_body.file_contents.into(), + // we actually don't know + editable_range_in_excerpt: 0..inputs.snapshot.len(), + cursor_offset_in_excerpt: request_body.cursor_position, }; let request = http_client::Request::builder() @@ -222,15 +219,20 @@ impl SweepAi { let response: AutocompleteResponse = serde_json::from_slice(&body)?; - let old_text = snapshot + let old_text = inputs + .snapshot .text_for_range(response.start_index..response.end_index) .collect::(); let edits = language::text_diff(&old_text, &response.completion) .into_iter() .map(|(range, text)| { ( - snapshot.anchor_after(response.start_index + range.start) - ..snapshot.anchor_before(response.start_index + range.end), + inputs + .snapshot + .anchor_after(response.start_index + range.start) + ..inputs + .snapshot + .anchor_before(response.start_index + range.end), text, ) }) @@ -239,13 +241,13 @@ impl SweepAi { anyhow::Ok(( response.autocomplete_id, edits, - snapshot, + inputs.snapshot, response_received_at, - inputs, + ep_inputs, )) }); - let buffer = active_buffer.clone(); + let buffer = inputs.buffer.clone(); cx.spawn(async move |cx| { let (id, edits, old_snapshot, response_received_at, inputs) = result.await?; @@ -266,46 +268,28 @@ impl SweepAi { } } -pub const SWEEP_CREDENTIALS_URL: &str = "https://autocomplete.sweep.dev"; +pub const SWEEP_CREDENTIALS_URL: SharedString = + SharedString::new_static("https://autocomplete.sweep.dev"); pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token"; +pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("SWEEP_AI_TOKEN"); -pub fn load_api_token(cx: &App) -> Task> { - if let Some(api_token) = std::env::var("SWEEP_AI_TOKEN") - .ok() - .filter(|value| !value.is_empty()) - { - return Task::ready(Some(api_token)); +struct GlobalSweepApiKey(Entity); + +impl Global for GlobalSweepApiKey {} + +pub fn sweep_api_token(cx: &mut App) -> Entity { + if let Some(global) = cx.try_global::() { + return global.0.clone(); } - let credentials_provider = ::global(cx); - cx.spawn(async move |cx| { - let (_, credentials) = credentials_provider - .read_credentials(SWEEP_CREDENTIALS_URL, &cx) - .await - .ok()??; - String::from_utf8(credentials).ok() - }) + let entity = + cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone())); + cx.set_global(GlobalSweepApiKey(entity.clone())); + entity } -fn store_api_token_in_keychain(api_token: Option, cx: &App) -> Task> { - let credentials_provider = ::global(cx); - - cx.spawn(async move |cx| { - if let Some(api_token) = api_token { - credentials_provider - .write_credentials( - SWEEP_CREDENTIALS_URL, - SWEEP_CREDENTIALS_USERNAME, - api_token.as_bytes(), - cx, - ) - .await - .context("Failed to save Sweep API token to system keychain") - } else { - credentials_provider - .delete_credentials(SWEEP_CREDENTIALS_URL, cx) - .await - .context("Failed to delete Sweep API token from system keychain") - } +pub fn load_sweep_api_token(cx: &mut App) -> Task> { + sweep_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx) }) } @@ -320,7 +304,7 @@ struct AutocompleteRequest { pub cursor_position: usize, pub original_file_contents: String, pub file_chunks: Vec, - pub retrieval_chunks: Vec, + pub retrieval_chunks: Vec, pub recent_user_actions: Vec, pub multiple_suggestions: bool, pub privacy_mode_enabled: bool, @@ -337,15 +321,6 @@ struct FileChunk { pub timestamp: Option, } -#[derive(Debug, Clone, Serialize)] -struct RetrievalChunk { - pub file_path: String, - pub start_line: usize, - pub end_line: usize, - pub content: String, - pub timestamp: u64, -} - #[derive(Debug, Clone, Serialize)] struct UserAction { pub action_type: ActionType, @@ -397,12 +372,9 @@ struct AdditionalCompletion { pub finish_reason: Option, } -fn write_event( - event: &cloud_llm_client::predict_edits_v3::Event, - f: &mut impl fmt::Write, -) -> fmt::Result { +fn write_event(event: &zeta_prompt::Event, f: &mut impl fmt::Write) -> fmt::Result { match event { - cloud_llm_client::predict_edits_v3::Event::BufferChange { + zeta_prompt::Event::BufferChange { old_path, path, diff, diff --git a/crates/zeta/src/udiff.rs b/crates/edit_prediction/src/udiff.rs similarity index 80% rename from crates/zeta/src/udiff.rs rename to crates/edit_prediction/src/udiff.rs index 5ae029c6c16c2c6b6d0c2451cc961e8399a64a8f..78fec03dd78301d56ac6e3f914ba60432e41637d 100644 --- a/crates/zeta/src/udiff.rs +++ b/crates/edit_prediction/src/udiff.rs @@ -14,87 +14,48 @@ use anyhow::anyhow; use collections::HashMap; use gpui::AsyncApp; use gpui::Entity; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, TextBufferSnapshot}; -use project::Project; +use language::{Anchor, Buffer, OffsetRangeExt as _, TextBufferSnapshot}; +use project::{Project, ProjectPath}; +use util::paths::PathStyle; +use util::rel_path::RelPath; -pub async fn parse_diff<'a>( - diff_str: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - let mut diff = DiffParser::new(diff_str); - let mut edited_buffer = None; - let mut edits = Vec::new(); - - while let Some(event) = diff.next()? { - match event { - DiffEvent::Hunk { - path: file_path, - hunk, - } => { - let (buffer, ranges) = match edited_buffer { - None => { - edited_buffer = get_buffer(&Path::new(file_path.as_ref())); - edited_buffer - .as_ref() - .context("Model tried to edit a file that wasn't included")? - } - Some(ref current) => current, - }; - - edits.extend( - resolve_hunk_edits_in_buffer(hunk, &buffer.text, ranges) - .with_context(|| format!("Diff:\n{diff_str}"))?, - ); - } - DiffEvent::FileEnd { renamed_to } => { - let (buffer, _) = edited_buffer - .take() - .context("Got a FileEnd event before an Hunk event")?; - - if renamed_to.is_some() { - anyhow::bail!("edit predictions cannot rename files"); - } - - if diff.next()?.is_some() { - anyhow::bail!("Edited more than one file"); - } - - return Ok((buffer, edits)); - } - } - } - - Err(anyhow::anyhow!("No EOF")) -} - -#[derive(Debug)] -pub struct OpenedBuffers<'a>(#[allow(unused)] HashMap, Entity>); +#[derive(Clone, Debug)] +pub struct OpenedBuffers(#[allow(unused)] HashMap>); #[must_use] -pub async fn apply_diff<'a>( - diff_str: &'a str, +pub async fn apply_diff( + diff_str: &str, project: &Entity, cx: &mut AsyncApp, -) -> Result> { +) -> Result { let mut included_files = HashMap::default(); + let worktree_id = project.read_with(cx, |project, cx| { + anyhow::Ok( + project + .visible_worktrees(cx) + .next() + .context("no worktrees")? + .read(cx) + .id(), + ) + })??; + for line in diff_str.lines() { let diff_line = DiffLine::parse(line); if let DiffLine::OldPath { path } = diff_line { let buffer = project .update(cx, |project, cx| { - let project_path = - project - .find_project_path(path.as_ref(), cx) - .with_context(|| { - format!("Failed to find worktree for new path: {}", path) - })?; + let project_path = ProjectPath { + worktree_id, + path: RelPath::new(Path::new(path.as_ref()), PathStyle::Posix)?.into_arc(), + }; anyhow::Ok(project.open_buffer(project_path, cx)) })?? .await?; - included_files.insert(path, buffer); + included_files.insert(path.to_string(), buffer); } } @@ -113,7 +74,7 @@ pub async fn apply_diff<'a>( let (buffer, ranges) = match current_file { None => { let buffer = included_files - .get_mut(&file_path) + .get_mut(file_path.as_ref()) .expect("Opened all files in diff"); current_file = Some((buffer, ranges.as_slice())); @@ -167,6 +128,29 @@ pub async fn apply_diff<'a>( Ok(OpenedBuffers(included_files)) } +pub fn apply_diff_to_string(diff_str: &str, text: &str) -> Result { + let mut diff = DiffParser::new(diff_str); + + let mut text = text.to_string(); + + while let Some(event) = diff.next()? { + match event { + DiffEvent::Hunk { hunk, .. } => { + let hunk_offset = text + .find(&hunk.context) + .ok_or_else(|| anyhow!("couldn't resolve hunk {:?}", hunk.context))?; + for edit in hunk.edits.iter().rev() { + let range = (hunk_offset + edit.range.start)..(hunk_offset + edit.range.end); + text.replace_range(range, &edit.text); + } + } + DiffEvent::FileEnd { .. } => {} + } + } + + Ok(text) +} + struct PatchFile<'a> { old_path: Cow<'a, str>, new_path: Cow<'a, str>, @@ -492,7 +476,6 @@ mod tests { use super::*; use gpui::TestAppContext; use indoc::indoc; - use language::Point; use pretty_assertions::assert_eq; use project::{FakeFs, Project}; use serde_json::json; @@ -754,38 +737,38 @@ mod tests { let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 one two -three +3 four five - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 3 -four -five +4 +5 - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 -one -two 3 4 - --- a/root/file2 - +++ b/root/file2 + --- a/file2 + +++ b/file2 +5 six - --- a/root/file2 - +++ b/root/file2 + --- a/file2 + +++ b/file2 seven +7.5 eight - --- a/root/file2 - +++ b/root/file2 + --- a/file2 + +++ b/file2 ten +11 "#}; @@ -817,137 +800,6 @@ mod tests { }); } - #[gpui::test] - async fn test_apply_diff_non_unique(cx: &mut TestAppContext) { - let fs = init_test(cx); - - let buffer_1_text = indoc! {r#" - one - two - three - four - five - one - two - three - four - five - "# }; - - fs.insert_tree( - path!("/root"), - json!({ - "file1": buffer_1_text, - }), - ) - .await; - - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/file1"), cx) - }) - .await - .unwrap(); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 - one - two - -three - +3 - four - five - "#}; - - let final_text = indoc! {r#" - one - two - three - four - five - one - two - 3 - four - five - "#}; - - apply_diff(diff, &project, &mut cx.to_async()) - .await - .expect_err("Non-unique edits should fail"); - - let ranges = [buffer_snapshot.anchor_before(Point::new(1, 0)) - ..buffer_snapshot.anchor_after(buffer_snapshot.max_point())]; - - let (edited_snapshot, edits) = parse_diff(diff, |_path| Some((&buffer_snapshot, &ranges))) - .await - .unwrap(); - - assert_eq!(edited_snapshot.remote_id(), buffer_snapshot.remote_id()); - buffer.update(cx, |buffer, cx| { - buffer.edit(edits, None, cx); - assert_eq!(buffer.text(), final_text); - }); - } - - #[gpui::test] - async fn test_parse_diff_with_edits_within_line(cx: &mut TestAppContext) { - let fs = init_test(cx); - - let buffer_1_text = indoc! {r#" - one two three four - five six seven eight - nine ten eleven twelve - "# }; - - fs.insert_tree( - path!("/root"), - json!({ - "file1": buffer_1_text, - }), - ) - .await; - - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/root/file1"), cx) - }) - .await - .unwrap(); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 - one two three four - -five six seven eight - +five SIX seven eight! - nine ten eleven twelve - "#}; - - let (buffer, edits) = parse_diff(diff, |_path| { - Some((&buffer_snapshot, &[(Anchor::MIN..Anchor::MAX)] as &[_])) - }) - .await - .unwrap(); - - let edits = edits - .into_iter() - .map(|(range, text)| (range.to_point(&buffer), text)) - .collect::>(); - assert_eq!( - edits, - &[ - (Point::new(1, 5)..Point::new(1, 8), "SIX".into()), - (Point::new(1, 20)..Point::new(1, 20), "!".into()) - ] - ); - } - #[gpui::test] async fn test_apply_diff_unique_via_previous_context(cx: &mut TestAppContext) { let fs = init_test(cx); @@ -985,8 +837,8 @@ mod tests { let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let diff = indoc! {r#" - --- a/root/file1 - +++ b/root/file1 + --- a/file1 + +++ b/file1 one two -three diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs new file mode 100644 index 0000000000000000000000000000000000000000..289bcd76daab2b9a4b82db88b86285e6c7aca00d --- /dev/null +++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs @@ -0,0 +1,230 @@ +use std::{cmp, sync::Arc}; + +use client::{Client, UserStore}; +use cloud_llm_client::EditPredictionRejectReason; +use edit_prediction_types::{DataCollectionState, EditPredictionDelegate}; +use gpui::{App, Entity, prelude::*}; +use language::{Buffer, ToPoint as _}; +use project::Project; + +use crate::{BufferEditPrediction, EditPredictionModel, EditPredictionStore}; + +pub struct ZedEditPredictionDelegate { + store: Entity, + project: Entity, + singleton_buffer: Option>, +} + +impl ZedEditPredictionDelegate { + pub fn new( + project: Entity, + singleton_buffer: Option>, + client: &Arc, + user_store: &Entity, + cx: &mut Context, + ) -> Self { + let store = EditPredictionStore::global(client, user_store, cx); + store.update(cx, |store, cx| { + store.register_project(&project, cx); + }); + + cx.observe(&store, |_this, _ep_store, cx| { + cx.notify(); + }) + .detach(); + + Self { + project: project, + store: store, + singleton_buffer, + } + } +} + +impl EditPredictionDelegate for ZedEditPredictionDelegate { + fn name() -> &'static str { + "zed-predict" + } + + fn display_name() -> &'static str { + "Zed's Edit Predictions" + } + + fn show_predictions_in_menu() -> bool { + true + } + + fn show_tab_accept_marker() -> bool { + true + } + + fn data_collection_state(&self, cx: &App) -> DataCollectionState { + if let Some(buffer) = &self.singleton_buffer + && let Some(file) = buffer.read(cx).file() + { + let is_project_open_source = + self.store + .read(cx) + .is_file_open_source(&self.project, file, cx); + if self.store.read(cx).data_collection_choice.is_enabled() { + DataCollectionState::Enabled { + is_project_open_source, + } + } else { + DataCollectionState::Disabled { + is_project_open_source, + } + } + } else { + return DataCollectionState::Disabled { + is_project_open_source: false, + }; + } + } + + fn toggle_data_collection(&mut self, cx: &mut App) { + self.store.update(cx, |store, cx| { + store.toggle_data_collection_choice(cx); + }); + } + + fn usage(&self, cx: &App) -> Option { + self.store.read(cx).usage(cx) + } + + fn is_enabled( + &self, + _buffer: &Entity, + _cursor_position: language::Anchor, + cx: &App, + ) -> bool { + let store = self.store.read(cx); + if store.edit_prediction_model == EditPredictionModel::Sweep { + store.has_sweep_api_token(cx) + } else { + true + } + } + + fn is_refreshing(&self, cx: &App) -> bool { + self.store.read(cx).is_refreshing(&self.project) + } + + fn refresh( + &mut self, + buffer: Entity, + cursor_position: language::Anchor, + _debounce: bool, + cx: &mut Context, + ) { + let store = self.store.read(cx); + + if store.user_store.read_with(cx, |user_store, _cx| { + user_store.account_too_young() || user_store.has_overdue_invoices() + }) { + return; + } + + self.store.update(cx, |store, cx| { + if let Some(current) = + store.prediction_at(&buffer, Some(cursor_position), &self.project, cx) + && let BufferEditPrediction::Local { prediction } = current + && prediction.interpolate(buffer.read(cx)).is_some() + { + return; + } + + store.refresh_context(&self.project, &buffer, cursor_position, cx); + store.refresh_prediction_from_buffer(self.project.clone(), buffer, cursor_position, cx) + }); + } + + fn accept(&mut self, cx: &mut Context) { + self.store.update(cx, |store, cx| { + store.accept_current_prediction(&self.project, cx); + }); + } + + fn discard(&mut self, cx: &mut Context) { + self.store.update(cx, |store, _cx| { + store.reject_current_prediction(EditPredictionRejectReason::Discarded, &self.project); + }); + } + + fn did_show(&mut self, cx: &mut Context) { + self.store.update(cx, |store, cx| { + store.did_show_current_prediction(&self.project, cx); + }); + } + + fn suggest( + &mut self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut Context, + ) -> Option { + self.store.update(cx, |store, cx| { + let prediction = + store.prediction_at(buffer, Some(cursor_position), &self.project, cx)?; + + let prediction = match prediction { + BufferEditPrediction::Local { prediction } => prediction, + BufferEditPrediction::Jump { prediction } => { + return Some(edit_prediction_types::EditPrediction::Jump { + id: Some(prediction.id.to_string().into()), + snapshot: prediction.snapshot.clone(), + target: prediction.edits.first().unwrap().0.start, + }); + } + }; + + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); + + let Some(edits) = prediction.interpolate(&snapshot) else { + store.reject_current_prediction( + EditPredictionRejectReason::InterpolatedEmpty, + &self.project, + ); + return None; + }; + + let cursor_row = cursor_position.to_point(&snapshot).row; + let (closest_edit_ix, (closest_edit_range, _)) = + edits.iter().enumerate().min_by_key(|(_, (range, _))| { + let distance_from_start = + cursor_row.abs_diff(range.start.to_point(&snapshot).row); + let distance_from_end = cursor_row.abs_diff(range.end.to_point(&snapshot).row); + cmp::min(distance_from_start, distance_from_end) + })?; + + let mut edit_start_ix = closest_edit_ix; + for (range, _) in edits[..edit_start_ix].iter().rev() { + let distance_from_closest_edit = closest_edit_range.start.to_point(&snapshot).row + - range.end.to_point(&snapshot).row; + if distance_from_closest_edit <= 1 { + edit_start_ix -= 1; + } else { + break; + } + } + + let mut edit_end_ix = closest_edit_ix + 1; + for (range, _) in &edits[edit_end_ix..] { + let distance_from_closest_edit = range.start.to_point(buffer).row + - closest_edit_range.end.to_point(&snapshot).row; + if distance_from_closest_edit <= 1 { + edit_end_ix += 1; + } else { + break; + } + } + + Some(edit_prediction_types::EditPrediction::Local { + id: Some(prediction.id.to_string().into()), + edits: edits[edit_start_ix..edit_end_ix].to_vec(), + edit_preview: Some(prediction.edit_preview.clone()), + }) + }) + } +} diff --git a/crates/zeta/src/zeta1.rs b/crates/edit_prediction/src/zeta1.rs similarity index 63% rename from crates/zeta/src/zeta1.rs rename to crates/edit_prediction/src/zeta1.rs index 0be5fad301242c51c4ad58c60a6d2fcb3441ea08..01c26573307e66cd6ca3bf8ab748ba8d082ea688 100644 --- a/crates/zeta/src/zeta1.rs +++ b/crates/edit_prediction/src/zeta1.rs @@ -1,24 +1,23 @@ -mod input_excerpt; - use std::{fmt::Write, ops::Range, path::Path, sync::Arc, time::Instant}; use crate::{ - EditPredictionId, ZedUpdateRequiredError, Zeta, - prediction::{EditPredictionInputs, EditPredictionResult}, + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, EditPredictionStore, ZedUpdateRequiredError, + cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count}, + prediction::EditPredictionResult, }; use anyhow::{Context as _, Result}; use cloud_llm_client::{ PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse, - predict_edits_v3::Event, }; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task}; -use input_excerpt::excerpt_for_cursor_position; use language::{ - Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToPoint as _, text_diff, + Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset, ToPoint as _, text_diff, }; use project::{Project, ProjectPath}; use release_channel::AppVersion; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; +use zeta_prompt::{Event, ZetaPromptInput}; const CURSOR_MARKER: &str = "<|user_cursor_is_here|>"; const START_OF_FILE_MARKER: &str = "<|start_of_file|>"; @@ -30,25 +29,28 @@ pub(crate) const MAX_REWRITE_TOKENS: usize = 350; pub(crate) const MAX_EVENT_TOKENS: usize = 500; pub(crate) fn request_prediction_with_zeta1( - zeta: &mut Zeta, - project: &Entity, - buffer: &Entity, - snapshot: BufferSnapshot, - position: language::Anchor, - events: Vec>, - trigger: PredictEditsRequestTrigger, - cx: &mut Context, + store: &mut EditPredictionStore, + EditPredictionModelInput { + project, + buffer, + snapshot, + position, + events, + trigger, + debug_tx, + .. + }: EditPredictionModelInput, + cx: &mut Context, ) -> Task>> { - let buffer = buffer.clone(); let buffer_snapshotted_at = Instant::now(); - let client = zeta.client.clone(); - let llm_token = zeta.llm_token.clone(); + let client = store.client.clone(); + let llm_token = store.llm_token.clone(); let app_version = AppVersion::global(cx); let (git_info, can_collect_file) = if let Some(file) = snapshot.file() { - let can_collect_file = zeta.can_collect_file(project, file, cx); + let can_collect_file = store.can_collect_file(&project, file, cx); let git_info = if can_collect_file { - git_info_for_file(project, &ProjectPath::from_file(file.as_ref(), cx), cx) + git_info_for_file(&project, &ProjectPath::from_file(file.as_ref(), cx), cx) } else { None }; @@ -76,6 +78,19 @@ pub(crate) fn request_prediction_with_zeta1( cx, ); + let (uri, require_auth) = match &store.custom_predict_edits_url { + Some(custom_url) => (custom_url.clone(), false), + None => { + match client + .http_client() + .build_zed_llm_url("/predict_edits/v2", &[]) + { + Ok(url) => (url.into(), true), + Err(err) => return Task::ready(Err(err)), + } + } + }; + cx.spawn(async move |this, cx| { let GatherContextOutput { mut body, @@ -100,63 +115,54 @@ pub(crate) fn request_prediction_with_zeta1( body.input_excerpt ); - let http_client = client.http_client(); - - let response = Zeta::send_api_request::( + let response = EditPredictionStore::send_api_request::( |request| { - let uri = if let Ok(predict_edits_url) = std::env::var("ZED_PREDICT_EDITS_URL") { - predict_edits_url - } else { - http_client - .build_zed_llm_url("/predict_edits/v2", &[])? - .as_str() - .into() - }; Ok(request - .uri(uri) + .uri(uri.as_str()) .body(serde_json::to_string(&body)?.into())?) }, client, llm_token, app_version, + require_auth, ) .await; - let inputs = EditPredictionInputs { + let context_start_offset = context_range.start.to_offset(&snapshot); + let editable_offset_range = editable_range.to_offset(&snapshot); + + let inputs = ZetaPromptInput { events: included_events.into(), - included_files: vec![cloud_llm_client::predict_edits_v3::IncludedFile { - path: full_path.clone(), - max_row: cloud_llm_client::predict_edits_v3::Line(snapshot.max_point().row), - excerpts: vec![cloud_llm_client::predict_edits_v3::Excerpt { - start_line: cloud_llm_client::predict_edits_v3::Line(context_range.start.row), - text: snapshot - .text_for_range(context_range) - .collect::() - .into(), - }], - }], - cursor_point: cloud_llm_client::predict_edits_v3::Point { - column: cursor_point.column, - line: cloud_llm_client::predict_edits_v3::Line(cursor_point.row), - }, + related_files: vec![].into(), cursor_path: full_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt: (editable_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset), + cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - context_start_offset, }; - // let response = perform_predict_edits(PerformPredictEditsParams { - // client, - // llm_token, - // app_version, - // body, - // }) - // .await; + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(serde_json::to_string(&inputs).unwrap()), + position, + }, + )) + .ok(); + } let (response, usage) = match response { Ok(response) => response, Err(err) => { if err.is::() { cx.update(|cx| { - this.update(cx, |zeta, _cx| { - zeta.update_required = true; + this.update(cx, |ep_store, _cx| { + ep_store.update_required = true; }) .ok(); @@ -191,6 +197,18 @@ pub(crate) fn request_prediction_with_zeta1( .ok(); } + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + model_output: Some(response.output_excerpt.clone()), + position, + }, + )) + .ok(); + } + let edit_prediction = process_completion_response( response, buffer, @@ -228,7 +246,7 @@ fn process_completion_response( buffer: Entity, snapshot: &BufferSnapshot, editable_range: Range, - inputs: EditPredictionInputs, + inputs: ZetaPromptInput, buffer_snapshotted_at: Instant, received_response_at: Instant, cx: &AsyncApp, @@ -495,10 +513,159 @@ pub fn format_event(event: &Event) -> String { } } -/// Typical number of string bytes per token for the purposes of limiting model input. This is -/// intentionally low to err on the side of underestimating limits. -pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3; +#[derive(Debug)] +pub struct InputExcerpt { + pub context_range: Range, + pub editable_range: Range, + pub prompt: String, +} + +pub fn excerpt_for_cursor_position( + position: Point, + path: &str, + snapshot: &BufferSnapshot, + editable_region_token_limit: usize, + context_token_limit: usize, +) -> InputExcerpt { + let (editable_range, context_range) = editable_and_context_ranges_for_cursor_position( + position, + snapshot, + editable_region_token_limit, + context_token_limit, + ); + + let mut prompt = String::new(); + + writeln!(&mut prompt, "```{path}").unwrap(); + if context_range.start == Point::zero() { + writeln!(&mut prompt, "{START_OF_FILE_MARKER}").unwrap(); + } + + for chunk in snapshot.chunks(context_range.start..editable_range.start, false) { + prompt.push_str(chunk.text); + } + + push_editable_range(position, snapshot, editable_range.clone(), &mut prompt); + + for chunk in snapshot.chunks(editable_range.end..context_range.end, false) { + prompt.push_str(chunk.text); + } + write!(prompt, "\n```").unwrap(); + + InputExcerpt { + context_range, + editable_range, + prompt, + } +} + +fn push_editable_range( + cursor_position: Point, + snapshot: &BufferSnapshot, + editable_range: Range, + prompt: &mut String, +) { + writeln!(prompt, "{EDITABLE_REGION_START_MARKER}").unwrap(); + for chunk in snapshot.chunks(editable_range.start..cursor_position, false) { + prompt.push_str(chunk.text); + } + prompt.push_str(CURSOR_MARKER); + for chunk in snapshot.chunks(cursor_position..editable_range.end, false) { + prompt.push_str(chunk.text); + } + write!(prompt, "\n{EDITABLE_REGION_END_MARKER}").unwrap(); +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{App, AppContext}; + use indoc::indoc; + use language::Buffer; + + #[gpui::test] + fn test_excerpt_for_cursor_position(cx: &mut App) { + let text = indoc! {r#" + fn foo() { + let x = 42; + println!("Hello, world!"); + } + + fn bar() { + let x = 42; + let mut sum = 0; + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + return sum; + } + + fn generate_random_numbers() -> Vec { + let mut rng = rand::thread_rng(); + let mut numbers = Vec::new(); + for _ in 0..5 { + numbers.push(rng.random_range(1..101)); + } + numbers + } + "#}; + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language::rust_lang(), cx)); + let snapshot = buffer.read(cx).snapshot(); + + // Ensure we try to fit the largest possible syntax scope, resorting to line-based expansion + // when a larger scope doesn't fit the editable region. + let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 50, 32); + assert_eq!( + excerpt.prompt, + indoc! {r#" + ```main.rs + let x = 42; + println!("Hello, world!"); + <|editable_region_start|> + } + + fn bar() { + let x = 42; + let mut sum = 0; + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + r<|user_cursor_is_here|>eturn sum; + } + + fn generate_random_numbers() -> Vec { + <|editable_region_end|> + let mut rng = rand::thread_rng(); + let mut numbers = Vec::new(); + ```"#} + ); -fn guess_token_count(bytes: usize) -> usize { - bytes / BYTES_PER_TOKEN_GUESS + // The `bar` function won't fit within the editable region, so we resort to line-based expansion. + let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 40, 32); + assert_eq!( + excerpt.prompt, + indoc! {r#" + ```main.rs + fn bar() { + let x = 42; + let mut sum = 0; + <|editable_region_start|> + for i in 0..x { + sum += i; + } + println!("Sum: {}", sum); + r<|user_cursor_is_here|>eturn sum; + } + + fn generate_random_numbers() -> Vec { + let mut rng = rand::thread_rng(); + <|editable_region_end|> + let mut numbers = Vec::new(); + for _ in 0..5 { + numbers.push(rng.random_range(1..101)); + ```"#} + ); + } } diff --git a/crates/edit_prediction/src/zeta2.rs b/crates/edit_prediction/src/zeta2.rs new file mode 100644 index 0000000000000000000000000000000000000000..9706e2b9ecd03f6e8ba592210722725f420643d3 --- /dev/null +++ b/crates/edit_prediction/src/zeta2.rs @@ -0,0 +1,243 @@ +#[cfg(feature = "cli-support")] +use crate::EvalCacheEntryKind; +use crate::open_ai_response::text_from_response; +use crate::prediction::EditPredictionResult; +use crate::{ + DebugEvent, EDIT_PREDICTIONS_MODEL_ID, EditPredictionFinishedDebugEvent, EditPredictionId, + EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, +}; +use anyhow::{Result, anyhow}; +use cloud_llm_client::EditPredictionRejectReason; +use gpui::{Task, prelude::*}; +use language::{OffsetRangeExt as _, ToOffset as _, ToPoint}; +use release_channel::AppVersion; +use std::{path::Path, sync::Arc, time::Instant}; +use zeta_prompt::CURSOR_MARKER; +use zeta_prompt::format_zeta_prompt; + +const MAX_CONTEXT_TOKENS: usize = 150; +const MAX_REWRITE_TOKENS: usize = 350; + +pub fn request_prediction_with_zeta2( + store: &mut EditPredictionStore, + EditPredictionModelInput { + buffer, + snapshot, + position, + related_files, + events, + debug_tx, + .. + }: EditPredictionModelInput, + cx: &mut Context, +) -> Task>> { + let buffer_snapshotted_at = Instant::now(); + + let Some(excerpt_path) = snapshot + .file() + .map(|file| -> Arc { file.full_path(cx).into() }) + else { + return Task::ready(Err(anyhow!("No file path for excerpt"))); + }; + + let client = store.client.clone(); + let llm_token = store.llm_token.clone(); + let app_version = AppVersion::global(cx); + + #[cfg(feature = "cli-support")] + let eval_cache = store.eval_cache.clone(); + + let request_task = cx.background_spawn({ + async move { + let cursor_offset = position.to_offset(&snapshot); + let (editable_offset_range, prompt_input) = zeta2_prompt_input( + &snapshot, + related_files, + events, + excerpt_path, + cursor_offset, + ); + + let prompt = format_zeta_prompt(&prompt_input); + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionStarted( + EditPredictionStartedDebugEvent { + buffer: buffer.downgrade(), + prompt: Some(prompt.clone()), + position, + }, + )) + .ok(); + } + + let request = open_ai::Request { + model: EDIT_PREDICTIONS_MODEL_ID.clone(), + messages: vec![open_ai::RequestMessage::User { + content: open_ai::MessageContent::Plain(prompt), + }], + stream: false, + max_completion_tokens: None, + stop: Default::default(), + temperature: Default::default(), + tool_choice: None, + parallel_tool_calls: None, + tools: vec![], + prompt_cache_key: None, + reasoning_effort: None, + }; + + log::trace!("Sending edit prediction request"); + + let response = EditPredictionStore::send_raw_llm_request( + request, + client, + llm_token, + app_version, + #[cfg(feature = "cli-support")] + eval_cache, + #[cfg(feature = "cli-support")] + EvalCacheEntryKind::Prediction, + ) + .await; + let received_response_at = Instant::now(); + + log::trace!("Got edit prediction response"); + + let (res, usage) = response?; + let request_id = EditPredictionId(res.id.clone().into()); + let Some(mut output_text) = text_from_response(res) else { + return Ok((Some((request_id, None)), usage)); + }; + + if let Some(debug_tx) = &debug_tx { + debug_tx + .unbounded_send(DebugEvent::EditPredictionFinished( + EditPredictionFinishedDebugEvent { + buffer: buffer.downgrade(), + position, + model_output: Some(output_text.clone()), + }, + )) + .ok(); + } + + if output_text.contains(CURSOR_MARKER) { + log::trace!("Stripping out {CURSOR_MARKER} from response"); + output_text = output_text.replace(CURSOR_MARKER, ""); + } + + let old_text = snapshot + .text_for_range(editable_offset_range.clone()) + .collect::(); + let edits: Vec<_> = language::text_diff(&old_text, &output_text) + .into_iter() + .map(|(range, text)| { + ( + snapshot.anchor_after(editable_offset_range.start + range.start) + ..snapshot.anchor_before(editable_offset_range.start + range.end), + text, + ) + }) + .collect(); + + anyhow::Ok(( + Some(( + request_id, + Some(( + prompt_input, + buffer, + snapshot.clone(), + edits, + received_response_at, + )), + )), + usage, + )) + } + }); + + cx.spawn(async move |this, cx| { + let Some((id, prediction)) = + EditPredictionStore::handle_api_response(&this, request_task.await, cx)? + else { + return Ok(None); + }; + + let Some((inputs, edited_buffer, edited_buffer_snapshot, edits, received_response_at)) = + prediction + else { + return Ok(Some(EditPredictionResult { + id, + prediction: Err(EditPredictionRejectReason::Empty), + })); + }; + + Ok(Some( + EditPredictionResult::new( + id, + &edited_buffer, + &edited_buffer_snapshot, + edits.into(), + buffer_snapshotted_at, + received_response_at, + inputs, + cx, + ) + .await, + )) + }) +} + +pub fn zeta2_prompt_input( + snapshot: &language::BufferSnapshot, + related_files: Arc<[zeta_prompt::RelatedFile]>, + events: Vec>, + excerpt_path: Arc, + cursor_offset: usize, +) -> (std::ops::Range, zeta_prompt::ZetaPromptInput) { + let cursor_point = cursor_offset.to_point(snapshot); + + let (editable_range, context_range) = + crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + snapshot, + MAX_CONTEXT_TOKENS, + MAX_REWRITE_TOKENS, + ); + + let context_start_offset = context_range.start.to_offset(snapshot); + let editable_offset_range = editable_range.to_offset(snapshot); + let cursor_offset_in_excerpt = cursor_offset - context_start_offset; + let editable_range_in_excerpt = (editable_offset_range.start - context_start_offset) + ..(editable_offset_range.end - context_start_offset); + + let prompt_input = zeta_prompt::ZetaPromptInput { + cursor_path: excerpt_path, + cursor_excerpt: snapshot + .text_for_range(context_range) + .collect::() + .into(), + editable_range_in_excerpt, + cursor_offset_in_excerpt, + events, + related_files, + }; + (editable_offset_range, prompt_input) +} + +#[cfg(feature = "cli-support")] +pub fn zeta2_output_for_patch(input: &zeta_prompt::ZetaPromptInput, patch: &str) -> Result { + let text = &input.cursor_excerpt; + let editable_region = input.editable_range_in_excerpt.clone(); + let old_prefix = &text[..editable_region.start]; + let old_suffix = &text[editable_region.end..]; + + let new = crate::udiff::apply_diff_to_string(patch, text)?; + if !new.starts_with(old_prefix) || !new.ends_with(old_suffix) { + anyhow::bail!("Patch shouldn't affect text outside of editable region"); + } + + Ok(new[editable_region.start..new.len() - old_suffix.len()].to_string()) +} diff --git a/crates/edit_prediction_button/src/sweep_api_token_modal.rs b/crates/edit_prediction_button/src/sweep_api_token_modal.rs deleted file mode 100644 index ab2102f25a2a7291644ca67ab3c89fd47da7ac0a..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_button/src/sweep_api_token_modal.rs +++ /dev/null @@ -1,84 +0,0 @@ -use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, -}; -use ui::{Button, ButtonStyle, Clickable, Headline, HeadlineSize, prelude::*}; -use ui_input::InputField; -use workspace::ModalView; -use zeta::Zeta; - -pub struct SweepApiKeyModal { - api_key_input: Entity, - focus_handle: FocusHandle, -} - -impl SweepApiKeyModal { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let api_key_input = cx.new(|cx| InputField::new(window, cx, "Enter your Sweep API token")); - - Self { - api_key_input, - focus_handle: cx.focus_handle(), - } - } - - fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_input.read(cx).text(cx); - let api_key = (!api_key.trim().is_empty()).then_some(api_key); - - if let Some(zeta) = Zeta::try_global(cx) { - zeta.update(cx, |zeta, cx| { - zeta.sweep_ai - .set_api_token(api_key, cx) - .detach_and_log_err(cx); - }); - } - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for SweepApiKeyModal {} - -impl ModalView for SweepApiKeyModal {} - -impl Focusable for SweepApiKeyModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for SweepApiKeyModal { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("SweepApiKeyModal") - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(px(400.)) - .p_4() - .gap_3() - .child(Headline::new("Sweep API Token").size(HeadlineSize::Small)) - .child(self.api_key_input.clone()) - .child( - h_flex() - .justify_end() - .gap_2() - .child(Button::new("cancel", "Cancel").on_click(cx.listener( - |_, _, _window, cx| { - cx.emit(DismissEvent); - }, - ))) - .child( - Button::new("save", "Save") - .style(ButtonStyle::Filled) - .on_click(cx.listener(|this, _, window, cx| { - this.confirm(&menu::Confirm, window, cx); - })), - ), - ) - } -} diff --git a/crates/zeta_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml similarity index 68% rename from crates/zeta_cli/Cargo.toml rename to crates/edit_prediction_cli/Cargo.toml index 2dbca537f55377e84f306e13649dfb71ccf2f181..b6bace2a2c080626126af96f9ef51e435d6ab8fa 100644 --- a/crates/zeta_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "zeta_cli" +name = "edit_prediction_cli" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,38 +9,37 @@ license = "GPL-3.0-or-later" workspace = true [[bin]] -name = "zeta" +name = "ep" path = "src/main.rs" [dependencies] - anyhow.workspace = true +anthropic.workspace = true +http_client.workspace = true chrono.workspace = true clap.workspace = true client.workspace = true cloud_llm_client.workspace= true -cloud_zeta2_prompt.workspace= true collections.workspace = true debug_adapter_extension.workspace = true -edit_prediction_context.workspace = true +dirs.workspace = true extension.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true gpui_tokio.workspace = true +indoc.workspace = true language.workspace = true language_extension.workspace = true language_model.workspace = true language_models.workspace = true languages = { workspace = true, features = ["load-grammars"] } +libc.workspace = true log.workspace = true node_runtime.workspace = true -ordered-float.workspace = true paths.workspace = true -polars = { version = "0.51", features = ["lazy", "dtype-struct", "parquet"] } project.workspace = true prompt_store.workspace = true -pulldown-cmark.workspace = true release_channel.workspace = true reqwest_client.workspace = true serde.workspace = true @@ -48,13 +47,22 @@ serde_json.workspace = true settings.workspace = true shellexpand.workspace = true smol.workspace = true -soa-rs = "0.8.1" +sqlez.workspace = true +sqlez_macros.workspace = true terminal_view.workspace = true -toml.workspace = true util.workspace = true watch.workspace = true -zeta = { workspace = true, features = ["eval-support"] } -zlog.workspace = true +edit_prediction = { workspace = true, features = ["cli-support"] } +wasmtime.workspace = true +zeta_prompt.workspace = true + +# Wasmtime is included as a dependency in order to enable the same +# features that are enabled in Zed. +# +# If we don't enable these features we get crashes when creating +# a Tree-sitter WasmStore. +[package.metadata.cargo-machete] +ignored = ["wasmtime"] [dev-dependencies] indoc.workspace = true diff --git a/crates/edit_prediction_button/LICENSE-GPL b/crates/edit_prediction_cli/LICENSE-GPL similarity index 100% rename from crates/edit_prediction_button/LICENSE-GPL rename to crates/edit_prediction_cli/LICENSE-GPL diff --git a/crates/zeta_cli/build.rs b/crates/edit_prediction_cli/build.rs similarity index 100% rename from crates/zeta_cli/build.rs rename to crates/edit_prediction_cli/build.rs diff --git a/crates/edit_prediction_cli/src/anthropic_client.rs b/crates/edit_prediction_cli/src/anthropic_client.rs new file mode 100644 index 0000000000000000000000000000000000000000..8afc4d1c03f8a37ae258cc2926daf85caebe3d8a --- /dev/null +++ b/crates/edit_prediction_cli/src/anthropic_client.rs @@ -0,0 +1,418 @@ +use anthropic::{ + ANTHROPIC_API_URL, Message, Request as AnthropicRequest, RequestContent, + Response as AnthropicResponse, Role, non_streaming_completion, +}; +use anyhow::Result; +use http_client::HttpClient; +use indoc::indoc; +use reqwest_client::ReqwestClient; +use sqlez::bindable::Bind; +use sqlez::bindable::StaticColumnCount; +use sqlez_macros::sql; +use std::hash::Hash; +use std::hash::Hasher; +use std::path::Path; +use std::sync::Arc; + +pub struct PlainLlmClient { + http_client: Arc, + api_key: String, +} + +impl PlainLlmClient { + fn new() -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; + Ok(Self { + http_client, + api_key, + }) + } + + async fn generate( + &self, + model: &str, + max_tokens: u64, + messages: Vec, + ) -> Result { + let request = AnthropicRequest { + model: model.to_string(), + max_tokens, + messages, + tools: Vec::new(), + thinking: None, + tool_choice: None, + system: None, + metadata: None, + stop_sequences: Vec::new(), + temperature: None, + top_k: None, + top_p: None, + }; + + let response = non_streaming_completion( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + request, + None, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + Ok(response) + } +} + +pub struct BatchingLlmClient { + connection: sqlez::connection::Connection, + http_client: Arc, + api_key: String, +} + +struct CacheRow { + request_hash: String, + request: Option, + response: Option, + batch_id: Option, +} + +impl StaticColumnCount for CacheRow { + fn column_count() -> usize { + 4 + } +} + +impl Bind for CacheRow { + fn bind(&self, statement: &sqlez::statement::Statement, start_index: i32) -> Result { + let next_index = statement.bind(&self.request_hash, start_index)?; + let next_index = statement.bind(&self.request, next_index)?; + let next_index = statement.bind(&self.response, next_index)?; + let next_index = statement.bind(&self.batch_id, next_index)?; + Ok(next_index) + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct SerializableRequest { + model: String, + max_tokens: u64, + messages: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct SerializableMessage { + role: String, + content: String, +} + +impl BatchingLlmClient { + fn new(cache_path: &Path) -> Result { + let http_client: Arc = Arc::new(ReqwestClient::new()); + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY environment variable not set"))?; + + let connection = sqlez::connection::Connection::open_file(&cache_path.to_str().unwrap()); + let mut statement = sqlez::statement::Statement::prepare( + &connection, + indoc! {" + CREATE TABLE IF NOT EXISTS cache ( + request_hash TEXT PRIMARY KEY, + request TEXT, + response TEXT, + batch_id TEXT + ); + "}, + )?; + statement.exec()?; + drop(statement); + + Ok(Self { + connection, + http_client, + api_key, + }) + } + + pub fn lookup( + &self, + model: &str, + max_tokens: u64, + messages: &[Message], + ) -> Result> { + let request_hash_str = Self::request_hash(model, max_tokens, messages); + let response: Vec = self.connection.select_bound( + &sql!(SELECT response FROM cache WHERE request_hash = ?1 AND response IS NOT NULL;), + )?(request_hash_str.as_str())?; + Ok(response + .into_iter() + .next() + .and_then(|text| serde_json::from_str(&text).ok())) + } + + pub fn mark_for_batch(&self, model: &str, max_tokens: u64, messages: &[Message]) -> Result<()> { + let request_hash = Self::request_hash(model, max_tokens, messages); + + let serializable_messages: Vec = messages + .iter() + .map(|msg| SerializableMessage { + role: match msg.role { + Role::User => "user".to_string(), + Role::Assistant => "assistant".to_string(), + }, + content: message_content_to_string(&msg.content), + }) + .collect(); + + let serializable_request = SerializableRequest { + model: model.to_string(), + max_tokens, + messages: serializable_messages, + }; + + let request = Some(serde_json::to_string(&serializable_request)?); + let cache_row = CacheRow { + request_hash, + request, + response: None, + batch_id: None, + }; + self.connection.exec_bound(sql!( + INSERT OR IGNORE INTO cache(request_hash, request, response, batch_id) VALUES (?, ?, ?, ?)))?( + cache_row, + ) + } + + async fn generate( + &self, + model: &str, + max_tokens: u64, + messages: Vec, + ) -> Result> { + let response = self.lookup(model, max_tokens, &messages)?; + if let Some(response) = response { + return Ok(Some(response)); + } + + self.mark_for_batch(model, max_tokens, &messages)?; + + Ok(None) + } + + /// Uploads pending requests as a new batch; downloads finished batches if any. + async fn sync_batches(&self) -> Result<()> { + self.upload_pending_requests().await?; + self.download_finished_batches().await + } + + async fn download_finished_batches(&self) -> Result<()> { + let q = sql!(SELECT DISTINCT batch_id FROM cache WHERE batch_id IS NOT NULL AND response IS NULL); + let batch_ids: Vec = self.connection.select(q)?()?; + + for batch_id in batch_ids { + let batch_status = anthropic::batches::retrieve_batch( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + &batch_id, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + log::info!( + "Batch {} status: {}", + batch_id, + batch_status.processing_status + ); + + if batch_status.processing_status == "ended" { + let results = anthropic::batches::retrieve_batch_results( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + &batch_id, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + let mut success_count = 0; + for result in results { + let request_hash = result + .custom_id + .strip_prefix("req_hash_") + .unwrap_or(&result.custom_id) + .to_string(); + + match result.result { + anthropic::batches::BatchResult::Succeeded { message } => { + let response_json = serde_json::to_string(&message)?; + let q = sql!(UPDATE cache SET response = ? WHERE request_hash = ?); + self.connection.exec_bound(q)?((response_json, request_hash))?; + success_count += 1; + } + anthropic::batches::BatchResult::Errored { error } => { + log::error!("Batch request {} failed: {:?}", request_hash, error); + } + anthropic::batches::BatchResult::Canceled => { + log::warn!("Batch request {} was canceled", request_hash); + } + anthropic::batches::BatchResult::Expired => { + log::warn!("Batch request {} expired", request_hash); + } + } + } + log::info!("Downloaded {} successful requests", success_count); + } + } + + Ok(()) + } + + async fn upload_pending_requests(&self) -> Result { + let q = sql!( + SELECT request_hash, request FROM cache WHERE batch_id IS NULL AND response IS NULL + ); + + let rows: Vec<(String, String)> = self.connection.select(q)?()?; + + if rows.is_empty() { + return Ok(String::new()); + } + + let batch_requests = rows + .iter() + .map(|(hash, request_str)| { + let serializable_request: SerializableRequest = + serde_json::from_str(&request_str).unwrap(); + + let messages: Vec = serializable_request + .messages + .into_iter() + .map(|msg| Message { + role: match msg.role.as_str() { + "user" => Role::User, + "assistant" => Role::Assistant, + _ => Role::User, + }, + content: vec![RequestContent::Text { + text: msg.content, + cache_control: None, + }], + }) + .collect(); + + let params = AnthropicRequest { + model: serializable_request.model, + max_tokens: serializable_request.max_tokens, + messages, + tools: Vec::new(), + thinking: None, + tool_choice: None, + system: None, + metadata: None, + stop_sequences: Vec::new(), + temperature: None, + top_k: None, + top_p: None, + }; + + let custom_id = format!("req_hash_{}", hash); + anthropic::batches::BatchRequest { custom_id, params } + }) + .collect::>(); + + let batch_len = batch_requests.len(); + let batch = anthropic::batches::create_batch( + self.http_client.as_ref(), + ANTHROPIC_API_URL, + &self.api_key, + anthropic::batches::CreateBatchRequest { + requests: batch_requests, + }, + ) + .await + .map_err(|e| anyhow::anyhow!("{:?}", e))?; + + let q = sql!( + UPDATE cache SET batch_id = ? WHERE batch_id is NULL + ); + self.connection.exec_bound(q)?(batch.id.as_str())?; + + log::info!("Uploaded batch with {} requests", batch_len); + + Ok(batch.id) + } + + fn request_hash(model: &str, max_tokens: u64, messages: &[Message]) -> String { + let mut hasher = std::hash::DefaultHasher::new(); + model.hash(&mut hasher); + max_tokens.hash(&mut hasher); + for msg in messages { + message_content_to_string(&msg.content).hash(&mut hasher); + } + let request_hash = hasher.finish(); + format!("{request_hash:016x}") + } +} + +fn message_content_to_string(content: &[RequestContent]) -> String { + content + .iter() + .filter_map(|c| match c { + RequestContent::Text { text, .. } => Some(text.clone()), + _ => None, + }) + .collect::>() + .join("\n") +} + +pub enum AnthropicClient { + // No batching + Plain(PlainLlmClient), + Batch(BatchingLlmClient), + Dummy, +} + +impl AnthropicClient { + pub fn plain() -> Result { + Ok(Self::Plain(PlainLlmClient::new()?)) + } + + pub fn batch(cache_path: &Path) -> Result { + Ok(Self::Batch(BatchingLlmClient::new(cache_path)?)) + } + + #[allow(dead_code)] + pub fn dummy() -> Self { + Self::Dummy + } + + pub async fn generate( + &self, + model: &str, + max_tokens: u64, + messages: Vec, + ) -> Result> { + match self { + AnthropicClient::Plain(plain_llm_client) => plain_llm_client + .generate(model, max_tokens, messages) + .await + .map(Some), + AnthropicClient::Batch(batching_llm_client) => { + batching_llm_client + .generate(model, max_tokens, messages) + .await + } + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + } + } + + pub async fn sync_batches(&self) -> Result<()> { + match self { + AnthropicClient::Plain(_) => Ok(()), + AnthropicClient::Batch(batching_llm_client) => batching_llm_client.sync_batches().await, + AnthropicClient::Dummy => panic!("Dummy LLM client is not expected to be used"), + } + } +} diff --git a/crates/edit_prediction_cli/src/distill.rs b/crates/edit_prediction_cli/src/distill.rs new file mode 100644 index 0000000000000000000000000000000000000000..abfe178ae61b6da522f43c93d40b6000800d0e4d --- /dev/null +++ b/crates/edit_prediction_cli/src/distill.rs @@ -0,0 +1,22 @@ +use anyhow::{Result, anyhow}; +use std::mem; + +use crate::example::Example; + +pub async fn run_distill(example: &mut Example) -> Result<()> { + let [prediction]: [_; 1] = + mem::take(&mut example.predictions) + .try_into() + .map_err(|preds: Vec<_>| { + anyhow!( + "Example has {} predictions, but it should have exactly one", + preds.len() + ) + })?; + + example.spec.expected_patch = prediction.actual_patch; + example.prompt = None; + example.predictions = Vec::new(); + example.score = Vec::new(); + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs new file mode 100644 index 0000000000000000000000000000000000000000..e37619bf224b3fa506516714856cfbc5024ece14 --- /dev/null +++ b/crates/edit_prediction_cli/src/example.rs @@ -0,0 +1,250 @@ +use crate::{PredictionProvider, PromptFormat, metrics::ClassificationMetrics}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use edit_prediction::example_spec::ExampleSpec; +use edit_prediction::udiff::OpenedBuffers; +use gpui::Entity; +use http_client::Url; +use language::{Anchor, Buffer}; +use project::Project; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::{ + borrow::Cow, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use zeta_prompt::RelatedFile; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Example { + #[serde(flatten)] + pub spec: ExampleSpec, + + /// The full content of the file where an edit is being predicted, and the + /// actual cursor offset. + #[serde(skip_serializing_if = "Option::is_none")] + pub buffer: Option, + + /// The context retrieved for the prediction. This requires the worktree to + /// be loaded and the language server to be started. + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + + /// The input and expected output from the edit prediction model. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + /// The actual predictions from the model. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub predictions: Vec, + + /// The scores, for how well the actual predictions match the expected + /// predictions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub score: Vec, + + /// The application state used to process this example. + #[serde(skip)] + pub state: Option, +} + +#[derive(Clone, Debug)] +pub struct ExampleState { + pub project: Entity, + pub buffer: Entity, + pub cursor_position: Anchor, + pub _open_buffers: OpenedBuffers, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleContext { + pub files: Arc<[RelatedFile]>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleBuffer { + pub content: String, + pub cursor_row: u32, + pub cursor_column: u32, + pub cursor_offset: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrompt { + pub input: String, + pub expected_output: String, + pub format: PromptFormat, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExamplePrediction { + pub actual_patch: String, + pub actual_output: String, + pub provider: PredictionProvider, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleScore { + pub delta_chr_f: f32, + pub line_match: ClassificationMetrics, +} + +impl Example { + pub fn repo_name(&self) -> Result<(Cow<'_, str>, Cow<'_, str>)> { + // git@github.com:owner/repo.git + if self.spec.repository_url.contains('@') { + let (owner, repo) = self + .spec + .repository_url + .split_once(':') + .context("expected : in git url")? + .1 + .split_once('/') + .context("expected / in git url")?; + Ok(( + Cow::Borrowed(owner), + Cow::Borrowed(repo.trim_end_matches(".git")), + )) + // http://github.com/owner/repo.git + } else { + let url = Url::parse(&self.spec.repository_url)?; + let mut segments = url.path_segments().context("empty http url")?; + let owner = segments + .next() + .context("expected owner path segment")? + .to_string(); + let repo = segments + .next() + .context("expected repo path segment")? + .trim_end_matches(".git") + .to_string(); + assert!(segments.next().is_none()); + + Ok((owner.into(), repo.into())) + } + } +} + +pub fn read_examples(inputs: &[PathBuf]) -> Vec { + let mut examples = Vec::new(); + + let stdin_path: PathBuf = PathBuf::from("-"); + + let inputs = if inputs.is_empty() { + &[stdin_path] + } else { + inputs + }; + + for path in inputs { + let is_stdin = path.as_path() == Path::new("-"); + let content = if is_stdin { + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .expect("Failed to read from stdin"); + buffer + } else { + std::fs::read_to_string(path) + .unwrap_or_else(|_| panic!("Failed to read path: {:?}", &path)) + }; + let filename = path.file_stem().unwrap().to_string_lossy().to_string(); + let ext = if !is_stdin { + path.extension() + .map(|ext| ext.to_string_lossy().to_string()) + .unwrap_or_else(|| panic!("{} should have an extension", path.display())) + } else { + "jsonl".to_string() + }; + + match ext.as_ref() { + "json" => { + let mut example = + serde_json::from_str::(&content).unwrap_or_else(|error| { + panic!("Failed to parse example file: {}\n{error}", path.display()) + }); + if example.spec.name.is_empty() { + example.spec.name = filename; + } + examples.push(example); + } + "jsonl" => examples.extend( + content + .lines() + .enumerate() + .map(|(line_ix, line)| { + let mut example = + serde_json::from_str::(line).unwrap_or_else(|error| { + panic!( + "Failed to parse example on {}:{}\n{error}", + path.display(), + line_ix + 1 + ) + }); + if example.spec.name.is_empty() { + example.spec.name = format!("{filename}-{line_ix}") + } + example + }) + .collect::>(), + ), + "md" => { + examples.push(parse_markdown_example(filename, &content).unwrap()); + } + ext => { + panic!("{} has invalid example extension `{ext}`", path.display()) + } + } + } + + sort_examples_by_repo_and_rev(&mut examples); + examples +} + +pub fn write_examples(examples: &[Example], output_path: Option<&PathBuf>) { + let mut content = String::new(); + for example in examples { + let line = serde_json::to_string(example).unwrap(); + content.push_str(&line); + content.push('\n'); + } + if let Some(output_path) = output_path { + std::fs::write(output_path, content).expect("Failed to write examples"); + } else { + std::io::stdout().write_all(&content.as_bytes()).unwrap(); + } +} + +pub fn sort_examples_by_repo_and_rev(examples: &mut [Example]) { + examples.sort_by(|a, b| { + a.spec + .repository_url + .cmp(&b.spec.repository_url) + .then(b.spec.revision.cmp(&a.spec.revision)) + }); +} + +pub fn group_examples_by_repo(examples: &mut [Example]) -> Vec> { + let mut examples_by_repo = HashMap::default(); + for example in examples.iter_mut() { + examples_by_repo + .entry(example.spec.repository_url.clone()) + .or_insert_with(Vec::new) + .push(example); + } + examples_by_repo.into_values().collect() +} + +fn parse_markdown_example(name: String, input: &str) -> Result { + let spec = ExampleSpec::from_markdown(name, input)?; + Ok(Example { + spec, + buffer: None, + context: None, + prompt: None, + predictions: Vec::new(), + score: Vec::new(), + state: None, + }) +} diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs new file mode 100644 index 0000000000000000000000000000000000000000..34e8a92d4140cdbdedb6bd2583e0994eb55b802d --- /dev/null +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -0,0 +1,293 @@ +use crate::{ + PromptFormat, + example::{Example, ExamplePrompt}, + headless::EpAppState, + load_project::run_load_project, + progress::{Progress, Step}, + retrieve_context::run_context_retrieval, +}; +use anyhow::{Context as _, Result, ensure}; +use edit_prediction::{ + EditPredictionStore, + zeta2::{zeta2_output_for_patch, zeta2_prompt_input}, +}; +use gpui::AsyncApp; +use std::sync::Arc; +use zeta_prompt::format_zeta_prompt; + +pub async fn run_format_prompt( + example: &mut Example, + prompt_format: PromptFormat, + app_state: Arc, + mut cx: AsyncApp, +) -> Result<()> { + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; + + let _step_progress = Progress::global().start(Step::FormatPrompt, &example.spec.name); + + match prompt_format { + PromptFormat::Teacher => { + let prompt = TeacherPrompt::format_prompt(example); + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output: example.spec.expected_patch.clone(), // TODO + format: prompt_format, + }); + } + PromptFormat::Zeta2 => { + run_load_project(example, app_state, cx.clone()).await?; + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + let state = example.state.as_ref().context("state must be set")?; + let snapshot = state.buffer.read_with(&cx, |buffer, _| buffer.snapshot())?; + let project = state.project.clone(); + let (_, input) = ep_store.update(&mut cx, |ep_store, cx| { + let events = ep_store + .edit_history_for_project(&project, cx) + .into_iter() + .map(|e| e.event) + .collect(); + anyhow::Ok(zeta2_prompt_input( + &snapshot, + example + .context + .as_ref() + .context("context must be set")? + .files + .clone(), + events, + example.spec.cursor_path.clone(), + example + .buffer + .as_ref() + .context("buffer must be set")? + .cursor_offset, + )) + })??; + let prompt = format_zeta_prompt(&input); + let expected_output = + zeta2_output_for_patch(&input, &example.spec.expected_patch.clone())?; + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output, + format: prompt_format, + }); + } + }; + Ok(()) +} + +pub struct TeacherPrompt; + +impl TeacherPrompt { + const PROMPT: &str = include_str!("teacher.prompt.md"); + pub(crate) const EDITABLE_REGION_START: &str = "<|editable_region_start|>\n"; + pub(crate) const EDITABLE_REGION_END: &str = "<|editable_region_end|>"; + + /// Truncate edit history to this number of last lines + const MAX_HISTORY_LINES: usize = 128; + + pub fn format_prompt(example: &Example) -> String { + let edit_history = Self::format_edit_history(&example.spec.edit_history); + let context = Self::format_context(example); + let editable_region = Self::format_editable_region(example); + + let prompt = Self::PROMPT + .replace("{{context}}", &context) + .replace("{{edit_history}}", &edit_history) + .replace("{{editable_region}}", &editable_region); + + prompt + } + + pub fn parse(example: &Example, response: &str) -> Result { + // Ideally, we should always be able to find cursor position in the retrieved context. + // In reality, sometimes we don't find it for these reasons: + // 1. `example.cursor_position` contains _more_ context than included in the retrieved context + // (can be fixed by getting cursor coordinates at the load_example stage) + // 2. Context retriever just didn't include cursor line. + // + // In that case, fallback to using `cursor_position` as excerpt. + let cursor_file = &example + .buffer + .as_ref() + .context("`buffer` should be filled in in the context collection step")? + .content; + + // Extract updated (new) editable region from the model response + let new_editable_region = extract_last_codeblock(response); + + // Reconstruct old editable region we sent to the model + let old_editable_region = Self::format_editable_region(example); + let old_editable_region = Self::extract_editable_region(&old_editable_region); + ensure!( + cursor_file.contains(&old_editable_region), + "Something's wrong: editable_region is not found in the cursor file" + ); + + // Apply editable region to a larger context and compute diff. + // This is needed to get a better context lines around the editable region + let edited_file = cursor_file.replace(&old_editable_region, &new_editable_region); + let diff = language::unified_diff(&cursor_file, &edited_file); + + let diff = indoc::formatdoc! {" + --- a/{path} + +++ b/{path} + {diff}", + path = example.spec.cursor_path.to_string_lossy(), + diff = diff, + }; + + Ok(diff) + } + + fn format_edit_history(edit_history: &str) -> String { + // Strip comments ("garbage lines") from edit history + let lines = edit_history + .lines() + .filter(|&s| Self::is_udiff_content_line(s)) + .collect::>(); + + let history_lines = if lines.len() > Self::MAX_HISTORY_LINES { + &lines[lines.len() - Self::MAX_HISTORY_LINES..] + } else { + &lines + }; + + if history_lines.is_empty() { + return "(No edit history)".to_string(); + } + + history_lines.join("\n") + } + + fn format_context(example: &Example) -> String { + assert!(example.context.is_some(), "Missing context retriever step"); + + let mut prompt = String::new(); + zeta_prompt::write_related_files(&mut prompt, &example.context.as_ref().unwrap().files); + + prompt + } + + fn format_editable_region(example: &Example) -> String { + let mut result = String::new(); + + let path_str = example.spec.cursor_path.to_string_lossy(); + result.push_str(&format!("`````path=\"{path_str}\"\n")); + result.push_str(Self::EDITABLE_REGION_START); + + // TODO: control number of lines around cursor + result.push_str(&example.spec.cursor_position); + if !example.spec.cursor_position.ends_with('\n') { + result.push('\n'); + } + + result.push_str(&format!("{}\n", Self::EDITABLE_REGION_END)); + result.push_str("`````"); + + result + } + + fn extract_editable_region(text: &str) -> String { + let start = text + .find(Self::EDITABLE_REGION_START) + .map_or(0, |pos| pos + Self::EDITABLE_REGION_START.len()); + let end = text.find(Self::EDITABLE_REGION_END).unwrap_or(text.len()); + + let region = &text[start..end]; + + region.replace("<|user_cursor|>", "") + } + + fn is_udiff_content_line(s: &str) -> bool { + s.starts_with("-") + || s.starts_with("+") + || s.starts_with(" ") + || s.starts_with("---") + || s.starts_with("+++") + || s.starts_with("@@") + } +} + +fn extract_last_codeblock(text: &str) -> String { + let mut last_block = None; + let mut search_start = 0; + + while let Some(start) = text[search_start..].find("```") { + let start = start + search_start; + let bytes = text.as_bytes(); + let mut backtick_end = start; + + while backtick_end < bytes.len() && bytes[backtick_end] == b'`' { + backtick_end += 1; + } + + let backtick_count = backtick_end - start; + let closing_backticks = "`".repeat(backtick_count); + + while backtick_end < bytes.len() && bytes[backtick_end] != b'\n' { + backtick_end += 1; + } + + if let Some(end_pos) = text[backtick_end..].find(&closing_backticks) { + let code_block = &text[backtick_end + 1..backtick_end + end_pos]; + last_block = Some(code_block.to_string()); + search_start = backtick_end + end_pos + backtick_count; + } else { + break; + } + } + + last_block.unwrap_or_else(|| text.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_last_code_block() { + let text = indoc::indoc! {" + Some thinking + + ``` + first block + ``` + + `````path='something' lines=1:2 + last block + ````` + "}; + let last_block = extract_last_codeblock(text); + assert_eq!(last_block, "last block\n"); + } + + #[test] + fn test_extract_editable_region() { + let text = indoc::indoc! {" + some lines + are + here + <|editable_region_start|> + one + two three + + <|editable_region_end|> + more + lines here + "}; + let parsed = TeacherPrompt::extract_editable_region(text); + assert_eq!( + parsed, + indoc::indoc! {" + one + two three + + "} + ); + } +} diff --git a/crates/zeta_cli/src/headless.rs b/crates/edit_prediction_cli/src/headless.rs similarity index 84% rename from crates/zeta_cli/src/headless.rs rename to crates/edit_prediction_cli/src/headless.rs index c4d8667d63dfb3dd39fbced609e0ae0bc44974d2..da96e7ef6520e952e2b7696eee6b82c243e90e4e 100644 --- a/crates/zeta_cli/src/headless.rs +++ b/crates/edit_prediction_cli/src/headless.rs @@ -1,4 +1,5 @@ use client::{Client, ProxySettings, UserStore}; +use collections::HashMap; use extension::ExtensionHostProxy; use fs::RealFs; use gpui::http_client::read_proxy_from_env; @@ -7,25 +8,38 @@ use gpui_tokio::Tokio; use language::LanguageRegistry; use language_extension::LspAccess; use node_runtime::{NodeBinaryOptions, NodeRuntime}; -use project::project_settings::ProjectSettings; +use project::{Project, project_settings::ProjectSettings}; use release_channel::{AppCommitSha, AppVersion}; use reqwest_client::ReqwestClient; use settings::{Settings, SettingsStore}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use util::ResultExt as _; /// Headless subset of `workspace::AppState`. -pub struct ZetaCliAppState { +pub struct EpAppState { pub languages: Arc, pub client: Arc, pub user_store: Entity, pub fs: Arc, pub node_runtime: NodeRuntime, + pub project_cache: ProjectCache, } -// TODO: dedupe with crates/eval/src/eval.rs -pub fn init(cx: &mut App) -> ZetaCliAppState { +#[derive(Default)] +pub struct ProjectCache(Mutex>>); + +impl ProjectCache { + pub fn insert(&self, repository_url: String, project: Entity) { + self.0.lock().unwrap().insert(repository_url, project); + } + + pub fn get(&self, repository_url: &String) -> Option> { + self.0.lock().unwrap().get(repository_url).cloned() + } +} + +pub fn init(cx: &mut App) -> EpAppState { let app_commit_sha = option_env!("ZED_COMMIT_SHA").map(|s| AppCommitSha::new(s.to_owned())); let app_version = AppVersion::load( @@ -112,11 +126,14 @@ pub fn init(cx: &mut App) -> ZetaCliAppState { prompt_store::init(cx); terminal_view::init(cx); - ZetaCliAppState { + let project_cache = ProjectCache::default(); + + EpAppState { languages, client, user_store, fs, node_runtime, + project_cache, } } diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs new file mode 100644 index 0000000000000000000000000000000000000000..70daf00b79486fd917556cffaa26b1fd01ed4d28 --- /dev/null +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -0,0 +1,357 @@ +use crate::{ + example::{Example, ExampleBuffer, ExampleState}, + headless::EpAppState, + paths::{REPOS_DIR, WORKTREES_DIR}, + progress::{InfoStyle, Progress, Step, StepProgress}, +}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use edit_prediction::EditPredictionStore; +use edit_prediction::udiff::OpenedBuffers; +use futures::{ + AsyncWriteExt as _, + lock::{Mutex, OwnedMutexGuard}, +}; +use gpui::{AsyncApp, Entity}; +use language::{Anchor, Buffer, LanguageNotFound, ToOffset, ToPoint}; +use project::buffer_store::BufferStoreEvent; +use project::{Project, ProjectPath}; +use std::{ + cell::RefCell, + fs, + path::{Path, PathBuf}, + sync::Arc, +}; +use util::{paths::PathStyle, rel_path::RelPath}; +use zeta_prompt::CURSOR_MARKER; + +pub async fn run_load_project( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) -> Result<()> { + if example.state.is_some() { + return Ok(()); + } + + let progress = Progress::global().start(Step::LoadProject, &example.spec.name); + + let project = setup_project(example, &app_state, &progress, &mut cx).await?; + + let _open_buffers = apply_edit_history(example, &project, &mut cx).await?; + + let (buffer, cursor_position) = cursor_position(example, &project, &mut cx).await?; + let (example_buffer, language_name) = buffer.read_with(&cx, |buffer, _cx| { + let cursor_point = cursor_position.to_point(&buffer); + let language_name = buffer + .language() + .map(|l| l.name().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + ( + ExampleBuffer { + content: buffer.text(), + cursor_row: cursor_point.row, + cursor_column: cursor_point.column, + cursor_offset: cursor_position.to_offset(&buffer), + }, + language_name, + ) + })?; + + progress.set_info(language_name, InfoStyle::Normal); + + example.buffer = Some(example_buffer); + example.state = Some(ExampleState { + buffer, + project, + cursor_position, + _open_buffers, + }); + Ok(()) +} + +async fn cursor_position( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> Result<(Entity, Anchor)> { + let language_registry = project.read_with(cx, |project, _| project.languages().clone())?; + let result = language_registry + .load_language_for_file_path(&example.spec.cursor_path) + .await; + + if let Err(error) = result + && !error.is::() + { + return Err(error); + } + + let worktree = project.read_with(cx, |project, cx| { + project + .visible_worktrees(cx) + .next() + .context("No visible worktrees") + })??; + + let cursor_path = RelPath::new(&example.spec.cursor_path, PathStyle::Posix) + .context("Failed to create RelPath")? + .into_arc(); + let cursor_buffer = project + .update(cx, |project, cx| { + project.open_buffer( + ProjectPath { + worktree_id: worktree.read(cx).id(), + path: cursor_path, + }, + cx, + ) + })? + .await?; + let cursor_offset_within_excerpt = example + .spec + .cursor_position + .find(CURSOR_MARKER) + .context("missing cursor marker")?; + let mut cursor_excerpt = example.spec.cursor_position.clone(); + cursor_excerpt.replace_range( + cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), + "", + ); + let excerpt_offset = cursor_buffer.read_with(cx, |buffer, _cx| { + let text = buffer.text(); + + let mut matches = text.match_indices(&cursor_excerpt); + let (excerpt_offset, _) = matches.next().with_context(|| { + format!( + "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Example: {}\nCursor excerpt did not exist in buffer.", + example.spec.name + ) + })?; + anyhow::ensure!( + matches.next().is_none(), + "More than one cursor position match found for {}", + &example.spec.name + ); + Ok(excerpt_offset) + })??; + + let cursor_offset = excerpt_offset + cursor_offset_within_excerpt; + let cursor_anchor = + cursor_buffer.read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset))?; + + Ok((cursor_buffer, cursor_anchor)) +} + +async fn setup_project( + example: &mut Example, + app_state: &Arc, + step_progress: &StepProgress, + cx: &mut AsyncApp, +) -> Result> { + let ep_store = cx + .update(|cx| EditPredictionStore::try_global(cx))? + .context("Store should be initialized at init")?; + + let worktree_path = setup_worktree(example, step_progress).await?; + + if let Some(project) = app_state.project_cache.get(&example.spec.repository_url) { + ep_store.update(cx, |ep_store, _| { + ep_store.clear_history_for_project(&project); + })?; + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + let buffers = buffer_store.read_with(cx, |buffer_store, _| { + buffer_store.buffers().collect::>() + })?; + for buffer in buffers { + buffer + .update(cx, |buffer, cx| buffer.reload(cx))? + .await + .ok(); + } + return Ok(project); + } + + let project = cx.update(|cx| { + Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + false, + cx, + ) + })?; + + project + .update(cx, |project, cx| { + project.disable_worktree_scanner(cx); + project.create_worktree(&worktree_path, true, cx) + })? + .await?; + + app_state + .project_cache + .insert(example.spec.repository_url.clone(), project.clone()); + + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + cx.subscribe(&buffer_store, { + let project = project.clone(); + move |_, event, cx| match event { + BufferStoreEvent::BufferAdded(buffer) => { + ep_store.update(cx, |store, cx| store.register_buffer(&buffer, &project, cx)); + } + _ => {} + } + })? + .detach(); + + Ok(project) +} + +async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Result { + let (repo_owner, repo_name) = example.repo_name().context("failed to get repo name")?; + let repo_dir = REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref()); + let worktree_path = WORKTREES_DIR + .join(repo_owner.as_ref()) + .join(repo_name.as_ref()); + let repo_lock = lock_repo(&repo_dir).await; + + if !repo_dir.is_dir() { + step_progress.set_substatus(format!("cloning {}", repo_name)); + fs::create_dir_all(&repo_dir)?; + run_git(&repo_dir, &["init"]).await?; + run_git( + &repo_dir, + &["remote", "add", "origin", &example.spec.repository_url], + ) + .await?; + } + + // Resolve the example to a revision, fetching it if needed. + let revision = run_git( + &repo_dir, + &[ + "rev-parse", + &format!("{}^{{commit}}", example.spec.revision), + ], + ) + .await; + let revision = if let Ok(revision) = revision { + revision + } else { + step_progress.set_substatus("fetching"); + if run_git( + &repo_dir, + &["fetch", "--depth", "1", "origin", &example.spec.revision], + ) + .await + .is_err() + { + run_git(&repo_dir, &["fetch", "origin"]).await?; + } + let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]).await?; + revision + }; + + // Create the worktree for this example if needed. + step_progress.set_substatus("preparing worktree"); + if worktree_path.is_dir() { + run_git(&worktree_path, &["clean", "--force", "-d"]).await?; + run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; + run_git(&worktree_path, &["checkout", revision.as_str()]).await?; + } else { + let worktree_path_string = worktree_path.to_string_lossy(); + run_git( + &repo_dir, + &["branch", "-f", &example.spec.name, revision.as_str()], + ) + .await?; + run_git( + &repo_dir, + &[ + "worktree", + "add", + "-f", + &worktree_path_string, + &example.spec.name, + ], + ) + .await?; + } + drop(repo_lock); + + // Apply the uncommitted diff for this example. + if !example.spec.uncommitted_diff.is_empty() { + step_progress.set_substatus("applying diff"); + let mut apply_process = smol::process::Command::new("git") + .current_dir(&worktree_path) + .args(&["apply", "-"]) + .stdin(std::process::Stdio::piped()) + .spawn()?; + + let mut stdin = apply_process.stdin.take().context("Failed to get stdin")?; + stdin + .write_all(example.spec.uncommitted_diff.as_bytes()) + .await?; + stdin.close().await?; + drop(stdin); + + let apply_result = apply_process.output().await?; + anyhow::ensure!( + apply_result.status.success(), + "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", + apply_result.status, + String::from_utf8_lossy(&apply_result.stderr), + String::from_utf8_lossy(&apply_result.stdout), + ); + } + + step_progress.clear_substatus(); + Ok(worktree_path) +} + +async fn apply_edit_history( + example: &Example, + project: &Entity, + cx: &mut AsyncApp, +) -> Result { + edit_prediction::udiff::apply_diff(&example.spec.edit_history, project, cx).await +} + +thread_local! { + static REPO_LOCKS: RefCell>>> = RefCell::new(HashMap::default()); +} + +#[must_use] +pub async fn lock_repo(path: impl AsRef) -> OwnedMutexGuard<()> { + REPO_LOCKS + .with(|cell| { + cell.borrow_mut() + .entry(path.as_ref().to_path_buf()) + .or_default() + .clone() + }) + .lock_owned() + .await +} + +async fn run_git(repo_path: &Path, args: &[&str]) -> Result { + let output = smol::process::Command::new("git") + .current_dir(repo_path) + .args(args) + .output() + .await?; + + anyhow::ensure!( + output.status.success(), + "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}", + args.join(" "), + repo_path.display(), + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..dce0fbbed57dbc4b18faf93787cfb8f2341a126a --- /dev/null +++ b/crates/edit_prediction_cli/src/main.rs @@ -0,0 +1,343 @@ +mod anthropic_client; +mod distill; +mod example; +mod format_prompt; +mod headless; +mod load_project; +mod metrics; +mod paths; +mod predict; +mod progress; +mod retrieve_context; +mod score; + +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use edit_prediction::EditPredictionStore; +use gpui::Application; +use reqwest_client::ReqwestClient; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; +use std::{path::PathBuf, sync::Arc}; + +use crate::distill::run_distill; +use crate::example::{group_examples_by_repo, read_examples, write_examples}; +use crate::format_prompt::run_format_prompt; +use crate::load_project::run_load_project; +use crate::paths::FAILED_EXAMPLES_DIR; +use crate::predict::run_prediction; +use crate::progress::Progress; +use crate::retrieve_context::run_context_retrieval; +use crate::score::run_scoring; + +#[derive(Parser, Debug)] +#[command(name = "ep")] +struct EpArgs { + #[arg(long, default_value_t = false)] + printenv: bool, + #[clap(long, default_value_t = 10, global = true)] + max_parallelism: usize, + #[command(subcommand)] + command: Option, + #[clap(global = true)] + inputs: Vec, + #[arg(long, short, global = true)] + output: Option, + #[arg(long, short, global = true)] + in_place: bool, + #[arg(long, short, global = true)] + failfast: bool, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Parse markdown examples and output a combined .jsonl file + ParseExample, + /// Create git worktrees for each example and load file contents + LoadProject, + /// Retrieve context for input examples. + Context, + /// Generate a prompt string for a specific model + FormatPrompt(FormatPromptArgs), + /// Runs edit prediction + Predict(PredictArgs), + /// Computes a score based on actual and expected patches + Score(PredictArgs), + /// Prepares a distillation dataset by copying expected outputs to + /// predicted outputs and removing actual outputs and prompts. + Distill, + /// Print aggregated scores + Eval(PredictArgs), + /// Remove git repositories and worktrees + Clean, +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Command::ParseExample => write!(f, "parse-example"), + Command::LoadProject => write!(f, "load-project"), + Command::Context => write!(f, "context"), + Command::FormatPrompt(format_prompt_args) => write!( + f, + "format-prompt --prompt-format={}", + format_prompt_args + .prompt_format + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Predict(predict_args) => { + write!( + f, + "predict --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Score(predict_args) => { + write!( + f, + "score --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Distill => write!(f, "distill"), + Command::Eval(predict_args) => write!( + f, + "eval --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Clean => write!(f, "clean"), + } + } +} + +#[derive(Debug, Args)] +struct FormatPromptArgs { + #[clap(long)] + prompt_format: PromptFormat, +} + +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] +enum PromptFormat { + Teacher, + Zeta2, +} + +#[derive(Debug, Args)] +struct PredictArgs { + #[clap(long)] + provider: PredictionProvider, + #[clap(long, default_value_t = 1)] + repetitions: usize, +} + +#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)] +enum PredictionProvider { + Sweep, + Mercury, + Zeta1, + Zeta2, + Teacher, + TeacherNonBatching, +} + +impl EpArgs { + fn output_path(&self) -> Option { + if self.in_place { + if self.inputs.len() == 1 { + self.inputs.first().cloned() + } else { + panic!("--in-place requires exactly one input file") + } + } else { + self.output.clone() + } + } +} + +fn main() { + let args = EpArgs::parse(); + + if args.printenv { + ::util::shell_env::print_env(); + return; + } + + let output = args.output_path(); + let command = match args.command { + Some(cmd) => cmd, + None => { + EpArgs::command().print_help().unwrap(); + return; + } + }; + + match &command { + Command::Clean => { + std::fs::remove_dir_all(&*paths::DATA_DIR).unwrap(); + return; + } + _ => {} + } + + let mut examples = read_examples(&args.inputs); + let http_client = Arc::new(ReqwestClient::new()); + let app = Application::headless().with_http_client(http_client); + + app.run(move |cx| { + let app_state = Arc::new(headless::init(cx)); + EditPredictionStore::global(&app_state.client, &app_state.user_store, cx); + + cx.spawn(async move |cx| { + let result = async { + if let Command::Predict(args) = &command { + predict::sync_batches(&args.provider).await?; + } + + let total_examples = examples.len(); + Progress::global().set_total_examples(total_examples); + + let mut grouped_examples = group_examples_by_repo(&mut examples); + let example_batches = grouped_examples.chunks_mut(args.max_parallelism); + + for example_batch in example_batches { + let futures = example_batch.into_iter().map(|repo_examples| async { + for example in repo_examples.iter_mut() { + let result = async { + match &command { + Command::ParseExample => {} + Command::LoadProject => { + run_load_project(example, app_state.clone(), cx.clone()) + .await?; + } + Command::Context => { + run_context_retrieval( + example, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::FormatPrompt(args) => { + run_format_prompt( + example, + args.prompt_format, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Predict(args) => { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Distill => { + run_distill(example).await?; + } + Command::Score(args) | Command::Eval(args) => { + run_scoring(example, &args, app_state.clone(), cx.clone()) + .await?; + } + Command::Clean => { + unreachable!() + } + } + anyhow::Ok(()) + } + .await; + + if let Err(e) = result { + Progress::global().increment_failed(); + let failed_example_path = + FAILED_EXAMPLES_DIR.join(format!("{}.json", example.spec.name)); + app_state + .fs + .write( + &failed_example_path, + &serde_json::to_vec_pretty(&example).unwrap(), + ) + .await + .unwrap(); + let err_path = FAILED_EXAMPLES_DIR + .join(format!("{}_err.txt", example.spec.name)); + app_state + .fs + .write(&err_path, e.to_string().as_bytes()) + .await + .unwrap(); + + let msg = format!( + indoc::indoc! {" + While processing {}: + + {:?} + + Written to: \x1b[36m{}\x1b[0m + + Explore this example data with: + fx \x1b[36m{}\x1b[0m + + Re-run this example with: + cargo run -p edit_prediction_cli -- {} \x1b[36m{}\x1b[0m + "}, + example.spec.name, + e, + err_path.display(), + failed_example_path.display(), + command, + failed_example_path.display(), + ); + if args.failfast || total_examples == 1 { + Progress::global().finalize(); + panic!("{}", msg); + } else { + log::error!("{}", msg); + } + } + } + }); + futures::future::join_all(futures).await; + } + Progress::global().finalize(); + + if args.output.is_some() || !matches!(command, Command::Eval(_)) { + write_examples(&examples, output.as_ref()); + } + + match &command { + Command::Predict(args) => predict::sync_batches(&args.provider).await?, + Command::Eval(_) => score::print_report(&examples), + _ => (), + }; + + anyhow::Ok(()) + } + .await; + + if let Err(e) = result { + panic!("Fatal error: {:?}", e); + } + + let _ = cx.update(|cx| cx.quit()); + }) + .detach(); + }); +} diff --git a/crates/zeta_cli/src/metrics.rs b/crates/edit_prediction_cli/src/metrics.rs similarity index 90% rename from crates/zeta_cli/src/metrics.rs rename to crates/edit_prediction_cli/src/metrics.rs index dd08459678eef6d04a6b656d19a4572d51a5b5c1..b3e5eb8688724c821953a56c4fe82e67c75e13b6 100644 --- a/crates/zeta_cli/src/metrics.rs +++ b/crates/edit_prediction_cli/src/metrics.rs @@ -1,30 +1,34 @@ use collections::{HashMap, HashSet}; -use zeta::udiff::DiffLine; +use edit_prediction::udiff::DiffLine; +use serde::{Deserialize, Serialize}; type Counts = HashMap; type CountsDelta = HashMap; -#[derive(Default, Debug, Clone)] -pub struct Scores { +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct ClassificationMetrics { pub true_positives: usize, pub false_positives: usize, pub false_negatives: usize, } -impl Scores { - pub fn from_sets(expected: &HashSet, actual: &HashSet) -> Scores { +impl ClassificationMetrics { + pub fn from_sets( + expected: &HashSet, + actual: &HashSet, + ) -> ClassificationMetrics { let true_positives = expected.intersection(actual).count(); let false_positives = actual.difference(expected).count(); let false_negatives = expected.difference(actual).count(); - Scores { + ClassificationMetrics { true_positives, false_positives, false_negatives, } } - pub fn from_counts(expected: &Counts, actual: &Counts) -> Scores { + pub fn from_counts(expected: &Counts, actual: &Counts) -> ClassificationMetrics { let mut true_positives = 0; let mut false_positives = 0; let mut false_negatives = 0; @@ -45,32 +49,16 @@ impl Scores { } } - Scores { + ClassificationMetrics { true_positives, false_positives, false_negatives, } } - pub fn to_markdown(&self) -> String { - format!( - " -Precision : {:.4} -Recall : {:.4} -F1 Score : {:.4} -True Positives : {} -False Positives : {} -False Negatives : {}", - self.precision(), - self.recall(), - self.f1_score(), - self.true_positives, - self.false_positives, - self.false_negatives - ) - } - - pub fn aggregate<'a>(scores: impl Iterator) -> Scores { + pub fn aggregate<'a>( + scores: impl Iterator, + ) -> ClassificationMetrics { let mut true_positives = 0; let mut false_positives = 0; let mut false_negatives = 0; @@ -81,7 +69,7 @@ False Negatives : {}", false_negatives += score.false_negatives; } - Scores { + ClassificationMetrics { true_positives, false_positives, false_negatives, @@ -115,7 +103,10 @@ False Negatives : {}", } } -pub fn line_match_score(expected_patch: &[DiffLine], actual_patch: &[DiffLine]) -> Scores { +pub fn line_match_score( + expected_patch: &[DiffLine], + actual_patch: &[DiffLine], +) -> ClassificationMetrics { let expected_change_lines = expected_patch .iter() .filter(|line| matches!(line, DiffLine::Addition(_) | DiffLine::Deletion(_))) @@ -128,7 +119,7 @@ pub fn line_match_score(expected_patch: &[DiffLine], actual_patch: &[DiffLine]) .map(|line| line.to_string()) .collect(); - Scores::from_sets(&expected_change_lines, &actual_change_lines) + ClassificationMetrics::from_sets(&expected_change_lines, &actual_change_lines) } enum ChrfWhitespace { @@ -204,7 +195,7 @@ pub fn delta_chr_f(expected: &[DiffLine], actual: &[DiffLine]) -> f64 { let expected_counts = ngram_delta_to_counts(&expected_delta); let actual_counts = ngram_delta_to_counts(&actual_delta); - let score = Scores::from_counts(&expected_counts, &actual_counts); + let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); total_precision += score.precision(); total_recall += score.recall(); } @@ -287,7 +278,7 @@ fn count_ngrams(text: &str, n: usize) -> Counts { #[cfg(test)] mod test { use super::*; - use zeta::udiff::DiffLine; + use edit_prediction::udiff::DiffLine; #[test] fn test_delta_chr_f_perfect_match() { diff --git a/crates/edit_prediction_cli/src/paths.rs b/crates/edit_prediction_cli/src/paths.rs new file mode 100644 index 0000000000000000000000000000000000000000..e5d420d0e3dbeda9c50b8e5a3683238149dbc604 --- /dev/null +++ b/crates/edit_prediction_cli/src/paths.rs @@ -0,0 +1,27 @@ +use std::{ + path::{Path, PathBuf}, + sync::LazyLock, +}; + +pub static DATA_DIR: LazyLock = LazyLock::new(|| { + let dir = dirs::home_dir().unwrap().join(".zed_ep"); + ensure_dir(&dir) +}); +pub static CACHE_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("cache"))); +pub static REPOS_DIR: LazyLock = LazyLock::new(|| ensure_dir(&DATA_DIR.join("repos"))); +pub static WORKTREES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&DATA_DIR.join("worktrees"))); +pub static RUN_DIR: LazyLock = LazyLock::new(|| { + DATA_DIR + .join("runs") + .join(chrono::Local::now().format("%d-%m-%y-%H_%M_%S").to_string()) +}); +pub static LATEST_EXAMPLE_RUN_DIR: LazyLock = LazyLock::new(|| DATA_DIR.join("latest")); +pub static LLM_CACHE_DB: LazyLock = LazyLock::new(|| CACHE_DIR.join("llm_cache.sqlite")); +pub static FAILED_EXAMPLES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&RUN_DIR.join("failed"))); + +fn ensure_dir(path: &Path) -> PathBuf { + std::fs::create_dir_all(path).expect("Failed to create directory"); + path.to_path_buf() +} diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs new file mode 100644 index 0000000000000000000000000000000000000000..aa93c5415dea091164a68b76a34242697aac70e3 --- /dev/null +++ b/crates/edit_prediction_cli/src/predict.rs @@ -0,0 +1,291 @@ +use crate::{ + PredictionProvider, PromptFormat, + anthropic_client::AnthropicClient, + example::{Example, ExamplePrediction}, + format_prompt::{TeacherPrompt, run_format_prompt}, + headless::EpAppState, + load_project::run_load_project, + paths::{LATEST_EXAMPLE_RUN_DIR, RUN_DIR}, + progress::{InfoStyle, Progress, Step}, + retrieve_context::run_context_retrieval, +}; +use anyhow::Context as _; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, future::Shared}; +use gpui::{AppContext as _, AsyncApp, Task}; +use std::{ + fs, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicUsize, Ordering::SeqCst}, + }, +}; + +pub async fn run_prediction( + example: &mut Example, + provider: Option, + repetition_count: usize, + app_state: Arc, + mut cx: AsyncApp, +) -> anyhow::Result<()> { + if !example.predictions.is_empty() { + return Ok(()); + } + + let provider = provider.context("provider is required")?; + + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; + + if matches!( + provider, + PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching + ) { + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); + + if example.prompt.is_none() { + run_format_prompt(example, PromptFormat::Teacher, app_state.clone(), cx).await?; + } + + let batched = matches!(provider, PredictionProvider::Teacher); + return predict_anthropic(example, repetition_count, batched).await; + } + + run_load_project(example, app_state.clone(), cx.clone()).await?; + + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); + + if matches!( + provider, + PredictionProvider::Zeta1 | PredictionProvider::Zeta2 + ) { + static AUTHENTICATED: OnceLock>> = OnceLock::new(); + AUTHENTICATED + .get_or_init(|| { + let client = app_state.client.clone(); + cx.spawn(async move |cx| { + if let Err(e) = client.sign_in_with_optional_connect(true, cx).await { + eprintln!("Authentication failed: {}", e); + } + }) + .shared() + }) + .clone() + .await; + } + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + ep_store.update(&mut cx, |store, _cx| { + let model = match provider { + PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta1, + PredictionProvider::Zeta2 => edit_prediction::EditPredictionModel::Zeta2, + PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, + PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, + PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching => { + unreachable!() + } + }; + store.set_edit_prediction_model(model); + })?; + let state = example.state.as_ref().context("state must be set")?; + let run_dir = RUN_DIR.join(&example.spec.name); + + let updated_example = Arc::new(Mutex::new(example.clone())); + let current_run_ix = Arc::new(AtomicUsize::new(0)); + + let mut debug_rx = + ep_store.update(&mut cx, |store, cx| store.debug_info(&state.project, cx))?; + let debug_task = cx.background_spawn({ + let updated_example = updated_example.clone(); + let current_run_ix = current_run_ix.clone(); + let run_dir = run_dir.clone(); + async move { + while let Some(event) = debug_rx.next().await { + let run_ix = current_run_ix.load(SeqCst); + let mut updated_example = updated_example.lock().unwrap(); + + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", run_ix)) + } else { + run_dir.clone() + }; + + match event { + DebugEvent::EditPredictionStarted(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(prompt) = request.prompt { + fs::write(run_dir.join("prediction_prompt.md"), &prompt)?; + } + } + DebugEvent::EditPredictionFinished(request) => { + assert_eq!(updated_example.predictions.len(), run_ix + 1); + + if let Some(output) = request.model_output { + fs::write(run_dir.join("prediction_response.md"), &output)?; + updated_example + .predictions + .last_mut() + .unwrap() + .actual_output = output; + } + if run_ix >= repetition_count { + break; + } + } + _ => {} + } + } + anyhow::Ok(()) + } + }); + + for ix in 0..repetition_count { + current_run_ix.store(ix, SeqCst); + let run_dir = if repetition_count > 1 { + run_dir.join(format!("{:03}", ix)) + } else { + run_dir.clone() + }; + + fs::create_dir_all(&run_dir)?; + if LATEST_EXAMPLE_RUN_DIR.is_symlink() { + fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR)?; + } + #[cfg(unix)] + std::os::unix::fs::symlink(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; + #[cfg(windows)] + std::os::windows::fs::symlink_dir(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; + + updated_example + .lock() + .unwrap() + .predictions + .push(ExamplePrediction { + actual_patch: String::new(), + actual_output: String::new(), + provider, + }); + + let prediction = ep_store + .update(&mut cx, |store, cx| { + store.request_prediction( + &state.project, + &state.buffer, + state.cursor_position, + cloud_llm_client::PredictEditsRequestTrigger::Cli, + cx, + ) + })? + .await?; + + let actual_patch = prediction + .and_then(|prediction| { + let prediction = prediction.prediction.ok()?; + prediction.edit_preview.as_unified_diff(&prediction.edits) + }) + .unwrap_or_default(); + + let has_prediction = !actual_patch.is_empty(); + + updated_example + .lock() + .unwrap() + .predictions + .last_mut() + .unwrap() + .actual_patch = actual_patch; + + if ix == repetition_count - 1 { + let (info, style) = if has_prediction { + ("predicted", InfoStyle::Normal) + } else { + ("no prediction", InfoStyle::Warning) + }; + _step_progress.set_info(info, style); + } + } + + ep_store.update(&mut cx, |store, _| { + store.remove_project(&state.project); + })?; + debug_task.await?; + + *example = Arc::into_inner(updated_example) + .ok_or_else(|| anyhow::anyhow!("Failed to unwrap Arc"))? + .into_inner() + .map_err(|_| anyhow::anyhow!("Failed to unwrap Mutex"))?; + Ok(()) +} + +async fn predict_anthropic( + example: &mut Example, + _repetition_count: usize, + batched: bool, +) -> anyhow::Result<()> { + let llm_model_name = "claude-sonnet-4-5"; + let max_tokens = 16384; + let llm_client = if batched { + AnthropicClient::batch(&crate::paths::LLM_CACHE_DB.as_ref()) + } else { + AnthropicClient::plain() + }; + let llm_client = llm_client.context("Failed to create LLM client")?; + + let prompt = example.prompt.as_ref().context("Prompt is required")?; + + let messages = vec![anthropic::Message { + role: anthropic::Role::User, + content: vec![anthropic::RequestContent::Text { + text: prompt.input.clone(), + cache_control: None, + }], + }]; + + let Some(response) = llm_client + .generate(llm_model_name, max_tokens, messages) + .await? + else { + // Request stashed for batched processing + return Ok(()); + }; + + let actual_output = response + .content + .into_iter() + .filter_map(|content| match content { + anthropic::ResponseContent::Text { text } => Some(text), + _ => None, + }) + .collect::>() + .join("\n"); + + let actual_patch = TeacherPrompt::parse(example, &actual_output)?; + + let prediction = ExamplePrediction { + actual_patch, + actual_output, + provider: PredictionProvider::Teacher, + }; + + example.predictions.push(prediction); + Ok(()) +} + +pub async fn sync_batches(provider: &PredictionProvider) -> anyhow::Result<()> { + match provider { + PredictionProvider::Teacher => { + let cache_path = crate::paths::LLM_CACHE_DB.as_ref(); + let llm_client = + AnthropicClient::batch(cache_path).context("Failed to create LLM client")?; + llm_client + .sync_batches() + .await + .context("Failed to sync batches")?; + } + _ => (), + }; + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/progress.rs b/crates/edit_prediction_cli/src/progress.rs new file mode 100644 index 0000000000000000000000000000000000000000..ddc710f202cc98e5932c234cb6bebcc93b28171c --- /dev/null +++ b/crates/edit_prediction_cli/src/progress.rs @@ -0,0 +1,508 @@ +use std::{ + borrow::Cow, + collections::HashMap, + io::{IsTerminal, Write}, + sync::{Arc, Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use log::{Level, Log, Metadata, Record}; + +pub struct Progress { + inner: Mutex, +} + +struct ProgressInner { + completed: Vec, + in_progress: HashMap, + is_tty: bool, + terminal_width: usize, + max_example_name_len: usize, + status_lines_displayed: usize, + total_examples: usize, + failed_examples: usize, + last_line_is_logging: bool, +} + +#[derive(Clone)] +struct InProgressTask { + step: Step, + started_at: Instant, + substatus: Option, + info: Option<(String, InfoStyle)>, +} + +struct CompletedTask { + step: Step, + example_name: String, + duration: Duration, + info: Option<(String, InfoStyle)>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Step { + LoadProject, + Context, + FormatPrompt, + Predict, + Score, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InfoStyle { + Normal, + Warning, +} + +impl Step { + pub fn label(&self) -> &'static str { + match self { + Step::LoadProject => "Load", + Step::Context => "Context", + Step::FormatPrompt => "Format", + Step::Predict => "Predict", + Step::Score => "Score", + } + } + + fn color_code(&self) -> &'static str { + match self { + Step::LoadProject => "\x1b[33m", + Step::Context => "\x1b[35m", + Step::FormatPrompt => "\x1b[34m", + Step::Predict => "\x1b[32m", + Step::Score => "\x1b[31m", + } + } +} + +static GLOBAL: OnceLock> = OnceLock::new(); +static LOGGER: ProgressLogger = ProgressLogger; + +const MARGIN: usize = 4; +const MAX_STATUS_LINES: usize = 10; + +impl Progress { + /// Returns the global Progress instance, initializing it if necessary. + pub fn global() -> Arc { + GLOBAL + .get_or_init(|| { + let progress = Arc::new(Self { + inner: Mutex::new(ProgressInner { + completed: Vec::new(), + in_progress: HashMap::new(), + is_tty: std::io::stderr().is_terminal(), + terminal_width: get_terminal_width(), + max_example_name_len: 0, + status_lines_displayed: 0, + total_examples: 0, + failed_examples: 0, + last_line_is_logging: false, + }), + }); + let _ = log::set_logger(&LOGGER); + log::set_max_level(log::LevelFilter::Error); + progress + }) + .clone() + } + + pub fn set_total_examples(&self, total: usize) { + let mut inner = self.inner.lock().unwrap(); + inner.total_examples = total; + } + + pub fn increment_failed(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.failed_examples += 1; + } + + /// Prints a message to stderr, clearing and redrawing status lines to avoid corruption. + /// This should be used for any output that needs to appear above the status lines. + fn log(&self, message: &str) { + let mut inner = self.inner.lock().unwrap(); + Self::clear_status_lines(&mut inner); + + if !inner.last_line_is_logging { + let reset = "\x1b[0m"; + let dim = "\x1b[2m"; + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); + eprintln!("{dim}{divider}{reset}"); + inner.last_line_is_logging = true; + } + + eprintln!("{}", message); + } + + pub fn start(self: &Arc, step: Step, example_name: &str) -> StepProgress { + let mut inner = self.inner.lock().unwrap(); + + Self::clear_status_lines(&mut inner); + + inner.max_example_name_len = inner.max_example_name_len.max(example_name.len()); + inner.in_progress.insert( + example_name.to_string(), + InProgressTask { + step, + started_at: Instant::now(), + substatus: None, + info: None, + }, + ); + + Self::print_status_lines(&mut inner); + + StepProgress { + progress: self.clone(), + step, + example_name: example_name.to_string(), + } + } + + fn finish(&self, step: Step, example_name: &str) { + let mut inner = self.inner.lock().unwrap(); + + let Some(task) = inner.in_progress.remove(example_name) else { + return; + }; + + if task.step == step { + inner.completed.push(CompletedTask { + step: task.step, + example_name: example_name.to_string(), + duration: task.started_at.elapsed(), + info: task.info, + }); + + Self::clear_status_lines(&mut inner); + Self::print_logging_closing_divider(&mut inner); + Self::print_completed(&inner, inner.completed.last().unwrap()); + Self::print_status_lines(&mut inner); + } else { + inner.in_progress.insert(example_name.to_string(), task); + } + } + + fn print_logging_closing_divider(inner: &mut ProgressInner) { + if inner.last_line_is_logging { + let reset = "\x1b[0m"; + let dim = "\x1b[2m"; + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); + eprintln!("{dim}{divider}{reset}"); + inner.last_line_is_logging = false; + } + } + + fn clear_status_lines(inner: &mut ProgressInner) { + if inner.is_tty && inner.status_lines_displayed > 0 { + // Move up and clear each line we previously displayed + for _ in 0..inner.status_lines_displayed { + eprint!("\x1b[A\x1b[K"); + } + let _ = std::io::stderr().flush(); + inner.status_lines_displayed = 0; + } + } + + fn print_completed(inner: &ProgressInner, task: &CompletedTask) { + let duration = format_duration(task.duration); + let name_width = inner.max_example_name_len; + + if inner.is_tty { + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + let dim = "\x1b[2m"; + + let yellow = "\x1b[33m"; + let info_part = task + .info + .as_ref() + .map(|(s, style)| { + if *style == InfoStyle::Warning { + format!("{yellow}{s}{reset}") + } else { + s.to_string() + } + }) + .unwrap_or_default(); + + let prefix = format!( + "{bold}{color}{label:>12}{reset} {name:12} {name: 0 { + format!(" {} failed ", failed_count) + } else { + String::new() + }; + + let range_label = format!( + " {}/{}/{} ", + done_count, in_progress_count, inner.total_examples + ); + + // Print a divider line with failed count on left, range label on right + let failed_visible_len = strip_ansi_len(&failed_label); + let range_visible_len = range_label.len(); + let middle_divider_len = inner + .terminal_width + .saturating_sub(MARGIN * 2) + .saturating_sub(failed_visible_len) + .saturating_sub(range_visible_len); + let left_divider = "─".repeat(MARGIN); + let middle_divider = "─".repeat(middle_divider_len); + let right_divider = "─".repeat(MARGIN); + eprintln!( + "{dim}{left_divider}{reset}{failed_label}{dim}{middle_divider}{reset}{range_label}{dim}{right_divider}{reset}" + ); + + let mut tasks: Vec<_> = inner.in_progress.iter().collect(); + tasks.sort_by_key(|(name, _)| *name); + + let total_tasks = tasks.len(); + let mut lines_printed = 0; + + for (name, task) in tasks.iter().take(MAX_STATUS_LINES) { + let elapsed = format_duration(task.started_at.elapsed()); + let substatus_part = task + .substatus + .as_ref() + .map(|s| truncate_with_ellipsis(s, 30)) + .unwrap_or_default(); + + let step_label = task.step.label(); + let step_color = task.step.color_code(); + let name_width = inner.max_example_name_len; + + let prefix = format!( + "{bold}{step_color}{step_label:>12}{reset} {name: MAX_STATUS_LINES { + let remaining = total_tasks - MAX_STATUS_LINES; + eprintln!("{:>12} +{remaining} more", ""); + lines_printed += 1; + } + + inner.status_lines_displayed = lines_printed + 1; // +1 for the divider line + let _ = std::io::stderr().flush(); + } + + pub fn finalize(&self) { + let mut inner = self.inner.lock().unwrap(); + Self::clear_status_lines(&mut inner); + + // Print summary if there were failures + if inner.failed_examples > 0 { + let total_processed = inner.completed.len() + inner.failed_examples; + let percentage = if total_processed > 0 { + inner.failed_examples as f64 / total_processed as f64 * 100.0 + } else { + 0.0 + }; + eprintln!( + "\n{} of {} examples failed ({:.1}%)", + inner.failed_examples, total_processed, percentage + ); + } + } +} + +pub struct StepProgress { + progress: Arc, + step: Step, + example_name: String, +} + +impl StepProgress { + pub fn set_substatus(&self, substatus: impl Into>) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.substatus = Some(substatus.into().into_owned()); + Progress::clear_status_lines(&mut inner); + Progress::print_status_lines(&mut inner); + } + } + + pub fn clear_substatus(&self) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.substatus = None; + Progress::clear_status_lines(&mut inner); + Progress::print_status_lines(&mut inner); + } + } + + pub fn set_info(&self, info: impl Into, style: InfoStyle) { + let mut inner = self.progress.inner.lock().unwrap(); + if let Some(task) = inner.in_progress.get_mut(&self.example_name) { + task.info = Some((info.into(), style)); + } + } +} + +impl Drop for StepProgress { + fn drop(&mut self) { + self.progress.finish(self.step, &self.example_name); + } +} + +struct ProgressLogger; + +impl Log for ProgressLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= Level::Info + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + + let level_color = match record.level() { + Level::Error => "\x1b[31m", + Level::Warn => "\x1b[33m", + Level::Info => "\x1b[32m", + Level::Debug => "\x1b[34m", + Level::Trace => "\x1b[35m", + }; + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + + let level_label = match record.level() { + Level::Error => "Error", + Level::Warn => "Warn", + Level::Info => "Info", + Level::Debug => "Debug", + Level::Trace => "Trace", + }; + + let message = format!( + "{bold}{level_color}{level_label:>12}{reset} {}", + record.args() + ); + + if let Some(progress) = GLOBAL.get() { + progress.log(&message); + } else { + eprintln!("{}", message); + } + } + + fn flush(&self) { + let _ = std::io::stderr().flush(); + } +} + +#[cfg(unix)] +fn get_terminal_width() -> usize { + unsafe { + let mut winsize: libc::winsize = std::mem::zeroed(); + if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut winsize) == 0 + && winsize.ws_col > 0 + { + winsize.ws_col as usize + } else { + 80 + } + } +} + +#[cfg(not(unix))] +fn get_terminal_width() -> usize { + 80 +} + +fn strip_ansi_len(s: &str) -> usize { + let mut len = 0; + let mut in_escape = false; + for c in s.chars() { + if c == '\x1b' { + in_escape = true; + } else if in_escape { + if c == 'm' { + in_escape = false; + } + } else { + len += 1; + } + } + len +} + +fn truncate_with_ellipsis(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}…", &s[..max_len.saturating_sub(1)]) + } +} + +fn format_duration(duration: Duration) -> String { + const MINUTE_IN_MILLIS: f32 = 60. * 1000.; + + let millis = duration.as_millis() as f32; + if millis < 1000.0 { + format!("{}ms", millis) + } else if millis < MINUTE_IN_MILLIS { + format!("{:.1}s", millis / 1_000.0) + } else { + format!("{:.1}m", millis / MINUTE_IN_MILLIS) + } +} diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs new file mode 100644 index 0000000000000000000000000000000000000000..abba4504edc6c0733ffd8c0677e2e3304d8100fa --- /dev/null +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -0,0 +1,192 @@ +use crate::{ + example::{Example, ExampleContext}, + headless::EpAppState, + load_project::run_load_project, + progress::{InfoStyle, Progress, Step, StepProgress}, +}; +use anyhow::Context as _; +use collections::HashSet; +use edit_prediction::{DebugEvent, EditPredictionStore}; +use futures::{FutureExt as _, StreamExt as _, channel::mpsc}; +use gpui::{AsyncApp, Entity}; +use language::Buffer; +use project::Project; +use std::sync::Arc; +use std::time::Duration; + +pub async fn run_context_retrieval( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) -> anyhow::Result<()> { + if example.context.is_some() { + return Ok(()); + } + + run_load_project(example, app_state.clone(), cx.clone()).await?; + + let step_progress: Arc = Progress::global() + .start(Step::Context, &example.spec.name) + .into(); + + let state = example.state.as_ref().unwrap(); + let project = state.project.clone(); + + let _lsp_handle = project.update(&mut cx, |project, cx| { + project.register_buffer_with_language_servers(&state.buffer, cx) + })?; + wait_for_language_servers_to_start(&project, &state.buffer, &step_progress, &mut cx).await?; + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + let mut events = ep_store.update(&mut cx, |store, cx| { + store.register_buffer(&state.buffer, &project, cx); + store.set_use_context(true); + store.refresh_context(&project, &state.buffer, state.cursor_position, cx); + store.debug_info(&project, cx) + })?; + + while let Some(event) = events.next().await { + match event { + DebugEvent::ContextRetrievalFinished(_) => { + break; + } + _ => {} + } + } + + let context_files = + ep_store.update(&mut cx, |store, cx| store.context_for_project(&project, cx))?; + + let excerpt_count: usize = context_files.iter().map(|f| f.excerpts.len()).sum(); + step_progress.set_info(format!("{} excerpts", excerpt_count), InfoStyle::Normal); + + example.context = Some(ExampleContext { + files: context_files, + }); + Ok(()) +} + +async fn wait_for_language_servers_to_start( + project: &Entity, + buffer: &Entity, + step_progress: &Arc, + cx: &mut AsyncApp, +) -> anyhow::Result<()> { + let lsp_store = project.read_with(cx, |project, _| project.lsp_store())?; + + let (language_server_ids, mut starting_language_server_ids) = buffer + .update(cx, |buffer, cx| { + lsp_store.update(cx, |lsp_store, cx| { + let ids = lsp_store.language_servers_for_local_buffer(buffer, cx); + let starting_ids = ids + .iter() + .copied() + .filter(|id| !lsp_store.language_server_statuses.contains_key(&id)) + .collect::>(); + (ids, starting_ids) + }) + }) + .unwrap_or_default(); + + step_progress.set_substatus(format!("waiting for {} LSPs", language_server_ids.len())); + + let timeout = cx + .background_executor() + .timer(Duration::from_secs(60 * 5)) + .shared(); + + let (mut tx, mut rx) = mpsc::channel(language_server_ids.len()); + let added_subscription = cx.subscribe(project, { + let step_progress = step_progress.clone(); + move |_, event, _| match event { + project::Event::LanguageServerAdded(language_server_id, name, _) => { + step_progress.set_substatus(format!("LSP started: {}", name)); + tx.try_send(*language_server_id).ok(); + } + _ => {} + } + }); + + while !starting_language_server_ids.is_empty() { + futures::select! { + language_server_id = rx.next() => { + if let Some(id) = language_server_id { + starting_language_server_ids.remove(&id); + } + }, + _ = timeout.clone().fuse() => { + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); + } + } + } + + drop(added_subscription); + + if !language_server_ids.is_empty() { + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .detach(); + } + + let (mut tx, mut rx) = mpsc::channel(language_server_ids.len()); + let subscriptions = [ + cx.subscribe(&lsp_store, { + let step_progress = step_progress.clone(); + move |_, event, _| { + if let project::LspStoreEvent::LanguageServerUpdate { + message: + client::proto::update_language_server::Variant::WorkProgress( + client::proto::LspWorkProgress { + message: Some(message), + .. + }, + ), + .. + } = event + { + step_progress.set_substatus(message.clone()); + } + } + }), + cx.subscribe(project, { + let step_progress = step_progress.clone(); + move |_, event, cx| match event { + project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { + let lsp_store = lsp_store.read(cx); + let name = lsp_store + .language_server_adapter_for_id(*language_server_id) + .unwrap() + .name(); + step_progress.set_substatus(format!("LSP idle: {}", name)); + tx.try_send(*language_server_id).ok(); + } + _ => {} + } + }), + ]; + + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .await?; + + let mut pending_language_server_ids = HashSet::from_iter(language_server_ids.into_iter()); + while !pending_language_server_ids.is_empty() { + futures::select! { + language_server_id = rx.next() => { + if let Some(id) = language_server_id { + pending_language_server_ids.remove(&id); + } + }, + _ = timeout.clone().fuse() => { + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); + } + } + } + + drop(subscriptions); + step_progress.clear_substatus(); + Ok(()) +} diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs new file mode 100644 index 0000000000000000000000000000000000000000..7b507e6d19c943de92eb0b22c7d24d4026789fed --- /dev/null +++ b/crates/edit_prediction_cli/src/score.rs @@ -0,0 +1,123 @@ +use crate::{ + PredictArgs, + example::{Example, ExampleScore}, + headless::EpAppState, + metrics::{self, ClassificationMetrics}, + predict::run_prediction, + progress::{Progress, Step}, +}; +use edit_prediction::udiff::DiffLine; +use gpui::AsyncApp; +use std::sync::Arc; + +pub async fn run_scoring( + example: &mut Example, + args: &PredictArgs, + app_state: Arc, + cx: AsyncApp, +) -> anyhow::Result<()> { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state, + cx, + ) + .await?; + + let _progress = Progress::global().start(Step::Score, &example.spec.name); + + let expected_patch = parse_patch(&example.spec.expected_patch); + + let mut scores = vec![]; + + for pred in &example.predictions { + let actual_patch = parse_patch(&pred.actual_patch); + let line_match = metrics::line_match_score(&expected_patch, &actual_patch); + let delta_chr_f = metrics::delta_chr_f(&expected_patch, &actual_patch) as f32; + + scores.push(ExampleScore { + delta_chr_f, + line_match, + }); + } + + example.score = scores; + Ok(()) +} + +fn parse_patch(patch: &str) -> Vec> { + patch.lines().map(DiffLine::parse).collect() +} + +pub fn print_report(examples: &[Example]) { + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>10} {:>8} {:>8} {:>10}", + "Example name", "TP", "FP", "FN", "Precision", "Recall", "F1", "DeltaChrF" + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + let mut all_line_match_scores = Vec::new(); + let mut all_delta_chr_f_scores = Vec::new(); + + for example in examples { + for score in example.score.iter() { + let line_match = &score.line_match; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + truncate_name(&example.spec.name, 30), + line_match.true_positives, + line_match.false_positives, + line_match.false_negatives, + line_match.precision() * 100.0, + line_match.recall() * 100.0, + line_match.f1_score() * 100.0, + score.delta_chr_f + ); + + all_line_match_scores.push(line_match.clone()); + all_delta_chr_f_scores.push(score.delta_chr_f); + } + } + + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + + if !all_line_match_scores.is_empty() { + let total_line_match = ClassificationMetrics::aggregate(all_line_match_scores.iter()); + let avg_delta_chr_f: f32 = + all_delta_chr_f_scores.iter().sum::() / all_delta_chr_f_scores.len() as f32; + + eprintln!( + "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", + "TOTAL", + total_line_match.true_positives, + total_line_match.false_positives, + total_line_match.false_negatives, + total_line_match.precision() * 100.0, + total_line_match.recall() * 100.0, + total_line_match.f1_score() * 100.0, + avg_delta_chr_f + ); + eprintln!( + "──────────────────────────────────────────────────────────────────────────────────────" + ); + } + + eprintln!("\n"); +} + +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() <= max_len { + name.to_string() + } else { + format!("{}...", &name[..max_len - 3]) + } +} diff --git a/crates/edit_prediction_cli/src/teacher.prompt.md b/crates/edit_prediction_cli/src/teacher.prompt.md new file mode 100644 index 0000000000000000000000000000000000000000..d629152da6739ec1d603857f6a9ee556c8986fe8 --- /dev/null +++ b/crates/edit_prediction_cli/src/teacher.prompt.md @@ -0,0 +1,53 @@ +# Instructions + +You are a code completion assistant helping a programmer finish their work. Your task is to: + +1. Analyze the edit history to understand what the programmer is trying to achieve +2. Identify any incomplete refactoring or changes that need to be finished +3. Make the remaining edits that a human programmer would logically make next (by rewriting the corresponding code sections) +4. Apply systematic changes consistently across the entire codebase - if you see a pattern starting, complete it everywhere. + +Focus on: +- Understanding the intent behind the changes (e.g., improving error handling, refactoring APIs, fixing bugs) +- Completing any partially-applied changes across the codebase +- Ensuring consistency with the programming style and patterns already established +- Making edits that maintain or improve code quality +- If the programmer started refactoring one instance of a pattern, find and update ALL similar instances +- Don't write a lot of code if you're not sure what to do + +Rules: +- Do not just mechanically apply patterns - reason about what changes make sense given the context and the programmer's apparent goals. +- Do not just fix syntax errors - look for the broader refactoring pattern and apply it systematically throughout the code. +- Keep existing formatting unless it's absolutely necessary + +Input format: +- You receive small code fragments called context (structs, field definitions, function signatures, etc.). They may or may not be relevant. +- Never modify the context code. +- You also receive a code snippet between <|editable_region_start|> and <|editable_region_end|>. This is the editable region. +- The cursor position is marked with <|user_cursor|>. + +Output format: +- Return the entire editable region, applying any edits you make. +- Remove the <|user_cursor|> marker. +- Wrap the edited code in a block of exactly five backticks. + +Output example: +````` + // `zed --askpass` Makes zed operate in nc/netcat mode for use with askpass + if let Some(socket) = &args.askpass {{ + askpass::main(socket); + return Ok(()); + }} +````` + +## User Edits History + +{{edit_history}} + +## Code Context + +{{context}} + +## Editable region + +{{editable_region}} diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml index 6976831b8cbbe2b998f713ff65f1585f28fc3005..731ffc85d159e285ad497c29fba2f74179d4149b 100644 --- a/crates/edit_prediction_context/Cargo.toml +++ b/crates/edit_prediction_context/Cargo.toml @@ -12,41 +12,33 @@ workspace = true path = "src/edit_prediction_context.rs" [dependencies] +parking_lot.workspace = true anyhow.workspace = true -arrayvec.workspace = true cloud_llm_client.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true -hashbrown.workspace = true -indoc.workspace = true -itertools.workspace = true language.workspace = true -log.workspace = true -ordered-float.workspace = true -postage.workspace = true +lsp.workspace = true project.workspace = true -regex.workspace = true +log.workspace = true serde.workspace = true -slotmap.workspace = true -strum.workspace = true -text.workspace = true +smallvec.workspace = true tree-sitter.workspace = true util.workspace = true +zeta_prompt.workspace = true [dev-dependencies] -clap.workspace = true +env_logger.workspace = true +indoc.workspace = true futures.workspace = true gpui = { workspace = true, features = ["test-support"] } -indoc.workspace = true language = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = {workspace= true, features = ["test-support"]} serde_json.workspace = true settings = {workspace= true, features = ["test-support"]} text = { workspace = true, features = ["test-support"] } -tree-sitter-c.workspace = true -tree-sitter-cpp.workspace = true -tree-sitter-go.workspace = true util = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/edit_prediction_context/src/assemble_excerpts.rs b/crates/edit_prediction_context/src/assemble_excerpts.rs new file mode 100644 index 0000000000000000000000000000000000000000..e337211cf90f0e4fbcb481f836e512b1ceb6477f --- /dev/null +++ b/crates/edit_prediction_context/src/assemble_excerpts.rs @@ -0,0 +1,156 @@ +use language::{BufferSnapshot, OffsetRangeExt as _, Point}; +use std::ops::Range; +use zeta_prompt::RelatedExcerpt; + +#[cfg(not(test))] +const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 512; +#[cfg(test)] +const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 24; + +pub fn assemble_excerpts( + buffer: &BufferSnapshot, + mut input_ranges: Vec>, +) -> Vec { + merge_ranges(&mut input_ranges); + + let mut outline_ranges = Vec::new(); + let outline_items = buffer.outline_items_as_points_containing(0..buffer.len(), false, None); + let mut outline_ix = 0; + for input_range in &mut input_ranges { + *input_range = clip_range_to_lines(input_range, false, buffer); + + while let Some(outline_item) = outline_items.get(outline_ix) { + let item_range = clip_range_to_lines(&outline_item.range, false, buffer); + + if item_range.start > input_range.start { + break; + } + + if item_range.end > input_range.start { + let body_range = outline_item + .body_range(buffer) + .map(|body| clip_range_to_lines(&body, true, buffer)) + .filter(|body_range| { + body_range.to_offset(buffer).len() > MAX_OUTLINE_ITEM_BODY_SIZE + }); + + add_outline_item( + item_range.clone(), + body_range.clone(), + buffer, + &mut outline_ranges, + ); + + if let Some(body_range) = body_range + && input_range.start < body_range.start + { + let mut child_outline_ix = outline_ix + 1; + while let Some(next_outline_item) = outline_items.get(child_outline_ix) { + if next_outline_item.range.end > body_range.end { + break; + } + if next_outline_item.depth == outline_item.depth + 1 { + let next_item_range = + clip_range_to_lines(&next_outline_item.range, false, buffer); + + add_outline_item( + next_item_range, + next_outline_item + .body_range(buffer) + .map(|body| clip_range_to_lines(&body, true, buffer)), + buffer, + &mut outline_ranges, + ); + } + child_outline_ix += 1; + } + } + } + + outline_ix += 1; + } + } + + input_ranges.extend_from_slice(&outline_ranges); + merge_ranges(&mut input_ranges); + + input_ranges + .into_iter() + .map(|range| RelatedExcerpt { + row_range: range.start.row..range.end.row, + text: buffer.text_for_range(range).collect(), + }) + .collect() +} + +fn clip_range_to_lines( + range: &Range, + inward: bool, + buffer: &BufferSnapshot, +) -> Range { + let mut range = range.clone(); + if inward { + if range.start.column > 0 { + range.start.column = buffer.line_len(range.start.row); + } + range.end.column = 0; + } else { + range.start.column = 0; + if range.end.column > 0 { + range.end.column = buffer.line_len(range.end.row); + } + } + range +} + +fn add_outline_item( + mut item_range: Range, + body_range: Option>, + buffer: &BufferSnapshot, + outline_ranges: &mut Vec>, +) { + if let Some(mut body_range) = body_range { + if body_range.start.column > 0 { + body_range.start.column = buffer.line_len(body_range.start.row); + } + body_range.end.column = 0; + + let head_range = item_range.start..body_range.start; + if head_range.start < head_range.end { + outline_ranges.push(head_range); + } + + let tail_range = body_range.end..item_range.end; + if tail_range.start < tail_range.end { + outline_ranges.push(tail_range); + } + } else { + item_range.start.column = 0; + item_range.end.column = buffer.line_len(item_range.end.row); + outline_ranges.push(item_range); + } +} + +pub fn merge_ranges(ranges: &mut Vec>) { + ranges.sort_unstable_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); + + let mut index = 1; + while index < ranges.len() { + let mut prev_range_end = ranges[index - 1].end; + if prev_range_end.column > 0 { + prev_range_end += Point::new(1, 0); + } + + if (prev_range_end + Point::new(1, 0)) + .cmp(&ranges[index].start) + .is_ge() + { + let removed = ranges.remove(index); + if removed.end.cmp(&ranges[index - 1].end).is_gt() { + ranges[index - 1].end = removed.end; + } + } else { + index += 1; + } + } +} diff --git a/crates/edit_prediction_context/src/declaration.rs b/crates/edit_prediction_context/src/declaration.rs deleted file mode 100644 index cc32640425ecc563b1f24a6c695be1c13199cd73..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/declaration.rs +++ /dev/null @@ -1,350 +0,0 @@ -use cloud_llm_client::predict_edits_v3::{self, Line}; -use language::{Language, LanguageId}; -use project::ProjectEntryId; -use std::ops::Range; -use std::sync::Arc; -use std::{borrow::Cow, path::Path}; -use text::{Bias, BufferId, Rope}; -use util::paths::{path_ends_with, strip_path_suffix}; -use util::rel_path::RelPath; - -use crate::outline::OutlineDeclaration; - -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct Identifier { - pub name: Arc, - pub language_id: LanguageId, -} - -slotmap::new_key_type! { - pub struct DeclarationId; -} - -#[derive(Debug, Clone)] -pub enum Declaration { - File { - project_entry_id: ProjectEntryId, - declaration: FileDeclaration, - cached_path: CachedDeclarationPath, - }, - Buffer { - project_entry_id: ProjectEntryId, - buffer_id: BufferId, - rope: Rope, - declaration: BufferDeclaration, - cached_path: CachedDeclarationPath, - }, -} - -const ITEM_TEXT_TRUNCATION_LENGTH: usize = 1024; - -impl Declaration { - pub fn identifier(&self) -> &Identifier { - match self { - Declaration::File { declaration, .. } => &declaration.identifier, - Declaration::Buffer { declaration, .. } => &declaration.identifier, - } - } - - pub fn parent(&self) -> Option { - match self { - Declaration::File { declaration, .. } => declaration.parent, - Declaration::Buffer { declaration, .. } => declaration.parent, - } - } - - pub fn as_buffer(&self) -> Option<&BufferDeclaration> { - match self { - Declaration::File { .. } => None, - Declaration::Buffer { declaration, .. } => Some(declaration), - } - } - - pub fn as_file(&self) -> Option<&FileDeclaration> { - match self { - Declaration::Buffer { .. } => None, - Declaration::File { declaration, .. } => Some(declaration), - } - } - - pub fn project_entry_id(&self) -> ProjectEntryId { - match self { - Declaration::File { - project_entry_id, .. - } => *project_entry_id, - Declaration::Buffer { - project_entry_id, .. - } => *project_entry_id, - } - } - - pub fn cached_path(&self) -> &CachedDeclarationPath { - match self { - Declaration::File { cached_path, .. } => cached_path, - Declaration::Buffer { cached_path, .. } => cached_path, - } - } - - pub fn item_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.item_range.clone(), - Declaration::Buffer { declaration, .. } => declaration.item_range.clone(), - } - } - - pub fn item_line_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.item_line_range.clone(), - Declaration::Buffer { - declaration, rope, .. - } => { - Line(rope.offset_to_point(declaration.item_range.start).row) - ..Line(rope.offset_to_point(declaration.item_range.end).row) - } - } - } - - pub fn item_text(&self) -> (Cow<'_, str>, bool) { - match self { - Declaration::File { declaration, .. } => ( - declaration.text.as_ref().into(), - declaration.text_is_truncated, - ), - Declaration::Buffer { - rope, declaration, .. - } => ( - rope.chunks_in_range(declaration.item_range.clone()) - .collect::>(), - declaration.item_range_is_truncated, - ), - } - } - - pub fn signature_text(&self) -> (Cow<'_, str>, bool) { - match self { - Declaration::File { declaration, .. } => ( - declaration.text[self.signature_range_in_item_text()].into(), - declaration.signature_is_truncated, - ), - Declaration::Buffer { - rope, declaration, .. - } => ( - rope.chunks_in_range(declaration.signature_range.clone()) - .collect::>(), - declaration.signature_range_is_truncated, - ), - } - } - - pub fn signature_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.signature_range.clone(), - Declaration::Buffer { declaration, .. } => declaration.signature_range.clone(), - } - } - - pub fn signature_line_range(&self) -> Range { - match self { - Declaration::File { declaration, .. } => declaration.signature_line_range.clone(), - Declaration::Buffer { - declaration, rope, .. - } => { - Line(rope.offset_to_point(declaration.signature_range.start).row) - ..Line(rope.offset_to_point(declaration.signature_range.end).row) - } - } - } - - pub fn signature_range_in_item_text(&self) -> Range { - let signature_range = self.signature_range(); - let item_range = self.item_range(); - signature_range.start.saturating_sub(item_range.start) - ..(signature_range.end.saturating_sub(item_range.start)).min(item_range.len()) - } -} - -fn expand_range_to_line_boundaries_and_truncate( - range: &Range, - limit: usize, - rope: &Rope, -) -> (Range, Range, bool) { - let mut point_range = rope.offset_to_point(range.start)..rope.offset_to_point(range.end); - point_range.start.column = 0; - point_range.end.row += 1; - point_range.end.column = 0; - - let mut item_range = - rope.point_to_offset(point_range.start)..rope.point_to_offset(point_range.end); - let is_truncated = item_range.len() > limit; - if is_truncated { - item_range.end = item_range.start + limit; - } - item_range.end = rope.clip_offset(item_range.end, Bias::Left); - - let line_range = - predict_edits_v3::Line(point_range.start.row)..predict_edits_v3::Line(point_range.end.row); - (item_range, line_range, is_truncated) -} - -#[derive(Debug, Clone)] -pub struct FileDeclaration { - pub parent: Option, - pub identifier: Identifier, - /// offset range of the declaration in the file, expanded to line boundaries and truncated - pub item_range: Range, - /// line range of the declaration in the file, potentially truncated - pub item_line_range: Range, - /// text of `item_range` - pub text: Arc, - /// whether `text` was truncated - pub text_is_truncated: bool, - /// offset range of the signature in the file, expanded to line boundaries and truncated - pub signature_range: Range, - /// line range of the signature in the file, truncated - pub signature_line_range: Range, - /// whether `signature` was truncated - pub signature_is_truncated: bool, -} - -impl FileDeclaration { - pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> FileDeclaration { - let (item_range_in_file, item_line_range_in_file, text_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.item_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - - let (mut signature_range_in_file, signature_line_range, mut signature_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.signature_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - - if signature_range_in_file.start < item_range_in_file.start { - signature_range_in_file.start = item_range_in_file.start; - signature_is_truncated = true; - } - if signature_range_in_file.end > item_range_in_file.end { - signature_range_in_file.end = item_range_in_file.end; - signature_is_truncated = true; - } - - FileDeclaration { - parent: None, - identifier: declaration.identifier, - signature_range: signature_range_in_file, - signature_line_range, - signature_is_truncated, - text: rope - .chunks_in_range(item_range_in_file.clone()) - .collect::() - .into(), - text_is_truncated, - item_range: item_range_in_file, - item_line_range: item_line_range_in_file, - } - } -} - -#[derive(Debug, Clone)] -pub struct BufferDeclaration { - pub parent: Option, - pub identifier: Identifier, - pub item_range: Range, - pub item_range_is_truncated: bool, - pub signature_range: Range, - pub signature_range_is_truncated: bool, -} - -impl BufferDeclaration { - pub fn from_outline(declaration: OutlineDeclaration, rope: &Rope) -> Self { - let (item_range, _item_line_range, item_range_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.item_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - let (signature_range, _signature_line_range, signature_range_is_truncated) = - expand_range_to_line_boundaries_and_truncate( - &declaration.signature_range, - ITEM_TEXT_TRUNCATION_LENGTH, - rope, - ); - Self { - parent: None, - identifier: declaration.identifier, - item_range, - item_range_is_truncated, - signature_range, - signature_range_is_truncated, - } - } -} - -#[derive(Debug, Clone)] -pub struct CachedDeclarationPath { - pub worktree_abs_path: Arc, - pub rel_path: Arc, - /// The relative path of the file, possibly stripped according to `import_path_strip_regex`. - pub rel_path_after_regex_stripping: Arc, -} - -impl CachedDeclarationPath { - pub fn new( - worktree_abs_path: Arc, - path: &Arc, - language: Option<&Arc>, - ) -> Self { - let rel_path = path.clone(); - let rel_path_after_regex_stripping = if let Some(language) = language - && let Some(strip_regex) = language.config().import_path_strip_regex.as_ref() - && let Ok(stripped) = RelPath::unix(&Path::new( - strip_regex.replace_all(rel_path.as_unix_str(), "").as_ref(), - )) { - Arc::from(stripped) - } else { - rel_path.clone() - }; - CachedDeclarationPath { - worktree_abs_path, - rel_path, - rel_path_after_regex_stripping, - } - } - - #[cfg(test)] - pub fn new_for_test(worktree_abs_path: &str, rel_path: &str) -> Self { - let rel_path: Arc = util::rel_path::rel_path(rel_path).into(); - CachedDeclarationPath { - worktree_abs_path: std::path::PathBuf::from(worktree_abs_path).into(), - rel_path_after_regex_stripping: rel_path.clone(), - rel_path, - } - } - - pub fn ends_with_posix_path(&self, path: &Path) -> bool { - if path.as_os_str().len() <= self.rel_path_after_regex_stripping.as_unix_str().len() { - path_ends_with(self.rel_path_after_regex_stripping.as_std_path(), path) - } else { - if let Some(remaining) = - strip_path_suffix(path, self.rel_path_after_regex_stripping.as_std_path()) - { - path_ends_with(&self.worktree_abs_path, remaining) - } else { - false - } - } - } - - pub fn equals_absolute_path(&self, path: &Path) -> bool { - if let Some(remaining) = - strip_path_suffix(path, &self.rel_path_after_regex_stripping.as_std_path()) - { - self.worktree_abs_path.as_ref() == remaining - } else { - false - } - } -} diff --git a/crates/edit_prediction_context/src/declaration_scoring.rs b/crates/edit_prediction_context/src/declaration_scoring.rs deleted file mode 100644 index 48a823362769770c836b44e7d8a6c1942d3a1196..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/declaration_scoring.rs +++ /dev/null @@ -1,539 +0,0 @@ -use cloud_llm_client::predict_edits_v3::DeclarationScoreComponents; -use collections::HashMap; -use language::BufferSnapshot; -use ordered_float::OrderedFloat; -use project::ProjectEntryId; -use serde::Serialize; -use std::{cmp::Reverse, ops::Range, path::Path, sync::Arc}; -use strum::EnumIter; -use text::{Point, ToPoint}; -use util::RangeExt as _; - -use crate::{ - CachedDeclarationPath, Declaration, EditPredictionExcerpt, Identifier, - imports::{Import, Imports, Module}, - reference::{Reference, ReferenceRegion}, - syntax_index::SyntaxIndexState, - text_similarity::{Occurrences, jaccard_similarity, weighted_overlap_coefficient}, -}; - -const MAX_IDENTIFIER_DECLARATION_COUNT: usize = 16; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct EditPredictionScoreOptions { - pub omit_excerpt_overlaps: bool, -} - -#[derive(Clone, Debug)] -pub struct ScoredDeclaration { - /// identifier used by the local reference - pub identifier: Identifier, - pub declaration: Declaration, - pub components: DeclarationScoreComponents, -} - -#[derive(EnumIter, Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub enum DeclarationStyle { - Signature, - Declaration, -} - -#[derive(Clone, Debug, Serialize, Default)] -pub struct DeclarationScores { - pub signature: f32, - pub declaration: f32, - pub retrieval: f32, -} - -impl ScoredDeclaration { - /// Returns the score for this declaration with the specified style. - pub fn score(&self, style: DeclarationStyle) -> f32 { - // TODO: handle truncation - - // Score related to how likely this is the correct declaration, range 0 to 1 - let retrieval = self.retrieval_score(); - - // Score related to the distance between the reference and cursor, range 0 to 1 - let distance_score = if self.components.is_referenced_nearby { - 1.0 / (1.0 + self.components.reference_line_distance as f32 / 10.0).powf(2.0) - } else { - // same score as ~14 lines away, rationale is to not overly penalize references from parent signatures - 0.5 - }; - - // For now instead of linear combination, the scores are just multiplied together. - let combined_score = 10.0 * retrieval * distance_score; - - match style { - DeclarationStyle::Signature => { - combined_score * self.components.excerpt_vs_signature_weighted_overlap - } - DeclarationStyle::Declaration => { - 2.0 * combined_score * self.components.excerpt_vs_item_weighted_overlap - } - } - } - - pub fn retrieval_score(&self) -> f32 { - let mut score = if self.components.is_same_file { - 10.0 / self.components.same_file_declaration_count as f32 - } else if self.components.path_import_match_count > 0 { - 3.0 - } else if self.components.wildcard_path_import_match_count > 0 { - 1.0 - } else if self.components.normalized_import_similarity > 0.0 { - self.components.normalized_import_similarity - } else if self.components.normalized_wildcard_import_similarity > 0.0 { - 0.5 * self.components.normalized_wildcard_import_similarity - } else { - 1.0 / self.components.declaration_count as f32 - }; - score *= 1. + self.components.included_by_others as f32 / 2.; - score *= 1. + self.components.includes_others as f32 / 4.; - score - } - - pub fn size(&self, style: DeclarationStyle) -> usize { - match &self.declaration { - Declaration::File { declaration, .. } => match style { - DeclarationStyle::Signature => declaration.signature_range.len(), - DeclarationStyle::Declaration => declaration.text.len(), - }, - Declaration::Buffer { declaration, .. } => match style { - DeclarationStyle::Signature => declaration.signature_range.len(), - DeclarationStyle::Declaration => declaration.item_range.len(), - }, - } - } - - pub fn score_density(&self, style: DeclarationStyle) -> f32 { - self.score(style) / self.size(style) as f32 - } -} - -pub fn scored_declarations( - options: &EditPredictionScoreOptions, - index: &SyntaxIndexState, - excerpt: &EditPredictionExcerpt, - excerpt_occurrences: &Occurrences, - adjacent_occurrences: &Occurrences, - imports: &Imports, - identifier_to_references: HashMap>, - cursor_offset: usize, - current_buffer: &BufferSnapshot, -) -> Vec { - let cursor_point = cursor_offset.to_point(¤t_buffer); - - let mut wildcard_import_occurrences = Vec::new(); - let mut wildcard_import_paths = Vec::new(); - for wildcard_import in imports.wildcard_modules.iter() { - match wildcard_import { - Module::Namespace(namespace) => { - wildcard_import_occurrences.push(namespace.occurrences()) - } - Module::SourceExact(path) => wildcard_import_paths.push(path), - Module::SourceFuzzy(path) => { - wildcard_import_occurrences.push(Occurrences::from_path(&path)) - } - } - } - - let mut scored_declarations = Vec::new(); - let mut project_entry_id_to_outline_ranges: HashMap>> = - HashMap::default(); - for (identifier, references) in identifier_to_references { - let mut import_occurrences = Vec::new(); - let mut import_paths = Vec::new(); - let mut found_external_identifier: Option<&Identifier> = None; - - if let Some(imports) = imports.identifier_to_imports.get(&identifier) { - // only use alias when it's the only import, could be generalized if some language - // has overlapping aliases - // - // TODO: when an aliased declaration is included in the prompt, should include the - // aliasing in the prompt. - // - // TODO: For SourceFuzzy consider having componentwise comparison that pays - // attention to ordering. - if let [ - Import::Alias { - module, - external_identifier, - }, - ] = imports.as_slice() - { - match module { - Module::Namespace(namespace) => { - import_occurrences.push(namespace.occurrences()) - } - Module::SourceExact(path) => import_paths.push(path), - Module::SourceFuzzy(path) => { - import_occurrences.push(Occurrences::from_path(&path)) - } - } - found_external_identifier = Some(&external_identifier); - } else { - for import in imports { - match import { - Import::Direct { module } => match module { - Module::Namespace(namespace) => { - import_occurrences.push(namespace.occurrences()) - } - Module::SourceExact(path) => import_paths.push(path), - Module::SourceFuzzy(path) => { - import_occurrences.push(Occurrences::from_path(&path)) - } - }, - Import::Alias { .. } => {} - } - } - } - } - - let identifier_to_lookup = found_external_identifier.unwrap_or(&identifier); - // TODO: update this to be able to return more declarations? Especially if there is the - // ability to quickly filter a large list (based on imports) - let identifier_declarations = index - .declarations_for_identifier::(&identifier_to_lookup); - let declaration_count = identifier_declarations.len(); - - if declaration_count == 0 { - continue; - } - - // TODO: option to filter out other candidates when same file / import match - let mut checked_declarations = Vec::with_capacity(declaration_count); - for (declaration_id, declaration) in identifier_declarations { - match declaration { - Declaration::Buffer { - buffer_id, - declaration: buffer_declaration, - .. - } => { - if buffer_id == ¤t_buffer.remote_id() { - let already_included_in_prompt = - range_intersection(&buffer_declaration.item_range, &excerpt.range) - .is_some() - || excerpt - .parent_declarations - .iter() - .any(|(excerpt_parent, _)| excerpt_parent == &declaration_id); - if !options.omit_excerpt_overlaps || !already_included_in_prompt { - let declaration_line = buffer_declaration - .item_range - .start - .to_point(current_buffer) - .row; - let declaration_line_distance = - (cursor_point.row as i32 - declaration_line as i32).unsigned_abs(); - checked_declarations.push(CheckedDeclaration { - declaration, - same_file_line_distance: Some(declaration_line_distance), - path_import_match_count: 0, - wildcard_path_import_match_count: 0, - }); - } - continue; - } else { - } - } - Declaration::File { .. } => {} - } - let declaration_path = declaration.cached_path(); - let path_import_match_count = import_paths - .iter() - .filter(|import_path| { - declaration_path_matches_import(&declaration_path, import_path) - }) - .count(); - let wildcard_path_import_match_count = wildcard_import_paths - .iter() - .filter(|import_path| { - declaration_path_matches_import(&declaration_path, import_path) - }) - .count(); - checked_declarations.push(CheckedDeclaration { - declaration, - same_file_line_distance: None, - path_import_match_count, - wildcard_path_import_match_count, - }); - } - - let mut max_import_similarity = 0.0; - let mut max_wildcard_import_similarity = 0.0; - - let mut scored_declarations_for_identifier = Vec::with_capacity(checked_declarations.len()); - for checked_declaration in checked_declarations { - let same_file_declaration_count = - index.file_declaration_count(checked_declaration.declaration); - - let declaration = score_declaration( - &identifier, - &references, - checked_declaration, - same_file_declaration_count, - declaration_count, - &excerpt_occurrences, - &adjacent_occurrences, - &import_occurrences, - &wildcard_import_occurrences, - cursor_point, - current_buffer, - ); - - if declaration.components.import_similarity > max_import_similarity { - max_import_similarity = declaration.components.import_similarity; - } - - if declaration.components.wildcard_import_similarity > max_wildcard_import_similarity { - max_wildcard_import_similarity = declaration.components.wildcard_import_similarity; - } - - project_entry_id_to_outline_ranges - .entry(declaration.declaration.project_entry_id()) - .or_default() - .push(declaration.declaration.item_range()); - scored_declarations_for_identifier.push(declaration); - } - - if max_import_similarity > 0.0 || max_wildcard_import_similarity > 0.0 { - for declaration in scored_declarations_for_identifier.iter_mut() { - if max_import_similarity > 0.0 { - declaration.components.max_import_similarity = max_import_similarity; - declaration.components.normalized_import_similarity = - declaration.components.import_similarity / max_import_similarity; - } - if max_wildcard_import_similarity > 0.0 { - declaration.components.normalized_wildcard_import_similarity = - declaration.components.wildcard_import_similarity - / max_wildcard_import_similarity; - } - } - } - - scored_declarations.extend(scored_declarations_for_identifier); - } - - // TODO: Inform this via import / retrieval scores of outline items - // TODO: Consider using a sweepline - for scored_declaration in scored_declarations.iter_mut() { - let project_entry_id = scored_declaration.declaration.project_entry_id(); - let Some(ranges) = project_entry_id_to_outline_ranges.get(&project_entry_id) else { - continue; - }; - for range in ranges { - if range.contains_inclusive(&scored_declaration.declaration.item_range()) { - scored_declaration.components.included_by_others += 1 - } else if scored_declaration - .declaration - .item_range() - .contains_inclusive(range) - { - scored_declaration.components.includes_others += 1 - } - } - } - - scored_declarations.sort_unstable_by_key(|declaration| { - Reverse(OrderedFloat( - declaration.score(DeclarationStyle::Declaration), - )) - }); - - scored_declarations -} - -struct CheckedDeclaration<'a> { - declaration: &'a Declaration, - same_file_line_distance: Option, - path_import_match_count: usize, - wildcard_path_import_match_count: usize, -} - -fn declaration_path_matches_import( - declaration_path: &CachedDeclarationPath, - import_path: &Arc, -) -> bool { - if import_path.is_absolute() { - declaration_path.equals_absolute_path(import_path) - } else { - declaration_path.ends_with_posix_path(import_path) - } -} - -fn range_intersection(a: &Range, b: &Range) -> Option> { - let start = a.start.clone().max(b.start.clone()); - let end = a.end.clone().min(b.end.clone()); - if start < end { - Some(Range { start, end }) - } else { - None - } -} - -fn score_declaration( - identifier: &Identifier, - references: &[Reference], - checked_declaration: CheckedDeclaration, - same_file_declaration_count: usize, - declaration_count: usize, - excerpt_occurrences: &Occurrences, - adjacent_occurrences: &Occurrences, - import_occurrences: &[Occurrences], - wildcard_import_occurrences: &[Occurrences], - cursor: Point, - current_buffer: &BufferSnapshot, -) -> ScoredDeclaration { - let CheckedDeclaration { - declaration, - same_file_line_distance, - path_import_match_count, - wildcard_path_import_match_count, - } = checked_declaration; - - let is_referenced_nearby = references - .iter() - .any(|r| r.region == ReferenceRegion::Nearby); - let is_referenced_in_breadcrumb = references - .iter() - .any(|r| r.region == ReferenceRegion::Breadcrumb); - let reference_count = references.len(); - let reference_line_distance = references - .iter() - .map(|r| { - let reference_line = r.range.start.to_point(current_buffer).row as i32; - (cursor.row as i32 - reference_line).unsigned_abs() - }) - .min() - .unwrap(); - - let is_same_file = same_file_line_distance.is_some(); - let declaration_line_distance = same_file_line_distance.unwrap_or(u32::MAX); - - let item_source_occurrences = Occurrences::within_string(&declaration.item_text().0); - let item_signature_occurrences = Occurrences::within_string(&declaration.signature_text().0); - let excerpt_vs_item_jaccard = jaccard_similarity(excerpt_occurrences, &item_source_occurrences); - let excerpt_vs_signature_jaccard = - jaccard_similarity(excerpt_occurrences, &item_signature_occurrences); - let adjacent_vs_item_jaccard = - jaccard_similarity(adjacent_occurrences, &item_source_occurrences); - let adjacent_vs_signature_jaccard = - jaccard_similarity(adjacent_occurrences, &item_signature_occurrences); - - let excerpt_vs_item_weighted_overlap = - weighted_overlap_coefficient(excerpt_occurrences, &item_source_occurrences); - let excerpt_vs_signature_weighted_overlap = - weighted_overlap_coefficient(excerpt_occurrences, &item_signature_occurrences); - let adjacent_vs_item_weighted_overlap = - weighted_overlap_coefficient(adjacent_occurrences, &item_source_occurrences); - let adjacent_vs_signature_weighted_overlap = - weighted_overlap_coefficient(adjacent_occurrences, &item_signature_occurrences); - - let mut import_similarity = 0f32; - let mut wildcard_import_similarity = 0f32; - if !import_occurrences.is_empty() || !wildcard_import_occurrences.is_empty() { - let cached_path = declaration.cached_path(); - let path_occurrences = Occurrences::from_worktree_path( - cached_path - .worktree_abs_path - .file_name() - .map(|f| f.to_string_lossy()), - &cached_path.rel_path, - ); - import_similarity = import_occurrences - .iter() - .map(|namespace_occurrences| { - OrderedFloat(jaccard_similarity(namespace_occurrences, &path_occurrences)) - }) - .max() - .map(|similarity| similarity.into_inner()) - .unwrap_or_default(); - - // TODO: Consider something other than max - wildcard_import_similarity = wildcard_import_occurrences - .iter() - .map(|namespace_occurrences| { - OrderedFloat(jaccard_similarity(namespace_occurrences, &path_occurrences)) - }) - .max() - .map(|similarity| similarity.into_inner()) - .unwrap_or_default(); - } - - // TODO: Consider adding declaration_file_count - let score_components = DeclarationScoreComponents { - is_same_file, - is_referenced_nearby, - is_referenced_in_breadcrumb, - reference_line_distance, - declaration_line_distance, - reference_count, - same_file_declaration_count, - declaration_count, - excerpt_vs_item_jaccard, - excerpt_vs_signature_jaccard, - adjacent_vs_item_jaccard, - adjacent_vs_signature_jaccard, - excerpt_vs_item_weighted_overlap, - excerpt_vs_signature_weighted_overlap, - adjacent_vs_item_weighted_overlap, - adjacent_vs_signature_weighted_overlap, - path_import_match_count, - wildcard_path_import_match_count, - import_similarity, - max_import_similarity: 0.0, - normalized_import_similarity: 0.0, - wildcard_import_similarity, - normalized_wildcard_import_similarity: 0.0, - included_by_others: 0, - includes_others: 0, - }; - - ScoredDeclaration { - identifier: identifier.clone(), - declaration: declaration.clone(), - components: score_components, - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_declaration_path_matches() { - let declaration_path = - CachedDeclarationPath::new_for_test("/home/user/project", "src/maths.ts"); - - assert!(declaration_path_matches_import( - &declaration_path, - &Path::new("maths.ts").into() - )); - - assert!(declaration_path_matches_import( - &declaration_path, - &Path::new("project/src/maths.ts").into() - )); - - assert!(declaration_path_matches_import( - &declaration_path, - &Path::new("user/project/src/maths.ts").into() - )); - - assert!(declaration_path_matches_import( - &declaration_path, - &Path::new("/home/user/project/src/maths.ts").into() - )); - - assert!(!declaration_path_matches_import( - &declaration_path, - &Path::new("other.ts").into() - )); - - assert!(!declaration_path_matches_import( - &declaration_path, - &Path::new("/home/user/project/src/other.ts").into() - )); - } -} diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 65623a825c2f7e2db42b98174748e5f04fb91d2a..15576a835d9b4b0781b1e3979edbed443fa40f62 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -1,335 +1,474 @@ -mod declaration; -mod declaration_scoring; +use crate::assemble_excerpts::assemble_excerpts; +use anyhow::Result; +use collections::HashMap; +use futures::{FutureExt, StreamExt as _, channel::mpsc, future}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity}; +use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset as _}; +use project::{LocationLink, Project, ProjectPath}; +use smallvec::SmallVec; +use std::{ + collections::hash_map, + ops::Range, + path::Path, + sync::Arc, + time::{Duration, Instant}, +}; +use util::{RangeExt as _, ResultExt}; + +mod assemble_excerpts; +#[cfg(test)] +mod edit_prediction_context_tests; mod excerpt; -mod imports; -mod outline; -mod reference; -mod syntax_index; -pub mod text_similarity; +#[cfg(test)] +mod fake_definition_lsp; -use std::{path::Path, sync::Arc}; +pub use cloud_llm_client::predict_edits_v3::Line; +pub use excerpt::{EditPredictionExcerpt, EditPredictionExcerptOptions, EditPredictionExcerptText}; +pub use zeta_prompt::{RelatedExcerpt, RelatedFile}; -use cloud_llm_client::predict_edits_v3; -use collections::HashMap; -use gpui::{App, AppContext as _, Entity, Task}; -use language::BufferSnapshot; -use text::{Point, ToOffset as _}; - -pub use declaration::*; -pub use declaration_scoring::*; -pub use excerpt::*; -pub use imports::*; -pub use reference::*; -pub use syntax_index::*; - -pub use predict_edits_v3::Line; - -#[derive(Clone, Debug, PartialEq)] -pub struct EditPredictionContextOptions { - pub use_imports: bool, - pub excerpt: EditPredictionExcerptOptions, - pub score: EditPredictionScoreOptions, - pub max_retrieved_declarations: u8, +const IDENTIFIER_LINE_COUNT: u32 = 3; + +pub struct RelatedExcerptStore { + project: WeakEntity, + related_files: Arc<[RelatedFile]>, + related_file_buffers: Vec>, + cache: HashMap>, + update_tx: mpsc::UnboundedSender<(Entity, Anchor)>, + identifier_line_count: u32, +} + +pub enum RelatedExcerptStoreEvent { + StartedRefresh, + FinishedRefresh { + cache_hit_count: usize, + cache_miss_count: usize, + mean_definition_latency: Duration, + max_definition_latency: Duration, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct Identifier { + pub name: String, + pub range: Range, +} + +enum DefinitionTask { + CacheHit(Arc), + CacheMiss(Task>>>), +} + +#[derive(Debug)] +struct CacheEntry { + definitions: SmallVec<[CachedDefinition; 1]>, } #[derive(Clone, Debug)] -pub struct EditPredictionContext { - pub excerpt: EditPredictionExcerpt, - pub excerpt_text: EditPredictionExcerptText, - pub cursor_point: Point, - pub declarations: Vec, +struct CachedDefinition { + path: ProjectPath, + buffer: Entity, + anchor_range: Range, } -impl EditPredictionContext { - pub fn gather_context_in_background( - cursor_point: Point, - buffer: BufferSnapshot, - options: EditPredictionContextOptions, - syntax_index: Option>, - cx: &mut App, - ) -> Task> { - let parent_abs_path = project::File::from_dyn(buffer.file()).and_then(|f| { - let mut path = f.worktree.read(cx).absolutize(&f.path); - if path.pop() { Some(path) } else { None } - }); +const DEBOUNCE_DURATION: Duration = Duration::from_millis(100); + +impl EventEmitter for RelatedExcerptStore {} + +impl RelatedExcerptStore { + pub fn new(project: &Entity, cx: &mut Context) -> Self { + let (update_tx, mut update_rx) = mpsc::unbounded::<(Entity, Anchor)>(); + cx.spawn(async move |this, cx| { + let executor = cx.background_executor().clone(); + while let Some((mut buffer, mut position)) = update_rx.next().await { + let mut timer = executor.timer(DEBOUNCE_DURATION).fuse(); + loop { + futures::select_biased! { + next = update_rx.next() => { + if let Some((new_buffer, new_position)) = next { + buffer = new_buffer; + position = new_position; + timer = executor.timer(DEBOUNCE_DURATION).fuse(); + } else { + return anyhow::Ok(()); + } + } + _ = timer => break, + } + } - if let Some(syntax_index) = syntax_index { - let index_state = - syntax_index.read_with(cx, |index, _cx| Arc::downgrade(index.state())); - cx.background_spawn(async move { - let parent_abs_path = parent_abs_path.as_deref(); - let index_state = index_state.upgrade()?; - let index_state = index_state.lock().await; - Self::gather_context( - cursor_point, - &buffer, - parent_abs_path, - &options, - Some(&index_state), - ) - }) - } else { - cx.background_spawn(async move { - let parent_abs_path = parent_abs_path.as_deref(); - Self::gather_context(cursor_point, &buffer, parent_abs_path, &options, None) - }) + Self::fetch_excerpts(this.clone(), buffer, position, cx).await?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + RelatedExcerptStore { + project: project.downgrade(), + update_tx, + related_files: Vec::new().into(), + related_file_buffers: Vec::new(), + cache: Default::default(), + identifier_line_count: IDENTIFIER_LINE_COUNT, } } - pub fn gather_context( - cursor_point: Point, - buffer: &BufferSnapshot, - parent_abs_path: Option<&Path>, - options: &EditPredictionContextOptions, - index_state: Option<&SyntaxIndexState>, - ) -> Option { - let imports = if options.use_imports { - Imports::gather(&buffer, parent_abs_path) - } else { - Imports::default() - }; - Self::gather_context_with_references_fn( - cursor_point, - buffer, - &imports, - options, - index_state, - references_in_excerpt, - ) + pub fn set_identifier_line_count(&mut self, count: u32) { + self.identifier_line_count = count; } - pub fn gather_context_with_references_fn( - cursor_point: Point, - buffer: &BufferSnapshot, - imports: &Imports, - options: &EditPredictionContextOptions, - index_state: Option<&SyntaxIndexState>, - get_references: impl FnOnce( - &EditPredictionExcerpt, - &EditPredictionExcerptText, - &BufferSnapshot, - ) -> HashMap>, - ) -> Option { - let excerpt = EditPredictionExcerpt::select_from_buffer( - cursor_point, - buffer, - &options.excerpt, - index_state, - )?; - let excerpt_text = excerpt.text(buffer); - - let declarations = if options.max_retrieved_declarations > 0 - && let Some(index_state) = index_state - { - let excerpt_occurrences = - text_similarity::Occurrences::within_string(&excerpt_text.body); - - let adjacent_start = Point::new(cursor_point.row.saturating_sub(2), 0); - let adjacent_end = Point::new(cursor_point.row + 1, 0); - let adjacent_occurrences = text_similarity::Occurrences::within_string( - &buffer - .text_for_range(adjacent_start..adjacent_end) - .collect::(), - ); + pub fn refresh(&mut self, buffer: Entity, position: Anchor, _: &mut Context) { + self.update_tx.unbounded_send((buffer, position)).ok(); + } - let cursor_offset_in_file = cursor_point.to_offset(buffer); + pub fn related_files(&self) -> Arc<[RelatedFile]> { + self.related_files.clone() + } - let references = get_references(&excerpt, &excerpt_text, buffer); + pub fn related_files_with_buffers( + &self, + ) -> impl Iterator)> { + self.related_files + .iter() + .cloned() + .zip(self.related_file_buffers.iter().cloned()) + } - let mut declarations = scored_declarations( - &options.score, - &index_state, - &excerpt, - &excerpt_occurrences, - &adjacent_occurrences, - &imports, - references, - cursor_offset_in_file, - buffer, - ); - // TODO [zeta2] if we need this when we ship, we should probably do it in a smarter way - declarations.truncate(options.max_retrieved_declarations as usize); - declarations - } else { - vec![] + pub fn set_related_files(&mut self, files: Vec) { + self.related_files = files.into(); + } + + async fn fetch_excerpts( + this: WeakEntity, + buffer: Entity, + position: Anchor, + cx: &mut AsyncApp, + ) -> Result<()> { + let (project, snapshot, identifier_line_count) = this.read_with(cx, |this, cx| { + ( + this.project.upgrade(), + buffer.read(cx).snapshot(), + this.identifier_line_count, + ) + })?; + let Some(project) = project else { + return Ok(()); }; - Some(Self { - excerpt, - excerpt_text, - cursor_point, - declarations, - }) - } -} + let file = snapshot.file().cloned(); + if let Some(file) = &file { + log::debug!("retrieving_context buffer:{}", file.path().as_unix_str()); + } -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - use gpui::{Entity, TestAppContext}; - use indoc::indoc; - use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - use crate::{EditPredictionExcerptOptions, SyntaxIndex}; - - #[gpui::test] - async fn test_call_site(cx: &mut TestAppContext) { - let (project, index, _rust_lang_id) = init_test(cx).await; - - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path("c.rs", cx).unwrap(); - project.open_buffer(project_path, cx) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - // first process_data call site - let cursor_point = language::Point::new(8, 21); - let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let context = cx - .update(|cx| { - EditPredictionContext::gather_context_in_background( - cursor_point, - buffer_snapshot, - EditPredictionContextOptions { - use_imports: true, - excerpt: EditPredictionExcerptOptions { - max_bytes: 60, - min_bytes: 10, - target_before_cursor_over_total_bytes: 0.5, - }, - score: EditPredictionScoreOptions { - omit_excerpt_overlaps: true, - }, - max_retrieved_declarations: u8::MAX, - }, - Some(index.clone()), - cx, - ) + this.update(cx, |_, cx| { + cx.emit(RelatedExcerptStoreEvent::StartedRefresh); + })?; + + let identifiers = cx + .background_spawn(async move { + identifiers_for_position(&snapshot, position, identifier_line_count) }) - .await - .unwrap(); + .await; + + let async_cx = cx.clone(); + let start_time = Instant::now(); + let futures = this.update(cx, |this, cx| { + identifiers + .into_iter() + .filter_map(|identifier| { + let task = if let Some(entry) = this.cache.get(&identifier) { + DefinitionTask::CacheHit(entry.clone()) + } else { + DefinitionTask::CacheMiss( + this.project + .update(cx, |project, cx| { + project.definitions(&buffer, identifier.range.start, cx) + }) + .ok()?, + ) + }; + + let cx = async_cx.clone(); + let project = project.clone(); + Some(async move { + match task { + DefinitionTask::CacheHit(cache_entry) => { + Some((identifier, cache_entry, None)) + } + DefinitionTask::CacheMiss(task) => { + let locations = task.await.log_err()??; + let duration = start_time.elapsed(); + cx.update(|cx| { + ( + identifier, + Arc::new(CacheEntry { + definitions: locations + .into_iter() + .filter_map(|location| { + process_definition(location, &project, cx) + }) + .collect(), + }), + Some(duration), + ) + }) + .ok() + } + } + }) + }) + .collect::>() + })?; + + let mut cache_hit_count = 0; + let mut cache_miss_count = 0; + let mut mean_definition_latency = Duration::ZERO; + let mut max_definition_latency = Duration::ZERO; + let mut new_cache = HashMap::default(); + new_cache.reserve(futures.len()); + for (identifier, entry, duration) in future::join_all(futures).await.into_iter().flatten() { + new_cache.insert(identifier, entry); + if let Some(duration) = duration { + cache_miss_count += 1; + mean_definition_latency += duration; + max_definition_latency = max_definition_latency.max(duration); + } else { + cache_hit_count += 1; + } + } + mean_definition_latency /= cache_miss_count.max(1) as u32; - let mut snippet_identifiers = context - .declarations - .iter() - .map(|snippet| snippet.identifier.name.as_ref()) - .collect::>(); - snippet_identifiers.sort(); - assert_eq!(snippet_identifiers, vec!["main", "process_data"]); - drop(buffer); - } + let (new_cache, related_files, related_file_buffers) = + rebuild_related_files(&project, new_cache, cx).await?; - async fn init_test( - cx: &mut TestAppContext, - ) -> (Entity, Entity, LanguageId) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); + if let Some(file) = &file { + log::debug!( + "finished retrieving context buffer:{}, latency:{:?}", + file.path().as_unix_str(), + start_time.elapsed() + ); + } - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "a.rs": indoc! {r#" - fn main() { - let x = 1; - let y = 2; - let z = add(x, y); - println!("Result: {}", z); - } + this.update(cx, |this, cx| { + this.cache = new_cache; + this.related_files = related_files.into(); + this.related_file_buffers = related_file_buffers; + cx.emit(RelatedExcerptStoreEvent::FinishedRefresh { + cache_hit_count, + cache_miss_count, + mean_definition_latency, + max_definition_latency, + }); + })?; + + anyhow::Ok(()) + } +} - fn add(a: i32, b: i32) -> i32 { - a + b - } - "#}, - "b.rs": indoc! {" - pub struct Config { - pub name: String, - pub value: i32, +async fn rebuild_related_files( + project: &Entity, + new_entries: HashMap>, + cx: &mut AsyncApp, +) -> Result<( + HashMap>, + Vec, + Vec>, +)> { + let mut snapshots = HashMap::default(); + let mut worktree_root_names = HashMap::default(); + for entry in new_entries.values() { + for definition in &entry.definitions { + if let hash_map::Entry::Vacant(e) = snapshots.entry(definition.buffer.entity_id()) { + definition + .buffer + .read_with(cx, |buffer, _| buffer.parsing_idle())? + .await; + e.insert( + definition + .buffer + .read_with(cx, |buffer, _| buffer.snapshot())?, + ); + } + let worktree_id = definition.path.worktree_id; + if let hash_map::Entry::Vacant(e) = + worktree_root_names.entry(definition.path.worktree_id) + { + project.read_with(cx, |project, cx| { + if let Some(worktree) = project.worktree_for_id(worktree_id, cx) { + e.insert(worktree.read(cx).root_name().as_unix_str().to_string()); } + })?; + } + } + } - impl Config { - pub fn new(name: String, value: i32) -> Self { - Config { name, value } - } - } - "}, - "c.rs": indoc! {r#" - use std::collections::HashMap; - - fn main() { - let args: Vec = std::env::args().collect(); - let data: Vec = args[1..] - .iter() - .filter_map(|s| s.parse().ok()) - .collect(); - let result = process_data(data); - println!("{:?}", result); - } + Ok(cx + .background_spawn(async move { + let mut files = Vec::new(); + let mut ranges_by_buffer = HashMap::<_, Vec>>::default(); + let mut paths_by_buffer = HashMap::default(); + for entry in new_entries.values() { + for definition in &entry.definitions { + let Some(snapshot) = snapshots.get(&definition.buffer.entity_id()) else { + continue; + }; + paths_by_buffer.insert(definition.buffer.entity_id(), definition.path.clone()); + ranges_by_buffer + .entry(definition.buffer.clone()) + .or_default() + .push(definition.anchor_range.to_point(snapshot)); + } + } + + for (buffer, ranges) in ranges_by_buffer { + let Some(snapshot) = snapshots.get(&buffer.entity_id()) else { + continue; + }; + let Some(project_path) = paths_by_buffer.get(&buffer.entity_id()) else { + continue; + }; + let excerpts = assemble_excerpts(snapshot, ranges); + let Some(root_name) = worktree_root_names.get(&project_path.worktree_id) else { + continue; + }; + + let path = Path::new(&format!( + "{}/{}", + root_name, + project_path.path.as_unix_str() + )) + .into(); + + files.push(( + buffer, + RelatedFile { + path, + excerpts, + max_row: snapshot.max_point().row, + }, + )); + } - fn process_data(data: Vec) -> HashMap { - let mut counts = HashMap::new(); - for value in data { - *counts.entry(value).or_insert(0) += 1; - } - counts - } + files.sort_by_key(|(_, file)| file.path.clone()); + let (related_buffers, related_files) = files.into_iter().unzip(); - #[cfg(test)] - mod tests { - use super::*; + (new_entries, related_files, related_buffers) + }) + .await) +} - #[test] - fn test_process_data() { - let data = vec![1, 2, 2, 3]; - let result = process_data(data); - assert_eq!(result.get(&2), Some(&2)); - } - } - "#} - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let lang = rust_lang(); - let lang_id = lang.id(); - language_registry.add(Arc::new(lang)); - - let file_indexing_parallelism = 2; - let index = cx.new(|cx| SyntaxIndex::new(&project, file_indexing_parallelism, cx)); - cx.run_until_parked(); - - (project, index, lang_id) +const MAX_TARGET_LEN: usize = 128; + +fn process_definition( + location: LocationLink, + project: &Entity, + cx: &mut App, +) -> Option { + let buffer = location.target.buffer.read(cx); + let anchor_range = location.target.range; + let file = buffer.file()?; + let worktree = project.read(cx).worktree_for_id(file.worktree_id(cx), cx)?; + if worktree.read(cx).is_single_file() { + return None; } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_highlights_query(include_str!("../../languages/src/rust/highlights.scm")) - .unwrap() - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() + // If the target range is large, it likely means we requested the definition of an entire module. + // For individual definitions, the target range should be small as it only covers the symbol. + let buffer = location.target.buffer.read(cx); + let target_len = anchor_range.to_offset(&buffer).len(); + if target_len > MAX_TARGET_LEN { + return None; } + + Some(CachedDefinition { + path: ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }, + buffer: location.target.buffer, + anchor_range, + }) +} + +/// Gets all of the identifiers that are present in the given line, and its containing +/// outline items. +fn identifiers_for_position( + buffer: &BufferSnapshot, + position: Anchor, + identifier_line_count: u32, +) -> Vec { + let offset = position.to_offset(buffer); + let point = buffer.offset_to_point(offset); + + // Search for identifiers on lines adjacent to the cursor. + let start = Point::new(point.row.saturating_sub(identifier_line_count), 0); + let end = Point::new(point.row + identifier_line_count + 1, 0).min(buffer.max_point()); + let line_range = start..end; + let mut ranges = vec![line_range.to_offset(&buffer)]; + + // Search for identifiers mentioned in headers/signatures of containing outline items. + let outline_items = buffer.outline_items_as_offsets_containing(offset..offset, false, None); + for item in outline_items { + if let Some(body_range) = item.body_range(&buffer) { + ranges.push(item.range.start..body_range.start.to_offset(&buffer)); + } else { + ranges.push(item.range.clone()); + } + } + + ranges.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); + ranges.dedup_by(|a, b| { + if a.start <= b.end { + b.start = b.start.min(a.start); + b.end = b.end.max(a.end); + true + } else { + false + } + }); + + let mut identifiers = Vec::new(); + let outer_range = + ranges.first().map_or(0, |r| r.start)..ranges.last().map_or(buffer.len(), |r| r.end); + + let mut captures = buffer + .syntax + .captures(outer_range.clone(), &buffer.text, |grammar| { + grammar + .highlights_config + .as_ref() + .map(|config| &config.query) + }); + + for range in ranges { + captures.set_byte_range(range.start..outer_range.end); + + let mut last_range = None; + while let Some(capture) = captures.peek() { + let node_range = capture.node.byte_range(); + if node_range.start > range.end { + break; + } + let config = captures.grammars()[capture.grammar_index] + .highlights_config + .as_ref(); + + if let Some(config) = config + && config.identifier_capture_indices.contains(&capture.index) + && range.contains_inclusive(&node_range) + && Some(&node_range) != last_range.as_ref() + { + let name = buffer.text_for_range(node_range.clone()).collect(); + identifiers.push(Identifier { + range: buffer.anchor_after(node_range.start) + ..buffer.anchor_before(node_range.end), + name, + }); + last_range = Some(node_range); + } + + captures.advance(); + } + } + + identifiers } diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..d93a66081164a3fc70f7e1072d91a02bd9adbd37 --- /dev/null +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -0,0 +1,510 @@ +use super::*; +use futures::channel::mpsc::UnboundedReceiver; +use gpui::TestAppContext; +use indoc::indoc; +use language::{Point, ToPoint as _, rust_lang}; +use lsp::FakeLanguageServer; +use project::{FakeFs, LocationLink, Project}; +use serde_json::json; +use settings::SettingsStore; +use std::fmt::Write as _; +use util::{path, test::marked_text_ranges}; + +#[gpui::test] +async fn test_edit_prediction_context(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), test_project_1()).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 _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("todo").unwrap(); + 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, _| { + let excerpts = store.related_files(); + assert_related_files( + &excerpts, + &[ + ( + "root/src/company.rs", + &[indoc! {" + pub struct Company { + owner: Arc, + address: Address, + }"}], + ), + ( + "root/src/main.rs", + &[ + indoc! {" + pub struct Session { + company: Arc, + } + + impl Session { + pub fn set_company(&mut self, company: Arc) {"}, + indoc! {" + } + }"}, + ], + ), + ( + "root/src/person.rs", + &[ + indoc! {" + impl Person { + pub fn get_first_name(&self) -> &str { + &self.first_name + }"}, + "}", + ], + ), + ], + ); + }); +} + +#[gpui::test] +fn test_assemble_excerpts(cx: &mut TestAppContext) { + let table = [ + ( + indoc! {r#" + struct User { + first_name: String, + «last_name»: String, + age: u32, + email: String, + create_at: Instant, + } + + impl User { + pub fn first_name(&self) -> String { + self.first_name.clone() + } + + pub fn full_name(&self) -> String { + « format!("{} {}", self.first_name, self.last_name) + » } + } + "#}, + indoc! {r#" + struct User { + first_name: String, + last_name: String, + … + } + + impl User { + … + pub fn full_name(&self) -> String { + format!("{} {}", self.first_name, self.last_name) + } + } + "#}, + ), + ( + indoc! {r#" + struct «User» { + first_name: String, + last_name: String, + age: u32, + } + + impl User { + // methods + } + "#}, + indoc! {r#" + struct User { + first_name: String, + last_name: String, + age: u32, + } + … + "#}, + ), + ( + indoc! {r#" + trait «FooProvider» { + const NAME: &'static str; + + fn provide_foo(&self, id: usize) -> Foo; + + fn provide_foo_batched(&self, ids: &[usize]) -> Vec { + ids.iter() + .map(|id| self.provide_foo(*id)) + .collect() + } + + fn sync(&self); + } + "# + }, + indoc! {r#" + trait FooProvider { + const NAME: &'static str; + + fn provide_foo(&self, id: usize) -> Foo; + + fn provide_foo_batched(&self, ids: &[usize]) -> Vec { + … + } + + fn sync(&self); + } + "#}, + ), + ( + indoc! {r#" + trait «Something» { + fn method1(&self, id: usize) -> Foo; + + fn method2(&self, ids: &[usize]) -> Vec { + struct Helper1 { + field1: usize, + } + + struct Helper2 { + field2: usize, + } + + struct Helper3 { + filed2: usize, + } + } + + fn sync(&self); + } + "# + }, + indoc! {r#" + trait Something { + fn method1(&self, id: usize) -> Foo; + + fn method2(&self, ids: &[usize]) -> Vec { + … + } + + fn sync(&self); + } + "#}, + ), + ]; + + for (input, expected_output) in table { + let (input, ranges) = marked_text_ranges(&input, false); + let buffer = cx.new(|cx| Buffer::local(input, cx).with_language(rust_lang(), cx)); + buffer.read_with(cx, |buffer, _cx| { + let ranges: Vec> = ranges + .into_iter() + .map(|range| range.to_point(&buffer)) + .collect(); + + let excerpts = assemble_excerpts(&buffer.snapshot(), ranges); + + let output = format_excerpts(buffer, &excerpts); + assert_eq!(output, expected_output); + }); + } +} + +#[gpui::test] +async fn test_fake_definition_lsp(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/root"), test_project_1()).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 _server = servers.next().await.unwrap(); + cx.run_until_parked(); + + let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); + + let definitions = project + .update(cx, |project, cx| { + let offset = buffer_text.find("Address {").unwrap(); + project.definitions(&buffer, offset, cx) + }) + .await + .unwrap() + .unwrap(); + assert_definitions(&definitions, &["pub struct Address {"], cx); + + let definitions = project + .update(cx, |project, cx| { + let offset = buffer_text.find("State::CA").unwrap(); + project.definitions(&buffer, offset, cx) + }) + .await + .unwrap() + .unwrap(); + assert_definitions(&definitions, &["pub enum State {"], cx); + + let definitions = project + .update(cx, |project, cx| { + let offset = buffer_text.find("to_string()").unwrap(); + project.definitions(&buffer, offset, cx) + }) + .await + .unwrap() + .unwrap(); + assert_definitions(&definitions, &["pub fn to_string(&self) -> String {"], cx); +} + +fn init_test(cx: &mut TestAppContext) { + let settings_store = cx.update(|cx| SettingsStore::test(cx)); + cx.set_global(settings_store); + env_logger::try_init().ok(); +} + +fn setup_fake_lsp( + project: &Entity, + cx: &mut TestAppContext, +) -> UnboundedReceiver { + let (language_registry, fs) = project.read_with(cx, |project, _| { + (project.languages().clone(), project.fs().clone()) + }); + let language = rust_lang(); + language_registry.add(language.clone()); + fake_definition_lsp::register_fake_definition_server(&language_registry, language, fs) +} + +fn test_project_1() -> serde_json::Value { + let person_rs = indoc! {r#" + pub struct Person { + first_name: String, + last_name: String, + email: String, + age: u32, + } + + impl Person { + pub fn get_first_name(&self) -> &str { + &self.first_name + } + + pub fn get_last_name(&self) -> &str { + &self.last_name + } + + pub fn get_email(&self) -> &str { + &self.email + } + + pub fn get_age(&self) -> u32 { + self.age + } + } + "#}; + + let address_rs = indoc! {r#" + pub struct Address { + street: String, + city: String, + state: State, + zip: u32, + } + + pub enum State { + CA, + OR, + WA, + TX, + // ... + } + + impl Address { + pub fn get_street(&self) -> &str { + &self.street + } + + pub fn get_city(&self) -> &str { + &self.city + } + + pub fn get_state(&self) -> State { + self.state + } + + pub fn get_zip(&self) -> u32 { + self.zip + } + } + "#}; + + let company_rs = indoc! {r#" + use super::person::Person; + use super::address::Address; + + pub struct Company { + owner: Arc, + address: Address, + } + + impl Company { + pub fn get_owner(&self) -> &Person { + &self.owner + } + + pub fn get_address(&self) -> &Address { + &self.address + } + + pub fn to_string(&self) -> String { + format!("{} ({})", self.owner.first_name, self.address.city) + } + } + "#}; + + let main_rs = indoc! {r#" + use std::sync::Arc; + use super::person::Person; + use super::address::Address; + use super::company::Company; + + pub struct Session { + company: Arc, + } + + impl Session { + pub fn set_company(&mut self, company: Arc) { + self.company = company; + if company.owner != self.company.owner { + log("new owner", company.owner.get_first_name()); todo(); + } + } + } + + fn main() { + let company = Company { + owner: Arc::new(Person { + first_name: "John".to_string(), + last_name: "Doe".to_string(), + email: "john@example.com".to_string(), + age: 30, + }), + address: Address { + street: "123 Main St".to_string(), + city: "Anytown".to_string(), + state: State::CA, + zip: 12345, + }, + }; + + println!("Company: {}", company.to_string()); + } + "#}; + + json!({ + "src": { + "person.rs": person_rs, + "address.rs": address_rs, + "company.rs": company_rs, + "main.rs": main_rs, + }, + }) +} + +fn assert_related_files(actual_files: &[RelatedFile], expected_files: &[(&str, &[&str])]) { + let actual_files = actual_files + .iter() + .map(|file| { + let excerpts = file + .excerpts + .iter() + .map(|excerpt| excerpt.text.to_string()) + .collect::>(); + (file.path.to_str().unwrap(), excerpts) + }) + .collect::>(); + let expected_excerpts = expected_files + .iter() + .map(|(path, texts)| { + ( + *path, + texts + .iter() + .map(|line| line.to_string()) + .collect::>(), + ) + }) + .collect::>(); + pretty_assertions::assert_eq!(actual_files, expected_excerpts) +} + +fn assert_definitions(definitions: &[LocationLink], first_lines: &[&str], cx: &mut TestAppContext) { + let actual_first_lines = definitions + .iter() + .map(|definition| { + definition.target.buffer.read_with(cx, |buffer, _| { + let mut start = definition.target.range.start.to_point(&buffer); + start.column = 0; + let end = Point::new(start.row, buffer.line_len(start.row)); + buffer + .text_for_range(start..end) + .collect::() + .trim() + .to_string() + }) + }) + .collect::>(); + + assert_eq!(actual_first_lines, first_lines); +} + +fn format_excerpts(buffer: &Buffer, excerpts: &[RelatedExcerpt]) -> String { + let mut output = String::new(); + let file_line_count = buffer.max_point().row; + let mut current_row = 0; + for excerpt in excerpts { + if excerpt.text.is_empty() { + continue; + } + if current_row < excerpt.row_range.start { + writeln!(&mut output, "…").unwrap(); + } + current_row = excerpt.row_range.start; + + for line in excerpt.text.to_string().lines() { + output.push_str(line); + output.push('\n'); + current_row += 1; + } + } + if current_row < file_line_count { + writeln!(&mut output, "…").unwrap(); + } + output +} diff --git a/crates/edit_prediction_context/src/excerpt.rs b/crates/edit_prediction_context/src/excerpt.rs index 7a4bb73edfa131b620a930d7f0e1c0da77e0afe6..3fc7eed4ace5a83992bf496aef3e364aea96e215 100644 --- a/crates/edit_prediction_context/src/excerpt.rs +++ b/crates/edit_prediction_context/src/excerpt.rs @@ -1,11 +1,9 @@ -use language::{BufferSnapshot, LanguageId}; +use cloud_llm_client::predict_edits_v3::Line; +use language::{BufferSnapshot, LanguageId, Point, ToOffset as _, ToPoint as _}; use std::ops::Range; -use text::{Point, ToOffset as _, ToPoint as _}; use tree_sitter::{Node, TreeCursor}; use util::RangeExt; -use crate::{BufferDeclaration, Line, declaration::DeclarationId, syntax_index::SyntaxIndexState}; - // TODO: // // - Test parent signatures @@ -31,19 +29,16 @@ pub struct EditPredictionExcerptOptions { pub target_before_cursor_over_total_bytes: f32, } -// TODO: consider merging these #[derive(Debug, Clone)] pub struct EditPredictionExcerpt { pub range: Range, pub line_range: Range, - pub parent_declarations: Vec<(DeclarationId, Range)>, pub size: usize, } #[derive(Debug, Clone)] pub struct EditPredictionExcerptText { pub body: String, - pub parent_signatures: Vec, pub language_id: Option, } @@ -52,17 +47,8 @@ impl EditPredictionExcerpt { let body = buffer .text_for_range(self.range.clone()) .collect::(); - let parent_signatures = self - .parent_declarations - .iter() - .map(|(_, range)| buffer.text_for_range(range.clone()).collect::()) - .collect(); let language_id = buffer.language().map(|l| l.id()); - EditPredictionExcerptText { - body, - parent_signatures, - language_id, - } + EditPredictionExcerptText { body, language_id } } /// Selects an excerpt around a buffer position, attempting to choose logical boundaries based @@ -79,7 +65,6 @@ impl EditPredictionExcerpt { query_point: Point, buffer: &BufferSnapshot, options: &EditPredictionExcerptOptions, - syntax_index: Option<&SyntaxIndexState>, ) -> Option { if buffer.len() <= options.max_bytes { log::debug!( @@ -89,11 +74,7 @@ impl EditPredictionExcerpt { ); let offset_range = 0..buffer.len(); let line_range = Line(0)..Line(buffer.max_point().row); - return Some(EditPredictionExcerpt::new( - offset_range, - line_range, - Vec::new(), - )); + return Some(EditPredictionExcerpt::new(offset_range, line_range)); } let query_offset = query_point.to_offset(buffer); @@ -104,19 +85,10 @@ impl EditPredictionExcerpt { return None; } - let parent_declarations = if let Some(syntax_index) = syntax_index { - syntax_index - .buffer_declarations_containing_range(buffer.remote_id(), query_range.clone()) - .collect() - } else { - Vec::new() - }; - let excerpt_selector = ExcerptSelector { query_offset, query_range, query_line_range: Line(query_line_range.start)..Line(query_line_range.end), - parent_declarations: &parent_declarations, buffer, options, }; @@ -139,20 +111,10 @@ impl EditPredictionExcerpt { excerpt_selector.select_lines() } - fn new( - range: Range, - line_range: Range, - parent_declarations: Vec<(DeclarationId, Range)>, - ) -> Self { - let size = range.len() - + parent_declarations - .iter() - .map(|(_, range)| range.len()) - .sum::(); + fn new(range: Range, line_range: Range) -> Self { Self { + size: range.len(), range, - parent_declarations, - size, line_range, } } @@ -162,14 +124,7 @@ impl EditPredictionExcerpt { // this is an issue because parent_signature_ranges may be incorrect log::error!("bug: with_expanded_range called with disjoint range"); } - let mut parent_declarations = Vec::with_capacity(self.parent_declarations.len()); - for (declaration_id, range) in &self.parent_declarations { - if !range.contains_inclusive(&new_range) { - break; - } - parent_declarations.push((*declaration_id, range.clone())); - } - Self::new(new_range, new_line_range, parent_declarations) + Self::new(new_range, new_line_range) } fn parent_signatures_size(&self) -> usize { @@ -181,7 +136,6 @@ struct ExcerptSelector<'a> { query_offset: usize, query_range: Range, query_line_range: Range, - parent_declarations: &'a [(DeclarationId, &'a BufferDeclaration)], buffer: &'a BufferSnapshot, options: &'a EditPredictionExcerptOptions, } @@ -409,13 +363,7 @@ impl<'a> ExcerptSelector<'a> { } fn make_excerpt(&self, range: Range, line_range: Range) -> EditPredictionExcerpt { - let parent_declarations = self - .parent_declarations - .iter() - .filter(|(_, declaration)| declaration.item_range.contains_inclusive(&range)) - .map(|(id, declaration)| (*id, declaration.signature_range.clone())) - .collect(); - EditPredictionExcerpt::new(range, line_range, parent_declarations) + EditPredictionExcerpt::new(range, line_range) } /// Returns `true` if the `forward` excerpt is a better choice than the `backward` excerpt. @@ -471,30 +419,14 @@ fn node_line_end(node: Node) -> Point { mod tests { use super::*; use gpui::{AppContext, TestAppContext}; - use language::{Buffer, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; + use language::Buffer; use util::test::{generate_marked_text, marked_text_offsets_by}; fn create_buffer(text: &str, cx: &mut TestAppContext) -> BufferSnapshot { - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang().into(), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language::rust_lang(), cx)); buffer.read_with(cx, |buffer, _| buffer.snapshot()) } - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } - fn cursor_and_excerpt_range(text: &str) -> (String, usize, Range) { let (text, offsets) = marked_text_offsets_by(text, vec!['ˇ', '«', '»']); (text, offsets[&'ˇ'][0], offsets[&'«'][0]..offsets[&'»'][0]) @@ -506,9 +438,8 @@ mod tests { let buffer = create_buffer(&text, cx); let cursor_point = cursor.to_point(&buffer); - let excerpt = - EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &options, None) - .expect("Should select an excerpt"); + let excerpt = EditPredictionExcerpt::select_from_buffer(cursor_point, &buffer, &options) + .expect("Should select an excerpt"); pretty_assertions::assert_eq!( generate_marked_text(&text, std::slice::from_ref(&excerpt.range), false), generate_marked_text(&text, &[expected_excerpt], false) diff --git a/crates/edit_prediction_context/src/fake_definition_lsp.rs b/crates/edit_prediction_context/src/fake_definition_lsp.rs new file mode 100644 index 0000000000000000000000000000000000000000..31fb681309c610a37c7f886390ef5adb92ee78ef --- /dev/null +++ b/crates/edit_prediction_context/src/fake_definition_lsp.rs @@ -0,0 +1,329 @@ +use collections::HashMap; +use futures::channel::mpsc::UnboundedReceiver; +use language::{Language, LanguageRegistry}; +use lsp::{ + FakeLanguageServer, LanguageServerBinary, TextDocumentSyncCapability, TextDocumentSyncKind, Uri, +}; +use parking_lot::Mutex; +use project::Fs; +use std::{ops::Range, path::PathBuf, sync::Arc}; +use tree_sitter::{Parser, QueryCursor, StreamingIterator, Tree}; + +/// Registers a fake language server that implements go-to-definition using tree-sitter, +/// making the assumption that all names are unique, and all variables' types are +/// explicitly declared. +pub fn register_fake_definition_server( + language_registry: &Arc, + language: Arc, + fs: Arc, +) -> UnboundedReceiver { + let index = Arc::new(Mutex::new(DefinitionIndex::new(language.clone()))); + + language_registry.register_fake_lsp( + language.name(), + language::FakeLspAdapter { + name: "fake-definition-lsp", + initialization_options: None, + prettier_plugins: Vec::new(), + disk_based_diagnostics_progress_token: None, + disk_based_diagnostics_sources: Vec::new(), + language_server_binary: LanguageServerBinary { + path: PathBuf::from("fake-definition-lsp"), + arguments: Vec::new(), + env: None, + }, + capabilities: lsp::ServerCapabilities { + definition_provider: Some(lsp::OneOf::Left(true)), + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + ..Default::default() + }, + label_for_completion: None, + initializer: Some(Box::new({ + move |server| { + server.handle_notification::({ + let index = index.clone(); + move |params, _cx| { + index + .lock() + .open_buffer(params.text_document.uri, ¶ms.text_document.text); + } + }); + + server.handle_notification::({ + let index = index.clone(); + let fs = fs.clone(); + move |params, cx| { + let uri = params.text_document.uri; + let path = uri.to_file_path().ok(); + index.lock().mark_buffer_closed(&uri); + + if let Some(path) = path { + let index = index.clone(); + let fs = fs.clone(); + cx.spawn(async move |_cx| { + if let Ok(content) = fs.load(&path).await { + index.lock().index_file(uri, &content); + } + }) + .detach(); + } + } + }); + + server.handle_notification::({ + let index = index.clone(); + let fs = fs.clone(); + move |params, cx| { + let index = index.clone(); + let fs = fs.clone(); + cx.spawn(async move |_cx| { + for event in params.changes { + if index.lock().is_buffer_open(&event.uri) { + continue; + } + + match event.typ { + lsp::FileChangeType::DELETED => { + index.lock().remove_definitions_for_file(&event.uri); + } + lsp::FileChangeType::CREATED + | lsp::FileChangeType::CHANGED => { + if let Some(path) = event.uri.to_file_path().ok() { + if let Ok(content) = fs.load(&path).await { + index.lock().index_file(event.uri, &content); + } + } + } + _ => {} + } + } + }) + .detach(); + } + }); + + server.handle_notification::({ + let index = index.clone(); + move |params, _cx| { + if let Some(change) = params.content_changes.into_iter().last() { + index + .lock() + .index_file(params.text_document.uri, &change.text); + } + } + }); + + server.handle_notification::( + { + let index = index.clone(); + let fs = fs.clone(); + move |params, cx| { + let index = index.clone(); + let fs = fs.clone(); + let files = fs.as_fake().files(); + cx.spawn(async move |_cx| { + for folder in params.event.added { + let Ok(path) = folder.uri.to_file_path() else { + continue; + }; + for file in &files { + if let Some(uri) = Uri::from_file_path(&file).ok() + && file.starts_with(&path) + && let Ok(content) = fs.load(&file).await + { + index.lock().index_file(uri, &content); + } + } + } + }) + .detach(); + } + }, + ); + + server.set_request_handler::({ + let index = index.clone(); + move |params, _cx| { + let result = index.lock().get_definitions( + params.text_document_position_params.text_document.uri, + params.text_document_position_params.position, + ); + async move { Ok(result) } + } + }); + } + })), + }, + ) +} + +struct DefinitionIndex { + language: Arc, + definitions: HashMap>, + files: HashMap, +} + +#[derive(Debug)] +struct FileEntry { + contents: String, + is_open_in_buffer: bool, +} + +impl DefinitionIndex { + fn new(language: Arc) -> Self { + Self { + language, + definitions: HashMap::default(), + files: HashMap::default(), + } + } + + fn remove_definitions_for_file(&mut self, uri: &Uri) { + self.definitions.retain(|_, locations| { + locations.retain(|loc| &loc.uri != uri); + !locations.is_empty() + }); + self.files.remove(uri); + } + + fn open_buffer(&mut self, uri: Uri, content: &str) { + self.index_file_inner(uri, content, true); + } + + fn mark_buffer_closed(&mut self, uri: &Uri) { + if let Some(entry) = self.files.get_mut(uri) { + entry.is_open_in_buffer = false; + } + } + + fn is_buffer_open(&self, uri: &Uri) -> bool { + self.files + .get(uri) + .map(|entry| entry.is_open_in_buffer) + .unwrap_or(false) + } + + fn index_file(&mut self, uri: Uri, content: &str) { + self.index_file_inner(uri, content, false); + } + + fn index_file_inner(&mut self, uri: Uri, content: &str, is_open_in_buffer: bool) -> Option<()> { + self.remove_definitions_for_file(&uri); + let grammar = self.language.grammar()?; + let outline_config = grammar.outline_config.as_ref()?; + let mut parser = Parser::new(); + parser.set_language(&grammar.ts_language).ok()?; + let tree = parser.parse(content, None)?; + let declarations = extract_declarations_from_tree(&tree, content, outline_config); + for (name, byte_range) in declarations { + let range = byte_range_to_lsp_range(content, byte_range); + let location = lsp::Location { + uri: uri.clone(), + range, + }; + self.definitions + .entry(name) + .or_insert_with(Vec::new) + .push(location); + } + self.files.insert( + uri, + FileEntry { + contents: content.to_string(), + is_open_in_buffer, + }, + ); + + Some(()) + } + + fn get_definitions( + &mut self, + uri: Uri, + position: lsp::Position, + ) -> Option { + let entry = self.files.get(&uri)?; + let name = word_at_position(&entry.contents, position)?; + let locations = self.definitions.get(name).cloned()?; + Some(lsp::GotoDefinitionResponse::Array(locations)) + } +} + +fn extract_declarations_from_tree( + tree: &Tree, + content: &str, + outline_config: &language::OutlineConfig, +) -> Vec<(String, Range)> { + let mut cursor = QueryCursor::new(); + let mut declarations = Vec::new(); + let mut matches = cursor.matches(&outline_config.query, tree.root_node(), content.as_bytes()); + while let Some(query_match) = matches.next() { + let mut name_range: Option> = None; + let mut has_item_range = false; + + for capture in query_match.captures { + let range = capture.node.byte_range(); + if capture.index == outline_config.name_capture_ix { + name_range = Some(range); + } else if capture.index == outline_config.item_capture_ix { + has_item_range = true; + } + } + + if let Some(name_range) = name_range + && has_item_range + { + let name = content[name_range.clone()].to_string(); + if declarations.iter().any(|(n, _)| n == &name) { + continue; + } + declarations.push((name, name_range)); + } + } + declarations +} + +fn byte_range_to_lsp_range(content: &str, byte_range: Range) -> lsp::Range { + let start = byte_offset_to_position(content, byte_range.start); + let end = byte_offset_to_position(content, byte_range.end); + lsp::Range { start, end } +} + +fn byte_offset_to_position(content: &str, offset: usize) -> lsp::Position { + let mut line = 0; + let mut character = 0; + let mut current_offset = 0; + for ch in content.chars() { + if current_offset >= offset { + break; + } + if ch == '\n' { + line += 1; + character = 0; + } else { + character += 1; + } + current_offset += ch.len_utf8(); + } + lsp::Position { line, character } +} + +fn word_at_position(content: &str, position: lsp::Position) -> Option<&str> { + let mut lines = content.lines(); + let line = lines.nth(position.line as usize)?; + let column = position.character as usize; + if column > line.len() { + return None; + } + let start = line[..column] + .rfind(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + 1) + .unwrap_or(0); + let end = line[column..] + .find(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + column) + .unwrap_or(line.len()); + Some(&line[start..end]).filter(|word| !word.is_empty()) +} diff --git a/crates/edit_prediction_context/src/imports.rs b/crates/edit_prediction_context/src/imports.rs deleted file mode 100644 index 70f175159340ddb9a6f26f23db0c1b3c843e7b96..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/imports.rs +++ /dev/null @@ -1,1319 +0,0 @@ -use collections::HashMap; -use language::BufferSnapshot; -use language::ImportsConfig; -use language::Language; -use std::ops::Deref; -use std::path::Path; -use std::sync::Arc; -use std::{borrow::Cow, ops::Range}; -use text::OffsetRangeExt as _; -use util::RangeExt; -use util::paths::PathStyle; - -use crate::Identifier; -use crate::text_similarity::Occurrences; - -// TODO: Write documentation for extension authors. The @import capture must match before or in the -// same pattern as all all captures it contains - -// Future improvements to consider: -// -// * Distinguish absolute vs relative paths in captures. `#include "maths.h"` is relative whereas -// `#include ` is not. -// -// * Provide the name used when importing whole modules (see tests with "named_module" in the name). -// To be useful, will require parsing of identifier qualification. -// -// * Scoping for imports that aren't at the top level -// -// * Only scan a prefix of the file, when possible. This could look like having query matches that -// indicate it reached a declaration that is not allowed in the import section. -// -// * Support directly parsing to occurrences instead of storing namespaces / paths. Types should be -// generic on this, so that tests etc can still use strings. Could do similar in syntax index. -// -// * Distinguish different types of namespaces when known. E.g. "name.type" capture. Once capture -// names are more open-ended like this may make sense to build and cache a jump table (direct -// dispatch from capture index). -// -// * There are a few "Language specific:" comments on behavior that gets applied to all languages. -// Would be cleaner to be conditional on the language or otherwise configured. - -#[derive(Debug, Clone, Default)] -pub struct Imports { - pub identifier_to_imports: HashMap>, - pub wildcard_modules: Vec, -} - -#[derive(Debug, Clone)] -pub enum Import { - Direct { - module: Module, - }, - Alias { - module: Module, - external_identifier: Identifier, - }, -} - -#[derive(Debug, Clone)] -pub enum Module { - SourceExact(Arc), - SourceFuzzy(Arc), - Namespace(Namespace), -} - -impl Module { - fn empty() -> Self { - Module::Namespace(Namespace::default()) - } - - fn push_range( - &mut self, - range: &ModuleRange, - snapshot: &BufferSnapshot, - language: &Language, - parent_abs_path: Option<&Path>, - ) -> usize { - if range.is_empty() { - return 0; - } - - match range { - ModuleRange::Source(range) => { - if let Self::Namespace(namespace) = self - && namespace.0.is_empty() - { - let path = snapshot.text_for_range(range.clone()).collect::>(); - - let path = if let Some(strip_regex) = - language.config().import_path_strip_regex.as_ref() - { - strip_regex.replace_all(&path, "") - } else { - path - }; - - let path = Path::new(path.as_ref()); - if (path.starts_with(".") || path.starts_with("..")) - && let Some(parent_abs_path) = parent_abs_path - && let Ok(abs_path) = - util::paths::normalize_lexically(&parent_abs_path.join(path)) - { - *self = Self::SourceExact(abs_path.into()); - } else { - *self = Self::SourceFuzzy(path.into()); - }; - } else if matches!(self, Self::SourceExact(_)) - || matches!(self, Self::SourceFuzzy(_)) - { - log::warn!("bug in imports query: encountered multiple @source matches"); - } else { - log::warn!( - "bug in imports query: encountered both @namespace and @source match" - ); - } - } - ModuleRange::Namespace(range) => { - if let Self::Namespace(namespace) = self { - let segment = range_text(snapshot, range); - if language.config().ignored_import_segments.contains(&segment) { - return 0; - } else { - namespace.0.push(segment); - return 1; - } - } else { - log::warn!( - "bug in imports query: encountered both @namespace and @source match" - ); - } - } - } - 0 - } -} - -#[derive(Debug, Clone)] -enum ModuleRange { - Source(Range), - Namespace(Range), -} - -impl Deref for ModuleRange { - type Target = Range; - - fn deref(&self) -> &Self::Target { - match self { - ModuleRange::Source(range) => range, - ModuleRange::Namespace(range) => range, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct Namespace(pub Vec>); - -impl Namespace { - pub fn occurrences(&self) -> Occurrences { - Occurrences::from_identifiers(&self.0) - } -} - -impl Imports { - pub fn gather(snapshot: &BufferSnapshot, parent_abs_path: Option<&Path>) -> Self { - // Query to match different import patterns - let mut matches = snapshot - .syntax - .matches(0..snapshot.len(), &snapshot.text, |grammar| { - grammar.imports_config().map(|imports| &imports.query) - }); - - let mut detached_nodes: Vec = Vec::new(); - let mut identifier_to_imports = HashMap::default(); - let mut wildcard_modules = Vec::new(); - let mut import_range = None; - - while let Some(query_match) = matches.peek() { - let ImportsConfig { - query: _, - import_ix, - name_ix, - namespace_ix, - source_ix, - list_ix, - wildcard_ix, - alias_ix, - } = matches.grammars()[query_match.grammar_index] - .imports_config() - .unwrap(); - - let mut new_import_range = None; - let mut alias_range = None; - let mut modules = Vec::new(); - let mut content: Option<(Range, ContentKind)> = None; - for capture in query_match.captures { - let capture_range = capture.node.byte_range(); - - if capture.index == *import_ix { - new_import_range = Some(capture_range); - } else if Some(capture.index) == *namespace_ix { - modules.push(ModuleRange::Namespace(capture_range)); - } else if Some(capture.index) == *source_ix { - modules.push(ModuleRange::Source(capture_range)); - } else if Some(capture.index) == *alias_ix { - alias_range = Some(capture_range); - } else { - let mut found_content = None; - if Some(capture.index) == *name_ix { - found_content = Some((capture_range, ContentKind::Name)); - } else if Some(capture.index) == *list_ix { - found_content = Some((capture_range, ContentKind::List)); - } else if Some(capture.index) == *wildcard_ix { - found_content = Some((capture_range, ContentKind::Wildcard)); - } - if let Some((found_content_range, found_kind)) = found_content { - if let Some((_, old_kind)) = content { - let point = found_content_range.to_point(snapshot); - log::warn!( - "bug in {} imports query: unexpected multiple captures of {} and {} ({}:{}:{})", - query_match.language.name(), - old_kind.capture_name(), - found_kind.capture_name(), - snapshot - .file() - .map(|p| p.path().display(PathStyle::Posix)) - .unwrap_or_default(), - point.start.row + 1, - point.start.column + 1 - ); - } - content = Some((found_content_range, found_kind)); - } - } - } - - if let Some(new_import_range) = new_import_range { - log::trace!("starting new import {:?}", new_import_range); - Self::gather_from_import_statement( - &detached_nodes, - &snapshot, - parent_abs_path, - &mut identifier_to_imports, - &mut wildcard_modules, - ); - detached_nodes.clear(); - import_range = Some(new_import_range.clone()); - } - - if let Some((content, content_kind)) = content { - if import_range - .as_ref() - .is_some_and(|import_range| import_range.contains_inclusive(&content)) - { - detached_nodes.push(DetachedNode { - modules, - content: content.clone(), - content_kind, - alias: alias_range.unwrap_or(0..0), - language: query_match.language.clone(), - }); - } else { - log::trace!( - "filtered out match not inside import range: {content_kind:?} at {content:?}" - ); - } - } - - matches.advance(); - } - - Self::gather_from_import_statement( - &detached_nodes, - &snapshot, - parent_abs_path, - &mut identifier_to_imports, - &mut wildcard_modules, - ); - - Imports { - identifier_to_imports, - wildcard_modules, - } - } - - fn gather_from_import_statement( - detached_nodes: &[DetachedNode], - snapshot: &BufferSnapshot, - parent_abs_path: Option<&Path>, - identifier_to_imports: &mut HashMap>, - wildcard_modules: &mut Vec, - ) { - let mut trees = Vec::new(); - - for detached_node in detached_nodes { - if let Some(node) = Self::attach_node(detached_node.into(), &mut trees) { - trees.push(node); - } - log::trace!( - "Attached node to tree\n{:#?}\nAttach result:\n{:#?}", - detached_node, - trees - .iter() - .map(|tree| tree.debug(snapshot)) - .collect::>() - ); - } - - for tree in &trees { - let mut module = Module::empty(); - Self::gather_from_tree( - tree, - snapshot, - parent_abs_path, - &mut module, - identifier_to_imports, - wildcard_modules, - ); - } - } - - fn attach_node(mut node: ImportTree, trees: &mut Vec) -> Option { - let mut tree_index = 0; - while tree_index < trees.len() { - let tree = &mut trees[tree_index]; - if !node.content.is_empty() && node.content == tree.content { - // multiple matches can apply to the same name/list/wildcard. This keeps the queries - // simpler by combining info from these matches. - if tree.module.is_empty() { - tree.module = node.module; - tree.module_children = node.module_children; - } - if tree.alias.is_empty() { - tree.alias = node.alias; - } - return None; - } else if !node.module.is_empty() && node.module.contains_inclusive(&tree.range()) { - node.module_children.push(trees.remove(tree_index)); - continue; - } else if !node.content.is_empty() && node.content.contains_inclusive(&tree.content) { - node.content_children.push(trees.remove(tree_index)); - continue; - } else if !tree.content.is_empty() && tree.content.contains_inclusive(&node.content) { - if let Some(node) = Self::attach_node(node, &mut tree.content_children) { - tree.content_children.push(node); - } - return None; - } - tree_index += 1; - } - Some(node) - } - - fn gather_from_tree( - tree: &ImportTree, - snapshot: &BufferSnapshot, - parent_abs_path: Option<&Path>, - current_module: &mut Module, - identifier_to_imports: &mut HashMap>, - wildcard_modules: &mut Vec, - ) { - let mut pop_count = 0; - - if tree.module_children.is_empty() { - pop_count += - current_module.push_range(&tree.module, snapshot, &tree.language, parent_abs_path); - } else { - for child in &tree.module_children { - pop_count += Self::extend_namespace_from_tree( - child, - snapshot, - parent_abs_path, - current_module, - ); - } - }; - - if tree.content_children.is_empty() && !tree.content.is_empty() { - match tree.content_kind { - ContentKind::Name | ContentKind::List => { - if tree.alias.is_empty() { - identifier_to_imports - .entry(Identifier { - language_id: tree.language.id(), - name: range_text(snapshot, &tree.content), - }) - .or_default() - .push(Import::Direct { - module: current_module.clone(), - }); - } else { - let alias_name: Arc = range_text(snapshot, &tree.alias); - let external_name = range_text(snapshot, &tree.content); - // Language specific: skip "_" aliases for Rust - if alias_name.as_ref() != "_" { - identifier_to_imports - .entry(Identifier { - language_id: tree.language.id(), - name: alias_name, - }) - .or_default() - .push(Import::Alias { - module: current_module.clone(), - external_identifier: Identifier { - language_id: tree.language.id(), - name: external_name, - }, - }); - } - } - } - ContentKind::Wildcard => wildcard_modules.push(current_module.clone()), - } - } else { - for child in &tree.content_children { - Self::gather_from_tree( - child, - snapshot, - parent_abs_path, - current_module, - identifier_to_imports, - wildcard_modules, - ); - } - } - - if pop_count > 0 { - match current_module { - Module::SourceExact(_) | Module::SourceFuzzy(_) => { - log::warn!( - "bug in imports query: encountered both @namespace and @source match" - ); - } - Module::Namespace(namespace) => { - namespace.0.drain(namespace.0.len() - pop_count..); - } - } - } - } - - fn extend_namespace_from_tree( - tree: &ImportTree, - snapshot: &BufferSnapshot, - parent_abs_path: Option<&Path>, - module: &mut Module, - ) -> usize { - let mut pop_count = 0; - if tree.module_children.is_empty() { - pop_count += module.push_range(&tree.module, snapshot, &tree.language, parent_abs_path); - } else { - for child in &tree.module_children { - pop_count += - Self::extend_namespace_from_tree(child, snapshot, parent_abs_path, module); - } - } - if tree.content_children.is_empty() { - pop_count += module.push_range( - &ModuleRange::Namespace(tree.content.clone()), - snapshot, - &tree.language, - parent_abs_path, - ); - } else { - for child in &tree.content_children { - pop_count += - Self::extend_namespace_from_tree(child, snapshot, parent_abs_path, module); - } - } - pop_count - } -} - -fn range_text(snapshot: &BufferSnapshot, range: &Range) -> Arc { - snapshot - .text_for_range(range.clone()) - .collect::>() - .into() -} - -#[derive(Debug)] -struct DetachedNode { - modules: Vec, - content: Range, - content_kind: ContentKind, - alias: Range, - language: Arc, -} - -#[derive(Debug, Clone, Copy)] -enum ContentKind { - Name, - Wildcard, - List, -} - -impl ContentKind { - fn capture_name(&self) -> &'static str { - match self { - ContentKind::Name => "name", - ContentKind::Wildcard => "wildcard", - ContentKind::List => "list", - } - } -} - -#[derive(Debug)] -struct ImportTree { - module: ModuleRange, - /// When non-empty, provides namespace / source info which should be used instead of `module`. - module_children: Vec, - content: Range, - /// When non-empty, provides content which should be used instead of `content`. - content_children: Vec, - content_kind: ContentKind, - alias: Range, - language: Arc, -} - -impl ImportTree { - fn range(&self) -> Range { - self.module.start.min(self.content.start)..self.module.end.max(self.content.end) - } - - #[allow(dead_code)] - fn debug<'a>(&'a self, snapshot: &'a BufferSnapshot) -> ImportTreeDebug<'a> { - ImportTreeDebug { - tree: self, - snapshot, - } - } - - fn from_module_range(module: &ModuleRange, language: Arc) -> Self { - ImportTree { - module: module.clone(), - module_children: Vec::new(), - content: 0..0, - content_children: Vec::new(), - content_kind: ContentKind::Name, - alias: 0..0, - language, - } - } -} - -impl From<&DetachedNode> for ImportTree { - fn from(value: &DetachedNode) -> Self { - let module; - let module_children; - match value.modules.len() { - 0 => { - module = ModuleRange::Namespace(0..0); - module_children = Vec::new(); - } - 1 => { - module = value.modules[0].clone(); - module_children = Vec::new(); - } - _ => { - module = ModuleRange::Namespace( - value.modules.first().unwrap().start..value.modules.last().unwrap().end, - ); - module_children = value - .modules - .iter() - .map(|module| ImportTree::from_module_range(module, value.language.clone())) - .collect(); - } - } - - ImportTree { - module, - module_children, - content: value.content.clone(), - content_children: Vec::new(), - content_kind: value.content_kind, - alias: value.alias.clone(), - language: value.language.clone(), - } - } -} - -struct ImportTreeDebug<'a> { - tree: &'a ImportTree, - snapshot: &'a BufferSnapshot, -} - -impl std::fmt::Debug for ImportTreeDebug<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ImportTree") - .field("module_range", &self.tree.module) - .field("module_text", &range_text(self.snapshot, &self.tree.module)) - .field( - "module_children", - &self - .tree - .module_children - .iter() - .map(|child| child.debug(&self.snapshot)) - .collect::>(), - ) - .field("content_range", &self.tree.content) - .field( - "content_text", - &range_text(self.snapshot, &self.tree.content), - ) - .field( - "content_children", - &self - .tree - .content_children - .iter() - .map(|child| child.debug(&self.snapshot)) - .collect::>(), - ) - .field("content_kind", &self.tree.content_kind) - .field("alias_range", &self.tree.alias) - .field("alias_text", &range_text(self.snapshot, &self.tree.alias)) - .finish() - } -} - -#[cfg(test)] -mod test { - use std::path::PathBuf; - use std::sync::{Arc, LazyLock}; - - use super::*; - use collections::HashSet; - use gpui::{TestAppContext, prelude::*}; - use indoc::indoc; - use language::{ - Buffer, Language, LanguageConfig, tree_sitter_python, tree_sitter_rust, - tree_sitter_typescript, - }; - use regex::Regex; - - #[gpui::test] - fn test_rust_simple(cx: &mut TestAppContext) { - check_imports( - &RUST, - "use std::collections::HashMap;", - &[&["std", "collections", "HashMap"]], - cx, - ); - - check_imports( - &RUST, - "pub use std::collections::HashMap;", - &[&["std", "collections", "HashMap"]], - cx, - ); - - check_imports( - &RUST, - "use std::collections::{HashMap, HashSet};", - &[ - &["std", "collections", "HashMap"], - &["std", "collections", "HashSet"], - ], - cx, - ); - } - - #[gpui::test] - fn test_rust_nested(cx: &mut TestAppContext) { - check_imports( - &RUST, - "use std::{any::TypeId, collections::{HashMap, HashSet}};", - &[ - &["std", "any", "TypeId"], - &["std", "collections", "HashMap"], - &["std", "collections", "HashSet"], - ], - cx, - ); - - check_imports( - &RUST, - "use a::b::c::{d::e::F, g::h::I};", - &[ - &["a", "b", "c", "d", "e", "F"], - &["a", "b", "c", "g", "h", "I"], - ], - cx, - ); - } - - #[gpui::test] - fn test_rust_multiple_imports(cx: &mut TestAppContext) { - check_imports( - &RUST, - indoc! {" - use std::collections::HashMap; - use std::any::{TypeId, Any}; - "}, - &[ - &["std", "collections", "HashMap"], - &["std", "any", "TypeId"], - &["std", "any", "Any"], - ], - cx, - ); - - check_imports( - &RUST, - indoc! {" - use std::collections::HashSet; - - fn main() { - let unqualified = HashSet::new(); - let qualified = std::collections::HashMap::new(); - } - - use std::any::TypeId; - "}, - &[ - &["std", "collections", "HashSet"], - &["std", "any", "TypeId"], - ], - cx, - ); - } - - #[gpui::test] - fn test_rust_wildcard(cx: &mut TestAppContext) { - check_imports(&RUST, "use prelude::*;", &[&["prelude", "WILDCARD"]], cx); - - check_imports( - &RUST, - "use zed::prelude::*;", - &[&["zed", "prelude", "WILDCARD"]], - cx, - ); - - check_imports(&RUST, "use prelude::{*};", &[&["prelude", "WILDCARD"]], cx); - - check_imports( - &RUST, - "use prelude::{File, *};", - &[&["prelude", "File"], &["prelude", "WILDCARD"]], - cx, - ); - - check_imports( - &RUST, - "use zed::{App, prelude::*};", - &[&["zed", "App"], &["zed", "prelude", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_rust_alias(cx: &mut TestAppContext) { - check_imports( - &RUST, - "use std::io::Result as IoResult;", - &[&["std", "io", "Result AS IoResult"]], - cx, - ); - } - - #[gpui::test] - fn test_rust_crate_and_super(cx: &mut TestAppContext) { - check_imports(&RUST, "use crate::a::b::c;", &[&["a", "b", "c"]], cx); - check_imports(&RUST, "use super::a::b::c;", &[&["a", "b", "c"]], cx); - // TODO: Consider stripping leading "::". Not done for now because for the text similarity matching usecase this - // is fine. - check_imports(&RUST, "use ::a::b::c;", &[&["::a", "b", "c"]], cx); - } - - #[gpui::test] - fn test_typescript_imports(cx: &mut TestAppContext) { - let parent_abs_path = PathBuf::from("/home/user/project"); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import "./maths.js";"#, - &[&["SOURCE /home/user/project/maths", "WILDCARD"]], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import "../maths.js";"#, - &[&["SOURCE /home/user/maths", "WILDCARD"]], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import RandomNumberGenerator, { pi as π } from "./maths.js";"#, - &[ - &["SOURCE /home/user/project/maths", "RandomNumberGenerator"], - &["SOURCE /home/user/project/maths", "pi AS π"], - ], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { pi, phi, absolute } from "./maths.js";"#, - &[ - &["SOURCE /home/user/project/maths", "pi"], - &["SOURCE /home/user/project/maths", "phi"], - &["SOURCE /home/user/project/maths", "absolute"], - ], - cx, - ); - - // index.js is removed by import_path_strip_regex - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { pi, phi, absolute } from "./maths/index.js";"#, - &[ - &["SOURCE /home/user/project/maths", "pi"], - &["SOURCE /home/user/project/maths", "phi"], - &["SOURCE /home/user/project/maths", "absolute"], - ], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import type { SomeThing } from "./some-module.js";"#, - &[&["SOURCE /home/user/project/some-module", "SomeThing"]], - cx, - ); - - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { type SomeThing, OtherThing } from "./some-module.js";"#, - &[ - &["SOURCE /home/user/project/some-module", "SomeThing"], - &["SOURCE /home/user/project/some-module", "OtherThing"], - ], - cx, - ); - - // index.js is removed by import_path_strip_regex - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { type SomeThing, OtherThing } from "./some-module/index.js";"#, - &[ - &["SOURCE /home/user/project/some-module", "SomeThing"], - &["SOURCE /home/user/project/some-module", "OtherThing"], - ], - cx, - ); - - // fuzzy paths - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import { type SomeThing, OtherThing } from "@my-app/some-module.js";"#, - &[ - &["SOURCE FUZZY @my-app/some-module", "SomeThing"], - &["SOURCE FUZZY @my-app/some-module", "OtherThing"], - ], - cx, - ); - } - - #[gpui::test] - fn test_typescript_named_module_imports(cx: &mut TestAppContext) { - let parent_abs_path = PathBuf::from("/home/user/project"); - - // TODO: These should provide the name that the module is bound to. - // For now instead these are treated as unqualified wildcard imports. - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import * as math from "./maths.js";"#, - // &[&["/home/user/project/maths.js", "WILDCARD AS math"]], - &[&["SOURCE /home/user/project/maths", "WILDCARD"]], - cx, - ); - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &TYPESCRIPT, - r#"import math = require("./maths");"#, - // &[&["/home/user/project/maths", "WILDCARD AS math"]], - &[&["SOURCE /home/user/project/maths", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_python_imports(cx: &mut TestAppContext) { - check_imports(&PYTHON, "from math import pi", &[&["math", "pi"]], cx); - - check_imports( - &PYTHON, - "from math import pi, sin, cos", - &[&["math", "pi"], &["math", "sin"], &["math", "cos"]], - cx, - ); - - check_imports(&PYTHON, "from math import *", &[&["math", "WILDCARD"]], cx); - - check_imports( - &PYTHON, - "from math import foo.bar.baz", - &[&["math", "foo", "bar", "baz"]], - cx, - ); - - check_imports( - &PYTHON, - "from math import pi as PI", - &[&["math", "pi AS PI"]], - cx, - ); - - check_imports( - &PYTHON, - "from serializers.json import JsonSerializer", - &[&["serializers", "json", "JsonSerializer"]], - cx, - ); - - check_imports( - &PYTHON, - "from custom.serializers import json, xml, yaml", - &[ - &["custom", "serializers", "json"], - &["custom", "serializers", "xml"], - &["custom", "serializers", "yaml"], - ], - cx, - ); - } - - #[gpui::test] - fn test_python_named_module_imports(cx: &mut TestAppContext) { - // TODO: These should provide the name that the module is bound to. - // For now instead these are treated as unqualified wildcard imports. - // - // check_imports(&PYTHON, "import math", &[&["math", "WILDCARD as math"]], cx); - // check_imports(&PYTHON, "import math as maths", &[&["math", "WILDCARD AS maths"]], cx); - // - // Something like: - // - // (import_statement - // name: [ - // (dotted_name - // (identifier)* @namespace - // (identifier) @name.module .) - // (aliased_import - // name: (dotted_name - // ((identifier) ".")* @namespace - // (identifier) @name.module .) - // alias: (identifier) @alias) - // ]) @import - - check_imports(&PYTHON, "import math", &[&["math", "WILDCARD"]], cx); - - check_imports( - &PYTHON, - "import math as maths", - &[&["math", "WILDCARD"]], - cx, - ); - - check_imports(&PYTHON, "import a.b.c", &[&["a", "b", "c", "WILDCARD"]], cx); - - check_imports( - &PYTHON, - "import a.b.c as d", - &[&["a", "b", "c", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_python_package_relative_imports(cx: &mut TestAppContext) { - // TODO: These should provide info about the dir they are relative to, to provide more - // precise resolution. Instead, fuzzy matching is used as usual. - - check_imports(&PYTHON, "from . import math", &[&["math"]], cx); - - check_imports(&PYTHON, "from .a import math", &[&["a", "math"]], cx); - - check_imports( - &PYTHON, - "from ..a.b import math", - &[&["a", "b", "math"]], - cx, - ); - - check_imports( - &PYTHON, - "from ..a.b import *", - &[&["a", "b", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_c_imports(cx: &mut TestAppContext) { - let parent_abs_path = PathBuf::from("/home/user/project"); - - // TODO: Distinguish that these are not relative to current path - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &C, - r#"#include "#, - &[&["SOURCE FUZZY math.h", "WILDCARD"]], - cx, - ); - - // TODO: These should be treated as relative, but don't start with ./ or ../ - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &C, - r#"#include "math.h""#, - &[&["SOURCE FUZZY math.h", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_cpp_imports(cx: &mut TestAppContext) { - let parent_abs_path = PathBuf::from("/home/user/project"); - - // TODO: Distinguish that these are not relative to current path - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &CPP, - r#"#include "#, - &[&["SOURCE FUZZY math.h", "WILDCARD"]], - cx, - ); - - // TODO: These should be treated as relative, but don't start with ./ or ../ - check_imports_with_file_abs_path( - Some(&parent_abs_path), - &CPP, - r#"#include "math.h""#, - &[&["SOURCE FUZZY math.h", "WILDCARD"]], - cx, - ); - } - - #[gpui::test] - fn test_go_imports(cx: &mut TestAppContext) { - check_imports( - &GO, - r#"import . "lib/math""#, - &[&["lib/math", "WILDCARD"]], - cx, - ); - - // not included, these are only for side-effects - check_imports(&GO, r#"import _ "lib/math""#, &[], cx); - } - - #[gpui::test] - fn test_go_named_module_imports(cx: &mut TestAppContext) { - // TODO: These should provide the name that the module is bound to. - // For now instead these are treated as unqualified wildcard imports. - - check_imports( - &GO, - r#"import "lib/math""#, - &[&["lib/math", "WILDCARD"]], - cx, - ); - check_imports( - &GO, - r#"import m "lib/math""#, - &[&["lib/math", "WILDCARD"]], - cx, - ); - } - - #[track_caller] - fn check_imports( - language: &Arc, - source: &str, - expected: &[&[&str]], - cx: &mut TestAppContext, - ) { - check_imports_with_file_abs_path(None, language, source, expected, cx); - } - - #[track_caller] - fn check_imports_with_file_abs_path( - parent_abs_path: Option<&Path>, - language: &Arc, - source: &str, - expected: &[&[&str]], - cx: &mut TestAppContext, - ) { - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local(source, cx); - buffer.set_language(Some(language.clone()), cx); - buffer - }); - cx.run_until_parked(); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - - let imports = Imports::gather(&snapshot, parent_abs_path); - let mut actual_symbols = imports - .identifier_to_imports - .iter() - .flat_map(|(identifier, imports)| { - imports - .iter() - .map(|import| import.to_identifier_parts(identifier.name.as_ref())) - }) - .chain( - imports - .wildcard_modules - .iter() - .map(|module| module.to_identifier_parts("WILDCARD")), - ) - .collect::>(); - let mut expected_symbols = expected - .iter() - .map(|expected| expected.iter().map(|s| s.to_string()).collect::>()) - .collect::>(); - actual_symbols.sort(); - expected_symbols.sort(); - if actual_symbols != expected_symbols { - let top_layer = snapshot.syntax_layers().next().unwrap(); - panic!( - "Expected imports: {:?}\n\ - Actual imports: {:?}\n\ - Tree:\n{}", - expected_symbols, - actual_symbols, - tree_to_string(&top_layer.node()), - ); - } - } - - fn tree_to_string(node: &tree_sitter::Node) -> String { - let mut cursor = node.walk(); - let mut result = String::new(); - let mut depth = 0; - 'outer: loop { - result.push_str(&" ".repeat(depth)); - if let Some(field_name) = cursor.field_name() { - result.push_str(field_name); - result.push_str(": "); - } - if cursor.node().is_named() { - result.push_str(cursor.node().kind()); - } else { - result.push('"'); - result.push_str(cursor.node().kind()); - result.push('"'); - } - result.push('\n'); - - if cursor.goto_first_child() { - depth += 1; - continue; - } - if cursor.goto_next_sibling() { - continue; - } - while cursor.goto_parent() { - depth -= 1; - if cursor.goto_next_sibling() { - continue 'outer; - } - } - break; - } - result - } - - static RUST: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "Rust".into(), - ignored_import_segments: HashSet::from_iter(["crate".into(), "super".into()]), - import_path_strip_regex: Some(Regex::new("/(lib|mod)\\.rs$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/rust/imports.scm")) - .unwrap(), - ) - }); - - static TYPESCRIPT: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "TypeScript".into(), - import_path_strip_regex: Some(Regex::new("(?:/index)?\\.[jt]s$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), - ) - .with_imports_query(include_str!("../../languages/src/typescript/imports.scm")) - .unwrap(), - ) - }); - - static PYTHON: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "Python".into(), - import_path_strip_regex: Some(Regex::new("/__init__\\.py$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_python::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/python/imports.scm")) - .unwrap(), - ) - }); - - // TODO: Ideally should use actual language configurations - static C: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "C".into(), - import_path_strip_regex: Some(Regex::new("^<|>$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_c::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/c/imports.scm")) - .unwrap(), - ) - }); - - static CPP: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "C++".into(), - import_path_strip_regex: Some(Regex::new("^<|>$").unwrap()), - ..Default::default() - }, - Some(tree_sitter_cpp::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/cpp/imports.scm")) - .unwrap(), - ) - }); - - static GO: LazyLock> = LazyLock::new(|| { - Arc::new( - Language::new( - LanguageConfig { - name: "Go".into(), - ..Default::default() - }, - Some(tree_sitter_go::LANGUAGE.into()), - ) - .with_imports_query(include_str!("../../languages/src/go/imports.scm")) - .unwrap(), - ) - }); - - impl Import { - fn to_identifier_parts(&self, identifier: &str) -> Vec { - match self { - Import::Direct { module } => module.to_identifier_parts(identifier), - Import::Alias { - module, - external_identifier: external_name, - } => { - module.to_identifier_parts(&format!("{} AS {}", external_name.name, identifier)) - } - } - } - } - - impl Module { - fn to_identifier_parts(&self, identifier: &str) -> Vec { - match self { - Self::Namespace(namespace) => namespace.to_identifier_parts(identifier), - Self::SourceExact(path) => { - vec![ - format!("SOURCE {}", path.display().to_string().replace("\\", "/")), - identifier.to_string(), - ] - } - Self::SourceFuzzy(path) => { - vec![ - format!( - "SOURCE FUZZY {}", - path.display().to_string().replace("\\", "/") - ), - identifier.to_string(), - ] - } - } - } - } - - impl Namespace { - fn to_identifier_parts(&self, identifier: &str) -> Vec { - self.0 - .iter() - .map(|chunk| chunk.to_string()) - .chain(std::iter::once(identifier.to_string())) - .collect::>() - } - } -} diff --git a/crates/edit_prediction_context/src/outline.rs b/crates/edit_prediction_context/src/outline.rs deleted file mode 100644 index ec02c869dfae4cb861206cb801c285462e734f36..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/outline.rs +++ /dev/null @@ -1,126 +0,0 @@ -use language::{BufferSnapshot, SyntaxMapMatches}; -use std::{cmp::Reverse, ops::Range}; - -use crate::declaration::Identifier; - -// TODO: -// -// * how to handle multiple name captures? for now last one wins -// -// * annotation ranges -// -// * new "signature" capture for outline queries -// -// * Check parent behavior of "int x, y = 0" declarations in a test - -pub struct OutlineDeclaration { - pub parent_index: Option, - pub identifier: Identifier, - pub item_range: Range, - pub signature_range: Range, -} - -pub fn declarations_in_buffer(buffer: &BufferSnapshot) -> Vec { - declarations_overlapping_range(0..buffer.len(), buffer) -} - -pub fn declarations_overlapping_range( - range: Range, - buffer: &BufferSnapshot, -) -> Vec { - let mut declarations = OutlineIterator::new(range, buffer).collect::>(); - declarations.sort_unstable_by_key(|item| (item.item_range.start, Reverse(item.item_range.end))); - - let mut parent_stack: Vec<(usize, Range)> = Vec::new(); - for (index, declaration) in declarations.iter_mut().enumerate() { - while let Some((top_parent_index, top_parent_range)) = parent_stack.last() { - if declaration.item_range.start >= top_parent_range.end { - parent_stack.pop(); - } else { - declaration.parent_index = Some(*top_parent_index); - break; - } - } - parent_stack.push((index, declaration.item_range.clone())); - } - declarations -} - -/// Iterates outline items without being ordered w.r.t. nested items and without populating -/// `parent`. -pub struct OutlineIterator<'a> { - buffer: &'a BufferSnapshot, - matches: SyntaxMapMatches<'a>, -} - -impl<'a> OutlineIterator<'a> { - pub fn new(range: Range, buffer: &'a BufferSnapshot) -> Self { - let matches = buffer.syntax.matches(range, &buffer.text, |grammar| { - grammar.outline_config.as_ref().map(|c| &c.query) - }); - - Self { buffer, matches } - } -} - -impl<'a> Iterator for OutlineIterator<'a> { - type Item = OutlineDeclaration; - - fn next(&mut self) -> Option { - while let Some(mat) = self.matches.peek() { - let config = self.matches.grammars()[mat.grammar_index] - .outline_config - .as_ref() - .unwrap(); - - let mut name_range = None; - let mut item_range = None; - let mut signature_start = None; - let mut signature_end = None; - - let mut add_to_signature = |range: Range| { - if signature_start.is_none() { - signature_start = Some(range.start); - } - signature_end = Some(range.end); - }; - - for capture in mat.captures { - let range = capture.node.byte_range(); - if capture.index == config.name_capture_ix { - name_range = Some(range.clone()); - add_to_signature(range); - } else if Some(capture.index) == config.context_capture_ix - || Some(capture.index) == config.extra_context_capture_ix - { - add_to_signature(range); - } else if capture.index == config.item_capture_ix { - item_range = Some(range.clone()); - } - } - - let language_id = mat.language.id(); - self.matches.advance(); - - if let Some(name_range) = name_range - && let Some(item_range) = item_range - && let Some(signature_start) = signature_start - && let Some(signature_end) = signature_end - { - let name = self - .buffer - .text_for_range(name_range) - .collect::() - .into(); - - return Some(OutlineDeclaration { - identifier: Identifier { name, language_id }, - item_range: item_range, - signature_range: signature_start..signature_end, - parent_index: None, - }); - } - } - None - } -} diff --git a/crates/edit_prediction_context/src/reference.rs b/crates/edit_prediction_context/src/reference.rs deleted file mode 100644 index 699adf1d8036802a7a4b9e34ca8e8094e4f97458..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/reference.rs +++ /dev/null @@ -1,173 +0,0 @@ -use collections::HashMap; -use language::BufferSnapshot; -use std::ops::Range; -use util::RangeExt; - -use crate::{ - declaration::Identifier, - excerpt::{EditPredictionExcerpt, EditPredictionExcerptText}, -}; - -#[derive(Debug, Clone)] -pub struct Reference { - pub identifier: Identifier, - pub range: Range, - pub region: ReferenceRegion, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum ReferenceRegion { - Breadcrumb, - Nearby, -} - -pub fn references_in_excerpt( - excerpt: &EditPredictionExcerpt, - excerpt_text: &EditPredictionExcerptText, - snapshot: &BufferSnapshot, -) -> HashMap> { - let mut references = references_in_range( - excerpt.range.clone(), - excerpt_text.body.as_str(), - ReferenceRegion::Nearby, - snapshot, - ); - - for ((_, range), text) in excerpt - .parent_declarations - .iter() - .zip(excerpt_text.parent_signatures.iter()) - { - references.extend(references_in_range( - range.clone(), - text.as_str(), - ReferenceRegion::Breadcrumb, - snapshot, - )); - } - - let mut identifier_to_references: HashMap> = HashMap::default(); - for reference in references { - identifier_to_references - .entry(reference.identifier.clone()) - .or_insert_with(Vec::new) - .push(reference); - } - identifier_to_references -} - -/// Finds all nodes which have a "variable" match from the highlights query within the offset range. -pub fn references_in_range( - range: Range, - range_text: &str, - reference_region: ReferenceRegion, - buffer: &BufferSnapshot, -) -> Vec { - let mut matches = buffer - .syntax - .matches(range.clone(), &buffer.text, |grammar| { - grammar - .highlights_config - .as_ref() - .map(|config| &config.query) - }); - - let mut references = Vec::new(); - let mut last_added_range = None; - while let Some(mat) = matches.peek() { - let config = matches.grammars()[mat.grammar_index] - .highlights_config - .as_ref(); - - if let Some(config) = config { - for capture in mat.captures { - if config.identifier_capture_indices.contains(&capture.index) { - let node_range = capture.node.byte_range(); - - // sometimes multiple highlight queries match - this deduplicates them - if Some(node_range.clone()) == last_added_range { - continue; - } - - if !range.contains_inclusive(&node_range) { - continue; - } - - let identifier_text = - &range_text[node_range.start - range.start..node_range.end - range.start]; - - references.push(Reference { - identifier: Identifier { - name: identifier_text.into(), - language_id: mat.language.id(), - }, - range: node_range.clone(), - region: reference_region, - }); - last_added_range = Some(node_range); - } - } - } - - matches.advance(); - } - references -} - -#[cfg(test)] -mod test { - use gpui::{TestAppContext, prelude::*}; - use indoc::indoc; - use language::{BufferSnapshot, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; - - use crate::reference::{ReferenceRegion, references_in_range}; - - #[gpui::test] - fn test_identifier_node_truncated(cx: &mut TestAppContext) { - let code = indoc! { r#" - fn main() { - add(1, 2); - } - - fn add(a: i32, b: i32) -> i32 { - a + b - } - "# }; - let buffer = create_buffer(code, cx); - - let range = 0..35; - let references = references_in_range( - range.clone(), - &code[range], - ReferenceRegion::Breadcrumb, - &buffer, - ); - assert_eq!(references.len(), 2); - assert_eq!(references[0].identifier.name.as_ref(), "main"); - assert_eq!(references[1].identifier.name.as_ref(), "add"); - } - - fn create_buffer(text: &str, cx: &mut TestAppContext) -> BufferSnapshot { - let buffer = - cx.new(|cx| language::Buffer::local(text, cx).with_language(rust_lang().into(), cx)); - buffer.read_with(cx, |buffer, _| buffer.snapshot()) - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_highlights_query(include_str!("../../languages/src/rust/highlights.scm")) - .unwrap() - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } -} diff --git a/crates/edit_prediction_context/src/syntax_index.rs b/crates/edit_prediction_context/src/syntax_index.rs deleted file mode 100644 index f489a083341b66c7cca3cdad76a9c7ea16fdc959..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/syntax_index.rs +++ /dev/null @@ -1,1069 +0,0 @@ -use anyhow::{Result, anyhow}; -use collections::{HashMap, HashSet}; -use futures::channel::mpsc; -use futures::lock::Mutex; -use futures::{FutureExt as _, StreamExt, future}; -use gpui::{App, AppContext as _, AsyncApp, Context, Entity, Task, WeakEntity}; -use itertools::Itertools; - -use language::{Buffer, BufferEvent}; -use postage::stream::Stream as _; -use project::buffer_store::{BufferStore, BufferStoreEvent}; -use project::worktree_store::{WorktreeStore, WorktreeStoreEvent}; -use project::{PathChange, Project, ProjectEntryId, ProjectPath}; -use slotmap::SlotMap; -use std::iter; -use std::ops::{DerefMut, Range}; -use std::sync::Arc; -use text::BufferId; -use util::{RangeExt as _, debug_panic, some_or_debug_panic}; - -use crate::CachedDeclarationPath; -use crate::declaration::{ - BufferDeclaration, Declaration, DeclarationId, FileDeclaration, Identifier, -}; -use crate::outline::declarations_in_buffer; - -// TODO -// -// * Also queue / debounce buffer changes. A challenge for this is that use of -// `buffer_declarations_containing_range` assumes that the index is always immediately up to date. -// -// * Add a per language configuration for skipping indexing. -// -// * Handle tsx / ts / js referencing each-other - -// Potential future improvements: -// -// * Prevent indexing of a large file from blocking the queue. -// -// * Send multiple selected excerpt ranges. Challenge is that excerpt ranges influence which -// references are present and their scores. -// -// * Include single-file worktrees / non visible worktrees? E.g. go to definition that resolves to a -// file in a build dependency. Should not be editable in that case - but how to distinguish the case -// where it should be editable? - -// Potential future optimizations: -// -// * Index files on multiple threads in Zed (currently only parallel for the CLI). Adding some kind -// of priority system to the background executor could help - it's single threaded for now to avoid -// interfering with other work. -// -// * Parse files directly instead of loading into a Rope. -// -// - This would allow the task handling dirty_files to be done entirely on the background executor. -// -// - Make SyntaxMap generic to handle embedded languages? Will also need to find line boundaries, -// but that can be done by scanning characters in the flat representation. -// -// * Use something similar to slotmap without key versions. -// -// * Concurrent slotmap - -pub struct SyntaxIndex { - state: Arc>, - project: WeakEntity, - initial_file_indexing_done_rx: postage::watch::Receiver, - _file_indexing_task: Option>, -} - -pub struct SyntaxIndexState { - declarations: SlotMap, - identifiers: HashMap>, - files: HashMap, - buffers: HashMap, - dirty_files: HashMap, - dirty_files_tx: mpsc::Sender<()>, -} - -#[derive(Debug, Default)] -struct FileState { - declarations: Vec, -} - -#[derive(Default)] -struct BufferState { - declarations: Vec, - task: Option>, -} - -impl SyntaxIndex { - pub fn new( - project: &Entity, - file_indexing_parallelism: usize, - cx: &mut Context, - ) -> Self { - assert!(file_indexing_parallelism > 0); - let (dirty_files_tx, mut dirty_files_rx) = mpsc::channel::<()>(1); - let (mut initial_file_indexing_done_tx, initial_file_indexing_done_rx) = - postage::watch::channel(); - - let initial_state = SyntaxIndexState { - declarations: SlotMap::default(), - identifiers: HashMap::default(), - files: HashMap::default(), - buffers: HashMap::default(), - dirty_files: HashMap::default(), - dirty_files_tx, - }; - let mut this = Self { - project: project.downgrade(), - state: Arc::new(Mutex::new(initial_state)), - initial_file_indexing_done_rx, - _file_indexing_task: None, - }; - - let worktree_store = project.read(cx).worktree_store(); - let initial_worktree_snapshots = worktree_store - .read(cx) - .worktrees() - .map(|w| w.read(cx).snapshot()) - .collect::>(); - this._file_indexing_task = Some(cx.spawn(async move |this, cx| { - let snapshots_file_count = initial_worktree_snapshots - .iter() - .map(|worktree| worktree.file_count()) - .sum::(); - if snapshots_file_count > 0 { - let chunk_size = snapshots_file_count.div_ceil(file_indexing_parallelism); - let chunk_count = snapshots_file_count.div_ceil(chunk_size); - let file_chunks = initial_worktree_snapshots - .iter() - .flat_map(|worktree| { - let worktree_id = worktree.id(); - worktree.files(false, 0).map(move |entry| { - ( - entry.id, - ProjectPath { - worktree_id, - path: entry.path.clone(), - }, - ) - }) - }) - .chunks(chunk_size); - - let mut tasks = Vec::with_capacity(chunk_count); - for chunk in file_chunks.into_iter() { - tasks.push(Self::update_dirty_files( - &this, - chunk.into_iter().collect(), - cx.clone(), - )); - } - futures::future::join_all(tasks).await; - log::info!("Finished initial file indexing"); - } - - *initial_file_indexing_done_tx.borrow_mut() = true; - - let Ok(state) = this.read_with(cx, |this, _cx| Arc::downgrade(&this.state)) else { - return; - }; - while dirty_files_rx.next().await.is_some() { - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - let was_underused = state.dirty_files.capacity() > 255 - && state.dirty_files.len() * 8 < state.dirty_files.capacity(); - let dirty_files = state.dirty_files.drain().collect::>(); - if was_underused { - state.dirty_files.shrink_to_fit(); - } - drop(state); - if dirty_files.is_empty() { - continue; - } - - let chunk_size = dirty_files.len().div_ceil(file_indexing_parallelism); - let chunk_count = dirty_files.len().div_ceil(chunk_size); - let mut tasks = Vec::with_capacity(chunk_count); - let chunks = dirty_files.into_iter().chunks(chunk_size); - for chunk in chunks.into_iter() { - tasks.push(Self::update_dirty_files( - &this, - chunk.into_iter().collect(), - cx.clone(), - )); - } - futures::future::join_all(tasks).await; - } - })); - - cx.subscribe(&worktree_store, Self::handle_worktree_store_event) - .detach(); - - let buffer_store = project.read(cx).buffer_store().clone(); - for buffer in buffer_store.read(cx).buffers().collect::>() { - this.register_buffer(&buffer, cx); - } - cx.subscribe(&buffer_store, Self::handle_buffer_store_event) - .detach(); - - this - } - - async fn update_dirty_files( - this: &WeakEntity, - dirty_files: Vec<(ProjectEntryId, ProjectPath)>, - mut cx: AsyncApp, - ) { - for (entry_id, project_path) in dirty_files { - let Ok(task) = this.update(&mut cx, |this, cx| { - this.update_file(entry_id, project_path, cx) - }) else { - return; - }; - task.await; - } - } - - pub fn wait_for_initial_file_indexing(&self, cx: &App) -> Task> { - if *self.initial_file_indexing_done_rx.borrow() { - Task::ready(Ok(())) - } else { - let mut rx = self.initial_file_indexing_done_rx.clone(); - cx.background_spawn(async move { - loop { - match rx.recv().await { - Some(true) => return Ok(()), - Some(false) => {} - None => { - return Err(anyhow!( - "SyntaxIndex dropped while waiting for initial file indexing" - )); - } - } - } - }) - } - } - - pub fn indexed_file_paths(&self, cx: &App) -> Task> { - let state = self.state.clone(); - let project = self.project.clone(); - - cx.spawn(async move |cx| { - let state = state.lock().await; - let Some(project) = project.upgrade() else { - return vec![]; - }; - project - .read_with(cx, |project, cx| { - state - .files - .keys() - .filter_map(|entry_id| project.path_for_entry(*entry_id, cx)) - .collect() - }) - .unwrap_or_default() - }) - } - - fn handle_worktree_store_event( - &mut self, - _worktree_store: Entity, - event: &WorktreeStoreEvent, - cx: &mut Context, - ) { - use WorktreeStoreEvent::*; - match event { - WorktreeUpdatedEntries(worktree_id, updated_entries_set) => { - let state = Arc::downgrade(&self.state); - let worktree_id = *worktree_id; - let updated_entries_set = updated_entries_set.clone(); - cx.background_spawn(async move { - let Some(state) = state.upgrade() else { return }; - let mut state = state.lock().await; - for (path, entry_id, path_change) in updated_entries_set.iter() { - if let PathChange::Removed = path_change { - state.files.remove(entry_id); - state.dirty_files.remove(entry_id); - } else { - let project_path = ProjectPath { - worktree_id, - path: path.clone(), - }; - state.dirty_files.insert(*entry_id, project_path); - } - } - match state.dirty_files_tx.try_send(()) { - Err(err) if err.is_disconnected() => { - log::error!("bug: syntax indexing queue is disconnected"); - } - _ => {} - } - }) - .detach(); - } - WorktreeDeletedEntry(_worktree_id, project_entry_id) => { - let project_entry_id = *project_entry_id; - self.with_state(cx, move |state| { - state.files.remove(&project_entry_id); - }) - } - _ => {} - } - } - - fn handle_buffer_store_event( - &mut self, - _buffer_store: Entity, - event: &BufferStoreEvent, - cx: &mut Context, - ) { - use BufferStoreEvent::*; - match event { - BufferAdded(buffer) => self.register_buffer(buffer, cx), - BufferOpened { .. } - | BufferChangedFilePath { .. } - | BufferDropped { .. } - | SharedBufferClosed { .. } => {} - } - } - - pub fn state(&self) -> &Arc> { - &self.state - } - - fn with_state(&self, cx: &mut App, f: impl FnOnce(&mut SyntaxIndexState) + Send + 'static) { - if let Some(mut state) = self.state.try_lock() { - f(&mut state); - return; - } - let state = Arc::downgrade(&self.state); - cx.background_spawn(async move { - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - f(&mut state) - }) - .detach(); - } - - fn register_buffer(&self, buffer: &Entity, cx: &mut Context) { - let buffer_id = buffer.read(cx).remote_id(); - cx.observe_release(buffer, move |this, _buffer, cx| { - this.with_state(cx, move |state| { - if let Some(buffer_state) = state.buffers.remove(&buffer_id) { - SyntaxIndexState::remove_buffer_declarations( - &buffer_state.declarations, - &mut state.declarations, - &mut state.identifiers, - ); - } - }) - }) - .detach(); - cx.subscribe(buffer, Self::handle_buffer_event).detach(); - - self.update_buffer(buffer.clone(), cx); - } - - fn handle_buffer_event( - &mut self, - buffer: Entity, - event: &BufferEvent, - cx: &mut Context, - ) { - match event { - BufferEvent::Edited | - // paths are cached and so should be updated - BufferEvent::FileHandleChanged => self.update_buffer(buffer, cx), - _ => {} - } - } - - fn update_buffer(&self, buffer_entity: Entity, cx: &mut Context) { - let buffer = buffer_entity.read(cx); - if buffer.language().is_none() { - return; - } - - let Some((project_entry_id, cached_path)) = project::File::from_dyn(buffer.file()) - .and_then(|f| { - let project_entry_id = f.project_entry_id()?; - let cached_path = CachedDeclarationPath::new( - f.worktree.read(cx).abs_path(), - &f.path, - buffer.language(), - ); - Some((project_entry_id, cached_path)) - }) - else { - return; - }; - let buffer_id = buffer.remote_id(); - - let mut parse_status = buffer.parse_status(); - let snapshot_task = cx.spawn({ - let weak_buffer = buffer_entity.downgrade(); - async move |_, cx| { - while *parse_status.borrow() != language::ParseStatus::Idle { - parse_status.changed().await?; - } - weak_buffer.read_with(cx, |buffer, _cx| buffer.snapshot()) - } - }); - - let state = Arc::downgrade(&self.state); - let task = cx.background_spawn(async move { - // TODO: How to handle errors? - let Ok(snapshot) = snapshot_task.await else { - return; - }; - let rope = snapshot.text.as_rope(); - - let declarations = declarations_in_buffer(&snapshot) - .into_iter() - .map(|item| { - ( - item.parent_index, - BufferDeclaration::from_outline(item, &rope), - ) - }) - .collect::>(); - - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - let state = state.deref_mut(); - - let buffer_state = state - .buffers - .entry(buffer_id) - .or_insert_with(Default::default); - - SyntaxIndexState::remove_buffer_declarations( - &buffer_state.declarations, - &mut state.declarations, - &mut state.identifiers, - ); - - let mut new_ids = Vec::with_capacity(declarations.len()); - state.declarations.reserve(declarations.len()); - for (parent_index, mut declaration) in declarations { - declaration.parent = - parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied())); - - let identifier = declaration.identifier.clone(); - let declaration_id = state.declarations.insert(Declaration::Buffer { - rope: rope.clone(), - buffer_id, - declaration, - project_entry_id, - cached_path: cached_path.clone(), - }); - new_ids.push(declaration_id); - - state - .identifiers - .entry(identifier) - .or_default() - .insert(declaration_id); - } - - buffer_state.declarations = new_ids; - }); - - self.with_state(cx, move |state| { - state - .buffers - .entry(buffer_id) - .or_insert_with(Default::default) - .task = Some(task) - }); - } - - fn update_file( - &mut self, - entry_id: ProjectEntryId, - project_path: ProjectPath, - cx: &mut Context, - ) -> Task<()> { - let Some(project) = self.project.upgrade() else { - return Task::ready(()); - }; - let project = project.read(cx); - - let language_registry = project.languages(); - let Some(available_language) = - language_registry.language_for_file_path(project_path.path.as_std_path()) - else { - return Task::ready(()); - }; - let language = if let Some(Ok(Ok(language))) = language_registry - .load_language(&available_language) - .now_or_never() - { - if language - .grammar() - .is_none_or(|grammar| grammar.outline_config.is_none()) - { - return Task::ready(()); - } - future::Either::Left(async { Ok(language) }) - } else { - let language_registry = language_registry.clone(); - future::Either::Right(async move { - anyhow::Ok( - language_registry - .load_language(&available_language) - .await??, - ) - }) - }; - - let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) else { - return Task::ready(()); - }; - - let snapshot_task = worktree.update(cx, |worktree, cx| { - let load_task = worktree.load_file(&project_path.path, cx); - let worktree_abs_path = worktree.abs_path(); - cx.spawn(async move |_this, cx| { - let loaded_file = load_task.await?; - let language = language.await?; - - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local(loaded_file.text, cx); - buffer.set_language(Some(language.clone()), cx); - buffer - })?; - - let mut parse_status = buffer.read_with(cx, |buffer, _| buffer.parse_status())?; - while *parse_status.borrow() != language::ParseStatus::Idle { - parse_status.changed().await?; - } - - let cached_path = CachedDeclarationPath::new( - worktree_abs_path, - &project_path.path, - Some(&language), - ); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - - anyhow::Ok((snapshot, cached_path)) - }) - }); - - let state = Arc::downgrade(&self.state); - cx.background_spawn(async move { - // TODO: How to handle errors? - let Ok((snapshot, cached_path)) = snapshot_task.await else { - return; - }; - let rope = snapshot.as_rope(); - let declarations = declarations_in_buffer(&snapshot) - .into_iter() - .map(|item| (item.parent_index, FileDeclaration::from_outline(item, rope))) - .collect::>(); - - let Some(state) = state.upgrade() else { - return; - }; - let mut state = state.lock().await; - let state = state.deref_mut(); - - let file_state = state.files.entry(entry_id).or_insert_with(Default::default); - for old_declaration_id in &file_state.declarations { - let Some(declaration) = state.declarations.remove(*old_declaration_id) else { - debug_panic!("declaration not found"); - continue; - }; - if let Some(identifier_declarations) = - state.identifiers.get_mut(declaration.identifier()) - { - identifier_declarations.remove(old_declaration_id); - } - } - - let mut new_ids = Vec::with_capacity(declarations.len()); - state.declarations.reserve(declarations.len()); - for (parent_index, mut declaration) in declarations { - declaration.parent = - parent_index.and_then(|ix| some_or_debug_panic(new_ids.get(ix).copied())); - - let identifier = declaration.identifier.clone(); - let declaration_id = state.declarations.insert(Declaration::File { - project_entry_id: entry_id, - declaration, - cached_path: cached_path.clone(), - }); - new_ids.push(declaration_id); - - state - .identifiers - .entry(identifier) - .or_default() - .insert(declaration_id); - } - file_state.declarations = new_ids; - }) - } -} - -impl SyntaxIndexState { - pub fn declaration(&self, id: DeclarationId) -> Option<&Declaration> { - self.declarations.get(id) - } - - /// Returns declarations for the identifier. If the limit is exceeded, returns an empty vector. - /// - /// TODO: Consider doing some pre-ranking and instead truncating when N is exceeded. - pub fn declarations_for_identifier( - &self, - identifier: &Identifier, - ) -> Vec<(DeclarationId, &Declaration)> { - // make sure to not have a large stack allocation - assert!(N < 32); - - let Some(declaration_ids) = self.identifiers.get(&identifier) else { - return vec![]; - }; - - let mut result = Vec::with_capacity(N); - let mut included_buffer_entry_ids = arrayvec::ArrayVec::<_, N>::new(); - let mut file_declarations = Vec::new(); - - for declaration_id in declaration_ids { - let declaration = self.declarations.get(*declaration_id); - let Some(declaration) = some_or_debug_panic(declaration) else { - continue; - }; - match declaration { - Declaration::Buffer { - project_entry_id, .. - } => { - included_buffer_entry_ids.push(*project_entry_id); - result.push((*declaration_id, declaration)); - if result.len() == N { - return Vec::new(); - } - } - Declaration::File { - project_entry_id, .. - } => { - if !included_buffer_entry_ids.contains(&project_entry_id) { - file_declarations.push((*declaration_id, declaration)); - } - } - } - } - - for (declaration_id, declaration) in file_declarations { - match declaration { - Declaration::File { - project_entry_id, .. - } => { - if !included_buffer_entry_ids.contains(&project_entry_id) { - result.push((declaration_id, declaration)); - - if result.len() == N { - return Vec::new(); - } - } - } - Declaration::Buffer { .. } => {} - } - } - - result - } - - pub fn buffer_declarations_containing_range( - &self, - buffer_id: BufferId, - range: Range, - ) -> impl Iterator { - let Some(buffer_state) = self.buffers.get(&buffer_id) else { - return itertools::Either::Left(iter::empty()); - }; - - let iter = buffer_state - .declarations - .iter() - .filter_map(move |declaration_id| { - let Some(declaration) = self - .declarations - .get(*declaration_id) - .and_then(|d| d.as_buffer()) - else { - log::error!("bug: missing buffer outline declaration"); - return None; - }; - if declaration.item_range.contains_inclusive(&range) { - return Some((*declaration_id, declaration)); - } - return None; - }); - itertools::Either::Right(iter) - } - - pub fn file_declaration_count(&self, declaration: &Declaration) -> usize { - match declaration { - Declaration::File { - project_entry_id, .. - } => self - .files - .get(project_entry_id) - .map(|file_state| file_state.declarations.len()) - .unwrap_or_default(), - Declaration::Buffer { buffer_id, .. } => self - .buffers - .get(buffer_id) - .map(|buffer_state| buffer_state.declarations.len()) - .unwrap_or_default(), - } - } - - fn remove_buffer_declarations( - old_declaration_ids: &[DeclarationId], - declarations: &mut SlotMap, - identifiers: &mut HashMap>, - ) { - for old_declaration_id in old_declaration_ids { - let Some(declaration) = declarations.remove(*old_declaration_id) else { - debug_panic!("declaration not found"); - continue; - }; - if let Some(identifier_declarations) = identifiers.get_mut(declaration.identifier()) { - identifier_declarations.remove(old_declaration_id); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Arc; - - use gpui::TestAppContext; - use indoc::indoc; - use language::{Language, LanguageConfig, LanguageId, LanguageMatcher, tree_sitter_rust}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use text::OffsetRangeExt as _; - use util::{path, rel_path::rel_path}; - - use crate::syntax_index::SyntaxIndex; - - #[gpui::test] - async fn test_unopen_indexed_files(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - let main = Identifier { - name: "main".into(), - language_id: rust_lang_id, - }; - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&main); - assert_eq!(decls.len(), 2); - - let decl = expect_file_decl("a.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, main); - assert_eq!(decl.item_range, 0..98); - - let decl = expect_file_decl("c.rs", &decls[1].1, &project, cx); - assert_eq!(decl.identifier, main.clone()); - assert_eq!(decl.item_range, 32..280); - }); - } - - #[gpui::test] - async fn test_parents_in_file(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - let test_process_data = Identifier { - name: "test_process_data".into(), - language_id: rust_lang_id, - }; - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&test_process_data); - assert_eq!(decls.len(), 1); - - let decl = expect_file_decl("c.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, test_process_data); - - let parent_id = decl.parent.unwrap(); - let parent = index_state.declaration(parent_id).unwrap(); - let parent_decl = expect_file_decl("c.rs", &parent, &project, cx); - assert_eq!( - parent_decl.identifier, - Identifier { - name: "tests".into(), - language_id: rust_lang_id - } - ); - assert_eq!(parent_decl.parent, None); - }); - } - - #[gpui::test] - async fn test_parents_in_buffer(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - let test_process_data = Identifier { - name: "test_process_data".into(), - language_id: rust_lang_id, - }; - - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path("c.rs", cx).unwrap(); - project.open_buffer(project_path, cx) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&test_process_data); - assert_eq!(decls.len(), 1); - - let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, test_process_data); - - let parent_id = decl.parent.unwrap(); - let parent = index_state.declaration(parent_id).unwrap(); - let parent_decl = expect_buffer_decl("c.rs", &parent, &project, cx); - assert_eq!( - parent_decl.identifier, - Identifier { - name: "tests".into(), - language_id: rust_lang_id - } - ); - assert_eq!(parent_decl.parent, None); - }); - - drop(buffer); - } - - #[gpui::test] - async fn test_declarations_limit(cx: &mut TestAppContext) { - let (_, index, rust_lang_id) = init_test(cx).await; - - let index_state = index.read_with(cx, |index, _cx| index.state().clone()); - let index_state = index_state.lock().await; - let decls = index_state.declarations_for_identifier::<1>(&Identifier { - name: "main".into(), - language_id: rust_lang_id, - }); - assert_eq!(decls.len(), 0); - } - - #[gpui::test] - async fn test_buffer_shadow(cx: &mut TestAppContext) { - let (project, index, rust_lang_id) = init_test(cx).await; - - let main = Identifier { - name: "main".into(), - language_id: rust_lang_id, - }; - - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path("c.rs", cx).unwrap(); - project.open_buffer(project_path, cx) - }) - .await - .unwrap(); - - cx.run_until_parked(); - - let index_state_arc = index.read_with(cx, |index, _cx| index.state().clone()); - { - let index_state = index_state_arc.lock().await; - - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&main); - assert_eq!(decls.len(), 2); - let decl = expect_buffer_decl("c.rs", &decls[0].1, &project, cx); - assert_eq!(decl.identifier, main); - assert_eq!(decl.item_range.to_offset(&buffer.read(cx)), 32..280); - - expect_file_decl("a.rs", &decls[1].1, &project, cx); - }); - } - - // Drop the buffer and wait for release - cx.update(|_| { - drop(buffer); - }); - cx.run_until_parked(); - - let index_state = index_state_arc.lock().await; - - cx.update(|cx| { - let decls = index_state.declarations_for_identifier::<8>(&main); - assert_eq!(decls.len(), 2); - expect_file_decl("a.rs", &decls[0].1, &project, cx); - expect_file_decl("c.rs", &decls[1].1, &project, cx); - }); - } - - fn expect_buffer_decl<'a>( - path: &str, - declaration: &'a Declaration, - project: &Entity, - cx: &App, - ) -> &'a BufferDeclaration { - if let Declaration::Buffer { - declaration, - project_entry_id, - .. - } = declaration - { - let project_path = project - .read(cx) - .path_for_entry(*project_entry_id, cx) - .unwrap(); - assert_eq!(project_path.path.as_ref(), rel_path(path),); - declaration - } else { - panic!("Expected a buffer declaration, found {:?}", declaration); - } - } - - fn expect_file_decl<'a>( - path: &str, - declaration: &'a Declaration, - project: &Entity, - cx: &App, - ) -> &'a FileDeclaration { - if let Declaration::File { - declaration, - project_entry_id: file, - .. - } = declaration - { - assert_eq!( - project - .read(cx) - .path_for_entry(*file, cx) - .unwrap() - .path - .as_ref(), - rel_path(path), - ); - declaration - } else { - panic!("Expected a file declaration, found {:?}", declaration); - } - } - - async fn init_test( - cx: &mut TestAppContext, - ) -> (Entity, Entity, LanguageId) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "a.rs": indoc! {r#" - fn main() { - let x = 1; - let y = 2; - let z = add(x, y); - println!("Result: {}", z); - } - - fn add(a: i32, b: i32) -> i32 { - a + b - } - "#}, - "b.rs": indoc! {" - pub struct Config { - pub name: String, - pub value: i32, - } - - impl Config { - pub fn new(name: String, value: i32) -> Self { - Config { name, value } - } - } - "}, - "c.rs": indoc! {r#" - use std::collections::HashMap; - - fn main() { - let args: Vec = std::env::args().collect(); - let data: Vec = args[1..] - .iter() - .filter_map(|s| s.parse().ok()) - .collect(); - let result = process_data(data); - println!("{:?}", result); - } - - fn process_data(data: Vec) -> HashMap { - let mut counts = HashMap::new(); - for value in data { - *counts.entry(value).or_insert(0) += 1; - } - counts - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn test_process_data() { - let data = vec![1, 2, 2, 3]; - let result = process_data(data); - assert_eq!(result.get(&2), Some(&2)); - } - } - "#} - }), - ) - .await; - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let lang = rust_lang(); - let lang_id = lang.id(); - language_registry.add(Arc::new(lang)); - - let file_indexing_parallelism = 2; - let index = cx.new(|cx| SyntaxIndex::new(&project, file_indexing_parallelism, cx)); - cx.run_until_parked(); - - (project, index, lang_id) - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } -} diff --git a/crates/edit_prediction_context/src/text_similarity.rs b/crates/edit_prediction_context/src/text_similarity.rs deleted file mode 100644 index 308a9570206084fc223c72f2e1c49109ea157714..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_context/src/text_similarity.rs +++ /dev/null @@ -1,314 +0,0 @@ -use hashbrown::HashTable; -use regex::Regex; -use std::{ - borrow::Cow, - hash::{Hash, Hasher as _}, - path::Path, - sync::LazyLock, -}; -use util::rel_path::RelPath; - -use crate::reference::Reference; - -// TODO: Consider implementing sliding window similarity matching like -// https://github.com/sourcegraph/cody-public-snapshot/blob/8e20ac6c1460c08b0db581c0204658112a246eda/vscode/src/completions/context/retrievers/jaccard-similarity/bestJaccardMatch.ts -// -// That implementation could actually be more efficient - no need to track words in the window that -// are not in the query. - -// TODO: Consider a flat sorted Vec<(String, usize)> representation. Intersection can just walk the -// two in parallel. - -static IDENTIFIER_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"\b\w+\b").unwrap()); - -/// Multiset of text occurrences for text similarity that only stores hashes and counts. -#[derive(Debug, Default)] -pub struct Occurrences { - table: HashTable, - total_count: usize, -} - -#[derive(Debug)] -struct OccurrenceEntry { - hash: u64, - count: usize, -} - -impl Occurrences { - pub fn within_string(text: &str) -> Self { - Self::from_identifiers(IDENTIFIER_REGEX.find_iter(text).map(|mat| mat.as_str())) - } - - #[allow(dead_code)] - pub fn within_references(references: &[Reference]) -> Self { - Self::from_identifiers( - references - .iter() - .map(|reference| reference.identifier.name.as_ref()), - ) - } - - pub fn from_identifiers(identifiers: impl IntoIterator>) -> Self { - let mut this = Self::default(); - // TODO: Score matches that match case higher? - // - // TODO: Also include unsplit identifier? - for identifier in identifiers { - for identifier_part in split_identifier(identifier.as_ref()) { - this.add_hash(fx_hash(&identifier_part.to_lowercase())); - } - } - this - } - - pub fn from_worktree_path(worktree_name: Option>, rel_path: &RelPath) -> Self { - if let Some(worktree_name) = worktree_name { - Self::from_identifiers( - std::iter::once(worktree_name) - .chain(iter_path_without_extension(rel_path.as_std_path())), - ) - } else { - Self::from_path(rel_path.as_std_path()) - } - } - - pub fn from_path(path: &Path) -> Self { - Self::from_identifiers(iter_path_without_extension(path)) - } - - fn add_hash(&mut self, hash: u64) { - self.table - .entry( - hash, - |entry: &OccurrenceEntry| entry.hash == hash, - |entry| entry.hash, - ) - .and_modify(|entry| entry.count += 1) - .or_insert(OccurrenceEntry { hash, count: 1 }); - self.total_count += 1; - } - - fn contains_hash(&self, hash: u64) -> bool { - self.get_count(hash) != 0 - } - - fn get_count(&self, hash: u64) -> usize { - self.table - .find(hash, |entry| entry.hash == hash) - .map(|entry| entry.count) - .unwrap_or(0) - } -} - -fn iter_path_without_extension(path: &Path) -> impl Iterator> { - let last_component: Option> = path.file_stem().map(|stem| stem.to_string_lossy()); - let mut path_components = path.components(); - path_components.next_back(); - path_components - .map(|component| component.as_os_str().to_string_lossy()) - .chain(last_component) -} - -pub fn fx_hash(data: &T) -> u64 { - let mut hasher = collections::FxHasher::default(); - data.hash(&mut hasher); - hasher.finish() -} - -// Splits camelcase / snakecase / kebabcase / pascalcase -// -// TODO: Make this more efficient / elegant. -fn split_identifier(identifier: &str) -> Vec<&str> { - let mut parts = Vec::new(); - let mut start = 0; - let chars: Vec = identifier.chars().collect(); - - if chars.is_empty() { - return parts; - } - - let mut i = 0; - while i < chars.len() { - let ch = chars[i]; - - // Handle explicit delimiters (underscore and hyphen) - if ch == '_' || ch == '-' { - if i > start { - parts.push(&identifier[start..i]); - } - start = i + 1; - i += 1; - continue; - } - - // Handle camelCase and PascalCase transitions - if i > 0 && i < chars.len() { - let prev_char = chars[i - 1]; - - // Transition from lowercase/digit to uppercase - if (prev_char.is_lowercase() || prev_char.is_ascii_digit()) && ch.is_uppercase() { - parts.push(&identifier[start..i]); - start = i; - } - // Handle sequences like "XMLParser" -> ["XML", "Parser"] - else if i + 1 < chars.len() - && ch.is_uppercase() - && chars[i + 1].is_lowercase() - && prev_char.is_uppercase() - { - parts.push(&identifier[start..i]); - start = i; - } - } - - i += 1; - } - - // Add the last part if there's any remaining - if start < identifier.len() { - parts.push(&identifier[start..]); - } - - // Filter out empty strings - parts.into_iter().filter(|s| !s.is_empty()).collect() -} - -pub fn jaccard_similarity<'a>(mut set_a: &'a Occurrences, mut set_b: &'a Occurrences) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - let intersection = set_a - .table - .iter() - .filter(|entry| set_b.contains_hash(entry.hash)) - .count(); - let union = set_a.table.len() + set_b.table.len() - intersection; - intersection as f32 / union as f32 -} - -// TODO -#[allow(dead_code)] -pub fn overlap_coefficient<'a>(mut set_a: &'a Occurrences, mut set_b: &'a Occurrences) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - let intersection = set_a - .table - .iter() - .filter(|entry| set_b.contains_hash(entry.hash)) - .count(); - intersection as f32 / set_a.table.len() as f32 -} - -// TODO -#[allow(dead_code)] -pub fn weighted_jaccard_similarity<'a>( - mut set_a: &'a Occurrences, - mut set_b: &'a Occurrences, -) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - - let mut numerator = 0; - let mut denominator_a = 0; - let mut used_count_b = 0; - for entry_a in set_a.table.iter() { - let count_a = entry_a.count; - let count_b = set_b.get_count(entry_a.hash); - numerator += count_a.min(count_b); - denominator_a += count_a.max(count_b); - used_count_b += count_b; - } - - let denominator = denominator_a + (set_b.total_count - used_count_b); - if denominator == 0 { - 0.0 - } else { - numerator as f32 / denominator as f32 - } -} - -pub fn weighted_overlap_coefficient<'a>( - mut set_a: &'a Occurrences, - mut set_b: &'a Occurrences, -) -> f32 { - if set_a.table.len() > set_b.table.len() { - std::mem::swap(&mut set_a, &mut set_b); - } - - let mut numerator = 0; - for entry_a in set_a.table.iter() { - let count_a = entry_a.count; - let count_b = set_b.get_count(entry_a.hash); - numerator += count_a.min(count_b); - } - - let denominator = set_a.total_count.min(set_b.total_count); - if denominator == 0 { - 0.0 - } else { - numerator as f32 / denominator as f32 - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_split_identifier() { - assert_eq!(split_identifier("snake_case"), vec!["snake", "case"]); - assert_eq!(split_identifier("kebab-case"), vec!["kebab", "case"]); - assert_eq!(split_identifier("PascalCase"), vec!["Pascal", "Case"]); - assert_eq!(split_identifier("camelCase"), vec!["camel", "Case"]); - assert_eq!(split_identifier("XMLParser"), vec!["XML", "Parser"]); - } - - #[test] - fn test_similarity_functions() { - // 10 identifier parts, 8 unique - // Repeats: 2 "outline", 2 "items" - let set_a = Occurrences::within_string( - "let mut outline_items = query_outline_items(&language, &tree, &source);", - ); - // 14 identifier parts, 11 unique - // Repeats: 2 "outline", 2 "language", 2 "tree" - let set_b = Occurrences::within_string( - "pub fn query_outline_items(language: &Language, tree: &Tree, source: &str) -> Vec {", - ); - - // 6 overlaps: "outline", "items", "query", "language", "tree", "source" - // 7 non-overlaps: "let", "mut", "pub", "fn", "vec", "item", "str" - assert_eq!(jaccard_similarity(&set_a, &set_b), 6.0 / (6.0 + 7.0)); - - // Numerator is one more than before due to both having 2 "outline". - // Denominator is the same except for 3 more due to the non-overlapping duplicates - assert_eq!( - weighted_jaccard_similarity(&set_a, &set_b), - 7.0 / (7.0 + 7.0 + 3.0) - ); - - // Numerator is the same as jaccard_similarity. Denominator is the size of the smaller set, 8. - assert_eq!(overlap_coefficient(&set_a, &set_b), 6.0 / 8.0); - - // Numerator is the same as weighted_jaccard_similarity. Denominator is the total weight of - // the smaller set, 10. - assert_eq!(weighted_overlap_coefficient(&set_a, &set_b), 7.0 / 10.0); - } - - #[test] - fn test_iter_path_without_extension() { - let mut iter = iter_path_without_extension(Path::new("")); - assert_eq!(iter.next(), None); - - let iter = iter_path_without_extension(Path::new("foo")); - assert_eq!(iter.collect::>(), ["foo"]); - - let iter = iter_path_without_extension(Path::new("foo/bar.txt")); - assert_eq!(iter.collect::>(), ["foo", "bar"]); - - let iter = iter_path_without_extension(Path::new("foo/bar/baz.txt")); - assert_eq!(iter.collect::>(), ["foo", "bar", "baz"]); - } -} diff --git a/crates/edit_prediction_types/Cargo.toml b/crates/edit_prediction_types/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..00a8577911af0afd012535fd324a68af8fd70391 --- /dev/null +++ b/crates/edit_prediction_types/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "edit_prediction_types" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/edit_prediction_types.rs" + +[dependencies] +client.workspace = true +gpui.workspace = true +language.workspace = true +text.workspace = true diff --git a/crates/zeta/LICENSE-GPL b/crates/edit_prediction_types/LICENSE-GPL similarity index 100% rename from crates/zeta/LICENSE-GPL rename to crates/edit_prediction_types/LICENSE-GPL diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs new file mode 100644 index 0000000000000000000000000000000000000000..5a37aba59923598b20becd91f07633e409b2bdb7 --- /dev/null +++ b/crates/edit_prediction_types/src/edit_prediction_types.rs @@ -0,0 +1,278 @@ +use std::{ops::Range, sync::Arc}; + +use client::EditPredictionUsage; +use gpui::{App, Context, Entity, SharedString}; +use language::{Anchor, Buffer, OffsetRangeExt}; + +// TODO: Find a better home for `Direction`. +// +// This should live in an ancestor crate of `editor` and `edit_prediction`, +// but at time of writing there isn't an obvious spot. +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum Direction { + Prev, + Next, +} + +#[derive(Clone)] +pub enum EditPrediction { + /// Edits within the buffer that requested the prediction + Local { + id: Option, + edits: Vec<(Range, Arc)>, + edit_preview: Option, + }, + /// Jump to a different file from the one that requested the prediction + Jump { + id: Option, + snapshot: language::BufferSnapshot, + target: language::Anchor, + }, +} + +pub enum DataCollectionState { + /// The provider doesn't support data collection. + Unsupported, + /// Data collection is enabled. + Enabled { is_project_open_source: bool }, + /// Data collection is disabled or unanswered. + Disabled { is_project_open_source: bool }, +} + +impl DataCollectionState { + pub fn is_supported(&self) -> bool { + !matches!(self, DataCollectionState::Unsupported) + } + + pub fn is_enabled(&self) -> bool { + matches!(self, DataCollectionState::Enabled { .. }) + } + + pub fn is_project_open_source(&self) -> bool { + match self { + Self::Enabled { + is_project_open_source, + } + | Self::Disabled { + is_project_open_source, + } => *is_project_open_source, + _ => false, + } + } +} + +pub trait EditPredictionDelegate: 'static + Sized { + fn name() -> &'static str; + fn display_name() -> &'static str; + fn show_predictions_in_menu() -> bool; + fn show_tab_accept_marker() -> bool { + false + } + fn supports_jump_to_edit() -> bool { + true + } + + fn data_collection_state(&self, _cx: &App) -> DataCollectionState { + DataCollectionState::Unsupported + } + + fn usage(&self, _cx: &App) -> Option { + None + } + + fn toggle_data_collection(&mut self, _cx: &mut App) {} + fn is_enabled( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &App, + ) -> bool; + fn is_refreshing(&self, cx: &App) -> bool; + fn refresh( + &mut self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut Context, + ); + fn accept(&mut self, cx: &mut Context); + fn discard(&mut self, cx: &mut Context); + fn did_show(&mut self, _cx: &mut Context) {} + fn suggest( + &mut self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut Context, + ) -> Option; +} + +pub trait EditPredictionDelegateHandle { + fn name(&self) -> &'static str; + fn display_name(&self) -> &'static str; + fn is_enabled( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &App, + ) -> bool; + fn show_predictions_in_menu(&self) -> bool; + fn show_tab_accept_marker(&self) -> bool; + fn supports_jump_to_edit(&self) -> bool; + fn data_collection_state(&self, cx: &App) -> DataCollectionState; + fn usage(&self, cx: &App) -> Option; + fn toggle_data_collection(&self, cx: &mut App); + fn is_refreshing(&self, cx: &App) -> bool; + fn refresh( + &self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut App, + ); + fn did_show(&self, cx: &mut App); + fn accept(&self, cx: &mut App); + fn discard(&self, cx: &mut App); + fn suggest( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut App, + ) -> Option; +} + +impl EditPredictionDelegateHandle for Entity +where + T: EditPredictionDelegate, +{ + fn name(&self) -> &'static str { + T::name() + } + + fn display_name(&self) -> &'static str { + T::display_name() + } + + fn show_predictions_in_menu(&self) -> bool { + T::show_predictions_in_menu() + } + + fn show_tab_accept_marker(&self) -> bool { + T::show_tab_accept_marker() + } + + fn supports_jump_to_edit(&self) -> bool { + T::supports_jump_to_edit() + } + + fn data_collection_state(&self, cx: &App) -> DataCollectionState { + self.read(cx).data_collection_state(cx) + } + + fn usage(&self, cx: &App) -> Option { + self.read(cx).usage(cx) + } + + fn toggle_data_collection(&self, cx: &mut App) { + self.update(cx, |this, cx| this.toggle_data_collection(cx)) + } + + fn is_enabled( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &App, + ) -> bool { + self.read(cx).is_enabled(buffer, cursor_position, cx) + } + + fn is_refreshing(&self, cx: &App) -> bool { + self.read(cx).is_refreshing(cx) + } + + fn refresh( + &self, + buffer: Entity, + cursor_position: language::Anchor, + debounce: bool, + cx: &mut App, + ) { + self.update(cx, |this, cx| { + this.refresh(buffer, cursor_position, debounce, cx) + }) + } + + fn accept(&self, cx: &mut App) { + self.update(cx, |this, cx| this.accept(cx)) + } + + fn discard(&self, cx: &mut App) { + self.update(cx, |this, cx| this.discard(cx)) + } + + fn did_show(&self, cx: &mut App) { + self.update(cx, |this, cx| this.did_show(cx)) + } + + fn suggest( + &self, + buffer: &Entity, + cursor_position: language::Anchor, + cx: &mut App, + ) -> Option { + self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditPredictionGranularity { + Word, + Line, + Full, +} +/// Returns edits updated based on user edits since the old snapshot. None is returned if any user +/// edit is not a prefix of a predicted insertion. +pub fn interpolate_edits( + old_snapshot: &text::BufferSnapshot, + new_snapshot: &text::BufferSnapshot, + current_edits: &[(Range, Arc)], +) -> Option, Arc)>> { + let mut edits = Vec::new(); + + let mut model_edits = current_edits.iter().peekable(); + for user_edit in new_snapshot.edits_since::(&old_snapshot.version) { + while let Some((model_old_range, _)) = model_edits.peek() { + let model_old_range = model_old_range.to_offset(old_snapshot); + if model_old_range.end < user_edit.old.start { + let (model_old_range, model_new_text) = model_edits.next().unwrap(); + edits.push((model_old_range.clone(), model_new_text.clone())); + } else { + break; + } + } + + if let Some((model_old_range, model_new_text)) = model_edits.peek() { + let model_old_offset_range = model_old_range.to_offset(old_snapshot); + if user_edit.old == model_old_offset_range { + let user_new_text = new_snapshot + .text_for_range(user_edit.new.clone()) + .collect::(); + + if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { + if !model_suffix.is_empty() { + let anchor = old_snapshot.anchor_after(user_edit.old.end); + edits.push((anchor..anchor, model_suffix.into())); + } + + model_edits.next(); + continue; + } + } + } + + return None; + } + + edits.extend(model_edits.cloned()); + + if edits.is_empty() { None } else { Some(edits) } +} diff --git a/crates/edit_prediction_button/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml similarity index 65% rename from crates/edit_prediction_button/Cargo.toml rename to crates/edit_prediction_ui/Cargo.toml index d336cf66926d37ab7c0ebb1d5aa5a2172342350c..6c4bf735e7cce1b95666b0195d2da8caab57f703 100644 --- a/crates/edit_prediction_button/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "edit_prediction_button" +name = "edit_prediction_ui" version = "0.1.0" edition.workspace = true publish.workspace = true @@ -9,42 +9,58 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/edit_prediction_button.rs" +path = "src/edit_prediction_ui.rs" doctest = false [dependencies] anyhow.workspace = true +buffer_diff.workspace = true +collections.workspace = true +time.workspace = true client.workspace = true cloud_llm_client.workspace = true codestral.workspace = true +command_palette_hooks.workspace = true copilot.workspace = true +edit_prediction_types.workspace = true edit_prediction.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true +futures.workspace = true gpui.workspace = true indoc.workspace = true language.workspace = true +markdown.workspace = true +menu.workspace = true +multi_buffer.workspace = true paths.workspace = true project.workspace = true regex.workspace = true settings.workspace = true supermaven.workspace = true telemetry.workspace = true +text.workspace = true +theme.workspace = true ui.workspace = true -ui_input.workspace = true -menu.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -zeta.workspace = true +zeta_prompt.workspace = true [dev-dependencies] +clock.workspace = true copilot = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } futures.workspace = true indoc.workspace = true +language_model.workspace = true lsp = { workspace = true, features = ["test-support"] } +pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } +release_channel.workspace = true +semver.workspace = true serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } +zlog.workspace = true diff --git a/crates/zeta2_tools/LICENSE-GPL b/crates/edit_prediction_ui/LICENSE-GPL similarity index 100% rename from crates/zeta2_tools/LICENSE-GPL rename to crates/edit_prediction_ui/LICENSE-GPL diff --git a/crates/edit_prediction_button/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs similarity index 77% rename from crates/edit_prediction_button/src/edit_prediction_button.rs rename to crates/edit_prediction_ui/src/edit_prediction_button.rs index 8ce8441859b7cc747a2b566dedd913e58259969d..ca9864753cc759409914b42daa5de6b517a473ba 100644 --- a/crates/edit_prediction_button/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -1,16 +1,16 @@ -mod sweep_api_token_modal; - -pub use sweep_api_token_modal::SweepApiKeyModal; - use anyhow::Result; use client::{Client, UserStore, zed_urls}; use cloud_llm_client::UsageLimit; -use codestral::CodestralCompletionProvider; +use codestral::CodestralEditPredictionDelegate; use copilot::{Copilot, Status}; +use edit_prediction::{ + EditPredictionStore, MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag, +}; +use edit_prediction_types::EditPredictionDelegateHandle; use editor::{ Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll, }; -use feature_flags::{FeatureFlagAppExt, PredictEditsRateCompletionsFeatureFlag}; +use feature_flags::FeatureFlagAppExt; use fs::Fs; use gpui::{ Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle, @@ -25,6 +25,7 @@ use language::{ use project::DisableAiSettings; use regex::Regex; use settings::{ + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore, update_settings_file, @@ -43,8 +44,11 @@ use workspace::{ StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle, notifications::NotificationId, }; -use zed_actions::OpenBrowser; -use zeta::{RateCompletions, SweepFeatureFlag, Zeta2FeatureFlag}; +use zed_actions::{OpenBrowser, OpenSettingsAt}; + +use crate::{ + CaptureExample, RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag, +}; actions!( edit_prediction, @@ -67,7 +71,7 @@ pub struct EditPredictionButton { editor_focus_handle: Option, language: Option>, file: Option>, - edit_prediction_provider: Option>, + edit_prediction_provider: Option>, fs: Arc, user_store: Entity, popover_menu_handle: PopoverMenuHandle, @@ -244,46 +248,22 @@ impl Render for EditPredictionButton { EditPredictionProvider::Codestral => { let enabled = self.editor_enabled.unwrap_or(true); - let has_api_key = CodestralCompletionProvider::has_api_key(cx); - let fs = self.fs.clone(); + let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx); let this = cx.weak_entity(); + let tooltip_meta = if has_api_key { + "Powered by Codestral" + } else { + "Missing API key for Codestral" + }; + div().child( PopoverMenu::new("codestral") .menu(move |window, cx| { - if has_api_key { - this.update(cx, |this, cx| { - this.build_codestral_context_menu(window, cx) - }) - .ok() - } else { - Some(ContextMenu::build(window, cx, |menu, _, _| { - let fs = fs.clone(); - - menu.entry( - "Configure Codestral API Key", - None, - move |window, cx| { - window.dispatch_action( - zed_actions::agent::OpenSettings.boxed_clone(), - cx, - ); - }, - ) - .separator() - .entry( - "Use Zed AI instead", - None, - move |_, cx| { - set_completion_provider( - fs.clone(), - cx, - EditPredictionProvider::Zed, - ) - }, - ) - })) - } + this.update(cx, |this, cx| { + this.build_codestral_context_menu(window, cx) + }) + .ok() }) .anchor(Corner::BottomRight) .trigger_with_tooltip( @@ -301,7 +281,14 @@ impl Render for EditPredictionButton { cx.theme().colors().status_bar_background, )) }), - move |_window, cx| Tooltip::for_action("Codestral", &ToggleMenu, cx), + move |_window, cx| { + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + tooltip_meta, + cx, + ) + }, ) .with_handle(self.popover_menu_handle.clone()), ) @@ -309,24 +296,46 @@ impl Render for EditPredictionButton { provider @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => { let enabled = self.editor_enabled.unwrap_or(true); - let is_sweep = matches!( - provider, - EditPredictionProvider::Experimental( - EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME - ) - ); - - let sweep_missing_token = is_sweep - && !zeta::Zeta::try_global(cx) - .map_or(false, |zeta| zeta.read(cx).has_sweep_api_token()); + let ep_icon; + let tooltip_meta; + let mut missing_token = false; - let zeta_icon = match (is_sweep, enabled) { - (true, _) => IconName::SweepAi, - (false, true) => IconName::ZedPredict, - (false, false) => IconName::ZedPredictDisabled, + match provider { + EditPredictionProvider::Experimental( + EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, + ) => { + ep_icon = IconName::SweepAi; + tooltip_meta = if missing_token { + "Missing API key for Sweep" + } else { + "Powered by Sweep" + }; + missing_token = edit_prediction::EditPredictionStore::try_global(cx) + .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx)); + } + EditPredictionProvider::Experimental( + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + ) => { + ep_icon = IconName::Inception; + missing_token = edit_prediction::EditPredictionStore::try_global(cx) + .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx)); + tooltip_meta = if missing_token { + "Missing API key for Mercury" + } else { + "Powered by Mercury" + }; + } + _ => { + ep_icon = if enabled { + IconName::ZedPredict + } else { + IconName::ZedPredictDisabled + }; + tooltip_meta = "Powered by Zeta" + } }; - if zeta::should_show_upsell_modal() { + if edit_prediction::should_show_upsell_modal() { let tooltip_meta = if self.user_store.read(cx).current_user().is_some() { "Choose a Plan" } else { @@ -334,7 +343,7 @@ impl Render for EditPredictionButton { }; return div().child( - IconButton::new("zed-predict-pending-button", zeta_icon) + IconButton::new("zed-predict-pending-button", ep_icon) .shape(IconButtonShape::Square) .indicator(Indicator::dot().color(Color::Muted)) .indicator_border_color(Some(cx.theme().colors().status_bar_background)) @@ -367,7 +376,7 @@ impl Render for EditPredictionButton { let show_editor_predictions = self.editor_show_predictions; let user = self.user_store.read(cx).current_user(); - let indicator_color = if sweep_missing_token { + let indicator_color = if missing_token { Some(Color::Error) } else if enabled && (!show_editor_predictions || over_limit) { Some(if over_limit { @@ -379,7 +388,7 @@ impl Render for EditPredictionButton { None }; - let icon_button = IconButton::new("zed-predict-pending-button", zeta_icon) + let icon_button = IconButton::new("zed-predict-pending-button", ep_icon) .shape(IconButtonShape::Square) .when_some(indicator_color, |this, color| { this.indicator(Indicator::dot().color(color)) @@ -387,45 +396,38 @@ impl Render for EditPredictionButton { }) .when(!self.popover_menu_handle.is_deployed(), |element| { let user = user.clone(); + element.tooltip(move |_window, cx| { - if enabled { + let description = if enabled { if show_editor_predictions { - Tooltip::for_action("Edit Prediction", &ToggleMenu, cx) + tooltip_meta } else if user.is_none() { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Sign In To Use", - cx, - ) + "Sign In To Use" } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Hidden For This File", - cx, - ) + "Hidden For This File" } } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Disabled For This File", - cx, - ) - } + "Disabled For This File" + }; + + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + description, + cx, + ) }) }); let this = cx.weak_entity(); - let mut popover_menu = PopoverMenu::new("zeta") + let mut popover_menu = PopoverMenu::new("edit-prediction") .when(user.is_some(), |popover_menu| { let this = this.clone(); popover_menu.menu(move |window, cx| { this.update(cx, |this, cx| { - this.build_zeta_context_menu(provider, window, cx) + this.build_edit_prediction_context_menu(provider, window, cx) }) .ok() }) @@ -485,7 +487,22 @@ impl EditPredictionButton { cx.observe_global::(move |_, cx| cx.notify()) .detach(); - CodestralCompletionProvider::ensure_api_key_loaded(client.http_client(), cx); + cx.observe_global::(move |_, cx| cx.notify()) + .detach(); + + let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx); + let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx); + + cx.spawn(async move |this, cx| { + _ = futures::join!(sweep_api_token_task, mercury_api_token_task); + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }) + .detach(); + + CodestralEditPredictionDelegate::ensure_api_key_loaded(client.http_client(), cx); Self { editor_subscription: None, @@ -501,11 +518,17 @@ impl EditPredictionButton { } } - fn get_available_providers(&self, cx: &App) -> Vec { + fn get_available_providers(&self, cx: &mut App) -> Vec { let mut providers = Vec::new(); providers.push(EditPredictionProvider::Zed); + if cx.has_flag::() { + providers.push(EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + )); + } + if let Some(copilot) = Copilot::global(cx) { if matches!(copilot.read(cx).status(), Status::Authorized) { providers.push(EditPredictionProvider::Copilot); @@ -520,19 +543,27 @@ impl EditPredictionButton { } } - if CodestralCompletionProvider::has_api_key(cx) { + if CodestralEditPredictionDelegate::has_api_key(cx) { providers.push(EditPredictionProvider::Codestral); } - if cx.has_flag::() { + if cx.has_flag::() + && edit_prediction::sweep_ai::sweep_api_token(cx) + .read(cx) + .has_key() + { providers.push(EditPredictionProvider::Experimental( EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, )); } - if cx.has_flag::() { + if cx.has_flag::() + && edit_prediction::mercury::mercury_api_token(cx) + .read(cx) + .has_key() + { providers.push(EditPredictionProvider::Experimental( - EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, )); } @@ -543,13 +574,10 @@ impl EditPredictionButton { &self, mut menu: ContextMenu, current_provider: EditPredictionProvider, - cx: &App, + cx: &mut App, ) -> ContextMenu { let available_providers = self.get_available_providers(cx); - const ZED_AI_CALLOUT: &str = - "Zed's edit prediction is powered by Zeta, an open-source, dataset mode."; - let providers: Vec<_> = available_providers .into_iter() .filter(|p| *p != EditPredictionProvider::None) @@ -562,94 +590,32 @@ impl EditPredictionButton { let is_current = provider == current_provider; let fs = self.fs.clone(); - menu = match provider { - EditPredictionProvider::Zed => menu.item( - ContextMenuEntry::new("Zed AI") - .toggleable(IconPosition::Start, is_current) - .documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| Label::new(ZED_AI_CALLOUT).into_any_element(), - ) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Copilot => menu.item( - ContextMenuEntry::new("GitHub Copilot") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Supermaven => menu.item( - ContextMenuEntry::new("Supermaven") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Codestral => menu.item( - ContextMenuEntry::new("Codestral") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), + let name = match provider { + EditPredictionProvider::Zed => "Zed AI", + EditPredictionProvider::Copilot => "GitHub Copilot", + EditPredictionProvider::Supermaven => "Supermaven", + EditPredictionProvider::Codestral => "Codestral", EditPredictionProvider::Experimental( EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, - ) => { - let has_api_token = zeta::Zeta::try_global(cx) - .map_or(false, |zeta| zeta.read(cx).has_sweep_api_token()); - - let should_open_modal = !has_api_token || is_current; - - let entry = if has_api_token { - ContextMenuEntry::new("Sweep") - .toggleable(IconPosition::Start, is_current) - } else { - ContextMenuEntry::new("Sweep") - .icon(IconName::XCircle) - .icon_color(Color::Error) - .documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| { - Label::new("Click to configure your Sweep API token") - .into_any_element() - }, - ) - }; - - let entry = entry.handler(move |window, cx| { - if should_open_modal { - if let Some(workspace) = window.root::().flatten() { - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - SweepApiKeyModal::new(window, cx) - }); - }); - }; - } else { - set_completion_provider(fs.clone(), cx, provider); - } - }); - - menu.item(entry) - } + ) => "Sweep", + EditPredictionProvider::Experimental( + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, + ) => "Mercury", EditPredictionProvider::Experimental( EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, - ) => menu.item( - ContextMenuEntry::new("Zeta2") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), + ) => "Zeta2", EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => { continue; } }; + + menu = menu.item( + ContextMenuEntry::new(name) + .toggleable(IconPosition::Start, is_current) + .handler(move |_, cx| { + set_completion_provider(fs.clone(), cx, provider); + }), + ) } } @@ -754,14 +720,7 @@ impl EditPredictionButton { let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle); let eager_mode = matches!(current_mode, EditPredictionsMode::Eager); - if matches!( - provider, - EditPredictionProvider::Zed - | EditPredictionProvider::Copilot - | EditPredictionProvider::Supermaven - | EditPredictionProvider::Codestral - ) { - menu = menu + menu = menu .separator() .header("Display Modes") .item( @@ -790,104 +749,111 @@ impl EditPredictionButton { } }), ); - } menu = menu.separator().header("Privacy"); - if let Some(provider) = &self.edit_prediction_provider { - let data_collection = provider.data_collection_state(cx); - - if data_collection.is_supported() { - let provider = provider.clone(); - let enabled = data_collection.is_enabled(); - let is_open_source = data_collection.is_project_open_source(); - let is_collecting = data_collection.is_enabled(); - let (icon_name, icon_color) = if is_open_source && is_collecting { - (IconName::Check, Color::Success) - } else { - (IconName::Check, Color::Accent) - }; - - menu = menu.item( - ContextMenuEntry::new("Training Data Collection") - .toggleable(IconPosition::Start, data_collection.is_enabled()) - .icon(icon_name) - .icon_color(icon_color) - .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { - let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { - (true, true) => ( - "Project identified as open source, and you're sharing data.", - Color::Default, - IconName::Check, - Color::Success, - ), - (true, false) => ( - "Project identified as open source, but you're not sharing data.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, true) => ( - "Project not identified as open source. No data captured.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, false) => ( - "Project not identified as open source, and setting turned off.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - }; - v_flex() - .gap_2() - .child( - Label::new(indoc!{ - "Help us improve our open dataset model by sharing data from open source repositories. \ - Zed must detect a license file in your repo for this setting to take effect. \ - Files with sensitive data and secrets are excluded by default." - }) - ) - .child( - h_flex() - .items_start() - .pt_2() - .pr_1() - .flex_1() - .gap_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) - .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) - ) - .into_any_element() - }) - .handler(move |_, cx| { - provider.toggle_data_collection(cx); - - if !enabled { - telemetry::event!( - "Data Collection Enabled", - source = "Edit Prediction Status Menu" - ); - } else { - telemetry::event!( - "Data Collection Disabled", - source = "Edit Prediction Status Menu" - ); - } - }) - ); + if matches!( + provider, + EditPredictionProvider::Zed + | EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + ) + ) { + if let Some(provider) = &self.edit_prediction_provider { + let data_collection = provider.data_collection_state(cx); + + if data_collection.is_supported() { + let provider = provider.clone(); + let enabled = data_collection.is_enabled(); + let is_open_source = data_collection.is_project_open_source(); + let is_collecting = data_collection.is_enabled(); + let (icon_name, icon_color) = if is_open_source && is_collecting { + (IconName::Check, Color::Success) + } else { + (IconName::Check, Color::Accent) + }; - if is_collecting && !is_open_source { menu = menu.item( - ContextMenuEntry::new("No data captured.") - .disabled(true) - .icon(IconName::Close) - .icon_color(Color::Error) - .icon_size(IconSize::Small), + ContextMenuEntry::new("Training Data Collection") + .toggleable(IconPosition::Start, data_collection.is_enabled()) + .icon(icon_name) + .icon_color(icon_color) + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { + let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { + (true, true) => ( + "Project identified as open source, and you're sharing data.", + Color::Default, + IconName::Check, + Color::Success, + ), + (true, false) => ( + "Project identified as open source, but you're not sharing data.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, true) => ( + "Project not identified as open source. No data captured.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, false) => ( + "Project not identified as open source, and setting turned off.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + }; + v_flex() + .gap_2() + .child( + Label::new(indoc!{ + "Help us improve our open dataset model by sharing data from open source repositories. \ + Zed must detect a license file in your repo for this setting to take effect. \ + Files with sensitive data and secrets are excluded by default." + }) + ) + .child( + h_flex() + .items_start() + .pt_2() + .pr_1() + .flex_1() + .gap_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) + .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) + ) + .into_any_element() + }) + .handler(move |_, cx| { + provider.toggle_data_collection(cx); + + if !enabled { + telemetry::event!( + "Data Collection Enabled", + source = "Edit Prediction Status Menu" + ); + } else { + telemetry::event!( + "Data Collection Disabled", + source = "Edit Prediction Status Menu" + ); + } + }) ); + + if is_collecting && !is_open_source { + menu = menu.item( + ContextMenuEntry::new("No data captured.") + .disabled(true) + .icon(IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::Small), + ); + } } } } @@ -947,8 +913,11 @@ impl EditPredictionButton { ) .context(editor_focus_handle) .when( - cx.has_flag::(), - |this| this.action("Rate Completions", RateCompletions.boxed_clone()), + cx.has_flag::(), + |this| { + this.action("Capture Prediction Example", CaptureExample.boxed_clone()) + .action("Rate Predictions", RatePredictions.boxed_clone()) + }, ); } @@ -1009,14 +978,11 @@ impl EditPredictionButton { let menu = self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx); - menu.separator() - .entry("Configure Codestral API Key", None, move |window, cx| { - window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx); - }) + menu }) } - fn build_zeta_context_menu( + fn build_edit_prediction_context_menu( &self, provider: EditPredictionProvider, window: &mut Window, @@ -1105,8 +1071,48 @@ impl EditPredictionButton { .separator(); } - let menu = self.build_language_settings_menu(menu, window, cx); - let menu = self.add_provider_switching_section(menu, provider, cx); + menu = self.build_language_settings_menu(menu, window, cx); + + if cx.has_flag::() { + let settings = all_language_settings(None, cx); + let context_retrieval = settings.edit_predictions.use_context; + menu = menu.separator().header("Context Retrieval").item( + ContextMenuEntry::new("Enable Context Retrieval") + .toggleable(IconPosition::Start, context_retrieval) + .action(workspace::ToggleEditPrediction.boxed_clone()) + .handler({ + let fs = self.fs.clone(); + move |_, cx| { + update_settings_file(fs.clone(), cx, move |settings, _| { + settings + .project + .all_languages + .features + .get_or_insert_default() + .experimental_edit_prediction_context_retrieval = + Some(!context_retrieval) + }); + } + }), + ); + } + + menu = self.add_provider_switching_section(menu, provider, cx); + menu = menu.separator().item( + ContextMenuEntry::new("Configure Providers") + .icon(IconName::Settings) + .icon_position(IconPosition::Start) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + OpenSettingsAt { + path: "edit_predictions.providers".to_string(), + } + .boxed_clone(), + cx, + ); + }), + ); menu }) diff --git a/crates/edit_prediction_ui/src/edit_prediction_context_view.rs b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..92d66d2bec3a7a3b35678f1d4da92fae6b071633 --- /dev/null +++ b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs @@ -0,0 +1,370 @@ +use std::{ + any::TypeId, + collections::VecDeque, + ops::Add, + sync::Arc, + time::{Duration, Instant}, +}; + +use anyhow::Result; +use client::{Client, UserStore}; +use editor::{Editor, PathKey}; +use futures::StreamExt as _; +use gpui::{ + Animation, AnimationExt, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement as _, IntoElement as _, ParentElement as _, SharedString, + Styled as _, Task, TextAlign, Window, actions, div, pulsating_between, +}; +use multi_buffer::MultiBuffer; +use project::Project; +use text::Point; +use ui::{ + ButtonCommon, Clickable, Disableable, FluentBuilder as _, IconButton, IconName, + StyledTypography as _, h_flex, v_flex, +}; + +use edit_prediction::{ + ContextRetrievalFinishedDebugEvent, ContextRetrievalStartedDebugEvent, DebugEvent, + EditPredictionStore, +}; +use workspace::Item; + +pub struct EditPredictionContextView { + empty_focus_handle: FocusHandle, + project: Entity, + store: Entity, + runs: VecDeque, + current_ix: usize, + _update_task: Task>, +} + +#[derive(Debug)] +struct RetrievalRun { + editor: Entity, + started_at: Instant, + metadata: Vec<(&'static str, SharedString)>, + finished_at: Option, +} + +actions!( + dev, + [ + /// Go to the previous context retrieval run + EditPredictionContextGoBack, + /// Go to the next context retrieval run + EditPredictionContextGoForward + ] +); + +impl EditPredictionContextView { + pub fn new( + project: Entity, + client: &Arc, + user_store: &Entity, + window: &mut gpui::Window, + cx: &mut Context, + ) -> Self { + let store = EditPredictionStore::global(client, user_store, cx); + + let mut debug_rx = store.update(cx, |store, cx| store.debug_info(&project, cx)); + let _update_task = cx.spawn_in(window, async move |this, cx| { + while let Some(event) = debug_rx.next().await { + this.update_in(cx, |this, window, cx| { + this.handle_store_event(event, window, cx) + })?; + } + Ok(()) + }); + + Self { + empty_focus_handle: cx.focus_handle(), + project, + runs: VecDeque::new(), + current_ix: 0, + store, + _update_task, + } + } + + fn handle_store_event( + &mut self, + event: DebugEvent, + window: &mut gpui::Window, + cx: &mut Context, + ) { + match event { + DebugEvent::ContextRetrievalStarted(info) => { + if info.project_entity_id == self.project.entity_id() { + self.handle_context_retrieval_started(info, window, cx); + } + } + DebugEvent::ContextRetrievalFinished(info) => { + if info.project_entity_id == self.project.entity_id() { + self.handle_context_retrieval_finished(info, window, cx); + } + } + DebugEvent::EditPredictionStarted(_) => {} + DebugEvent::EditPredictionFinished(_) => {} + } + } + + fn handle_context_retrieval_started( + &mut self, + info: ContextRetrievalStartedDebugEvent, + window: &mut Window, + cx: &mut Context, + ) { + if self + .runs + .back() + .is_some_and(|run| run.finished_at.is_none()) + { + self.runs.pop_back(); + } + + let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadOnly)); + let editor = cx + .new(|cx| Editor::for_multibuffer(multibuffer, Some(self.project.clone()), window, cx)); + + if self.runs.len() == 32 { + self.runs.pop_front(); + } + + self.runs.push_back(RetrievalRun { + editor, + started_at: info.timestamp, + finished_at: None, + metadata: Vec::new(), + }); + + cx.notify(); + } + + fn handle_context_retrieval_finished( + &mut self, + info: ContextRetrievalFinishedDebugEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(run) = self.runs.back_mut() else { + return; + }; + + run.finished_at = Some(info.timestamp); + run.metadata = info.metadata; + + let related_files = self + .store + .read(cx) + .context_for_project_with_buffers(&self.project, cx) + .map_or(Vec::new(), |files| files.collect()); + + let editor = run.editor.clone(); + let multibuffer = run.editor.read(cx).buffer().clone(); + + if self.current_ix + 2 == self.runs.len() { + self.current_ix += 1; + } + + cx.spawn_in(window, async move |this, cx| { + let mut paths = Vec::new(); + for (related_file, buffer) in related_files { + let point_ranges = related_file + .excerpts + .iter() + .map(|excerpt| { + Point::new(excerpt.row_range.start, 0)..Point::new(excerpt.row_range.end, 0) + }) + .collect::>(); + cx.update(|_, cx| { + let path = PathKey::for_buffer(&buffer, cx); + paths.push((path, buffer, point_ranges)); + })?; + } + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.clear(cx); + + for (path, buffer, ranges) in paths { + multibuffer.set_excerpts_for_path(path, buffer, ranges, 0, cx); + } + })?; + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_beginning(&Default::default(), window, cx); + })?; + + this.update(cx, |_, cx| cx.notify()) + }) + .detach(); + } + + fn handle_go_back( + &mut self, + _: &EditPredictionContextGoBack, + window: &mut Window, + cx: &mut Context, + ) { + self.current_ix = self.current_ix.saturating_sub(1); + cx.focus_self(window); + cx.notify(); + } + + fn handle_go_forward( + &mut self, + _: &EditPredictionContextGoForward, + window: &mut Window, + cx: &mut Context, + ) { + self.current_ix = self + .current_ix + .add(1) + .min(self.runs.len().saturating_sub(1)); + cx.focus_self(window); + cx.notify(); + } + + fn render_informational_footer( + &self, + cx: &mut Context<'_, EditPredictionContextView>, + ) -> ui::Div { + let run = &self.runs[self.current_ix]; + let new_run_started = self + .runs + .back() + .map_or(false, |latest_run| latest_run.finished_at.is_none()); + + h_flex() + .p_2() + .w_full() + .font_buffer(cx) + .text_xs() + .border_t_1() + .gap_2() + .child(v_flex().h_full().flex_1().child({ + let t0 = run.started_at; + let mut table = ui::Table::<2>::new().width(ui::px(300.)).no_ui_font(); + for (key, value) in &run.metadata { + table = table.row([key.into_any_element(), value.clone().into_any_element()]) + } + table = table.row([ + "Total Time".into_any_element(), + format!("{} ms", (run.finished_at.unwrap_or(t0) - t0).as_millis()) + .into_any_element(), + ]); + table + })) + .child( + v_flex().h_full().text_align(TextAlign::Right).child( + h_flex() + .justify_end() + .child( + IconButton::new("go-back", IconName::ChevronLeft) + .disabled(self.current_ix == 0 || self.runs.len() < 2) + .tooltip(ui::Tooltip::for_action_title( + "Go to previous run", + &EditPredictionContextGoBack, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_go_back(&EditPredictionContextGoBack, window, cx); + })), + ) + .child( + div() + .child(format!("{}/{}", self.current_ix + 1, self.runs.len())) + .map(|this| { + if new_run_started { + this.with_animation( + "pulsating-count", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.opacity(delta), + ) + .into_any_element() + } else { + this.into_any_element() + } + }), + ) + .child( + IconButton::new("go-forward", IconName::ChevronRight) + .disabled(self.current_ix + 1 == self.runs.len()) + .tooltip(ui::Tooltip::for_action_title( + "Go to next run", + &EditPredictionContextGoBack, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_go_forward( + &EditPredictionContextGoForward, + window, + cx, + ); + })), + ), + ), + ) + } +} + +impl Focusable for EditPredictionContextView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.runs + .get(self.current_ix) + .map(|run| run.editor.read(cx).focus_handle(cx)) + .unwrap_or_else(|| self.empty_focus_handle.clone()) + } +} + +impl EventEmitter<()> for EditPredictionContextView {} + +impl Item for EditPredictionContextView { + type Event = (); + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Edit Prediction Context".into() + } + + fn buffer_kind(&self, _cx: &App) -> workspace::item::ItemBufferKind { + workspace::item::ItemBufferKind::Multibuffer + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.runs.get(self.current_ix)?.editor.clone().into()) + } else { + None + } + } +} + +impl gpui::Render for EditPredictionContextView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { + v_flex() + .key_context("EditPredictionContext") + .on_action(cx.listener(Self::handle_go_back)) + .on_action(cx.listener(Self::handle_go_forward)) + .size_full() + .map(|this| { + if self.runs.is_empty() { + this.child( + v_flex() + .size_full() + .justify_center() + .items_center() + .child("No retrieval runs yet"), + ) + } else { + this.child(self.runs[self.current_ix].editor.clone()) + .child(self.render_informational_footer(cx)) + } + }) + } +} diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs new file mode 100644 index 0000000000000000000000000000000000000000..9dc7623f79cab61350a06740440bae5cb4e3f40f --- /dev/null +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -0,0 +1,191 @@ +mod edit_prediction_button; +mod edit_prediction_context_view; +mod rate_prediction_modal; + +use command_palette_hooks::CommandPaletteFilter; +use edit_prediction::{ResetOnboarding, Zeta2FeatureFlag, capture_example}; +use edit_prediction_context_view::EditPredictionContextView; +use editor::Editor; +use feature_flags::FeatureFlagAppExt as _; +use gpui::actions; +use language::language_settings::AllLanguageSettings; +use project::DisableAiSettings; +use rate_prediction_modal::RatePredictionsModal; +use settings::{Settings as _, SettingsStore}; +use std::any::{Any as _, TypeId}; +use ui::{App, prelude::*}; +use workspace::{SplitDirection, Workspace}; + +pub use edit_prediction_button::{EditPredictionButton, ToggleMenu}; + +use crate::rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag; + +actions!( + dev, + [ + /// Opens the edit prediction context view. + OpenEditPredictionContextView, + ] +); + +actions!( + edit_prediction, + [ + /// Opens the rate completions modal. + RatePredictions, + /// Captures an ExampleSpec from the current editing session and opens it as Markdown. + CaptureExample, + ] +); + +pub fn init(cx: &mut App) { + feature_gate_predict_edits_actions(cx); + + cx.observe_new(move |workspace: &mut Workspace, _, _cx| { + workspace.register_action(|workspace, _: &RatePredictions, window, cx| { + if cx.has_flag::() { + RatePredictionsModal::toggle(workspace, window, cx); + } + }); + + workspace.register_action(|workspace, _: &CaptureExample, window, cx| { + capture_example_as_markdown(workspace, window, cx); + }); + workspace.register_action_renderer(|div, _, _, cx| { + let has_flag = cx.has_flag::(); + div.when(has_flag, |div| { + div.on_action(cx.listener( + move |workspace, _: &OpenEditPredictionContextView, window, cx| { + let project = workspace.project(); + workspace.split_item( + SplitDirection::Right, + Box::new(cx.new(|cx| { + EditPredictionContextView::new( + project.clone(), + workspace.client(), + workspace.user_store(), + window, + cx, + ) + })), + window, + cx, + ); + }, + )) + }) + }); + }) + .detach(); +} + +fn feature_gate_predict_edits_actions(cx: &mut App) { + let rate_completion_action_types = [TypeId::of::()]; + let reset_onboarding_action_types = [TypeId::of::()]; + let all_action_types = [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + zed_actions::OpenZedPredictOnboarding.type_id(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ]; + + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&rate_completion_action_types); + filter.hide_action_types(&reset_onboarding_action_types); + filter.hide_action_types(&[zed_actions::OpenZedPredictOnboarding.type_id()]); + }); + + cx.observe_global::(move |cx| { + let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; + let has_feature_flag = cx.has_flag::(); + + CommandPaletteFilter::update_global(cx, |filter, _cx| { + if is_ai_disabled { + filter.hide_action_types(&all_action_types); + } else if has_feature_flag { + filter.show_action_types(&rate_completion_action_types); + } else { + filter.hide_action_types(&rate_completion_action_types); + } + }); + }) + .detach(); + + cx.observe_flag::(move |is_enabled, cx| { + if !DisableAiSettings::get_global(cx).disable_ai { + if is_enabled { + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.show_action_types(&rate_completion_action_types); + }); + } else { + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&rate_completion_action_types); + }); + } + } + }) + .detach(); +} + +fn capture_example_as_markdown( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) -> Option<()> { + let markdown_language = workspace + .app_state() + .languages + .language_for_name("Markdown"); + + let fs = workspace.app_state().fs.clone(); + let project = workspace.project().clone(); + let editor = workspace.active_item_as::(cx)?; + let editor = editor.read(cx); + let (buffer, cursor_anchor) = editor + .buffer() + .read(cx) + .text_anchor_for_position(editor.selections.newest_anchor().head(), cx)?; + let example = capture_example(project.clone(), buffer, cursor_anchor, true, cx)?; + + let examples_dir = AllLanguageSettings::get_global(cx) + .edit_predictions + .examples_dir + .clone(); + + cx.spawn_in(window, async move |workspace_entity, cx| { + let markdown_language = markdown_language.await?; + let example_spec = example.await?; + let buffer = if let Some(dir) = examples_dir { + fs.create_dir(&dir).await.ok(); + let mut path = dir.join(&example_spec.name.replace(' ', "--").replace(':', "-")); + path.set_extension("md"); + project.update(cx, |project, cx| project.open_local_buffer(&path, cx)) + } else { + project.update(cx, |project, cx| project.create_buffer(false, cx)) + }? + .await?; + + buffer.update(cx, |buffer, cx| { + buffer.set_text(example_spec.to_markdown(), cx); + buffer.set_language(Some(markdown_language), cx); + })?; + workspace_entity.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new( + cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)), + ), + None, + true, + window, + cx, + ); + }) + }) + .detach_and_log_err(cx); + None +} diff --git a/crates/zeta/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs similarity index 92% rename from crates/zeta/src/rate_prediction_modal.rs rename to crates/edit_prediction_ui/src/rate_prediction_modal.rs index 4311a6146a851cb8b585a83d9d15ac4ac2e4463d..e36f9774185b53d2f3c2ae00005c85895fb5309a 100644 --- a/crates/zeta/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -1,7 +1,7 @@ -use crate::{EditPrediction, EditPredictionRating, Zeta}; use buffer_diff::BufferDiff; -use cloud_zeta2_prompt::write_codeblock; +use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore}; use editor::{Editor, ExcerptRange, MultiBuffer}; +use feature_flags::FeatureFlag; use gpui::{ App, BorderStyle, DismissEvent, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable, Length, StyleRefinement, TextStyleRefinement, Window, actions, prelude::*, @@ -9,9 +9,7 @@ use gpui::{ use language::{LanguageRegistry, Point, language_settings}; use markdown::{Markdown, MarkdownStyle}; use settings::Settings as _; -use std::fmt::Write; -use std::sync::Arc; -use std::time::Duration; +use std::{fmt::Write, sync::Arc, time::Duration}; use theme::ThemeSettings; use ui::{KeyBinding, List, ListItem, ListItemSpacing, Tooltip, prelude::*}; use workspace::{ModalView, Workspace}; @@ -34,8 +32,14 @@ actions!( ] ); +pub struct PredictEditsRatePredictionsFeatureFlag; + +impl FeatureFlag for PredictEditsRatePredictionsFeatureFlag { + const NAME: &'static str = "predict-edits-rate-completions"; +} + pub struct RatePredictionsModal { - zeta: Entity, + ep_store: Entity, language_registry: Arc, active_prediction: Option, selected_index: usize, @@ -68,10 +72,10 @@ impl RatePredictionView { impl RatePredictionsModal { pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { - if let Some(zeta) = Zeta::try_global(cx) { + if let Some(ep_store) = EditPredictionStore::try_global(cx) { let language_registry = workspace.app_state().languages.clone(); workspace.toggle_modal(window, cx, |window, cx| { - RatePredictionsModal::new(zeta, language_registry, window, cx) + RatePredictionsModal::new(ep_store, language_registry, window, cx) }); telemetry::event!("Rate Prediction Modal Open", source = "Edit Prediction"); @@ -79,15 +83,15 @@ impl RatePredictionsModal { } pub fn new( - zeta: Entity, + ep_store: Entity, language_registry: Arc, window: &mut Window, cx: &mut Context, ) -> Self { - let subscription = cx.observe(&zeta, |_, _, cx| cx.notify()); + let subscription = cx.observe(&ep_store, |_, _, cx| cx.notify()); Self { - zeta, + ep_store, language_registry, selected_index: 0, focus_handle: cx.focus_handle(), @@ -113,7 +117,7 @@ impl RatePredictionsModal { self.selected_index += 1; self.selected_index = usize::min( self.selected_index, - self.zeta.read(cx).shown_predictions().count(), + self.ep_store.read(cx).shown_predictions().count(), ); cx.notify(); } @@ -130,7 +134,7 @@ impl RatePredictionsModal { fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context) { let next_index = self - .zeta + .ep_store .read(cx) .shown_predictions() .skip(self.selected_index) @@ -146,11 +150,11 @@ impl RatePredictionsModal { } fn select_prev_edit(&mut self, _: &PreviousEdit, _: &mut Window, cx: &mut Context) { - let zeta = self.zeta.read(cx); - let completions_len = zeta.shown_completions_len(); + let ep_store = self.ep_store.read(cx); + let completions_len = ep_store.shown_completions_len(); let prev_index = self - .zeta + .ep_store .read(cx) .shown_predictions() .rev() @@ -173,7 +177,7 @@ impl RatePredictionsModal { } fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.selected_index = self.zeta.read(cx).shown_completions_len() - 1; + self.selected_index = self.ep_store.read(cx).shown_completions_len() - 1; cx.notify(); } @@ -183,9 +187,9 @@ impl RatePredictionsModal { window: &mut Window, cx: &mut Context, ) { - self.zeta.update(cx, |zeta, cx| { + self.ep_store.update(cx, |ep_store, cx| { if let Some(active) = &self.active_prediction { - zeta.rate_prediction( + ep_store.rate_prediction( &active.prediction, EditPredictionRating::Positive, active.feedback_editor.read(cx).text(cx), @@ -216,8 +220,8 @@ impl RatePredictionsModal { return; } - self.zeta.update(cx, |zeta, cx| { - zeta.rate_prediction( + self.ep_store.update(cx, |ep_store, cx| { + ep_store.rate_prediction( &active.prediction, EditPredictionRating::Negative, active.feedback_editor.read(cx).text(cx), @@ -254,7 +258,7 @@ impl RatePredictionsModal { cx: &mut Context, ) { let completion = self - .zeta + .ep_store .read(cx) .shown_predictions() .skip(self.selected_index) @@ -267,7 +271,7 @@ impl RatePredictionsModal { fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { let completion = self - .zeta + .ep_store .read(cx) .shown_predictions() .skip(self.selected_index) @@ -288,7 +292,7 @@ impl RatePredictionsModal { // Avoid resetting completion rating if it's already selected. if let Some(prediction) = prediction { self.selected_index = self - .zeta + .ep_store .read(cx) .shown_predictions() .enumerate() @@ -301,7 +305,7 @@ impl RatePredictionsModal { && prediction.id == prev_prediction.prediction.id { if focus { - window.focus(&prev_prediction.feedback_editor.focus_handle(cx)); + window.focus(&prev_prediction.feedback_editor.focus_handle(cx), cx); } return; } @@ -358,14 +362,14 @@ impl RatePredictionsModal { write!(&mut formatted_inputs, "## Events\n\n").unwrap(); for event in &prediction.inputs.events { - write!(&mut formatted_inputs, "```diff\n{event}```\n\n").unwrap(); + formatted_inputs.push_str("```diff\n"); + zeta_prompt::write_event(&mut formatted_inputs, event.as_ref()); + formatted_inputs.push_str("```\n\n"); } - write!(&mut formatted_inputs, "## Included files\n\n").unwrap(); - - for included_file in &prediction.inputs.included_files { - let cursor_insertions = &[(prediction.inputs.cursor_point, "<|CURSOR|>")]; + write!(&mut formatted_inputs, "## Related files\n\n").unwrap(); + for included_file in prediction.inputs.related_files.as_ref() { write!( &mut formatted_inputs, "### {}\n\n", @@ -373,20 +377,28 @@ impl RatePredictionsModal { ) .unwrap(); - write_codeblock( - &included_file.path, - &included_file.excerpts, - if included_file.path == prediction.inputs.cursor_path { - cursor_insertions - } else { - &[] - }, - included_file.max_row, - false, - &mut formatted_inputs, - ); + for excerpt in included_file.excerpts.iter() { + write!( + &mut formatted_inputs, + "```{}\n{}\n```\n", + included_file.path.display(), + excerpt.text + ) + .unwrap(); + } } + write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap(); + + writeln!( + &mut formatted_inputs, + "```{}\n{}{}\n```\n", + prediction.inputs.cursor_path.display(), + &prediction.inputs.cursor_excerpt[..prediction.inputs.cursor_offset_in_excerpt], + &prediction.inputs.cursor_excerpt[prediction.inputs.cursor_offset_in_excerpt..], + ) + .unwrap(); + self.active_prediction = Some(ActivePrediction { prediction, feedback_editor: cx.new(|cx| { @@ -499,13 +511,13 @@ impl RatePredictionsModal { base_text_style: window.text_style(), syntax: cx.theme().syntax().clone(), code_block: StyleRefinement { - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some( theme_settings.buffer_font.family.clone(), ), font_size: Some(buffer_font_size.into()), ..Default::default() - }), + }, padding: EdgesRefinement { top: Some(DefiniteLength::Absolute( AbsoluteLength::Pixels(px(8.)), @@ -565,7 +577,7 @@ impl RatePredictionsModal { let border_color = cx.theme().colors().border; let bg_color = cx.theme().colors().editor_background; - let rated = self.zeta.read(cx).is_prediction_rated(&completion_id); + let rated = self.ep_store.read(cx).is_prediction_rated(&completion_id); let feedback_empty = active_prediction .feedback_editor .read(cx) @@ -716,7 +728,7 @@ impl RatePredictionsModal { } fn render_shown_completions(&self, cx: &Context) -> impl Iterator { - self.zeta + self.ep_store .read(cx) .shown_predictions() .cloned() @@ -726,7 +738,7 @@ impl RatePredictionsModal { .active_prediction .as_ref() .is_some_and(|selected| selected.prediction.id == completion.id); - let rated = self.zeta.read(cx).is_prediction_rated(&completion.id); + let rated = self.ep_store.read(cx).is_prediction_rated(&completion.id); let (icon_name, icon_color, tooltip_text) = match (rated, completion.edits.is_empty()) { diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 2aa02e293dd44d5fdd920ac8cd98da48b9c1a912..f3ed28ab05c6839a478ebbf6c81ca5e66fc372e3 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -49,7 +49,7 @@ fs.workspace = true git.workspace = true gpui.workspace = true indoc.workspace = true -edit_prediction.workspace = true +edit_prediction_types.workspace = true itertools.workspace = true language.workspace = true linkify.workspace = true @@ -84,6 +84,8 @@ tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } tree-sitter-python = { workspace = true, optional = true } +ztracing.workspace = true +tracing.workspace = true unicode-segmentation.workspace = true unicode-script.workspace = true unindent = { workspace = true, optional = true } @@ -94,6 +96,7 @@ uuid.workspace = true vim_mode_setting.workspace = true workspace.workspace = true zed_actions.workspace = true +zlog.workspace = true [dev-dependencies] criterion.workspace = true @@ -118,6 +121,7 @@ tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true tree-sitter-yaml.workspace = true tree-sitter-bash.workspace = true +tree-sitter-md.workspace = true unindent.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs index 4323c6c973f3729623d8939ca89ecf3ac403bcbf..daaeede790cbd75a7238a81559513c5d3165a054 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/editor/benches/editor_render.rs @@ -29,7 +29,7 @@ fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &TestAppContext ); editor }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); @@ -72,7 +72,7 @@ fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, Tes editor.set_style(editor::EditorStyle::default(), window, cx); editor }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); }); @@ -100,7 +100,7 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { editor.set_style(editor::EditorStyle::default(), window, cx); editor }); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index e823b06910fba67a38754ece6ad746f5f632e613..ba36f88f6380ade2a0d70f0f7ac3eb221446b781 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -327,6 +327,23 @@ pub struct AddSelectionBelow { pub skip_soft_wrap: bool, } +/// Inserts a snippet at the cursor. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct InsertSnippet { + /// Language name if using a named snippet, or `None` for a global snippet + /// + /// This is typically lowercase and matches the filename containing the snippet, without the `.json` extension. + pub language: Option, + /// Name if using a named snippet + pub name: Option, + + /// Snippet body, if not using a named snippet + // todo(andrew): use `ListOrDirect` or similar for multiline snippet body + pub snippet: Option, +} + actions!( debugger, [ @@ -353,7 +370,8 @@ actions!( AcceptEditPrediction, /// Accepts a partial edit prediction. #[action(deprecated_aliases = ["editor::AcceptPartialCopilotSuggestion"])] - AcceptPartialEditPrediction, + AcceptNextWordEditPrediction, + AcceptNextLineEditPrediction, /// Applies all diff hunks in the editor. ApplyAllDiffHunks, /// Applies the diff hunk at the current position. @@ -453,8 +471,6 @@ actions!( CollapseAllDiffHunks, /// Expands macros recursively at cursor position. ExpandMacroRecursively, - /// Finds all references to the symbol at cursor. - FindAllReferences, /// Finds the next match in the search. FindNextMatch, /// Finds the previous match in the search. @@ -665,6 +681,10 @@ actions!( ReloadFile, /// Rewraps text to fit within the preferred line length. Rewrap, + /// Rotates selections or lines backward. + RotateSelectionsBackward, + /// Rotates selections or lines forward. + RotateSelectionsForward, /// Runs flycheck diagnostics. RunFlycheck, /// Scrolls the cursor to the bottom of the viewport. @@ -827,3 +847,20 @@ actions!( WrapSelectionsInTag ] ); + +/// Finds all references to the symbol at cursor. +#[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct FindAllReferences { + #[serde(default = "default_true")] + pub always_open_multibuffer: bool, +} + +impl Default for FindAllReferences { + fn default() -> Self { + Self { + always_open_multibuffer: true, + } + } +} diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 053ddbc002a95ee65ca34088310afb16a1141b82..ee7e785ed30a14bce53bb777b67bdf69a9cecd07 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -45,7 +45,7 @@ impl Editor { let bracket_matches_by_accent = self.visible_excerpts(false, cx).into_iter().fold( HashMap::default(), - |mut acc, (excerpt_id, (buffer, buffer_version, buffer_range))| { + |mut acc, (excerpt_id, (buffer, _, buffer_range))| { let buffer_snapshot = buffer.read(cx).snapshot(); if language_settings::language_settings( buffer_snapshot.language().map(|language| language.name()), @@ -62,7 +62,7 @@ impl Editor { let brackets_by_accent = buffer_snapshot .fetch_bracket_ranges( buffer_range.start..buffer_range.end, - Some((&buffer_version, fetched_chunks)), + Some(fetched_chunks), ) .into_iter() .flat_map(|(chunk_range, pairs)| { @@ -333,6 +333,74 @@ where &bracket_colors_markup(&mut cx), "All markdown brackets should be colored based on their depth" ); + + cx.set_state(indoc! {r#"ˇ{{}}"#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + 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) +"#, + &bracket_colors_markup(&mut cx), + "All markdown brackets should be colored based on their depth, again" + ); + } + + #[gpui::test] + async fn test_bracket_colorization_after_language_swap(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + + let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); + language_registry.add(markdown_lang()); + language_registry.add(rust_lang()); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language_registry(language_registry.clone()); + buffer.set_language(Some(markdown_lang()), cx); + }); + + cx.set_state(indoc! {r#" + fn main() { + let v: Vec = vec![]; + } + "#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"fn main«1()1» «1{ + let v: Vec = vec!«2[]2»; +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "Markdown does not colorize <> brackets" + ); + + cx.update_buffer(|buffer, cx| { + buffer.set_language(Some(rust_lang()), cx); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"fn main«1()1» «1{ + let v: Vec«22» = vec!«2[]2»; +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "After switching to Rust, <> brackets are now colorized" + ); } #[gpui::test] diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index dcd96674207f02101b4066924b011d2b9ebd7a08..e5520be88e34307220126ebafdba6c6371a5db12 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -51,6 +51,8 @@ pub const MENU_ASIDE_MIN_WIDTH: Pixels = px(260.); pub const MENU_ASIDE_MAX_WIDTH: Pixels = px(500.); pub const COMPLETION_MENU_MIN_WIDTH: Pixels = px(280.); pub const COMPLETION_MENU_MAX_WIDTH: Pixels = px(540.); +pub const CODE_ACTION_MENU_MIN_WIDTH: Pixels = px(220.); +pub const CODE_ACTION_MENU_MAX_WIDTH: Pixels = px(540.); // Constants for the markdown cache. The purpose of this cache is to reduce flickering due to // documentation not yet being parsed. @@ -179,7 +181,7 @@ impl CodeContextMenu { ) -> Option { match self { CodeContextMenu::Completions(menu) => menu.render_aside(max_size, window, cx), - CodeContextMenu::CodeActions(_) => None, + CodeContextMenu::CodeActions(menu) => menu.render_aside(max_size, window, cx), } } @@ -206,6 +208,13 @@ impl CodeContextMenu { CodeContextMenu::CodeActions(_) => (), } } + + pub fn primary_scroll_handle(&self) -> UniformListScrollHandle { + match self { + CodeContextMenu::Completions(menu) => menu.scroll_handle.clone(), + CodeContextMenu::CodeActions(menu) => menu.scroll_handle.clone(), + } + } } pub enum ContextMenuOrigin { @@ -303,6 +312,7 @@ impl CompletionsMenu { is_incomplete: bool, buffer: Entity, completions: Box<[Completion]>, + scroll_handle: Option, display_options: CompletionDisplayOptions, snippet_sort_order: SnippetSortOrder, language_registry: Option>, @@ -332,7 +342,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: true, last_rendered_range: RefCell::new(None).into(), @@ -354,6 +364,7 @@ impl CompletionsMenu { choices: &Vec, selection: Range, buffer: Entity, + scroll_handle: Option, snippet_sort_order: SnippetSortOrder, ) -> Self { let completions = choices @@ -404,7 +415,7 @@ impl CompletionsMenu { selected_item: 0, filter_task: Task::ready(()), cancel_filter: Arc::new(AtomicBool::new(false)), - scroll_handle: UniformListScrollHandle::new(), + scroll_handle: scroll_handle.unwrap_or_else(UniformListScrollHandle::new), scroll_handle_aside: ScrollHandle::new(), resolve_completions: false, show_completion_documentation: false, @@ -882,7 +893,7 @@ impl CompletionsMenu { None } else { Some( - Label::new(text.clone()) + Label::new(text.trim().to_string()) .ml_4() .size(LabelSize::Small) .color(Color::Muted), @@ -1410,26 +1421,6 @@ pub enum CodeActionsItem { } impl CodeActionsItem { - fn as_task(&self) -> Option<&ResolvedTask> { - let Self::Task(_, task) = self else { - return None; - }; - Some(task) - } - - fn as_code_action(&self) -> Option<&CodeAction> { - let Self::CodeAction { action, .. } = self else { - return None; - }; - Some(action) - } - fn as_debug_scenario(&self) -> Option<&DebugScenario> { - let Self::DebugScenario(scenario) = self else { - return None; - }; - Some(scenario) - } - pub fn label(&self) -> String { match self { Self::CodeAction { action, .. } => action.lsp_action.title().to_owned(), @@ -1437,6 +1428,14 @@ impl CodeActionsItem { Self::DebugScenario(scenario) => scenario.label.to_string(), } } + + pub fn menu_label(&self) -> String { + match self { + Self::CodeAction { action, .. } => action.lsp_action.title().replace("\n", ""), + Self::Task(_, task) => task.resolved_label.replace("\n", ""), + Self::DebugScenario(scenario) => format!("debug: {}", scenario.label), + } + } } pub struct CodeActionsMenu { @@ -1546,60 +1545,33 @@ impl CodeActionsMenu { let item_ix = range.start + ix; let selected = item_ix == selected_item; let colors = cx.theme().colors(); - div().min_w(px(220.)).max_w(px(540.)).child( - ListItem::new(item_ix) - .inset(true) - .toggle_state(selected) - .when_some(action.as_code_action(), |this, action| { - this.child( - h_flex() - .overflow_hidden() - .when(is_quick_action_bar, |this| this.text_ui(cx)) - .child( - // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here. - action.lsp_action.title().replace("\n", ""), - ) - .when(selected, |this| { - this.text_color(colors.text_accent) - }), - ) - }) - .when_some(action.as_task(), |this, task| { - this.child( - h_flex() - .overflow_hidden() - .when(is_quick_action_bar, |this| this.text_ui(cx)) - .child(task.resolved_label.replace("\n", "")) - .when(selected, |this| { - this.text_color(colors.text_accent) - }), - ) - }) - .when_some(action.as_debug_scenario(), |this, scenario| { - this.child( - h_flex() - .overflow_hidden() - .when(is_quick_action_bar, |this| this.text_ui(cx)) - .child("debug: ") - .child(scenario.label.clone()) - .when(selected, |this| { - this.text_color(colors.text_accent) - }), - ) - }) - .on_click(cx.listener(move |editor, _, window, cx| { - cx.stop_propagation(); - if let Some(task) = editor.confirm_code_action( - &ConfirmCodeAction { - item_ix: Some(item_ix), - }, - window, - cx, - ) { - task.detach_and_log_err(cx) - } - })), - ) + + ListItem::new(item_ix) + .inset(true) + .toggle_state(selected) + .overflow_x() + .child( + div() + .min_w(CODE_ACTION_MENU_MIN_WIDTH) + .max_w(CODE_ACTION_MENU_MAX_WIDTH) + .overflow_hidden() + .text_ellipsis() + .when(is_quick_action_bar, |this| this.text_ui(cx)) + .when(selected, |this| this.text_color(colors.text_accent)) + .child(action.menu_label()), + ) + .on_click(cx.listener(move |editor, _, window, cx| { + cx.stop_propagation(); + if let Some(task) = editor.confirm_code_action( + &ConfirmCodeAction { + item_ix: Some(item_ix), + }, + window, + cx, + ) { + task.detach_and_log_err(cx) + } + })) }) .collect() }), @@ -1626,4 +1598,46 @@ impl CodeActionsMenu { Popover::new().child(list).into_any_element() } + + fn render_aside( + &mut self, + max_size: Size, + window: &mut Window, + _cx: &mut Context, + ) -> Option { + let Some(action) = self.actions.get(self.selected_item) else { + return None; + }; + + let label = action.menu_label(); + let text_system = window.text_system(); + let mut line_wrapper = text_system.line_wrapper( + window.text_style().font(), + window.text_style().font_size.to_pixels(window.rem_size()), + ); + let is_truncated = line_wrapper.should_truncate_line( + &label, + CODE_ACTION_MENU_MAX_WIDTH, + "…", + gpui::TruncateFrom::End, + ); + + if is_truncated.is_none() { + return None; + } + + Some( + Popover::new() + .child( + div() + .child(label) + .id("code_actions_menu_extended") + .px(MENU_ASIDE_X_PADDING / 2.) + .max_w(max_size.width) + .max_h(max_size.height) + .occlude(), + ) + .into_any_element(), + ) + } } diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 7189dd9f2061b4d542c7d50ee4b90c5681a9d86e..e7d0ef9f0faa88e888328d2028dea2b2b6457421 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -14,8 +14,57 @@ //! - [`DisplayMap`] that adds background highlights to the regions of text. //! Each one of those builds on top of preceding map. //! +//! ## Structure of the display map layers +//! +//! Each layer in the map (and the multibuffer itself to some extent) has a few +//! structures that are used to implement the public API available to the layer +//! above: +//! - a `Transform` type - this represents a region of text that the layer in +//! question is "managing", that it transforms into a more "processed" text +//! for the layer above. For example, the inlay map has an `enum Transform` +//! that has two variants: +//! - `Isomorphic`, representing a region of text that has no inlay hints (i.e. +//! is passed through the map transparently) +//! - `Inlay`, representing a location where an inlay hint is to be inserted. +//! - a `TransformSummary` type, which is usually a struct with two fields: +//! [`input: TextSummary`][`TextSummary`] and [`output: TextSummary`][`TextSummary`]. Here, +//! `input` corresponds to "text in the layer below", and `output` corresponds to the text +//! exposed to the layer above. So in the inlay map case, a `Transform::Isomorphic`'s summary is +//! just `input = output = summary`, where `summary` is the [`TextSummary`] stored in that +//! variant. Conversely, a `Transform::Inlay` always has an empty `input` summary, because it's +//! not "replacing" any text that exists on disk. The `output` is the summary of the inlay text +//! to be injected. - Various newtype wrappers for co-ordinate spaces (e.g. [`WrapRow`] +//! represents a row index, after soft-wrapping (and all lower layers)). +//! - A `Snapshot` type (e.g. [`InlaySnapshot`]) that captures the state of a layer at a specific +//! point in time. +//! - various APIs which drill through the layers below to work with the underlying text. Notably: +//! - `fn text_summary_for_offset()` returns a [`TextSummary`] for the range in the co-ordinate +//! space that the map in question is responsible for. +//! - `fn _point_to__point()` converts a point in co-ordinate space `A` into co-ordinate +//! space `B`. +//! - A [`RowInfo`] iterator (e.g. [`InlayBufferRows`]) and a [`Chunk`] iterator +//! (e.g. [`InlayChunks`]) +//! - A `sync` function (e.g. [`InlayMap::sync`]) that takes a snapshot and list of [`Edit`]s, +//! and returns a new snapshot and a list of transformed [`Edit`]s. Note that the generic +//! parameter on `Edit` changes, since these methods take in edits in the co-ordinate space of +//! the lower layer, and return edits in their own co-ordinate space. The term "edit" is +//! slightly misleading, since an [`Edit`] doesn't tell you what changed - rather it can be +//! thought of as a "region to invalidate". In theory, it would be correct to always use a +//! single edit that covers the entire range. However, this would lead to lots of unnecessary +//! recalculation. +//! +//! See the docs for the [`inlay_map`] module for a more in-depth explanation of how a single layer +//! works. +//! //! [Editor]: crate::Editor //! [EditorElement]: crate::element::EditorElement +//! [`TextSummary`]: multi_buffer::MBTextSummary +//! [`WrapRow`]: wrap_map::WrapRow +//! [`InlayBufferRows`]: inlay_map::InlayBufferRows +//! [`InlayChunks`]: inlay_map::InlayChunks +//! [`Edit`]: text::Edit +//! [`Edit`]: text::Edit +//! [`Chunk`]: language::Chunk #[macro_use] mod dimensions; @@ -56,6 +105,7 @@ use sum_tree::{Bias, TreeMap}; use text::{BufferId, LineIndent}; use ui::{SharedString, px}; use unicode_segmentation::UnicodeSegmentation; +use ztracing::instrument; use std::{ any::TypeId, @@ -168,6 +218,7 @@ impl DisplayMap { } } + #[instrument(skip_all)] pub fn snapshot(&mut self, cx: &mut Context) -> DisplaySnapshot { let tab_size = Self::tab_size(&self.buffer, cx); @@ -193,6 +244,7 @@ impl DisplayMap { } } + #[instrument(skip_all)] pub fn set_state(&mut self, other: &DisplaySnapshot, cx: &mut Context) { self.fold( other @@ -209,6 +261,7 @@ impl DisplayMap { } /// Creates folds for the given creases. + #[instrument(skip_all)] pub fn fold(&mut self, creases: Vec>, cx: &mut Context) { let buffer_snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); @@ -277,6 +330,7 @@ impl DisplayMap { } /// Removes any folds with the given ranges. + #[instrument(skip_all)] pub fn remove_folds_with_type( &mut self, ranges: impl IntoIterator>, @@ -302,6 +356,7 @@ impl DisplayMap { } /// Removes any folds whose ranges intersect any of the given ranges. + #[instrument(skip_all)] pub fn unfold_intersecting( &mut self, ranges: impl IntoIterator>, @@ -333,6 +388,7 @@ impl DisplayMap { block_map.remove_intersecting_replace_blocks(offset_ranges, inclusive); } + #[instrument(skip_all)] pub fn disable_header_for_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { let snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); @@ -347,6 +403,7 @@ impl DisplayMap { block_map.disable_header_for_buffer(buffer_id) } + #[instrument(skip_all)] pub fn fold_buffers( &mut self, buffer_ids: impl IntoIterator, @@ -365,6 +422,7 @@ impl DisplayMap { block_map.fold_buffers(buffer_ids, self.buffer.read(cx), cx) } + #[instrument(skip_all)] pub fn unfold_buffers( &mut self, buffer_ids: impl IntoIterator, @@ -383,14 +441,17 @@ impl DisplayMap { block_map.unfold_buffers(buffer_ids, self.buffer.read(cx), cx) } + #[instrument(skip_all)] pub(crate) fn is_buffer_folded(&self, buffer_id: language::BufferId) -> bool { self.block_map.folded_buffers.contains(&buffer_id) } + #[instrument(skip_all)] pub(crate) fn folded_buffers(&self) -> &HashSet { &self.block_map.folded_buffers } + #[instrument(skip_all)] pub fn insert_creases( &mut self, creases: impl IntoIterator>, @@ -400,6 +461,7 @@ impl DisplayMap { self.crease_map.insert(creases, &snapshot) } + #[instrument(skip_all)] pub fn remove_creases( &mut self, crease_ids: impl IntoIterator, @@ -409,6 +471,7 @@ impl DisplayMap { self.crease_map.remove(crease_ids, &snapshot) } + #[instrument(skip_all)] pub fn insert_blocks( &mut self, blocks: impl IntoIterator>, @@ -427,6 +490,7 @@ impl DisplayMap { block_map.insert(blocks) } + #[instrument(skip_all)] pub fn resize_blocks(&mut self, heights: HashMap, cx: &mut Context) { let snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); @@ -441,10 +505,12 @@ impl DisplayMap { block_map.resize(heights); } + #[instrument(skip_all)] pub fn replace_blocks(&mut self, renderers: HashMap) { self.block_map.replace_blocks(renderers); } + #[instrument(skip_all)] pub fn remove_blocks(&mut self, ids: HashSet, cx: &mut Context) { let snapshot = self.buffer.read(cx).snapshot(cx); let edits = self.buffer_subscription.consume().into_inner(); @@ -459,6 +525,7 @@ impl DisplayMap { block_map.remove(ids); } + #[instrument(skip_all)] pub fn row_for_block( &mut self, block_id: CustomBlockId, @@ -478,6 +545,7 @@ impl DisplayMap { Some(DisplayRow(block_row.0)) } + #[instrument(skip_all)] pub fn highlight_text( &mut self, key: HighlightKey, @@ -505,6 +573,7 @@ impl DisplayMap { self.text_highlights.insert(key, to_insert); } + #[instrument(skip_all)] pub(crate) fn highlight_inlays( &mut self, type_id: TypeId, @@ -524,6 +593,7 @@ impl DisplayMap { } } + #[instrument(skip_all)] pub fn text_highlights(&self, type_id: TypeId) -> Option<(HighlightStyle, &[Range])> { let highlights = self.text_highlights.get(&HighlightKey::Type(type_id))?; Some((highlights.0, &highlights.1)) @@ -536,6 +606,7 @@ impl DisplayMap { self.text_highlights.values() } + #[instrument(skip_all)] pub fn clear_highlights(&mut self, type_id: TypeId) -> bool { let mut cleared = self .text_highlights @@ -564,6 +635,7 @@ impl DisplayMap { .update(cx, |map, cx| map.set_wrap_width(width, cx)) } + #[instrument(skip_all)] pub fn update_fold_widths( &mut self, widths: impl IntoIterator, @@ -595,6 +667,7 @@ impl DisplayMap { self.inlay_map.current_inlays() } + #[instrument(skip_all)] pub(crate) fn splice_inlays( &mut self, to_remove: &[InlayId], @@ -624,6 +697,7 @@ impl DisplayMap { self.block_map.read(snapshot, edits); } + #[instrument(skip_all)] fn tab_size(buffer: &Entity, cx: &App) -> NonZeroU32 { let buffer = buffer.read(cx).as_singleton().map(|buffer| buffer.read(cx)); let language = buffer @@ -673,6 +747,7 @@ pub struct HighlightedChunk<'a> { } impl<'a> HighlightedChunk<'a> { + #[instrument(skip_all)] fn highlight_invisibles( self, editor_style: &'a EditorStyle, @@ -830,6 +905,7 @@ impl DisplaySnapshot { self.buffer_snapshot().widest_line_number() } + #[instrument(skip_all)] pub fn prev_line_boundary(&self, mut point: MultiBufferPoint) -> (Point, DisplayPoint) { loop { let mut inlay_point = self.inlay_snapshot().to_inlay_point(point); @@ -848,6 +924,7 @@ impl DisplaySnapshot { } } + #[instrument(skip_all)] pub fn next_line_boundary( &self, mut point: MultiBufferPoint, @@ -886,6 +963,7 @@ impl DisplaySnapshot { new_start..new_end } + #[instrument(skip_all)] pub fn point_to_display_point(&self, point: MultiBufferPoint, bias: Bias) -> DisplayPoint { let inlay_point = self.inlay_snapshot().to_inlay_point(point); let fold_point = self.fold_snapshot().to_fold_point(inlay_point, bias); @@ -915,6 +993,7 @@ impl DisplaySnapshot { .anchor_at(point.to_offset(self, bias), bias) } + #[instrument(skip_all)] fn display_point_to_inlay_point(&self, point: DisplayPoint, bias: Bias) -> InlayPoint { let block_point = point.0; let wrap_point = self.block_snapshot.to_wrap_point(block_point, bias); @@ -926,6 +1005,7 @@ impl DisplaySnapshot { fold_point.to_inlay_point(self.fold_snapshot()) } + #[instrument(skip_all)] pub fn display_point_to_fold_point(&self, point: DisplayPoint, bias: Bias) -> FoldPoint { let block_point = point.0; let wrap_point = self.block_snapshot.to_wrap_point(block_point, bias); @@ -935,6 +1015,7 @@ impl DisplaySnapshot { .0 } + #[instrument(skip_all)] pub fn fold_point_to_display_point(&self, fold_point: FoldPoint) -> DisplayPoint { let tab_point = self.tab_snapshot().fold_point_to_tab_point(fold_point); let wrap_point = self.wrap_snapshot().tab_point_to_wrap_point(tab_point); @@ -947,6 +1028,7 @@ impl DisplaySnapshot { } /// Returns text chunks starting at the given display row until the end of the file + #[instrument(skip_all)] pub fn text_chunks(&self, display_row: DisplayRow) -> impl Iterator { self.block_snapshot .chunks( @@ -959,6 +1041,7 @@ impl DisplaySnapshot { } /// Returns text chunks starting at the end of the given display row in reverse until the start of the file + #[instrument(skip_all)] pub fn reverse_text_chunks(&self, display_row: DisplayRow) -> impl Iterator { (0..=display_row.0).rev().flat_map(move |row| { self.block_snapshot @@ -975,6 +1058,7 @@ impl DisplaySnapshot { }) } + #[instrument(skip_all)] pub fn chunks( &self, display_rows: Range, @@ -993,6 +1077,7 @@ impl DisplaySnapshot { ) } + #[instrument(skip_all)] pub fn highlighted_chunks<'a>( &'a self, display_rows: Range, @@ -1069,6 +1154,7 @@ impl DisplaySnapshot { }) } + #[instrument(skip_all)] pub fn layout_row( &self, display_row: DisplayRow, @@ -1130,6 +1216,7 @@ impl DisplaySnapshot { layout_line.closest_index_for_x(x) as u32 } + #[instrument(skip_all)] pub fn grapheme_at(&self, mut point: DisplayPoint) -> Option { point = DisplayPoint(self.block_snapshot.clip_point(point.0, Bias::Left)); let chars = self @@ -1319,6 +1406,7 @@ impl DisplaySnapshot { .unwrap_or(false) } + #[instrument(skip_all)] pub fn crease_for_buffer_row(&self, buffer_row: MultiBufferRow) -> Option> { let start = MultiBufferPoint::new(buffer_row.0, self.buffer_snapshot().line_len(buffer_row)); @@ -1405,6 +1493,7 @@ impl DisplaySnapshot { } #[cfg(any(test, feature = "test-support"))] + #[instrument(skip_all)] pub fn text_highlight_ranges( &self, ) -> Option>)>> { @@ -1415,6 +1504,7 @@ impl DisplaySnapshot { } #[cfg(any(test, feature = "test-support"))] + #[instrument(skip_all)] pub fn all_text_highlight_ranges( &self, ) -> Vec<(gpui::Hsla, Range)> { @@ -1464,6 +1554,7 @@ impl DisplaySnapshot { /// /// This moves by buffer rows instead of display rows, a distinction that is /// important when soft wrapping is enabled. + #[instrument(skip_all)] pub fn start_of_relative_buffer_row(&self, point: DisplayPoint, times: isize) -> DisplayPoint { let start = self.display_point_to_fold_point(point, Bias::Left); let target = start.row() as isize + times; diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index a6744041971101dafa4957523fb7a16250f38996..15bf012cd907da2455c1a2205bcccd363162fd46 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -164,6 +164,7 @@ impl BlockPlacement { } impl BlockPlacement { + #[ztracing::instrument(skip_all)] fn cmp(&self, other: &Self, buffer: &MultiBufferSnapshot) -> Ordering { self.start() .cmp(other.start(), buffer) @@ -171,6 +172,7 @@ impl BlockPlacement { .then_with(|| self.tie_break().cmp(&other.tie_break())) } + #[ztracing::instrument(skip_all)] fn to_wrap_row(&self, wrap_snapshot: &WrapSnapshot) -> Option> { let buffer_snapshot = wrap_snapshot.buffer_snapshot(); match self { @@ -474,6 +476,7 @@ pub struct BlockRows<'a> { } impl BlockMap { + #[ztracing::instrument(skip_all)] pub fn new( wrap_snapshot: WrapSnapshot, buffer_header_height: u32, @@ -503,6 +506,7 @@ impl BlockMap { map } + #[ztracing::instrument(skip_all)] pub fn read(&self, wrap_snapshot: WrapSnapshot, edits: WrapPatch) -> BlockMapReader<'_> { self.sync(&wrap_snapshot, edits); *self.wrap_snapshot.borrow_mut() = wrap_snapshot.clone(); @@ -518,13 +522,17 @@ impl BlockMap { } } + #[ztracing::instrument(skip_all)] pub fn write(&mut self, wrap_snapshot: WrapSnapshot, edits: WrapPatch) -> BlockMapWriter<'_> { self.sync(&wrap_snapshot, edits); *self.wrap_snapshot.borrow_mut() = wrap_snapshot; BlockMapWriter(self) } + #[ztracing::instrument(skip_all, fields(edits = ?edits))] fn sync(&self, wrap_snapshot: &WrapSnapshot, mut edits: WrapPatch) { + let _timer = zlog::time!("BlockMap::sync").warn_if_gt(std::time::Duration::from_millis(50)); + let buffer = wrap_snapshot.buffer_snapshot(); // Handle changing the last excerpt if it is empty. @@ -537,7 +545,7 @@ impl BlockMap { { let max_point = wrap_snapshot.max_point(); let edit_start = wrap_snapshot.prev_row_boundary(max_point); - let edit_end = max_point.row() + WrapRow(1); + let edit_end = max_point.row() + WrapRow(1); // this is end of file edits = edits.compose([WrapEdit { old: edit_start..edit_end, new: edit_start..edit_end, @@ -562,6 +570,9 @@ impl BlockMap { let mut wrap_point_cursor = wrap_snapshot.wrap_point_cursor(); while let Some(edit) = edits.next() { + let span = ztracing::debug_span!("while edits", edit = ?edit); + let _enter = span.enter(); + let mut old_start = edit.old.start; let mut new_start = edit.new.start; @@ -620,6 +631,8 @@ impl BlockMap { let mut old_end = edit.old.end; let mut new_end = edit.new.end; loop { + let span = ztracing::debug_span!("decide where edit ends loop"); + let _enter = span.enter(); // Seek to the transform starting at or after the end of the edit cursor.seek(&old_end, Bias::Left); cursor.next(); @@ -702,6 +715,7 @@ impl BlockMap { let placement = block.placement.to_wrap_row(wrap_snapshot)?; if let BlockPlacement::Above(row) = placement && row < new_start + // this will be true more often now { return None; } @@ -728,6 +742,10 @@ impl BlockMap { // and then insert the block itself. let mut just_processed_folded_buffer = false; for (block_placement, block) in blocks_in_edit.drain(..) { + let span = + ztracing::debug_span!("for block in edits", block_height = block.height()); + let _enter = span.enter(); + let mut summary = TransformSummary { input_rows: WrapRow(0), output_rows: BlockRow(block.height()), @@ -784,6 +802,7 @@ impl BlockMap { *transforms = new_transforms; } + #[ztracing::instrument(skip_all)] pub fn replace_blocks(&mut self, mut renderers: HashMap) { for block in &mut self.custom_blocks { if let Some(render) = renderers.remove(&block.id) { @@ -793,6 +812,7 @@ impl BlockMap { } /// Guarantees that `wrap_row_for` is called with points in increasing order. + #[ztracing::instrument(skip_all)] fn header_and_footer_blocks<'a, R, T>( &'a self, buffer: &'a multi_buffer::MultiBufferSnapshot, @@ -880,6 +900,7 @@ impl BlockMap { }) } + #[ztracing::instrument(skip_all)] fn sort_blocks(blocks: &mut Vec<(BlockPlacement, Block)>) { blocks.sort_unstable_by(|(placement_a, block_a), (placement_b, block_b)| { placement_a @@ -946,6 +967,7 @@ impl BlockMap { } } +#[ztracing::instrument(skip(tree, wrap_snapshot))] fn push_isomorphic(tree: &mut SumTree, rows: RowDelta, wrap_snapshot: &WrapSnapshot) { if rows == RowDelta(0) { return; @@ -1016,6 +1038,7 @@ impl DerefMut for BlockMapReader<'_> { } impl BlockMapReader<'_> { + #[ztracing::instrument(skip_all)] pub fn row_for_block(&self, block_id: CustomBlockId) -> Option { let block = self.blocks.iter().find(|block| block.id == block_id)?; let buffer_row = block @@ -1054,6 +1077,7 @@ impl BlockMapReader<'_> { } impl BlockMapWriter<'_> { + #[ztracing::instrument(skip_all)] pub fn insert( &mut self, blocks: impl IntoIterator>, @@ -1120,6 +1144,7 @@ impl BlockMapWriter<'_> { ids } + #[ztracing::instrument(skip_all)] pub fn resize(&mut self, mut heights: HashMap) { let wrap_snapshot = &*self.0.wrap_snapshot.borrow(); let buffer = wrap_snapshot.buffer_snapshot(); @@ -1172,6 +1197,7 @@ impl BlockMapWriter<'_> { self.0.sync(wrap_snapshot, edits); } + #[ztracing::instrument(skip_all)] pub fn remove(&mut self, block_ids: HashSet) { let wrap_snapshot = &*self.0.wrap_snapshot.borrow(); let buffer = wrap_snapshot.buffer_snapshot(); @@ -1217,6 +1243,7 @@ impl BlockMapWriter<'_> { self.0.sync(wrap_snapshot, edits); } + #[ztracing::instrument(skip_all)] pub fn remove_intersecting_replace_blocks( &mut self, ranges: impl IntoIterator>, @@ -1239,6 +1266,7 @@ impl BlockMapWriter<'_> { self.0.buffers_with_disabled_headers.insert(buffer_id); } + #[ztracing::instrument(skip_all)] pub fn fold_buffers( &mut self, buffer_ids: impl IntoIterator, @@ -1248,6 +1276,7 @@ impl BlockMapWriter<'_> { self.fold_or_unfold_buffers(true, buffer_ids, multi_buffer, cx); } + #[ztracing::instrument(skip_all)] pub fn unfold_buffers( &mut self, buffer_ids: impl IntoIterator, @@ -1257,6 +1286,7 @@ impl BlockMapWriter<'_> { self.fold_or_unfold_buffers(false, buffer_ids, multi_buffer, cx); } + #[ztracing::instrument(skip_all)] fn fold_or_unfold_buffers( &mut self, fold: bool, @@ -1292,6 +1322,7 @@ impl BlockMapWriter<'_> { self.0.sync(&wrap_snapshot, edits); } + #[ztracing::instrument(skip_all)] fn blocks_intersecting_buffer_range( &self, range: Range, @@ -1326,6 +1357,7 @@ impl BlockMapWriter<'_> { impl BlockSnapshot { #[cfg(test)] + #[ztracing::instrument(skip_all)] pub fn text(&self) -> String { self.chunks( BlockRow(0)..self.transforms.summary().output_rows, @@ -1337,6 +1369,7 @@ impl BlockSnapshot { .collect() } + #[ztracing::instrument(skip_all)] pub(crate) fn chunks<'a>( &'a self, rows: Range, @@ -1378,6 +1411,7 @@ impl BlockSnapshot { } } + #[ztracing::instrument(skip_all)] pub(super) fn row_infos(&self, start_row: BlockRow) -> BlockRows<'_> { let mut cursor = self.transforms.cursor::>(()); cursor.seek(&start_row, Bias::Right); @@ -1399,6 +1433,7 @@ impl BlockSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn blocks_in_range( &self, rows: Range, @@ -1432,6 +1467,7 @@ impl BlockSnapshot { }) } + #[ztracing::instrument(skip_all)] pub(crate) fn sticky_header_excerpt(&self, position: f64) -> Option> { let top_row = position as u32; let mut cursor = self.transforms.cursor::(()); @@ -1455,6 +1491,7 @@ impl BlockSnapshot { None } + #[ztracing::instrument(skip_all)] pub fn block_for_id(&self, block_id: BlockId) -> Option { let buffer = self.wrap_snapshot.buffer_snapshot(); let wrap_point = match block_id { @@ -1491,6 +1528,7 @@ impl BlockSnapshot { None } + #[ztracing::instrument(skip_all)] pub fn max_point(&self) -> BlockPoint { let row = self .transforms @@ -1500,10 +1538,12 @@ impl BlockSnapshot { BlockPoint::new(row, self.line_len(row)) } + #[ztracing::instrument(skip_all)] pub fn longest_row(&self) -> BlockRow { self.transforms.summary().longest_row } + #[ztracing::instrument(skip_all)] pub fn longest_row_in_range(&self, range: Range) -> BlockRow { let mut cursor = self.transforms.cursor::>(()); cursor.seek(&range.start, Bias::Right); @@ -1555,6 +1595,7 @@ impl BlockSnapshot { longest_row } + #[ztracing::instrument(skip_all)] pub(super) fn line_len(&self, row: BlockRow) -> u32 { let (start, _, item) = self.transforms @@ -1574,11 +1615,13 @@ impl BlockSnapshot { } } + #[ztracing::instrument(skip_all)] pub(super) fn is_block_line(&self, row: BlockRow) -> bool { let (_, _, item) = self.transforms.find::((), &row, Bias::Right); item.is_some_and(|t| t.block.is_some()) } + #[ztracing::instrument(skip_all)] pub(super) fn is_folded_buffer_header(&self, row: BlockRow) -> bool { let (_, _, item) = self.transforms.find::((), &row, Bias::Right); let Some(transform) = item else { @@ -1587,6 +1630,7 @@ impl BlockSnapshot { matches!(transform.block, Some(Block::FoldedBuffer { .. })) } + #[ztracing::instrument(skip_all)] pub(super) fn is_line_replaced(&self, row: MultiBufferRow) -> bool { let wrap_point = self .wrap_snapshot @@ -1602,6 +1646,7 @@ impl BlockSnapshot { }) } + #[ztracing::instrument(skip_all)] pub fn clip_point(&self, point: BlockPoint, bias: Bias) -> BlockPoint { let mut cursor = self.transforms.cursor::>(()); cursor.seek(&BlockRow(point.row), Bias::Right); @@ -1663,6 +1708,7 @@ impl BlockSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn to_block_point(&self, wrap_point: WrapPoint) -> BlockPoint { let (start, _, item) = self.transforms.find::, _>( (), @@ -1684,6 +1730,7 @@ impl BlockSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn to_wrap_point(&self, block_point: BlockPoint, bias: Bias) -> WrapPoint { let (start, end, item) = self.transforms.find::, _>( (), @@ -1719,6 +1766,7 @@ impl BlockSnapshot { impl BlockChunks<'_> { /// Go to the next transform + #[ztracing::instrument(skip_all)] fn advance(&mut self) { self.input_chunk = Chunk::default(); self.transforms.next(); @@ -1759,6 +1807,7 @@ pub struct StickyHeaderExcerpt<'a> { impl<'a> Iterator for BlockChunks<'a> { type Item = Chunk<'a>; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { if self.output_row >= self.max_output_row { return None; @@ -1858,6 +1907,7 @@ impl<'a> Iterator for BlockChunks<'a> { impl Iterator for BlockRows<'_> { type Item = RowInfo; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { if self.started { self.output_row.0 += 1; @@ -1960,14 +2010,17 @@ impl DerefMut for BlockContext<'_, '_> { } impl CustomBlock { + #[ztracing::instrument(skip_all)] pub fn render(&self, cx: &mut BlockContext) -> AnyElement { self.render.lock()(cx) } + #[ztracing::instrument(skip_all)] pub fn start(&self) -> Anchor { *self.placement.start() } + #[ztracing::instrument(skip_all)] pub fn end(&self) -> Anchor { *self.placement.end() } diff --git a/crates/editor/src/display_map/crease_map.rs b/crates/editor/src/display_map/crease_map.rs index a68c27886733d34a60ef0ce2ef4006b92b679db9..8f4a3781f4f335f1a3e61ec5a19818661a7c6ea5 100644 --- a/crates/editor/src/display_map/crease_map.rs +++ b/crates/editor/src/display_map/crease_map.rs @@ -19,6 +19,7 @@ pub struct CreaseMap { } impl CreaseMap { + #[ztracing::instrument(skip_all)] pub fn new(snapshot: &MultiBufferSnapshot) -> Self { CreaseMap { snapshot: CreaseSnapshot::new(snapshot), @@ -40,11 +41,13 @@ impl CreaseSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn creases(&self) -> impl Iterator)> { self.creases.iter().map(|item| (item.id, &item.crease)) } /// Returns the first Crease starting on the specified buffer row. + #[ztracing::instrument(skip_all)] pub fn query_row<'a>( &'a self, row: MultiBufferRow, @@ -69,6 +72,7 @@ impl CreaseSnapshot { None } + #[ztracing::instrument(skip_all)] pub fn creases_in_range<'a>( &'a self, range: Range, @@ -95,6 +99,7 @@ impl CreaseSnapshot { }) } + #[ztracing::instrument(skip_all)] pub fn crease_items_with_offsets( &self, snapshot: &MultiBufferSnapshot, @@ -156,6 +161,7 @@ pub struct CreaseMetadata { } impl Crease { + #[ztracing::instrument(skip_all)] pub fn simple(range: Range, placeholder: FoldPlaceholder) -> Self { Crease::Inline { range, @@ -166,6 +172,7 @@ impl Crease { } } + #[ztracing::instrument(skip_all)] pub fn block(range: Range, height: u32, style: BlockStyle, render: RenderBlock) -> Self { Self::Block { range, @@ -177,6 +184,7 @@ impl Crease { } } + #[ztracing::instrument(skip_all)] pub fn inline( range: Range, placeholder: FoldPlaceholder, @@ -216,6 +224,7 @@ impl Crease { } } + #[ztracing::instrument(skip_all)] pub fn with_metadata(self, metadata: CreaseMetadata) -> Self { match self { Crease::Inline { @@ -235,6 +244,7 @@ impl Crease { } } + #[ztracing::instrument(skip_all)] pub fn range(&self) -> &Range { match self { Crease::Inline { range, .. } => range, @@ -242,6 +252,7 @@ impl Crease { } } + #[ztracing::instrument(skip_all)] pub fn metadata(&self) -> Option<&CreaseMetadata> { match self { Self::Inline { metadata, .. } => metadata.as_ref(), @@ -287,6 +298,7 @@ impl CreaseMap { self.snapshot.clone() } + #[ztracing::instrument(skip_all)] pub fn insert( &mut self, creases: impl IntoIterator>, @@ -312,6 +324,7 @@ impl CreaseMap { new_ids } + #[ztracing::instrument(skip_all)] pub fn remove( &mut self, ids: impl IntoIterator, @@ -379,6 +392,7 @@ impl sum_tree::Summary for ItemSummary { impl sum_tree::Item for CreaseItem { type Summary = ItemSummary; + #[ztracing::instrument(skip_all)] fn summary(&self, _cx: &MultiBufferSnapshot) -> Self::Summary { ItemSummary { range: self.crease.range().clone(), @@ -388,12 +402,14 @@ impl sum_tree::Item for CreaseItem { /// Implements `SeekTarget` for `Range` to enable seeking within a `SumTree` of `CreaseItem`s. impl SeekTarget<'_, ItemSummary, ItemSummary> for Range { + #[ztracing::instrument(skip_all)] fn cmp(&self, cursor_location: &ItemSummary, snapshot: &MultiBufferSnapshot) -> Ordering { AnchorRangeExt::cmp(self, &cursor_location.range, snapshot) } } impl SeekTarget<'_, ItemSummary, ItemSummary> for Anchor { + #[ztracing::instrument(skip_all)] fn cmp(&self, other: &ItemSummary, snapshot: &MultiBufferSnapshot) -> Ordering { self.cmp(&other.range.start, snapshot) } @@ -461,6 +477,7 @@ mod test { } #[gpui::test] + #[ztracing::instrument(skip_all)] fn test_creases_in_range(cx: &mut App) { let text = "line1\nline2\nline3\nline4\nline5\nline6\nline7"; let buffer = MultiBuffer::build_simple(text, cx); diff --git a/crates/editor/src/display_map/custom_highlights.rs b/crates/editor/src/display_map/custom_highlights.rs index a40d1adc82f4bc79308eaec901586232e9e2e5c2..1ece2493e3228536999036a32959a6228f0f7cd1 100644 --- a/crates/editor/src/display_map/custom_highlights.rs +++ b/crates/editor/src/display_map/custom_highlights.rs @@ -30,6 +30,7 @@ struct HighlightEndpoint { } impl<'a> CustomHighlightsChunks<'a> { + #[ztracing::instrument(skip_all)] pub fn new( range: Range, language_aware: bool, @@ -51,6 +52,7 @@ impl<'a> CustomHighlightsChunks<'a> { } } + #[ztracing::instrument(skip_all)] pub fn seek(&mut self, new_range: Range) { self.highlight_endpoints = create_highlight_endpoints(&new_range, self.text_highlights, self.multibuffer_snapshot); @@ -77,12 +79,15 @@ fn create_highlight_endpoints( let start_ix = ranges .binary_search_by(|probe| probe.end.cmp(&start, buffer).then(cmp::Ordering::Less)) .unwrap_or_else(|i| i); + let end_ix = ranges[start_ix..] + .binary_search_by(|probe| { + probe.start.cmp(&end, buffer).then(cmp::Ordering::Greater) + }) + .unwrap_or_else(|i| i); - for range in &ranges[start_ix..] { - if range.start.cmp(&end, buffer).is_ge() { - break; - } + highlight_endpoints.reserve(2 * end_ix); + for range in &ranges[start_ix..][..end_ix] { let start = range.start.to_offset(buffer); let end = range.end.to_offset(buffer); if start == end { @@ -108,6 +113,7 @@ fn create_highlight_endpoints( impl<'a> Iterator for CustomHighlightsChunks<'a> { type Item = Chunk<'a>; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { let mut next_highlight_endpoint = MultiBufferOffset(usize::MAX); while let Some(endpoint) = self.highlight_endpoints.peek().copied() { diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index 2d37dea38a93cc609a3a92064a6e35cdc76eb3da..bb0d6885acc2afd95e97fe9121acd2d0580554f3 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -99,6 +99,7 @@ impl FoldPoint { &mut self.0.column } + #[ztracing::instrument(skip_all)] pub fn to_inlay_point(self, snapshot: &FoldSnapshot) -> InlayPoint { let (start, _, _) = snapshot .transforms @@ -107,6 +108,7 @@ impl FoldPoint { InlayPoint(start.1.0 + overshoot) } + #[ztracing::instrument(skip_all)] pub fn to_offset(self, snapshot: &FoldSnapshot) -> FoldOffset { let (start, _, item) = snapshot .transforms @@ -138,6 +140,7 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for FoldPoint { pub(crate) struct FoldMapWriter<'a>(&'a mut FoldMap); impl FoldMapWriter<'_> { + #[ztracing::instrument(skip_all)] pub(crate) fn fold( &mut self, ranges: impl IntoIterator, FoldPlaceholder)>, @@ -202,6 +205,7 @@ impl FoldMapWriter<'_> { } /// Removes any folds with the given ranges. + #[ztracing::instrument(skip_all)] pub(crate) fn remove_folds( &mut self, ranges: impl IntoIterator>, @@ -215,6 +219,7 @@ impl FoldMapWriter<'_> { } /// Removes any folds whose ranges intersect the given ranges. + #[ztracing::instrument(skip_all)] pub(crate) fn unfold_intersecting( &mut self, ranges: impl IntoIterator>, @@ -225,6 +230,7 @@ impl FoldMapWriter<'_> { /// Removes any folds that intersect the given ranges and for which the given predicate /// returns true. + #[ztracing::instrument(skip_all)] fn remove_folds_with( &mut self, ranges: impl IntoIterator>, @@ -277,6 +283,7 @@ impl FoldMapWriter<'_> { (self.0.snapshot.clone(), edits) } + #[ztracing::instrument(skip_all)] pub(crate) fn update_fold_widths( &mut self, new_widths: impl IntoIterator, @@ -326,6 +333,7 @@ pub struct FoldMap { } impl FoldMap { + #[ztracing::instrument(skip_all)] pub fn new(inlay_snapshot: InlaySnapshot) -> (Self, FoldSnapshot) { let this = Self { snapshot: FoldSnapshot { @@ -350,6 +358,7 @@ impl FoldMap { (this, snapshot) } + #[ztracing::instrument(skip_all)] pub fn read( &mut self, inlay_snapshot: InlaySnapshot, @@ -360,6 +369,7 @@ impl FoldMap { (self.snapshot.clone(), edits) } + #[ztracing::instrument(skip_all)] pub(crate) fn write( &mut self, inlay_snapshot: InlaySnapshot, @@ -369,6 +379,7 @@ impl FoldMap { (FoldMapWriter(self), snapshot, edits) } + #[ztracing::instrument(skip_all)] fn check_invariants(&self) { if cfg!(test) { assert_eq!( @@ -398,6 +409,7 @@ impl FoldMap { } } + #[ztracing::instrument(skip_all)] fn sync( &mut self, inlay_snapshot: InlaySnapshot, @@ -645,6 +657,7 @@ impl FoldSnapshot { &self.inlay_snapshot.buffer } + #[ztracing::instrument(skip_all)] fn fold_width(&self, fold_id: &FoldId) -> Option { self.fold_metadata_by_id.get(fold_id)?.width } @@ -665,6 +678,7 @@ impl FoldSnapshot { self.folds.items(&self.inlay_snapshot.buffer).len() } + #[ztracing::instrument(skip_all)] pub fn text_summary_for_range(&self, range: Range) -> MBTextSummary { let mut summary = MBTextSummary::default(); @@ -718,6 +732,7 @@ impl FoldSnapshot { summary } + #[ztracing::instrument(skip_all)] pub fn to_fold_point(&self, point: InlayPoint, bias: Bias) -> FoldPoint { let (start, end, item) = self .transforms @@ -734,6 +749,7 @@ impl FoldSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn fold_point_cursor(&self) -> FoldPointCursor<'_> { let cursor = self .transforms @@ -741,10 +757,12 @@ impl FoldSnapshot { FoldPointCursor { cursor } } + #[ztracing::instrument(skip_all)] pub fn len(&self) -> FoldOffset { FoldOffset(self.transforms.summary().output.len) } + #[ztracing::instrument(skip_all)] pub fn line_len(&self, row: u32) -> u32 { let line_start = FoldPoint::new(row, 0).to_offset(self).0; let line_end = if row >= self.max_point().row() { @@ -755,6 +773,7 @@ impl FoldSnapshot { (line_end - line_start) as u32 } + #[ztracing::instrument(skip_all)] pub fn row_infos(&self, start_row: u32) -> FoldRows<'_> { if start_row > self.transforms.summary().output.lines.row { panic!("invalid display row {}", start_row); @@ -777,6 +796,7 @@ impl FoldSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn max_point(&self) -> FoldPoint { FoldPoint(self.transforms.summary().output.lines) } @@ -786,6 +806,7 @@ impl FoldSnapshot { self.transforms.summary().output.longest_row } + #[ztracing::instrument(skip_all)] pub fn folds_in_range(&self, range: Range) -> impl Iterator where T: ToOffset, @@ -800,6 +821,7 @@ impl FoldSnapshot { }) } + #[ztracing::instrument(skip_all)] pub fn intersects_fold(&self, offset: T) -> bool where T: ToOffset, @@ -812,6 +834,7 @@ impl FoldSnapshot { item.is_some_and(|t| t.placeholder.is_some()) } + #[ztracing::instrument(skip_all)] pub fn is_line_folded(&self, buffer_row: MultiBufferRow) -> bool { let mut inlay_point = self .inlay_snapshot @@ -840,6 +863,7 @@ impl FoldSnapshot { } } + #[ztracing::instrument(skip_all)] pub(crate) fn chunks<'a>( &'a self, range: Range, @@ -884,6 +908,7 @@ impl FoldSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn chars_at(&self, start: FoldPoint) -> impl '_ + Iterator { self.chunks( start.to_offset(self)..self.len(), @@ -893,6 +918,7 @@ impl FoldSnapshot { .flat_map(|chunk| chunk.text.chars()) } + #[ztracing::instrument(skip_all)] pub fn chunks_at(&self, start: FoldPoint) -> FoldChunks<'_> { self.chunks( start.to_offset(self)..self.len(), @@ -902,6 +928,7 @@ impl FoldSnapshot { } #[cfg(test)] + #[ztracing::instrument(skip_all)] pub fn clip_offset(&self, offset: FoldOffset, bias: Bias) -> FoldOffset { if offset > self.len() { self.len() @@ -910,6 +937,7 @@ impl FoldSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn clip_point(&self, point: FoldPoint, bias: Bias) -> FoldPoint { let (start, end, item) = self .transforms @@ -939,6 +967,7 @@ pub struct FoldPointCursor<'transforms> { } impl FoldPointCursor<'_> { + #[ztracing::instrument(skip_all)] pub fn map(&mut self, point: InlayPoint, bias: Bias) -> FoldPoint { let cursor = &mut self.cursor; if cursor.did_seek() { @@ -1267,6 +1296,7 @@ pub struct FoldRows<'a> { } impl FoldRows<'_> { + #[ztracing::instrument(skip_all)] pub(crate) fn seek(&mut self, row: u32) { let fold_point = FoldPoint::new(row, 0); self.cursor.seek(&fold_point, Bias::Left); @@ -1280,6 +1310,7 @@ impl FoldRows<'_> { impl Iterator for FoldRows<'_> { type Item = RowInfo; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { let mut traversed_fold = false; while self.fold_point > self.cursor.end().0 { @@ -1391,6 +1422,7 @@ pub struct FoldChunks<'a> { } impl FoldChunks<'_> { + #[ztracing::instrument(skip_all)] pub(crate) fn seek(&mut self, range: Range) { self.transform_cursor.seek(&range.start, Bias::Right); @@ -1425,6 +1457,7 @@ impl FoldChunks<'_> { impl<'a> Iterator for FoldChunks<'a> { type Item = Chunk<'a>; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { if self.output_offset >= self.max_output_offset { return None; @@ -1524,6 +1557,7 @@ impl<'a> Iterator for FoldChunks<'a> { pub struct FoldOffset(pub MultiBufferOffset); impl FoldOffset { + #[ztracing::instrument(skip_all)] pub fn to_point(self, snapshot: &FoldSnapshot) -> FoldPoint { let (start, _, item) = snapshot .transforms @@ -1539,6 +1573,7 @@ impl FoldOffset { } #[cfg(test)] + #[ztracing::instrument(skip_all)] pub fn to_inlay_offset(self, snapshot: &FoldSnapshot) -> InlayOffset { let (start, _, _) = snapshot .transforms diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 73174c3018e1f76a16acbff3f4bad1c7af84da33..cbdc4b18fee452163c5a11932c968cb7cc500f96 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -1,3 +1,10 @@ +//! The inlay map. See the [`display_map`][super] docs for an overview of how the inlay map fits +//! into the rest of the [`DisplayMap`][super::DisplayMap]. Much of the documentation for this +//! module generalizes to other layers. +//! +//! The core of this module is the [`InlayMap`] struct, which maintains a vec of [`Inlay`]s, and +//! [`InlaySnapshot`], which holds a sum tree of [`Transform`]s. + use crate::{ ChunkRenderer, HighlightStyles, inlays::{Inlay, InlayContent}, @@ -52,6 +59,7 @@ enum Transform { impl sum_tree::Item for Transform { type Summary = TransformSummary; + #[ztracing::instrument(skip_all)] fn summary(&self, _: ()) -> Self::Summary { match self { Transform::Isomorphic(summary) => TransformSummary { @@ -68,7 +76,9 @@ impl sum_tree::Item for Transform { #[derive(Clone, Debug, Default)] struct TransformSummary { + /// Summary of the text before inlays have been applied. input: MBTextSummary, + /// Summary of the text after inlays have been applied. output: MBTextSummary, } @@ -228,6 +238,7 @@ pub struct InlayChunk<'a> { } impl InlayChunks<'_> { + #[ztracing::instrument(skip_all)] pub fn seek(&mut self, new_range: Range) { self.transforms.seek(&new_range.start, Bias::Right); @@ -248,6 +259,7 @@ impl InlayChunks<'_> { impl<'a> Iterator for InlayChunks<'a> { type Item = InlayChunk<'a>; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { if self.output_offset == self.max_output_offset { return None; @@ -441,6 +453,7 @@ impl<'a> Iterator for InlayChunks<'a> { } impl InlayBufferRows<'_> { + #[ztracing::instrument(skip_all)] pub fn seek(&mut self, row: u32) { let inlay_point = InlayPoint::new(row, 0); self.transforms.seek(&inlay_point, Bias::Left); @@ -465,6 +478,7 @@ impl InlayBufferRows<'_> { impl Iterator for InlayBufferRows<'_> { type Item = RowInfo; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { let buffer_row = if self.inlay_row == 0 { self.buffer_rows.next().unwrap() @@ -494,6 +508,7 @@ impl InlayPoint { } impl InlayMap { + #[ztracing::instrument(skip_all)] pub fn new(buffer: MultiBufferSnapshot) -> (Self, InlaySnapshot) { let version = 0; let snapshot = InlaySnapshot { @@ -511,6 +526,7 @@ impl InlayMap { ) } + #[ztracing::instrument(skip_all)] pub fn sync( &mut self, buffer_snapshot: MultiBufferSnapshot, @@ -643,6 +659,7 @@ impl InlayMap { } } + #[ztracing::instrument(skip_all)] pub fn splice( &mut self, to_remove: &[InlayId], @@ -693,11 +710,13 @@ impl InlayMap { (snapshot, edits) } + #[ztracing::instrument(skip_all)] pub fn current_inlays(&self) -> impl Iterator { self.inlays.iter() } #[cfg(test)] + #[ztracing::instrument(skip_all)] pub(crate) fn randomly_mutate( &mut self, next_inlay_id: &mut usize, @@ -766,6 +785,7 @@ impl InlayMap { } impl InlaySnapshot { + #[ztracing::instrument(skip_all)] pub fn to_point(&self, offset: InlayOffset) -> InlayPoint { let (start, _, item) = self.transforms.find:: InlayOffset { InlayOffset(self.transforms.summary().output.len) } + #[ztracing::instrument(skip_all)] pub fn max_point(&self) -> InlayPoint { InlayPoint(self.transforms.summary().output.lines) } + #[ztracing::instrument(skip_all, fields(point))] pub fn to_offset(&self, point: InlayPoint) -> InlayOffset { let (start, _, item) = self .transforms @@ -817,6 +840,7 @@ impl InlaySnapshot { None => self.len(), } } + #[ztracing::instrument(skip_all)] pub fn to_buffer_point(&self, point: InlayPoint) -> Point { let (start, _, item) = self.transforms @@ -830,6 +854,7 @@ impl InlaySnapshot { None => self.buffer.max_point(), } } + #[ztracing::instrument(skip_all)] pub fn to_buffer_offset(&self, offset: InlayOffset) -> MultiBufferOffset { let (start, _, item) = self .transforms @@ -844,6 +869,7 @@ impl InlaySnapshot { } } + #[ztracing::instrument(skip_all)] pub fn to_inlay_offset(&self, offset: MultiBufferOffset) -> InlayOffset { let mut cursor = self .transforms @@ -880,10 +906,12 @@ impl InlaySnapshot { } } + #[ztracing::instrument(skip_all)] pub fn to_inlay_point(&self, point: Point) -> InlayPoint { self.inlay_point_cursor().map(point) } + #[ztracing::instrument(skip_all)] pub fn inlay_point_cursor(&self) -> InlayPointCursor<'_> { let cursor = self.transforms.cursor::>(()); InlayPointCursor { @@ -892,6 +920,7 @@ impl InlaySnapshot { } } + #[ztracing::instrument(skip_all)] pub fn clip_point(&self, mut point: InlayPoint, mut bias: Bias) -> InlayPoint { let mut cursor = self.transforms.cursor::>(()); cursor.seek(&point, Bias::Left); @@ -983,10 +1012,12 @@ impl InlaySnapshot { } } + #[ztracing::instrument(skip_all)] pub fn text_summary(&self) -> MBTextSummary { self.transforms.summary().output } + #[ztracing::instrument(skip_all)] pub fn text_summary_for_range(&self, range: Range) -> MBTextSummary { let mut summary = MBTextSummary::default(); @@ -1044,6 +1075,7 @@ impl InlaySnapshot { summary } + #[ztracing::instrument(skip_all)] pub fn row_infos(&self, row: u32) -> InlayBufferRows<'_> { let mut cursor = self.transforms.cursor::>(()); let inlay_point = InlayPoint::new(row, 0); @@ -1071,6 +1103,7 @@ impl InlaySnapshot { } } + #[ztracing::instrument(skip_all)] pub fn line_len(&self, row: u32) -> u32 { let line_start = self.to_offset(InlayPoint::new(row, 0)).0; let line_end = if row >= self.max_point().row() { @@ -1081,6 +1114,7 @@ impl InlaySnapshot { (line_end - line_start) as u32 } + #[ztracing::instrument(skip_all)] pub(crate) fn chunks<'a>( &'a self, range: Range, @@ -1115,12 +1149,14 @@ impl InlaySnapshot { } #[cfg(test)] + #[ztracing::instrument(skip_all)] pub fn text(&self) -> String { self.chunks(Default::default()..self.len(), false, Highlights::default()) .map(|chunk| chunk.chunk.text) .collect() } + #[ztracing::instrument(skip_all)] fn check_invariants(&self) { #[cfg(any(debug_assertions, feature = "test-support"))] { @@ -1147,6 +1183,7 @@ pub struct InlayPointCursor<'transforms> { } impl InlayPointCursor<'_> { + #[ztracing::instrument(skip_all)] pub fn map(&mut self, point: Point) -> InlayPoint { let cursor = &mut self.cursor; if cursor.did_seek() { diff --git a/crates/editor/src/display_map/invisibles.rs b/crates/editor/src/display_map/invisibles.rs index 5622a659b7acf850d24f6a476b23b53d214d855d..90bd54ab2807bbef703ac29e4ac4eaf49bcf71fd 100644 --- a/crates/editor/src/display_map/invisibles.rs +++ b/crates/editor/src/display_map/invisibles.rs @@ -30,6 +30,7 @@ // ref: https://gist.github.com/ConradIrwin/f759e1fc29267143c4c7895aa495dca5?h=1 // ref: https://unicode.org/Public/emoji/13.0/emoji-test.txt // https://github.com/bits/UTF-8-Unicode-Test-Documents/blob/master/UTF-8_sequence_separated/utf8_sequence_0-0x10ffff_assigned_including-unprintable-asis.txt +#[ztracing::instrument(skip_all)] pub fn is_invisible(c: char) -> bool { if c <= '\u{1f}' { c != '\t' && c != '\n' && c != '\r' diff --git a/crates/editor/src/display_map/tab_map.rs b/crates/editor/src/display_map/tab_map.rs index 347d7732151e172812de1e0252ca8d65f4cdbb8b..4e768a477159820ea380aa48a123d103c0c2f6a2 100644 --- a/crates/editor/src/display_map/tab_map.rs +++ b/crates/editor/src/display_map/tab_map.rs @@ -20,6 +20,7 @@ const MAX_TABS: NonZeroU32 = NonZeroU32::new(SPACES.len() as u32).unwrap(); pub struct TabMap(TabSnapshot); impl TabMap { + #[ztracing::instrument(skip_all)] pub fn new(fold_snapshot: FoldSnapshot, tab_size: NonZeroU32) -> (Self, TabSnapshot) { let snapshot = TabSnapshot { fold_snapshot, @@ -36,6 +37,7 @@ impl TabMap { self.0.clone() } + #[ztracing::instrument(skip_all)] pub fn sync( &mut self, fold_snapshot: FoldSnapshot, @@ -176,10 +178,12 @@ impl std::ops::Deref for TabSnapshot { } impl TabSnapshot { + #[ztracing::instrument(skip_all)] pub fn buffer_snapshot(&self) -> &MultiBufferSnapshot { &self.fold_snapshot.inlay_snapshot.buffer } + #[ztracing::instrument(skip_all)] pub fn line_len(&self, row: u32) -> u32 { let max_point = self.max_point(); if row < max_point.row() { @@ -191,10 +195,12 @@ impl TabSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn text_summary(&self) -> TextSummary { self.text_summary_for_range(TabPoint::zero()..self.max_point()) } + #[ztracing::instrument(skip_all, fields(rows))] pub fn text_summary_for_range(&self, range: Range) -> TextSummary { let input_start = self.tab_point_to_fold_point(range.start, Bias::Left).0; let input_end = self.tab_point_to_fold_point(range.end, Bias::Right).0; @@ -234,6 +240,7 @@ impl TabSnapshot { } } + #[ztracing::instrument(skip_all)] pub(crate) fn chunks<'a>( &'a self, range: Range, @@ -276,11 +283,13 @@ impl TabSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn rows(&self, row: u32) -> fold_map::FoldRows<'_> { self.fold_snapshot.row_infos(row) } #[cfg(test)] + #[ztracing::instrument(skip_all)] pub fn text(&self) -> String { self.chunks( TabPoint::zero()..self.max_point(), @@ -291,10 +300,12 @@ impl TabSnapshot { .collect() } + #[ztracing::instrument(skip_all)] pub fn max_point(&self) -> TabPoint { self.fold_point_to_tab_point(self.fold_snapshot.max_point()) } + #[ztracing::instrument(skip_all)] pub fn clip_point(&self, point: TabPoint, bias: Bias) -> TabPoint { self.fold_point_to_tab_point( self.fold_snapshot @@ -302,6 +313,7 @@ impl TabSnapshot { ) } + #[ztracing::instrument(skip_all)] pub fn fold_point_to_tab_point(&self, input: FoldPoint) -> TabPoint { let chunks = self.fold_snapshot.chunks_at(FoldPoint::new(input.row(), 0)); let tab_cursor = TabStopCursor::new(chunks); @@ -309,10 +321,12 @@ impl TabSnapshot { TabPoint::new(input.row(), expanded) } + #[ztracing::instrument(skip_all)] pub fn tab_point_cursor(&self) -> TabPointCursor<'_> { TabPointCursor { this: self } } + #[ztracing::instrument(skip_all)] pub fn tab_point_to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) { let chunks = self .fold_snapshot @@ -330,12 +344,14 @@ impl TabSnapshot { ) } + #[ztracing::instrument(skip_all)] pub fn point_to_tab_point(&self, point: Point, bias: Bias) -> TabPoint { let inlay_point = self.fold_snapshot.inlay_snapshot.to_inlay_point(point); let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias); self.fold_point_to_tab_point(fold_point) } + #[ztracing::instrument(skip_all)] pub fn tab_point_to_point(&self, point: TabPoint, bias: Bias) -> Point { let fold_point = self.tab_point_to_fold_point(point, bias).0; let inlay_point = fold_point.to_inlay_point(&self.fold_snapshot); @@ -344,6 +360,7 @@ impl TabSnapshot { .to_buffer_point(inlay_point) } + #[ztracing::instrument(skip_all)] fn expand_tabs<'a, I>(&self, mut cursor: TabStopCursor<'a, I>, column: u32) -> u32 where I: Iterator>, @@ -377,6 +394,7 @@ impl TabSnapshot { expanded_bytes + column.saturating_sub(collapsed_bytes) } + #[ztracing::instrument(skip_all)] fn collapse_tabs<'a, I>( &self, mut cursor: TabStopCursor<'a, I>, @@ -442,6 +460,7 @@ pub struct TabPointCursor<'this> { } impl TabPointCursor<'_> { + #[ztracing::instrument(skip_all)] pub fn map(&mut self, point: FoldPoint) -> TabPoint { self.this.fold_point_to_tab_point(point) } @@ -486,6 +505,7 @@ pub struct TextSummary { } impl<'a> From<&'a str> for TextSummary { + #[ztracing::instrument(skip_all)] fn from(text: &'a str) -> Self { let sum = text::TextSummary::from(text); @@ -500,6 +520,7 @@ impl<'a> From<&'a str> for TextSummary { } impl<'a> std::ops::AddAssign<&'a Self> for TextSummary { + #[ztracing::instrument(skip_all)] fn add_assign(&mut self, other: &'a Self) { let joined_chars = self.last_line_chars + other.first_line_chars; if joined_chars > self.longest_row_chars { @@ -541,6 +562,7 @@ pub struct TabChunks<'a> { } impl TabChunks<'_> { + #[ztracing::instrument(skip_all)] pub(crate) fn seek(&mut self, range: Range) { let (input_start, expanded_char_column, to_next_stop) = self .snapshot @@ -576,6 +598,7 @@ impl TabChunks<'_> { impl<'a> Iterator for TabChunks<'a> { type Item = Chunk<'a>; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { if self.chunk.text.is_empty() { if let Some(chunk) = self.fold_chunks.next() { @@ -1452,6 +1475,7 @@ impl<'a, I> TabStopCursor<'a, I> where I: Iterator>, { + #[ztracing::instrument(skip_all)] fn new(chunks: impl IntoIterator, IntoIter = I>) -> Self { Self { chunks: chunks.into_iter(), @@ -1461,6 +1485,7 @@ where } } + #[ztracing::instrument(skip_all)] fn bytes_until_next_char(&self) -> Option { self.current_chunk.as_ref().and_then(|(chunk, idx)| { let mut idx = *idx; @@ -1482,6 +1507,7 @@ where }) } + #[ztracing::instrument(skip_all)] fn is_char_boundary(&self) -> bool { self.current_chunk .as_ref() @@ -1489,6 +1515,7 @@ where } /// distance: length to move forward while searching for the next tab stop + #[ztracing::instrument(skip_all)] fn seek(&mut self, distance: u32) -> Option { if distance == 0 { return None; diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index 30e0e652195af38dbe25f0b7b71c5c5fff1c7179..43309e435746e8cff1443fbb505d9f168d5c0f7e 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -86,6 +86,7 @@ pub struct WrapRows<'a> { } impl WrapRows<'_> { + #[ztracing::instrument(skip_all)] pub(crate) fn seek(&mut self, start_row: WrapRow) { self.transforms .seek(&WrapPoint::new(start_row, 0), Bias::Left); @@ -101,6 +102,7 @@ impl WrapRows<'_> { } impl WrapMap { + #[ztracing::instrument(skip_all)] pub fn new( tab_snapshot: TabSnapshot, font: Font, @@ -131,6 +133,7 @@ impl WrapMap { self.background_task.is_some() } + #[ztracing::instrument(skip_all)] pub fn sync( &mut self, tab_snapshot: TabSnapshot, @@ -150,6 +153,7 @@ impl WrapMap { (self.snapshot.clone(), mem::take(&mut self.edits_since_sync)) } + #[ztracing::instrument(skip_all)] pub fn set_font_with_size( &mut self, font: Font, @@ -167,6 +171,7 @@ impl WrapMap { } } + #[ztracing::instrument(skip_all)] pub fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut Context) -> bool { if wrap_width == self.wrap_width { return false; @@ -177,6 +182,7 @@ impl WrapMap { true } + #[ztracing::instrument(skip_all)] fn rewrap(&mut self, cx: &mut Context) { self.background_task.take(); self.interpolated_edits.clear(); @@ -248,6 +254,7 @@ impl WrapMap { } } + #[ztracing::instrument(skip_all)] fn flush_edits(&mut self, cx: &mut Context) { if !self.snapshot.interpolated { let mut to_remove_len = 0; @@ -330,6 +337,7 @@ impl WrapMap { } impl WrapSnapshot { + #[ztracing::instrument(skip_all)] fn new(tab_snapshot: TabSnapshot) -> Self { let mut transforms = SumTree::default(); let extent = tab_snapshot.text_summary(); @@ -343,10 +351,12 @@ impl WrapSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn buffer_snapshot(&self) -> &MultiBufferSnapshot { self.tab_snapshot.buffer_snapshot() } + #[ztracing::instrument(skip_all)] fn interpolate(&mut self, new_tab_snapshot: TabSnapshot, tab_edits: &[TabEdit]) -> WrapPatch { let mut new_transforms; if tab_edits.is_empty() { @@ -411,6 +421,7 @@ impl WrapSnapshot { old_snapshot.compute_edits(tab_edits, self) } + #[ztracing::instrument(skip_all)] async fn update( &mut self, new_tab_snapshot: TabSnapshot, @@ -570,6 +581,7 @@ impl WrapSnapshot { old_snapshot.compute_edits(tab_edits, self) } + #[ztracing::instrument(skip_all)] fn compute_edits(&self, tab_edits: &[TabEdit], new_snapshot: &WrapSnapshot) -> WrapPatch { let mut wrap_edits = Vec::with_capacity(tab_edits.len()); let mut old_cursor = self.transforms.cursor::(()); @@ -606,6 +618,7 @@ impl WrapSnapshot { Patch::new(wrap_edits) } + #[ztracing::instrument(skip_all)] pub(crate) fn chunks<'a>( &'a self, rows: Range, @@ -640,10 +653,12 @@ impl WrapSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn max_point(&self) -> WrapPoint { WrapPoint(self.transforms.summary().output.lines) } + #[ztracing::instrument(skip_all)] pub fn line_len(&self, row: WrapRow) -> u32 { let (start, _, item) = self.transforms.find::, _>( (), @@ -664,6 +679,7 @@ impl WrapSnapshot { } } + #[ztracing::instrument(skip_all, fields(rows))] pub fn text_summary_for_range(&self, rows: Range) -> TextSummary { let mut summary = TextSummary::default(); @@ -725,6 +741,7 @@ impl WrapSnapshot { summary } + #[ztracing::instrument(skip_all)] pub fn soft_wrap_indent(&self, row: WrapRow) -> Option { let (.., item) = self.transforms.find::( (), @@ -740,10 +757,12 @@ impl WrapSnapshot { }) } + #[ztracing::instrument(skip_all)] pub fn longest_row(&self) -> u32 { self.transforms.summary().output.longest_row } + #[ztracing::instrument(skip_all)] pub fn row_infos(&self, start_row: WrapRow) -> WrapRows<'_> { let mut transforms = self .transforms @@ -766,6 +785,7 @@ impl WrapSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn to_tab_point(&self, point: WrapPoint) -> TabPoint { let (start, _, item) = self.transforms @@ -777,15 +797,18 @@ impl WrapSnapshot { TabPoint(tab_point) } + #[ztracing::instrument(skip_all)] pub fn to_point(&self, point: WrapPoint, bias: Bias) -> Point { self.tab_snapshot .tab_point_to_point(self.to_tab_point(point), bias) } + #[ztracing::instrument(skip_all)] pub fn make_wrap_point(&self, point: Point, bias: Bias) -> WrapPoint { self.tab_point_to_wrap_point(self.tab_snapshot.point_to_tab_point(point, bias)) } + #[ztracing::instrument(skip_all)] pub fn tab_point_to_wrap_point(&self, point: TabPoint) -> WrapPoint { let (start, ..) = self.transforms @@ -793,6 +816,7 @@ impl WrapSnapshot { WrapPoint(start.1.0 + (point.0 - start.0.0)) } + #[ztracing::instrument(skip_all)] pub fn wrap_point_cursor(&self) -> WrapPointCursor<'_> { WrapPointCursor { cursor: self @@ -801,6 +825,7 @@ impl WrapSnapshot { } } + #[ztracing::instrument(skip_all)] pub fn clip_point(&self, mut point: WrapPoint, bias: Bias) -> WrapPoint { if bias == Bias::Left { let (start, _, item) = self @@ -815,32 +840,65 @@ impl WrapSnapshot { self.tab_point_to_wrap_point(self.tab_snapshot.clip_point(self.to_tab_point(point), bias)) } - pub fn prev_row_boundary(&self, mut point: WrapPoint) -> WrapRow { + /// Try to find a TabRow start that is also a WrapRow start + /// Every TabRow start is a WrapRow start + #[ztracing::instrument(skip_all, fields(point=?point))] + pub fn prev_row_boundary(&self, point: WrapPoint) -> WrapRow { if self.transforms.is_empty() { return WrapRow(0); } - *point.column_mut() = 0; + let point = WrapPoint::new(point.row(), 0); let mut cursor = self .transforms .cursor::>(()); + cursor.seek(&point, Bias::Right); if cursor.item().is_none() { cursor.prev(); } + // real newline fake fake + // text: helloworldasldlfjasd\njdlasfalsk\naskdjfasdkfj\n + // dimensions v v v v v + // transforms |-------|-----NW----|-----W------|-----W------| + // cursor ^ ^^^^^^^^^^^^^ ^ + // (^) ^^^^^^^^^^^^^^ + // point: ^ + // point(col_zero): (^) + while let Some(transform) = cursor.item() { - if transform.is_isomorphic() && cursor.start().1.column() == 0 { - return cmp::min(cursor.end().0.row(), point.row()); - } else { - cursor.prev(); + if transform.is_isomorphic() { + // this transform only has real linefeeds + let tab_summary = &transform.summary.input; + // is the wrap just before the end of the transform a tab row? + // thats only if this transform has at least one newline + // + // "this wrap row is a tab row" <=> self.to_tab_point(WrapPoint::new(wrap_row, 0)).column() == 0 + + // Note on comparison: + // We have code that relies on this to be row > 1 + // It should work with row >= 1 but it does not :( + // + // That means that if every line is wrapped we walk back all the + // way to the start. Which invalidates the entire state triggering + // a full re-render. + if tab_summary.lines.row > 1 { + let wrap_point_at_end = cursor.end().0.row(); + return cmp::min(wrap_point_at_end - RowDelta(1), point.row()); + } else if cursor.start().1.column() == 0 { + return cmp::min(cursor.end().0.row(), point.row()); + } } + + cursor.prev(); } - unreachable!() + WrapRow(0) } + #[ztracing::instrument(skip_all)] pub fn next_row_boundary(&self, mut point: WrapPoint) -> Option { point.0 += Point::new(1, 0); @@ -874,6 +932,7 @@ impl WrapSnapshot { .map(|h| h.text) } + #[ztracing::instrument(skip_all)] fn check_invariants(&self) { #[cfg(test)] { @@ -927,6 +986,7 @@ pub struct WrapPointCursor<'transforms> { } impl WrapPointCursor<'_> { + #[ztracing::instrument(skip_all)] pub fn map(&mut self, point: TabPoint) -> WrapPoint { let cursor = &mut self.cursor; if cursor.did_seek() { @@ -939,6 +999,7 @@ impl WrapPointCursor<'_> { } impl WrapChunks<'_> { + #[ztracing::instrument(skip_all)] pub(crate) fn seek(&mut self, rows: Range) { let output_start = WrapPoint::new(rows.start, 0); let output_end = WrapPoint::new(rows.end, 0); @@ -961,6 +1022,7 @@ impl WrapChunks<'_> { impl<'a> Iterator for WrapChunks<'a> { type Item = Chunk<'a>; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { if self.output_position.row() >= self.max_output_row { return None; @@ -1033,6 +1095,7 @@ impl<'a> Iterator for WrapChunks<'a> { impl Iterator for WrapRows<'_> { type Item = RowInfo; + #[ztracing::instrument(skip_all)] fn next(&mut self) -> Option { if self.output_row > self.max_output_row { return None; @@ -1068,6 +1131,7 @@ impl Iterator for WrapRows<'_> { } impl Transform { + #[ztracing::instrument(skip_all)] fn isomorphic(summary: TextSummary) -> Self { #[cfg(test)] assert!(!summary.lines.is_zero()); @@ -1081,6 +1145,7 @@ impl Transform { } } + #[ztracing::instrument(skip_all)] fn wrap(indent: u32) -> Self { static WRAP_TEXT: LazyLock = LazyLock::new(|| { let mut wrap_text = String::new(); @@ -1133,6 +1198,7 @@ trait SumTreeExt { } impl SumTreeExt for SumTree { + #[ztracing::instrument(skip_all)] fn push_or_extend(&mut self, transform: Transform) { let mut transform = Some(transform); self.update_last( @@ -1196,6 +1262,7 @@ impl<'a> sum_tree::Dimension<'a, TransformSummary> for TabPoint { } impl sum_tree::SeekTarget<'_, TransformSummary, TransformSummary> for TabPoint { + #[ztracing::instrument(skip_all)] fn cmp(&self, cursor_location: &TransformSummary, _: ()) -> std::cmp::Ordering { Ord::cmp(&self.0, &cursor_location.input.lines) } @@ -1255,6 +1322,71 @@ mod tests { use text::Rope; use theme::LoadThemes; + #[gpui::test] + async fn test_prev_row_boundary(cx: &mut gpui::TestAppContext) { + init_test(cx); + + fn test_wrap_snapshot( + text: &str, + soft_wrap_every: usize, // font size multiple + cx: &mut gpui::TestAppContext, + ) -> WrapSnapshot { + let text_system = cx.read(|cx| cx.text_system().clone()); + let tab_size = 4.try_into().unwrap(); + let font = test_font(); + let _font_id = text_system.resolve_font(&font); + let font_size = px(14.0); + // this is very much an estimate to try and get the wrapping to + // occur at `soft_wrap_every` we check that it pans out for every test case + let soft_wrapping = Some(font_size * soft_wrap_every * 0.6); + + let buffer = cx.new(|cx| language::Buffer::local(text, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + let (_inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot); + let (_fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (mut tab_map, _) = TabMap::new(fold_snapshot, tab_size); + let tabs_snapshot = tab_map.set_max_expansion_column(32); + let (_wrap_map, wrap_snapshot) = + cx.update(|cx| WrapMap::new(tabs_snapshot, font, font_size, soft_wrapping, cx)); + + wrap_snapshot + } + + // These two should pass but dont, see the comparison note in + // prev_row_boundary about why. + // + // // 0123 4567 wrap_rows + // let wrap_snapshot = test_wrap_snapshot("1234\n5678", 1, cx); + // assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8"); + // let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + // assert_eq!(row.0, 3); + + // // 012 345 678 wrap_rows + // let wrap_snapshot = test_wrap_snapshot("123\n456\n789", 1, cx); + // assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9"); + // let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + // assert_eq!(row.0, 5); + + // 012345678 wrap_rows + let wrap_snapshot = test_wrap_snapshot("123456789", 1, cx); + assert_eq!(wrap_snapshot.text(), "1\n2\n3\n4\n5\n6\n7\n8\n9"); + let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + assert_eq!(row.0, 0); + + // 111 2222 44 wrap_rows + let wrap_snapshot = test_wrap_snapshot("123\n4567\n\n89", 4, cx); + assert_eq!(wrap_snapshot.text(), "123\n4567\n\n89"); + let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + assert_eq!(row.0, 2); + + // 11 2223 wrap_rows + let wrap_snapshot = test_wrap_snapshot("12\n3456\n\n", 3, cx); + assert_eq!(wrap_snapshot.text(), "12\n345\n6\n\n"); + let row = wrap_snapshot.prev_row_boundary(wrap_snapshot.max_point()); + assert_eq!(row.0, 3); + } + #[gpui::test(iterations = 100)] async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) { // todo this test is flaky diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index a1839144a47a81f668ba2743cd5e362f6711d0e9..b5931cde42a4e2c0e21b2d1f68558879de9750b4 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -1,4 +1,4 @@ -use edit_prediction::EditPredictionProvider; +use edit_prediction_types::EditPredictionDelegate; use gpui::{Entity, KeyBinding, Modifiers, prelude::*}; use indoc::indoc; use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint}; @@ -15,7 +15,7 @@ async fn test_edit_prediction_insert(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionProvider::default()); + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); assign_editor_completion_provider(provider.clone(), &mut cx); cx.set_state("let absolute_zero_celsius = ˇ;"); @@ -37,7 +37,7 @@ async fn test_edit_prediction_modification(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionProvider::default()); + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); assign_editor_completion_provider(provider.clone(), &mut cx); cx.set_state("let pi = ˇ\"foo\";"); @@ -59,7 +59,7 @@ async fn test_edit_prediction_jump_button(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionProvider::default()); + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); assign_editor_completion_provider(provider.clone(), &mut cx); // Cursor is 2+ lines above the proposed edit @@ -128,7 +128,7 @@ async fn test_edit_prediction_invalidation_range(cx: &mut gpui::TestAppContext) init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionProvider::default()); + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); assign_editor_completion_provider(provider.clone(), &mut cx); // Cursor is 3+ lines above the proposed edit @@ -233,7 +233,7 @@ async fn test_edit_prediction_jump_disabled_for_non_zed_providers(cx: &mut gpui: init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeNonZedEditPredictionProvider::default()); + let provider = cx.new(|_| FakeNonZedEditPredictionDelegate::default()); assign_editor_completion_provider_non_zed(provider.clone(), &mut cx); // Cursor is 2+ lines above the proposed edit @@ -281,7 +281,7 @@ async fn test_edit_prediction_preview_cleanup_on_toggle_off(cx: &mut gpui::TestA cx.update(|cx| cx.bind_keys([KeyBinding::new("ctrl-shift-a", AcceptEditPrediction, None)])); let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionProvider::default()); + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); assign_editor_completion_provider(provider.clone(), &mut cx); cx.set_state("let x = ˇ;"); @@ -371,7 +371,7 @@ fn accept_completion(cx: &mut EditorTestContext) { } fn propose_edits( - provider: &Entity, + provider: &Entity, edits: Vec<(Range, &str)>, cx: &mut EditorTestContext, ) { @@ -383,7 +383,7 @@ fn propose_edits( cx.update(|_, cx| { provider.update(cx, |provider, _| { - provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local { + provider.set_edit_prediction(Some(edit_prediction_types::EditPrediction::Local { id: None, edits: edits.collect(), edit_preview: None, @@ -393,7 +393,7 @@ fn propose_edits( } fn assign_editor_completion_provider( - provider: Entity, + provider: Entity, cx: &mut EditorTestContext, ) { cx.update_editor(|editor, window, cx| { @@ -402,7 +402,7 @@ fn assign_editor_completion_provider( } fn propose_edits_non_zed( - provider: &Entity, + provider: &Entity, edits: Vec<(Range, &str)>, cx: &mut EditorTestContext, ) { @@ -414,7 +414,7 @@ fn propose_edits_non_zed( cx.update(|_, cx| { provider.update(cx, |provider, _| { - provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local { + provider.set_edit_prediction(Some(edit_prediction_types::EditPrediction::Local { id: None, edits: edits.collect(), edit_preview: None, @@ -424,7 +424,7 @@ fn propose_edits_non_zed( } fn assign_editor_completion_provider_non_zed( - provider: Entity, + provider: Entity, cx: &mut EditorTestContext, ) { cx.update_editor(|editor, window, cx| { @@ -433,17 +433,20 @@ fn assign_editor_completion_provider_non_zed( } #[derive(Default, Clone)] -pub struct FakeEditPredictionProvider { - pub completion: Option, +pub struct FakeEditPredictionDelegate { + pub completion: Option, } -impl FakeEditPredictionProvider { - pub fn set_edit_prediction(&mut self, completion: Option) { +impl FakeEditPredictionDelegate { + pub fn set_edit_prediction( + &mut self, + completion: Option, + ) { self.completion = completion; } } -impl EditPredictionProvider for FakeEditPredictionProvider { +impl EditPredictionDelegate for FakeEditPredictionDelegate { fn name() -> &'static str { "fake-completion-provider" } @@ -452,7 +455,7 @@ impl EditPredictionProvider for FakeEditPredictionProvider { "Fake Completion Provider" } - fn show_completions_in_menu() -> bool { + fn show_predictions_in_menu() -> bool { true } @@ -482,15 +485,6 @@ impl EditPredictionProvider for FakeEditPredictionProvider { ) { } - fn cycle( - &mut self, - _buffer: gpui::Entity, - _cursor_position: language::Anchor, - _direction: edit_prediction::Direction, - _cx: &mut gpui::Context, - ) { - } - fn accept(&mut self, _cx: &mut gpui::Context) {} fn discard(&mut self, _cx: &mut gpui::Context) {} @@ -500,23 +494,26 @@ impl EditPredictionProvider for FakeEditPredictionProvider { _buffer: &gpui::Entity, _cursor_position: language::Anchor, _cx: &mut gpui::Context, - ) -> Option { + ) -> Option { self.completion.clone() } } #[derive(Default, Clone)] -pub struct FakeNonZedEditPredictionProvider { - pub completion: Option, +pub struct FakeNonZedEditPredictionDelegate { + pub completion: Option, } -impl FakeNonZedEditPredictionProvider { - pub fn set_edit_prediction(&mut self, completion: Option) { +impl FakeNonZedEditPredictionDelegate { + pub fn set_edit_prediction( + &mut self, + completion: Option, + ) { self.completion = completion; } } -impl EditPredictionProvider for FakeNonZedEditPredictionProvider { +impl EditPredictionDelegate for FakeNonZedEditPredictionDelegate { fn name() -> &'static str { "fake-non-zed-provider" } @@ -525,7 +522,7 @@ impl EditPredictionProvider for FakeNonZedEditPredictionProvider { "Fake Non-Zed Provider" } - fn show_completions_in_menu() -> bool { + fn show_predictions_in_menu() -> bool { false } @@ -555,15 +552,6 @@ impl EditPredictionProvider for FakeNonZedEditPredictionProvider { ) { } - fn cycle( - &mut self, - _buffer: gpui::Entity, - _cursor_position: language::Anchor, - _direction: edit_prediction::Direction, - _cx: &mut gpui::Context, - ) { - } - fn accept(&mut self, _cx: &mut gpui::Context) {} fn discard(&mut self, _cx: &mut gpui::Context) {} @@ -573,7 +561,7 @@ impl EditPredictionProvider for FakeNonZedEditPredictionProvider { _buffer: &gpui::Entity, _cursor_position: language::Anchor, _cx: &mut gpui::Context, - ) -> Option { + ) -> Option { self.completion.clone() } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9a4453db170102372caa05ace24ac2bbe4740b35..56332871bc434caa5a9afa9d33d879d534a007df 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -51,7 +51,7 @@ pub mod test; pub(crate) use actions::*; pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder}; -pub use edit_prediction::Direction; +pub use edit_prediction_types::Direction; pub use editor_settings::{ CurrentLineHighlight, DocumentColorsRenderMode, EditorSettings, HideMouseMode, ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap, @@ -73,13 +73,9 @@ pub use multi_buffer::{ pub use split::SplittableEditor; pub use text::Bias; -use ::git::{ - Restore, - blame::{BlameEntry, ParsedCommitMessage}, - status::FileStatus, -}; +use ::git::{Restore, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError}; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result, anyhow, bail}; use blink_manager::BlinkManager; use buffer_diff::DiffHunkStatus; use client::{Collaborator, ParticipantIndex, parse_zed_link}; @@ -92,7 +88,9 @@ use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; use dap::TelemetrySpawnLocation; use display_map::*; -use edit_prediction::{EditPredictionProvider, EditPredictionProviderHandle}; +use edit_prediction_types::{ + EditPredictionDelegate, EditPredictionDelegateHandle, EditPredictionGranularity, +}; use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings}; use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap, layout_line}; use futures::{ @@ -107,10 +105,11 @@ use gpui::{ AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, - MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, Render, - ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextStyle, - TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, - WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, size, + MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, PressureStage, + Render, ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextRun, + TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, + WeakEntity, WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, + size, }; use hover_links::{HoverLink, HoveredLinkState, find_file}; use hover_popover::{HoverState, hide_hover}; @@ -121,8 +120,9 @@ use language::{ AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow, BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape, DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, - IndentSize, Language, LanguageName, LanguageRegistry, OffsetRangeExt, OutlineItem, Point, - Runnable, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery, + IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, OffsetRangeExt, + OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId, + TreeSitterOptions, WordsQuery, language_settings::{ self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings, language_settings, @@ -163,6 +163,7 @@ use project::{ project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings}, }; use rand::seq::SliceRandom; +use regex::Regex; use rpc::{ErrorCode, ErrorExt, proto::PeerId}; use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager}; use selections_collection::{MutableSelectionsCollection, SelectionsCollection}; @@ -182,7 +183,7 @@ use std::{ iter::{self, Peekable}, mem, num::NonZeroU32, - ops::{Deref, DerefMut, Not, Range, RangeInclusive}, + ops::{ControlFlow, Deref, DerefMut, Not, Range, RangeInclusive}, path::{Path, PathBuf}, rc::Rc, sync::Arc, @@ -191,7 +192,7 @@ use std::{ use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables}; use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _}; use theme::{ - ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings, + AccentColors, ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings, observe_buffer_font_size_adjustment, }; use ui::{ @@ -351,8 +352,8 @@ pub fn init(cx: &mut App) { ) .detach(); } - }); - cx.on_action(move |_: &workspace::NewWindow, cx| { + }) + .on_action(move |_: &workspace::NewWindow, cx| { let app_state = workspace::AppState::global(cx); if let Some(app_state) = app_state.upgrade() { workspace::open_new( @@ -575,7 +576,7 @@ impl Default for EditorStyle { } } -pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { +pub fn make_inlay_hints_style(cx: &App) -> HighlightStyle { let show_background = language_settings::language_settings(None, None, cx) .inlay_hints .show_background; @@ -598,7 +599,7 @@ pub fn make_inlay_hints_style(cx: &mut App) -> HighlightStyle { style } -pub fn make_suggestion_styles(cx: &mut App) -> EditPredictionStyles { +pub fn make_suggestion_styles(cx: &App) -> EditPredictionStyles { EditPredictionStyles { insertion: HighlightStyle { color: Some(cx.theme().status().predictive), @@ -726,7 +727,10 @@ impl EditorActionId { // type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor; // type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option; -type BackgroundHighlight = (fn(&Theme) -> Hsla, Arc<[Range]>); +type BackgroundHighlight = ( + Arc Hsla + Send + Sync>, + Arc<[Range]>, +); type GutterHighlight = (fn(&App) -> Hsla, Vec>); #[derive(Default)] @@ -1076,6 +1080,7 @@ pub struct Editor { show_breakpoints: Option, show_wrap_guides: Option, show_indent_guides: Option, + buffers_with_disabled_indent_guides: HashSet, highlight_order: usize, highlighted_rows: HashMap>, background_highlights: HashMap, @@ -1103,6 +1108,9 @@ pub struct Editor { pending_rename: Option, searchable: bool, cursor_shape: CursorShape, + /// Whether the cursor is offset one character to the left when something is + /// selected (needed for vim visual mode) + cursor_offset_on_selection: bool, current_line_highlight: Option, pub collapse_matches: bool, autoindent_mode: Option, @@ -1114,9 +1122,10 @@ pub struct Editor { remote_id: Option, pub hover_state: HoverState, pending_mouse_down: Option>>>, + prev_pressure_stage: Option, gutter_hovered: bool, hovered_link_state: Option, - edit_prediction_provider: Option, + edit_prediction_provider: Option, code_action_providers: Vec>, active_edit_prediction: Option, /// Used to prevent flickering as the user types while the menu is open @@ -1124,6 +1133,7 @@ pub struct Editor { edit_prediction_settings: EditPredictionSettings, edit_predictions_hidden_for_vim_mode: bool, show_edit_predictions_override: Option, + show_completions_on_input_override: Option, menu_edit_predictions_policy: MenuEditPredictionsPolicy, edit_prediction_preview: EditPredictionPreview, edit_prediction_indent_conflict: bool, @@ -1172,6 +1182,7 @@ pub struct Editor { gutter_breakpoint_indicator: (Option, Option>), hovered_diff_hunk_row: Option, pull_diagnostics_task: Task<()>, + pull_diagnostics_background_task: Task<()>, in_project_search: bool, previous_search_ranges: Option]>>, breadcrumb_header: Option, @@ -1203,10 +1214,16 @@ pub struct Editor { select_next_is_case_sensitive: Option, pub lookup_key: Option>, applicable_language_settings: HashMap, LanguageSettings>, - accent_overrides: Vec, + accent_data: Option, fetched_tree_sitter_chunks: HashMap>>, } +#[derive(Debug, PartialEq)] +struct AccentData { + colors: AccentColors, + overrides: Vec, +} + fn debounce_value(debounce_ms: u64) -> Option { if debounce_ms > 0 { Some(Duration::from_millis(debounce_ms)) @@ -1237,6 +1254,7 @@ impl NextScrollCursorCenterTopBottom { pub struct EditorSnapshot { pub mode: EditorMode, show_gutter: bool, + offset_content: bool, show_line_numbers: Option, number_deleted_lines: bool, show_git_diff_gutter: Option, @@ -1552,8 +1570,8 @@ pub struct RenameState { struct InvalidationStack(Vec); -struct RegisteredEditPredictionProvider { - provider: Arc, +struct RegisteredEditPredictionDelegate { + provider: Arc, _subscription: Subscription, } @@ -1581,6 +1599,45 @@ pub struct ClipboardSelection { pub is_entire_line: bool, /// The indentation of the first line when this content was originally copied. pub first_line_indent: u32, + #[serde(default)] + pub file_path: Option, + #[serde(default)] + pub line_range: Option>, +} + +impl ClipboardSelection { + pub fn for_buffer( + len: usize, + is_entire_line: bool, + range: Range, + buffer: &MultiBufferSnapshot, + project: Option<&Entity>, + cx: &App, + ) -> Self { + let first_line_indent = buffer + .indent_size_for_line(MultiBufferRow(range.start.row)) + .len; + + let file_path = util::maybe!({ + let project = project?.read(cx); + let file = buffer.file_at(range.start)?; + let project_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }; + project.absolute_path(&project_path, cx) + }); + + let line_range = file_path.as_ref().map(|_| range.start.row..=range.end.row); + + Self { + len, + is_entire_line, + first_line_indent, + file_path, + line_range, + } + } } // selections, scroll behavior, was newest selection reversed @@ -1775,7 +1832,11 @@ impl Editor { Editor::new_internal(mode, buffer, project, None, window, cx) } - pub fn sticky_headers(&self, cx: &App) -> Option>> { + pub fn sticky_headers( + &self, + style: &EditorStyle, + cx: &App, + ) -> Option>> { let multi_buffer = self.buffer().read(cx); let multi_buffer_snapshot = multi_buffer.snapshot(cx); let multi_buffer_visible_start = self @@ -1793,7 +1854,7 @@ impl Editor { .outline_items_containing( Point::new(start_row, 0)..Point::new(end_row, 0), true, - self.style().map(|style| style.syntax.as_ref()), + Some(style.syntax.as_ref()), ) .into_iter() .map(|outline_item| OutlineItem { @@ -2001,46 +2062,34 @@ impl Editor { }) }); }); - let edited_buffers_already_open = { - let other_editors: Vec> = workspace - .read(cx) - .panes() - .iter() - .flat_map(|pane| pane.read(cx).items_of_type::()) - .filter(|editor| editor.entity_id() != cx.entity_id()) - .collect(); - - transaction.0.keys().all(|buffer| { - other_editors.iter().any(|editor| { - let multi_buffer = editor.read(cx).buffer(); - multi_buffer.read(cx).is_singleton() - && multi_buffer.read(cx).as_singleton().map_or( - false, - |singleton| { - singleton.entity_id() == buffer.entity_id() - }, - ) - }) - }) - }; - if !edited_buffers_already_open { - let workspace = workspace.downgrade(); - let transaction = transaction.clone(); - cx.defer_in(window, move |_, window, cx| { - cx.spawn_in(window, async move |editor, cx| { - Self::open_project_transaction( - &editor, - workspace, - transaction, - "Rename".to_string(), - cx, - ) - .await - .ok() - }) - .detach(); - }); - } + + Self::open_transaction_for_hidden_buffers( + workspace, + transaction.clone(), + "Rename".to_string(), + window, + cx, + ); + } + } + + project::Event::WorkspaceEditApplied(transaction) => { + let Some(workspace) = editor.workspace() else { + return; + }; + let Some(active_editor) = workspace.read(cx).active_item_as::(cx) + else { + return; + }; + + if active_editor.entity_id() == cx.entity_id() { + Self::open_transaction_for_hidden_buffers( + workspace, + transaction.clone(), + "LSP Edit".to_string(), + window, + cx, + ); } } @@ -2195,6 +2244,7 @@ impl Editor { show_breakpoints: None, show_wrap_guides: None, show_indent_guides, + buffers_with_disabled_indent_guides: HashSet::default(), highlight_order: 0, highlighted_rows: HashMap::default(), background_highlights: HashMap::default(), @@ -2225,6 +2275,7 @@ impl Editor { cursor_shape: EditorSettings::get_global(cx) .cursor_shape .unwrap_or_default(), + cursor_offset_on_selection: false, current_line_highlight: None, autoindent_mode: Some(AutoindentMode::EachLine), collapse_matches: false, @@ -2240,6 +2291,7 @@ impl Editor { remote_id: None, hover_state: HoverState::default(), pending_mouse_down: None, + prev_pressure_stage: None, hovered_link_state: None, edit_prediction_provider: None, active_edit_prediction: None, @@ -2264,6 +2316,7 @@ impl Editor { editor_actions: Rc::default(), edit_predictions_hidden_for_vim_mode: false, show_edit_predictions_override: None, + show_completions_on_input_override: None, menu_edit_predictions_policy: MenuEditPredictionsPolicy::ByProvider, edit_prediction_settings: EditPredictionSettings::Disabled, edit_prediction_indent_conflict: false, @@ -2317,6 +2370,7 @@ impl Editor { .unwrap_or_default(), tasks_update_task: None, pull_diagnostics_task: Task::ready(()), + pull_diagnostics_background_task: Task::ready(()), colors: None, refresh_colors_task: Task::ready(()), inlay_hints: None, @@ -2350,7 +2404,7 @@ impl Editor { lookup_key: None, select_next_is_case_sensitive: None, applicable_language_settings: HashMap::default(), - accent_overrides: Vec::new(), + accent_data: None, fetched_tree_sitter_chunks: HashMap::default(), number_deleted_lines: false, }; @@ -2360,7 +2414,7 @@ impl Editor { } editor.applicable_language_settings = editor.fetch_applicable_language_settings(cx); - editor.accent_overrides = editor.fetch_accent_overrides(cx); + editor.accent_data = editor.fetch_accent_data(cx); if let Some(breakpoints) = editor.breakpoint_store.as_ref() { editor @@ -2493,7 +2547,6 @@ impl Editor { if let Some(buffer) = multi_buffer.read(cx).as_singleton() { editor.register_buffer(buffer.read(cx).remote_id(), cx); } - editor.update_lsp_data(None, window, cx); editor.report_editor_event(ReportEditorEvent::EditorOpened, None, cx); } @@ -2714,21 +2767,24 @@ impl Editor { pub fn accept_edit_prediction_keybind( &self, - accept_partial: bool, + granularity: EditPredictionGranularity, window: &mut Window, cx: &mut App, ) -> AcceptEditPredictionBinding { let key_context = self.key_context_internal(true, window, cx); let in_conflict = self.edit_prediction_in_conflict(); - let bindings = if accept_partial { - window.bindings_for_action_in_context(&AcceptPartialEditPrediction, key_context) - } else { - window.bindings_for_action_in_context(&AcceptEditPrediction, key_context) - }; + let bindings = + match granularity { + EditPredictionGranularity::Word => window + .bindings_for_action_in_context(&AcceptNextWordEditPrediction, key_context), + EditPredictionGranularity::Line => window + .bindings_for_action_in_context(&AcceptNextLineEditPrediction, key_context), + EditPredictionGranularity::Full => { + window.bindings_for_action_in_context(&AcceptEditPrediction, key_context) + } + }; - // TODO: if the binding contains multiple keystrokes, display all of them, not - // just the first one. AcceptEditPredictionBinding(bindings.into_iter().rev().find(|binding| { !in_conflict || binding @@ -2883,6 +2939,7 @@ impl Editor { EditorSnapshot { mode: self.mode.clone(), show_gutter: self.show_gutter, + offset_content: self.offset_content, show_line_numbers: self.show_line_numbers, number_deleted_lines: self.number_deleted_lines, show_git_diff_gutter: self.show_git_diff_gutter, @@ -2978,9 +3035,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) where - T: EditPredictionProvider, + T: EditPredictionDelegate, { - self.edit_prediction_provider = provider.map(|provider| RegisteredEditPredictionProvider { + self.edit_prediction_provider = provider.map(|provider| RegisteredEditPredictionDelegate { _subscription: cx.observe_in(&provider, window, |this, _, window, cx| { if this.focus_handle.is_focused(window) { this.update_visible_edit_prediction(window, cx); @@ -3038,6 +3095,10 @@ impl Editor { self.cursor_shape } + pub fn set_cursor_offset_on_selection(&mut self, set_cursor_offset_on_selection: bool) { + self.cursor_offset_on_selection = set_cursor_offset_on_selection; + } + pub fn set_current_line_highlight( &mut self, current_line_highlight: Option, @@ -3147,6 +3208,10 @@ impl Editor { } } + pub fn set_show_completions_on_input(&mut self, show_completions_on_input: Option) { + self.show_completions_on_input_override = show_completions_on_input; + } + pub fn set_show_edit_predictions( &mut self, show_edit_predictions: Option, @@ -3350,7 +3415,8 @@ impl Editor { data.selections = inmemory_selections; }); - if WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None + if WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::EmptyTab && let Some(workspace_id) = self.workspace_serialization_id(cx) { let snapshot = self.buffer().read(cx).snapshot(cx); @@ -3390,7 +3456,8 @@ impl Editor { use text::ToPoint as _; if self.mode.is_minimap() - || WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None + || WorkspaceSettings::get(None, cx).restore_on_startup + == RestoreOnStartupBehavior::EmptyTab { return; } @@ -3748,7 +3815,7 @@ impl Editor { ) { if !self.focus_handle.is_focused(window) { self.last_focused_descendant = None; - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); @@ -3853,7 +3920,7 @@ impl Editor { ) { if !self.focus_handle.is_focused(window) { self.last_focused_descendant = None; - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); @@ -4327,10 +4394,50 @@ impl Editor { && bracket_pair.start.len() == 1 { let target = bracket_pair.start.chars().next().unwrap(); + let mut byte_offset = 0u32; let current_line_count = snapshot .reversed_chars_at(selection.start) .take_while(|&c| c != '\n') - .filter(|&c| c == target) + .filter(|c| { + byte_offset += c.len_utf8() as u32; + if *c != target { + return false; + } + + let point = Point::new( + selection.start.row, + selection.start.column.saturating_sub(byte_offset), + ); + + let is_enabled = snapshot + .language_scope_at(point) + .and_then(|scope| { + scope + .brackets() + .find(|(pair, _)| { + pair.start == bracket_pair.start + }) + .map(|(_, enabled)| enabled) + }) + .unwrap_or(true); + + let is_delimiter = snapshot + .language_scope_at(Point::new( + point.row, + point.column + 1, + )) + .and_then(|scope| { + scope + .brackets() + .find(|(pair, _)| { + pair.start == bracket_pair.start + }) + .map(|(_, enabled)| !enabled) + }) + .unwrap_or(false); + + is_enabled && !is_delimiter + }) .count(); current_line_count % 2 == 1 } else { @@ -4683,18 +4790,27 @@ impl Editor { let end = selection.end; let selection_is_empty = start == end; let language_scope = buffer.language_scope_at(start); - let ( - comment_delimiter, - doc_delimiter, - insert_extra_newline, - indent_on_newline, - indent_on_extra_newline, - ) = if let Some(language) = &language_scope { - let mut insert_extra_newline = - insert_extra_newline_brackets(&buffer, start..end, language) - || insert_extra_newline_tree_sitter(&buffer, start..end); - - // Comment extension on newline is allowed only for cursor selections + let (delimiter, newline_config) = if let Some(language) = &language_scope { + let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets( + &buffer, + start..end, + language, + ) + || NewlineConfig::insert_extra_newline_tree_sitter( + &buffer, + start..end, + ); + + let mut newline_config = NewlineConfig::Newline { + additional_indent: IndentSize::spaces(0), + extra_line_additional_indent: if needs_extra_newline { + Some(IndentSize::spaces(0)) + } else { + None + }, + prevent_auto_indent: false, + }; + let comment_delimiter = maybe!({ if !selection_is_empty { return None; @@ -4704,63 +4820,13 @@ impl Editor { return None; } - let delimiters = language.line_comment_prefixes(); - let max_len_of_delimiter = - delimiters.iter().map(|delimiter| delimiter.len()).max()?; - let (snapshot, range) = - buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; - - let num_of_whitespaces = snapshot - .chars_for_range(range.clone()) - .take_while(|c| c.is_whitespace()) - .count(); - let comment_candidate = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(max_len_of_delimiter) - .collect::(); - let (delimiter, trimmed_len) = delimiters - .iter() - .filter_map(|delimiter| { - let prefix = delimiter.trim_end(); - if comment_candidate.starts_with(prefix) { - Some((delimiter, prefix.len())) - } else { - None - } - }) - .max_by_key(|(_, len)| *len)?; - - if let Some(BlockCommentConfig { - start: block_start, .. - }) = language.block_comment() - { - let block_start_trimmed = block_start.trim_end(); - if block_start_trimmed.starts_with(delimiter.trim_end()) { - let line_content = snapshot - .chars_for_range(range) - .skip(num_of_whitespaces) - .take(block_start_trimmed.len()) - .collect::(); - - if line_content.starts_with(block_start_trimmed) { - return None; - } - } - } - - let cursor_is_placed_after_comment_marker = - num_of_whitespaces + trimmed_len <= start_point.column as usize; - if cursor_is_placed_after_comment_marker { - Some(delimiter.clone()) - } else { - None - } + return comment_delimiter_for_newline( + &start_point, + &buffer, + language, + ); }); - let mut indent_on_newline = IndentSize::spaces(0); - let mut indent_on_extra_newline = IndentSize::spaces(0); - let doc_delimiter = maybe!({ if !selection_is_empty { return None; @@ -4770,149 +4836,100 @@ impl Editor { return None; } - let BlockCommentConfig { - start: start_tag, - end: end_tag, - prefix: delimiter, - tab_size: len, - } = language.documentation_comment()?; - let is_within_block_comment = buffer - .language_scope_at(start_point) - .is_some_and(|scope| scope.override_name() == Some("comment")); - if !is_within_block_comment { + return documentation_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); + }); + + let list_delimiter = maybe!({ + if !selection_is_empty { return None; } - let (snapshot, range) = - buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; - - let num_of_whitespaces = snapshot - .chars_for_range(range.clone()) - .take_while(|c| c.is_whitespace()) - .count(); - - // It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time. - let column = start_point.column; - let cursor_is_after_start_tag = { - let start_tag_len = start_tag.len(); - let start_tag_line = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(start_tag_len) - .collect::(); - if start_tag_line.starts_with(start_tag.as_ref()) { - num_of_whitespaces + start_tag_len <= column as usize - } else { - false - } - }; - - let cursor_is_after_delimiter = { - let delimiter_trim = delimiter.trim_end(); - let delimiter_line = snapshot - .chars_for_range(range.clone()) - .skip(num_of_whitespaces) - .take(delimiter_trim.len()) - .collect::(); - if delimiter_line.starts_with(delimiter_trim) { - num_of_whitespaces + delimiter_trim.len() <= column as usize - } else { - false - } - }; - - let cursor_is_before_end_tag_if_exists = { - let mut char_position = 0u32; - let mut end_tag_offset = None; - - 'outer: for chunk in snapshot.text_for_range(range) { - if let Some(byte_pos) = chunk.find(&**end_tag) { - let chars_before_match = - chunk[..byte_pos].chars().count() as u32; - end_tag_offset = - Some(char_position + chars_before_match); - break 'outer; - } - char_position += chunk.chars().count() as u32; - } - - if let Some(end_tag_offset) = end_tag_offset { - let cursor_is_before_end_tag = column <= end_tag_offset; - if cursor_is_after_start_tag { - if cursor_is_before_end_tag { - insert_extra_newline = true; - } - let cursor_is_at_start_of_end_tag = - column == end_tag_offset; - if cursor_is_at_start_of_end_tag { - indent_on_extra_newline.len = *len; - } - } - cursor_is_before_end_tag - } else { - true - } - }; - - if (cursor_is_after_start_tag || cursor_is_after_delimiter) - && cursor_is_before_end_tag_if_exists - { - if cursor_is_after_start_tag { - indent_on_newline.len = *len; - } - Some(delimiter.clone()) - } else { - None + if !multi_buffer.language_settings(cx).extend_list_on_newline { + return None; } + + return list_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); }); ( - comment_delimiter, - doc_delimiter, - insert_extra_newline, - indent_on_newline, - indent_on_extra_newline, + comment_delimiter.or(doc_delimiter).or(list_delimiter), + newline_config, ) } else { ( None, - None, - false, - IndentSize::default(), - IndentSize::default(), + NewlineConfig::Newline { + additional_indent: IndentSize::spaces(0), + extra_line_additional_indent: None, + prevent_auto_indent: false, + }, ) }; - let prevent_auto_indent = doc_delimiter.is_some(); - let delimiter = comment_delimiter.or(doc_delimiter); - - let capacity_for_delimiter = - delimiter.as_deref().map(str::len).unwrap_or_default(); - let mut new_text = String::with_capacity( - 1 + capacity_for_delimiter - + existing_indent.len as usize - + indent_on_newline.len as usize - + indent_on_extra_newline.len as usize, - ); - new_text.push('\n'); - new_text.extend(existing_indent.chars()); - new_text.extend(indent_on_newline.chars()); - - if let Some(delimiter) = &delimiter { - new_text.push_str(delimiter); - } - - if insert_extra_newline { - new_text.push('\n'); - new_text.extend(existing_indent.chars()); - new_text.extend(indent_on_extra_newline.chars()); - } + let (edit_start, new_text, prevent_auto_indent) = match &newline_config { + NewlineConfig::ClearCurrentLine => { + let row_start = + buffer.point_to_offset(Point::new(start_point.row, 0)); + (row_start, String::new(), false) + } + NewlineConfig::UnindentCurrentLine { continuation } => { + let row_start = + buffer.point_to_offset(Point::new(start_point.row, 0)); + let tab_size = buffer.language_settings_at(start, cx).tab_size; + let tab_size_indent = IndentSize::spaces(tab_size.get()); + let reduced_indent = + existing_indent.with_delta(Ordering::Less, tab_size_indent); + let mut new_text = String::new(); + new_text.extend(reduced_indent.chars()); + new_text.push_str(continuation); + (row_start, new_text, true) + } + NewlineConfig::Newline { + additional_indent, + extra_line_additional_indent, + prevent_auto_indent, + } => { + let capacity_for_delimiter = + delimiter.as_deref().map(str::len).unwrap_or_default(); + let extra_line_len = extra_line_additional_indent + .map(|i| 1 + existing_indent.len as usize + i.len as usize) + .unwrap_or(0); + let mut new_text = String::with_capacity( + 1 + capacity_for_delimiter + + existing_indent.len as usize + + additional_indent.len as usize + + extra_line_len, + ); + new_text.push('\n'); + new_text.extend(existing_indent.chars()); + new_text.extend(additional_indent.chars()); + if let Some(delimiter) = &delimiter { + new_text.push_str(delimiter); + } + if let Some(extra_indent) = extra_line_additional_indent { + new_text.push('\n'); + new_text.extend(existing_indent.chars()); + new_text.extend(extra_indent.chars()); + } + (start, new_text, *prevent_auto_indent) + } + }; let anchor = buffer.anchor_after(end); let new_selection = selection.map(|_| anchor); ( - ((start..end, new_text), prevent_auto_indent), - (insert_extra_newline, new_selection), + ((edit_start..end, new_text), prevent_auto_indent), + (newline_config.has_extra_line(), new_selection), ) }) .unzip() @@ -4949,6 +4966,9 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| s.select(new_selections)); this.refresh_edit_prediction(true, false, window, cx); + if let Some(task) = this.trigger_on_type_formatting("\n".to_owned(), window, cx) { + task.detach_and_log_err(cx); + } }); } @@ -5013,6 +5033,9 @@ impl Editor { } } editor.edit(indent_edits, cx); + if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { + format.detach_and_log_err(cx); + } }); } @@ -5075,6 +5098,9 @@ impl Editor { } } editor.edit(indent_edits, cx); + if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { + format.detach_and_log_err(cx); + } }); } @@ -5385,7 +5411,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option>> { - if input.len() != 1 { + if input.chars().count() != 1 { return None; } @@ -5511,6 +5537,25 @@ impl Editor { }; let buffer_snapshot = buffer.read(cx).snapshot(); + let menu_is_open = matches!( + self.context_menu.borrow().as_ref(), + Some(CodeContextMenu::Completions(_)) + ); + + let language = buffer_snapshot + .language_at(buffer_position.text_anchor) + .map(|language| language.name()); + + let language_settings = language_settings(language.clone(), buffer_snapshot.file(), cx); + let completion_settings = language_settings.completions.clone(); + + let show_completions_on_input = self + .show_completions_on_input_override + .unwrap_or(language_settings.show_completions_on_input); + if !menu_is_open && trigger.is_some() && !show_completions_on_input { + return; + } + let query: Option> = Self::completion_query(&multibuffer_snapshot, buffer_position) .map(|query| query.into()); @@ -5519,14 +5564,8 @@ impl Editor { // Hide the current completions menu when query is empty. Without this, cached // completions from before the trigger char may be reused (#32774). - if query.is_none() { - let menu_is_open = matches!( - self.context_menu.borrow().as_ref(), - Some(CodeContextMenu::Completions(_)) - ); - if menu_is_open { - self.hide_context_menu(window, cx); - } + if query.is_none() && menu_is_open { + self.hide_context_menu(window, cx); } let mut ignore_word_threshold = false; @@ -5615,14 +5654,6 @@ impl Editor { (buffer_position..buffer_position, None) }; - let language = buffer_snapshot - .language_at(buffer_position) - .map(|language| language.name()); - - let completion_settings = language_settings(language.clone(), buffer_snapshot.file(), cx) - .completions - .clone(); - let show_completion_documentation = buffer_snapshot .settings_at(buffer_position, cx) .show_completion_documentation; @@ -5653,7 +5684,6 @@ impl Editor { position.text_anchor, trigger, trigger_in_words, - completions_source.is_some(), cx, ) }) @@ -5823,6 +5853,11 @@ impl Editor { is_incomplete, buffer.clone(), completions.into(), + editor + .context_menu() + .borrow_mut() + .as_ref() + .map(|menu| menu.primary_scroll_handle()), display_options, snippet_sort_order, languages, @@ -6535,6 +6570,52 @@ impl Editor { } } + fn open_transaction_for_hidden_buffers( + workspace: Entity, + transaction: ProjectTransaction, + title: String, + window: &mut Window, + cx: &mut Context, + ) { + if transaction.0.is_empty() { + return; + } + + let edited_buffers_already_open = { + let other_editors: Vec> = workspace + .read(cx) + .panes() + .iter() + .flat_map(|pane| pane.read(cx).items_of_type::()) + .filter(|editor| editor.entity_id() != cx.entity_id()) + .collect(); + + transaction.0.keys().all(|buffer| { + other_editors.iter().any(|editor| { + let multi_buffer = editor.read(cx).buffer(); + multi_buffer.read(cx).is_singleton() + && multi_buffer + .read(cx) + .as_singleton() + .map_or(false, |singleton| { + singleton.entity_id() == buffer.entity_id() + }) + }) + }) + }; + if !edited_buffers_already_open { + let workspace = workspace.downgrade(); + cx.defer_in(window, move |_, window, cx| { + cx.spawn_in(window, async move |editor, cx| { + Self::open_project_transaction(&editor, workspace, transaction, title, cx) + .await + .ok() + }) + .detach(); + }); + } + } + pub async fn open_project_transaction( editor: &WeakEntity, workspace: WeakEntity, @@ -6610,7 +6691,7 @@ impl Editor { editor.update(cx, |editor, cx| { editor.highlight_background::( &ranges_to_highlight, - |theme| theme.colors().editor_highlighted_line_background, + |_, theme| theme.colors().editor_highlighted_line_background, cx, ); }); @@ -6694,7 +6775,7 @@ impl Editor { }) }) .on_click(cx.listener(move |editor, _: &ClickEvent, window, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.toggle_code_actions( &crate::actions::ToggleCodeActions { deployed_from: Some(crate::actions::CodeActionSource::Indicator( @@ -6811,6 +6892,9 @@ impl Editor { return; }; + if self.blame.is_none() { + self.start_git_blame(true, window, cx); + } let Some(blame) = self.blame.as_ref() else { return; }; @@ -6828,7 +6912,7 @@ impl Editor { }; let anchor = self.selections.newest_anchor().head(); - let position = self.to_pixel_point(anchor, &snapshot, window); + let position = self.to_pixel_point(anchor, &snapshot, window, cx); if let (Some(position), Some(last_bounds)) = (position, self.last_bounds) { self.show_blame_popover( buffer, @@ -6897,7 +6981,11 @@ impl Editor { } } - fn hide_blame_popover(&mut self, ignore_timeout: bool, cx: &mut Context) -> bool { + pub fn has_mouse_context_menu(&self) -> bool { + self.mouse_context_menu.is_some() + } + + pub fn hide_blame_popover(&mut self, ignore_timeout: bool, cx: &mut Context) -> bool { self.inline_blame_popover_show_task.take(); if let Some(state) = &mut self.inline_blame_popover { let hide_task = cx.spawn(async move |editor, cx| { @@ -7009,12 +7097,12 @@ impl Editor { this.highlight_background::( &read_ranges, - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); this.highlight_background::( &write_ranges, - |theme| theme.colors().editor_document_highlight_write_background, + |_, theme| theme.colors().editor_document_highlight_write_background, cx, ); cx.notify(); @@ -7058,6 +7146,7 @@ impl Editor { Some((query, selection_anchor_range)) } + #[ztracing::instrument(skip_all)] fn update_selection_occurrence_highlights( &mut self, query_text: String, @@ -7122,7 +7211,7 @@ impl Editor { if !match_ranges.is_empty() { editor.highlight_background::( &match_ranges, - |theme| theme.colors().editor_document_highlight_bracket_background, + |_, theme| theme.colors().editor_document_highlight_bracket_background, cx, ) } @@ -7202,6 +7291,7 @@ impl Editor { }); } + #[ztracing::instrument(skip_all)] fn refresh_selected_text_highlights( &mut self, on_buffer_edit: bool, @@ -7380,7 +7470,7 @@ impl Editor { && self .edit_prediction_provider .as_ref() - .is_some_and(|provider| provider.provider.show_completions_in_menu()); + .is_some_and(|provider| provider.provider.show_predictions_in_menu()); let preview_requires_modifier = all_language_settings(file, cx).edit_predictions_mode() == EditPredictionsMode::Subtle; @@ -7441,26 +7531,6 @@ impl Editor { .unwrap_or(false) } - fn cycle_edit_prediction( - &mut self, - direction: Direction, - window: &mut Window, - cx: &mut Context, - ) -> Option<()> { - let provider = self.edit_prediction_provider()?; - let cursor = self.selections.newest_anchor().head(); - let (buffer, cursor_buffer_position) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; - if self.edit_predictions_hidden_for_vim_mode || !self.should_show_edit_predictions() { - return None; - } - - provider.cycle(buffer, cursor_buffer_position, direction, cx); - self.update_visible_edit_prediction(window, cx); - - Some(()) - } - pub fn show_edit_prediction( &mut self, _: &ShowEditPrediction, @@ -7498,45 +7568,9 @@ impl Editor { .detach(); } - pub fn next_edit_prediction( - &mut self, - _: &NextEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - if self.has_active_edit_prediction() { - self.cycle_edit_prediction(Direction::Next, window, cx); - } else { - let is_copilot_disabled = self - .refresh_edit_prediction(false, true, window, cx) - .is_none(); - if is_copilot_disabled { - cx.propagate(); - } - } - } - - pub fn previous_edit_prediction( - &mut self, - _: &PreviousEditPrediction, - window: &mut Window, - cx: &mut Context, - ) { - if self.has_active_edit_prediction() { - self.cycle_edit_prediction(Direction::Prev, window, cx); - } else { - let is_copilot_disabled = self - .refresh_edit_prediction(false, true, window, cx) - .is_none(); - if is_copilot_disabled { - cx.propagate(); - } - } - } - - pub fn accept_edit_prediction( + pub fn accept_partial_edit_prediction( &mut self, - _: &AcceptEditPrediction, + granularity: EditPredictionGranularity, window: &mut Window, cx: &mut Context, ) { @@ -7548,55 +7582,67 @@ impl Editor { return; }; + if !matches!(granularity, EditPredictionGranularity::Full) && self.selections.count() != 1 { + return; + } + match &active_edit_prediction.completion { EditPrediction::MoveWithin { target, .. } => { let target = *target; - if let Some(position_map) = &self.last_position_map { - if position_map - .visible_row_range - .contains(&target.to_display_point(&position_map.snapshot).row()) - || !self.edit_prediction_requires_modifier() - { - self.unfold_ranges(&[target..target], true, false, cx); - // Note that this is also done in vim's handler of the Tab action. - self.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_anchor_ranges([target..target]); - }, - ); - self.clear_row_highlights::(); + if matches!(granularity, EditPredictionGranularity::Full) { + if let Some(position_map) = &self.last_position_map { + let target_row = target.to_display_point(&position_map.snapshot).row(); + let is_visible = position_map.visible_row_range.contains(&target_row); - self.edit_prediction_preview - .set_previous_scroll_position(None); - } else { - self.edit_prediction_preview - .set_previous_scroll_position(Some( - position_map.snapshot.scroll_anchor, - )); - - self.highlight_rows::( - target..target, - cx.theme().colors().editor_highlighted_line_background, - RowHighlightOptions { - autoscroll: true, - ..Default::default() - }, - cx, - ); - self.request_autoscroll(Autoscroll::fit(), cx); - } - } - } - EditPrediction::MoveOutside { snapshot, target } => { - if let Some(workspace) = self.workspace() { - Self::open_editor_at_anchor(snapshot, *target, &workspace, window, cx) - .detach_and_log_err(cx); - } - } + if is_visible || !self.edit_prediction_requires_modifier() { + self.unfold_ranges(&[target..target], true, false, cx); + self.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_anchor_ranges([target..target]); + }, + ); + self.clear_row_highlights::(); + self.edit_prediction_preview + .set_previous_scroll_position(None); + } else { + // Highlight and request scroll + self.edit_prediction_preview + .set_previous_scroll_position(Some( + position_map.snapshot.scroll_anchor, + )); + self.highlight_rows::( + target..target, + cx.theme().colors().editor_highlighted_line_background, + RowHighlightOptions { + autoscroll: true, + ..Default::default() + }, + cx, + ); + self.request_autoscroll(Autoscroll::fit(), cx); + } + } + } else { + self.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_anchor_ranges([target..target]); + }, + ); + } + } + EditPrediction::MoveOutside { snapshot, target } => { + if let Some(workspace) = self.workspace() { + Self::open_editor_at_anchor(snapshot, *target, &workspace, window, cx) + .detach_and_log_err(cx); + } + } EditPrediction::Edit { edits, .. } => { self.report_edit_prediction_event( active_edit_prediction.completion_id.clone(), @@ -7604,126 +7650,131 @@ impl Editor { cx, ); - if let Some(provider) = self.edit_prediction_provider() { - provider.accept(cx); - } + match granularity { + EditPredictionGranularity::Full => { + if let Some(provider) = self.edit_prediction_provider() { + provider.accept(cx); + } - // Store the transaction ID and selections before applying the edit - let transaction_id_prev = self.buffer.read(cx).last_transaction_id(cx); + let transaction_id_prev = self.buffer.read(cx).last_transaction_id(cx); + let snapshot = self.buffer.read(cx).snapshot(cx); + let last_edit_end = edits.last().unwrap().0.end.bias_right(&snapshot); - let snapshot = self.buffer.read(cx).snapshot(cx); - let last_edit_end = edits.last().unwrap().0.end.bias_right(&snapshot); + self.buffer.update(cx, |buffer, cx| { + buffer.edit(edits.iter().cloned(), None, cx) + }); - self.buffer.update(cx, |buffer, cx| { - buffer.edit(edits.iter().cloned(), None, cx) - }); + self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_anchor_ranges([last_edit_end..last_edit_end]); + }); - self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_anchor_ranges([last_edit_end..last_edit_end]); - }); + let selections = self.selections.disjoint_anchors_arc(); + if let Some(transaction_id_now) = + self.buffer.read(cx).last_transaction_id(cx) + { + if transaction_id_prev != Some(transaction_id_now) { + self.selection_history + .insert_transaction(transaction_id_now, selections); + } + } - let selections = self.selections.disjoint_anchors_arc(); - if let Some(transaction_id_now) = self.buffer.read(cx).last_transaction_id(cx) { - let has_new_transaction = transaction_id_prev != Some(transaction_id_now); - if has_new_transaction { - self.selection_history - .insert_transaction(transaction_id_now, selections); + self.update_visible_edit_prediction(window, cx); + if self.active_edit_prediction.is_none() { + self.refresh_edit_prediction(true, true, window, cx); + } + cx.notify(); } - } + _ => { + let snapshot = self.buffer.read(cx).snapshot(cx); + let cursor_offset = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); + + let insertion = edits.iter().find_map(|(range, text)| { + let range = range.to_offset(&snapshot); + if range.is_empty() && range.start == cursor_offset { + Some(text) + } else { + None + } + }); - self.update_visible_edit_prediction(window, cx); - if self.active_edit_prediction.is_none() { - self.refresh_edit_prediction(true, true, window, cx); - } + if let Some(text) = insertion { + let text_to_insert = match granularity { + EditPredictionGranularity::Word => { + let mut partial = text + .chars() + .by_ref() + .take_while(|c| c.is_alphabetic()) + .collect::(); + if partial.is_empty() { + partial = text + .chars() + .by_ref() + .take_while(|c| c.is_whitespace() || !c.is_alphabetic()) + .collect::(); + } + partial + } + EditPredictionGranularity::Line => { + if let Some(line) = text.split_inclusive('\n').next() { + line.to_string() + } else { + text.to_string() + } + } + EditPredictionGranularity::Full => unreachable!(), + }; - cx.notify(); + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: None, + text: text_to_insert.clone().into(), + }); + + self.insert_with_autoindent_mode(&text_to_insert, None, window, cx); + self.refresh_edit_prediction(true, true, window, cx); + cx.notify(); + } else { + self.accept_partial_edit_prediction( + EditPredictionGranularity::Full, + window, + cx, + ); + } + } + } } } self.edit_prediction_requires_modifier_in_indent_conflict = false; } - pub fn accept_partial_edit_prediction( + pub fn accept_next_word_edit_prediction( &mut self, - _: &AcceptPartialEditPrediction, + _: &AcceptNextWordEditPrediction, window: &mut Window, cx: &mut Context, ) { - let Some(active_edit_prediction) = self.active_edit_prediction.as_ref() else { - return; - }; - if self.selections.count() != 1 { - return; - } - - match &active_edit_prediction.completion { - EditPrediction::MoveWithin { target, .. } => { - let target = *target; - self.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_anchor_ranges([target..target]); - }, - ); - } - EditPrediction::MoveOutside { snapshot, target } => { - if let Some(workspace) = self.workspace() { - Self::open_editor_at_anchor(snapshot, *target, &workspace, window, cx) - .detach_and_log_err(cx); - } - } - EditPrediction::Edit { edits, .. } => { - self.report_edit_prediction_event( - active_edit_prediction.completion_id.clone(), - true, - cx, - ); - - // Find an insertion that starts at the cursor position. - let snapshot = self.buffer.read(cx).snapshot(cx); - let cursor_offset = self - .selections - .newest::(&self.display_snapshot(cx)) - .head(); - let insertion = edits.iter().find_map(|(range, text)| { - let range = range.to_offset(&snapshot); - if range.is_empty() && range.start == cursor_offset { - Some(text) - } else { - None - } - }); - - if let Some(text) = insertion { - let mut partial_completion = text - .chars() - .by_ref() - .take_while(|c| c.is_alphabetic()) - .collect::(); - if partial_completion.is_empty() { - partial_completion = text - .chars() - .by_ref() - .take_while(|c| c.is_whitespace() || !c.is_alphabetic()) - .collect::(); - } - - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: None, - text: partial_completion.clone().into(), - }); + self.accept_partial_edit_prediction(EditPredictionGranularity::Word, window, cx); + } - self.insert_with_autoindent_mode(&partial_completion, None, window, cx); + pub fn accept_next_line_edit_prediction( + &mut self, + _: &AcceptNextLineEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + self.accept_partial_edit_prediction(EditPredictionGranularity::Line, window, cx); + } - self.refresh_edit_prediction(true, true, window, cx); - cx.notify(); - } else { - self.accept_edit_prediction(&Default::default(), window, cx); - } - } - } + pub fn accept_edit_prediction( + &mut self, + _: &AcceptEditPrediction, + window: &mut Window, + cx: &mut Context, + ) { + self.accept_partial_edit_prediction(EditPredictionGranularity::Full, window, cx); } fn discard_edit_prediction( @@ -7943,21 +7994,23 @@ impl Editor { cx: &mut Context, ) { let mut modifiers_held = false; - if let Some(accept_keystroke) = self - .accept_edit_prediction_keybind(false, window, cx) - .keystroke() - { - modifiers_held = modifiers_held - || (accept_keystroke.modifiers() == modifiers - && accept_keystroke.modifiers().modified()); - }; - if let Some(accept_partial_keystroke) = self - .accept_edit_prediction_keybind(true, window, cx) - .keystroke() - { - modifiers_held = modifiers_held - || (accept_partial_keystroke.modifiers() == modifiers - && accept_partial_keystroke.modifiers().modified()); + + // Check bindings for all granularities. + // If the user holds the key for Word, Line, or Full, we want to show the preview. + let granularities = [ + EditPredictionGranularity::Full, + EditPredictionGranularity::Line, + EditPredictionGranularity::Word, + ]; + + for granularity in granularities { + if let Some(keystroke) = self + .accept_edit_prediction_keybind(granularity, window, cx) + .keystroke() + { + modifiers_held = modifiers_held + || (keystroke.modifiers() == modifiers && keystroke.modifiers().modified()); + } } if modifiers_held { @@ -8061,10 +8114,17 @@ impl Editor { if self.edit_prediction_indent_conflict { let cursor_point = cursor.to_point(&multibuffer); + let mut suggested_indent = None; + multibuffer.suggested_indents_callback( + cursor_point.row..cursor_point.row + 1, + |_, indent| { + suggested_indent = Some(indent); + ControlFlow::Break(()) + }, + cx, + ); - let indents = multibuffer.suggested_indents(cursor_point.row..cursor_point.row + 1, cx); - - if let Some((_, indent)) = indents.iter().next() + if let Some(indent) = suggested_indent && indent.len == cursor_point.column { self.edit_prediction_indent_conflict = false; @@ -8074,12 +8134,12 @@ impl Editor { let edit_prediction = provider.suggest(&buffer, cursor_buffer_position, cx)?; let (completion_id, edits, edit_preview) = match edit_prediction { - edit_prediction::EditPrediction::Local { + edit_prediction_types::EditPrediction::Local { id, edits, edit_preview, } => (id, edits, edit_preview), - edit_prediction::EditPrediction::Jump { + edit_prediction_types::EditPrediction::Jump { id, snapshot, target, @@ -8220,7 +8280,7 @@ impl Editor { Some(()) } - pub fn edit_prediction_provider(&self) -> Option> { + pub fn edit_prediction_provider(&self) -> Option> { Some(self.edit_prediction_provider.as_ref()?.provider.clone()) } @@ -8552,7 +8612,7 @@ impl Editor { BreakpointEditAction::Toggle }; - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.edit_breakpoint_at_anchor( position, breakpoint.as_ref().clone(), @@ -8744,7 +8804,7 @@ impl Editor { ClickEvent::Mouse(e) => e.down.button == MouseButton::Left, }; - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.toggle_code_actions( &ToggleCodeActions { deployed_from: Some(CodeActionSource::RunMenu(row)), @@ -9130,7 +9190,8 @@ impl Editor { let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); - let line_origin = self.display_to_pixel_point(target_line_end, editor_snapshot, window)?; + let line_origin = + self.display_to_pixel_point(target_line_end, editor_snapshot, window, cx)?; let start_point = content_origin - point(scroll_pixel_position.x.into(), Pixels::ZERO); let mut origin = start_point @@ -9369,7 +9430,8 @@ impl Editor { window: &mut Window, cx: &mut App, ) -> Option { - let accept_binding = self.accept_edit_prediction_keybind(false, window, cx); + let accept_binding = + self.accept_edit_prediction_keybind(EditPredictionGranularity::Full, window, cx); let accept_keystroke = accept_binding.keystroke()?; let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; @@ -9542,7 +9604,7 @@ impl Editor { editor_bg_color.blend(accent_color.opacity(0.6)) } fn get_prediction_provider_icon_name( - provider: &Option, + provider: &Option, ) -> IconName { match provider { Some(provider) => match provider.provider.name() { @@ -9872,8 +9934,7 @@ impl Editor { } pub fn render_context_menu( - &self, - style: &EditorStyle, + &mut self, max_height_in_lines: u32, window: &mut Window, cx: &mut Context, @@ -9883,7 +9944,9 @@ impl Editor { if !menu.visible() { return None; }; - Some(menu.render(style, max_height_in_lines, window, cx)) + self.style + .as_ref() + .map(|style| menu.render(style, max_height_in_lines, window, cx)) } fn render_context_menu_aside( @@ -9943,13 +10006,16 @@ impl Editor { let id = post_inc(&mut self.next_completion_id); let snippet_sort_order = EditorSettings::get_global(cx).snippet_sort_order; - *self.context_menu.borrow_mut() = Some(CodeContextMenu::Completions( + let mut context_menu = self.context_menu.borrow_mut(); + let old_menu = context_menu.take(); + *context_menu = Some(CodeContextMenu::Completions( CompletionsMenu::new_snippet_choices( id, true, choices, selection, buffer, + old_menu.map(|menu| menu.primary_scroll_handle()), snippet_sort_order, ), )); @@ -10388,6 +10454,22 @@ impl Editor { } prev_edited_row = selection.end.row; + // If cursor is after a list prefix, make selection non-empty to trigger line indent + if selection.is_empty() { + let cursor = selection.head(); + let settings = buffer.language_settings_at(cursor, cx); + if settings.indent_list_on_tab { + if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) { + if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) { + row_delta = Self::indent_selection( + buffer, &snapshot, selection, &mut edits, row_delta, cx, + ); + continue; + } + } + } + } + // If the selection is non-empty, then increase the indentation of the selected lines. if !selection.is_empty() { row_delta = @@ -11153,7 +11235,7 @@ impl Editor { }]; let focus_handle = bp_prompt.focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); let block_ids = self.insert_blocks(blocks, None, cx); bp_prompt.update(cx, |prompt, _| { @@ -11443,6 +11525,168 @@ impl Editor { self.manipulate_immutable_lines(window, cx, |lines| lines.shuffle(&mut rand::rng())) } + pub fn rotate_selections_forward( + &mut self, + _: &RotateSelectionsForward, + window: &mut Window, + cx: &mut Context, + ) { + self.rotate_selections(window, cx, false) + } + + pub fn rotate_selections_backward( + &mut self, + _: &RotateSelectionsBackward, + window: &mut Window, + cx: &mut Context, + ) { + self.rotate_selections(window, cx, true) + } + + fn rotate_selections(&mut self, window: &mut Window, cx: &mut Context, reverse: bool) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + let display_snapshot = self.display_snapshot(cx); + let selections = self.selections.all::(&display_snapshot); + + if selections.len() < 2 { + return; + } + + let (edits, new_selections) = { + let buffer = self.buffer.read(cx).read(cx); + let has_selections = selections.iter().any(|s| !s.is_empty()); + if has_selections { + let mut selected_texts: Vec = selections + .iter() + .map(|selection| { + buffer + .text_for_range(selection.start..selection.end) + .collect() + }) + .collect(); + + if reverse { + selected_texts.rotate_left(1); + } else { + selected_texts.rotate_right(1); + } + + let mut offset_delta: i64 = 0; + let mut new_selections = Vec::new(); + let edits: Vec<_> = selections + .iter() + .zip(selected_texts.iter()) + .map(|(selection, new_text)| { + let old_len = (selection.end.0 - selection.start.0) as i64; + let new_len = new_text.len() as i64; + let adjusted_start = + MultiBufferOffset((selection.start.0 as i64 + offset_delta) as usize); + let adjusted_end = + MultiBufferOffset((adjusted_start.0 as i64 + new_len) as usize); + + new_selections.push(Selection { + id: selection.id, + start: adjusted_start, + end: adjusted_end, + reversed: selection.reversed, + goal: selection.goal, + }); + + offset_delta += new_len - old_len; + (selection.start..selection.end, new_text.clone()) + }) + .collect(); + (edits, new_selections) + } else { + let mut all_rows: Vec = selections + .iter() + .map(|selection| buffer.offset_to_point(selection.start).row) + .collect(); + all_rows.sort_unstable(); + all_rows.dedup(); + + if all_rows.len() < 2 { + return; + } + + let line_ranges: Vec> = all_rows + .iter() + .map(|&row| { + let start = Point::new(row, 0); + let end = Point::new(row, buffer.line_len(MultiBufferRow(row))); + buffer.point_to_offset(start)..buffer.point_to_offset(end) + }) + .collect(); + + let mut line_texts: Vec = line_ranges + .iter() + .map(|range| buffer.text_for_range(range.clone()).collect()) + .collect(); + + if reverse { + line_texts.rotate_left(1); + } else { + line_texts.rotate_right(1); + } + + let edits = line_ranges + .iter() + .zip(line_texts.iter()) + .map(|(range, new_text)| (range.clone(), new_text.clone())) + .collect(); + + let num_rows = all_rows.len(); + let row_to_index: std::collections::HashMap = all_rows + .iter() + .enumerate() + .map(|(i, &row)| (row, i)) + .collect(); + + // Compute new line start offsets after rotation (handles CRLF) + let newline_len = line_ranges[1].start.0 - line_ranges[0].end.0; + let first_line_start = line_ranges[0].start.0; + let mut new_line_starts: Vec = vec![first_line_start]; + for text in line_texts.iter().take(num_rows - 1) { + let prev_start = *new_line_starts.last().unwrap(); + new_line_starts.push(prev_start + text.len() + newline_len); + } + + let new_selections = selections + .iter() + .map(|selection| { + let point = buffer.offset_to_point(selection.start); + let old_index = row_to_index[&point.row]; + let new_index = if reverse { + (old_index + num_rows - 1) % num_rows + } else { + (old_index + 1) % num_rows + }; + let new_offset = + MultiBufferOffset(new_line_starts[new_index] + point.column as usize); + Selection { + id: selection.id, + start: new_offset, + end: new_offset, + reversed: selection.reversed, + goal: selection.goal, + } + }) + .collect(); + + (edits, new_selections) + } + }; + + self.transact(window, cx, |this, window, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.edit(edits, None, cx); + }); + this.change_selections(Default::default(), window, cx, |s| { + s.select(new_selections); + }); + }); + } + fn manipulate_lines( &mut self, window: &mut Window, @@ -12778,13 +13022,15 @@ impl Editor { text.push_str(chunk); len += chunk.len(); } - clipboard_selections.push(ClipboardSelection { + + clipboard_selections.push(ClipboardSelection::for_buffer( len, is_entire_line, - first_line_indent: buffer - .indent_size_for_line(MultiBufferRow(selection.start.row)) - .len, - }); + selection.range(), + &buffer, + self.project.as_ref(), + cx, + )); } } @@ -12927,13 +13173,14 @@ impl Editor { text.push('\n'); len += 1; } - clipboard_selections.push(ClipboardSelection { + clipboard_selections.push(ClipboardSelection::for_buffer( len, is_entire_line, - first_line_indent: buffer - .indent_size_for_line(MultiBufferRow(trimmed_range.start.row)) - .len, - }); + trimmed_range, + &buffer, + self.project.as_ref(), + cx, + )); } } } @@ -14735,6 +14982,52 @@ impl Editor { } } + pub fn insert_snippet_at_selections( + &mut self, + action: &InsertSnippet, + window: &mut Window, + cx: &mut Context, + ) { + self.try_insert_snippet_at_selections(action, window, cx) + .log_err(); + } + + fn try_insert_snippet_at_selections( + &mut self, + action: &InsertSnippet, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let insertion_ranges = self + .selections + .all::(&self.display_snapshot(cx)) + .into_iter() + .map(|selection| selection.range()) + .collect_vec(); + + let snippet = if let Some(snippet_body) = &action.snippet { + if action.language.is_none() && action.name.is_none() { + Snippet::parse(snippet_body)? + } else { + bail!("`snippet` is mutually exclusive with `language` and `name`") + } + } else if let Some(name) = &action.name { + let project = self.project().context("no project")?; + let snippet_store = project.read(cx).snippets().read(cx); + let snippet = snippet_store + .snippets_for(action.language.clone(), cx) + .into_iter() + .find(|snippet| snippet.name == *name) + .context("snippet not found")?; + Snippet::parse(&snippet.body)? + } else { + // todo(andrew): open modal to select snippet + bail!("`name` or `snippet` is required") + }; + + self.insert_snippet(&insertion_ranges, snippet, window, cx) + } + fn select_match_ranges( &mut self, range: Range, @@ -15145,10 +15438,9 @@ impl Editor { I: IntoIterator, P: AsRef<[u8]>, { - let case_sensitive = self.select_next_is_case_sensitive.map_or_else( - || EditorSettings::get_global(cx).search.case_sensitive, - |value| value, - ); + let case_sensitive = self + .select_next_is_case_sensitive + .unwrap_or_else(|| EditorSettings::get_global(cx).search.case_sensitive); let mut builder = AhoCorasickBuilder::new(); builder.ascii_case_insensitive(!case_sensitive); @@ -16780,7 +17072,7 @@ impl Editor { GoToDefinitionFallback::None => Ok(Navigated::No), GoToDefinitionFallback::FindAllReferences => { match editor.update_in(cx, |editor, window, cx| { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) })? { Some(references) => references.await, None => Ok(Navigated::No), @@ -17034,10 +17326,6 @@ impl Editor { } if num_locations > 1 { - let Some(workspace) = workspace else { - return Ok(Navigated::No); - }; - let tab_kind = match kind { Some(GotoDefinitionKind::Implementation) => "Implementations", Some(GotoDefinitionKind::Symbol) | None => "Definitions", @@ -17067,13 +17355,20 @@ impl Editor { }) .context("buffer title")?; + let Some(workspace) = workspace else { + return Ok(Navigated::No); + }; + let opened = workspace .update_in(cx, |workspace, window, cx| { + let allow_preview = PreviewTabsSettings::get_global(cx) + .enable_preview_multibuffer_from_code_navigation; Self::open_locations_in_multibuffer( workspace, locations, title, split, + allow_preview, MultibufferSelectionMode::First, window, cx, @@ -17086,14 +17381,23 @@ impl Editor { // If there is one url or file, open it directly match first_url_or_file { Some(Either::Left(url)) => { - cx.update(|_, cx| cx.open_url(&url))?; + cx.update(|window, cx| { + if parse_zed_link(&url, cx).is_some() { + window + .dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx); + } else { + cx.open_url(&url); + } + })?; Ok(Navigated::Yes) } Some(Either::Right(path)) => { + // TODO(andrew): respect preview tab settings + // `enable_keep_preview_on_code_navigation` and + // `enable_preview_file_from_code_navigation` let Some(workspace) = workspace else { return Ok(Navigated::No); }; - workspace .update_in(cx, |workspace, window, cx| { workspace.open_resolved_path(path, window, cx) @@ -17104,10 +17408,6 @@ impl Editor { None => Ok(Navigated::No), } } else { - let Some(workspace) = workspace else { - return Ok(Navigated::No); - }; - let (target_buffer, target_ranges) = locations.into_iter().next().unwrap(); let target_range = target_ranges.first().unwrap().clone(); @@ -17121,6 +17421,9 @@ impl Editor { { editor.go_to_singleton_buffer_range(range, window, cx); } else { + let Some(workspace) = workspace else { + return Navigated::No; + }; let pane = workspace.read(cx).active_pane().clone(); window.defer(cx, move |window, cx| { let target_editor: Entity = @@ -17131,11 +17434,19 @@ impl Editor { workspace.active_pane().clone() }; + let preview_tabs_settings = PreviewTabsSettings::get_global(cx); + let keep_old_preview = preview_tabs_settings + .enable_keep_preview_on_code_navigation; + let allow_new_preview = preview_tabs_settings + .enable_preview_file_from_code_navigation; + workspace.open_project_item( pane, target_buffer.clone(), true, true, + keep_old_preview, + allow_new_preview, window, cx, ) @@ -17324,20 +17635,21 @@ impl Editor { pub fn find_all_references( &mut self, - _: &FindAllReferences, + action: &FindAllReferences, window: &mut Window, cx: &mut Context, ) -> Option>> { - let selection = self - .selections - .newest::(&self.display_snapshot(cx)); + let always_open_multibuffer = action.always_open_multibuffer; + let selection = self.selections.newest_anchor(); let multi_buffer = self.buffer.read(cx); - let head = selection.head(); - let multi_buffer_snapshot = multi_buffer.snapshot(cx); + let selection_offset = selection.map(|anchor| anchor.to_offset(&multi_buffer_snapshot)); + let selection_point = selection.map(|anchor| anchor.to_point(&multi_buffer_snapshot)); + let head = selection_offset.head(); + let head_anchor = multi_buffer_snapshot.anchor_at( head, - if head < selection.tail() { + if head < selection_offset.tail() { Bias::Right } else { Bias::Left @@ -17383,6 +17695,15 @@ impl Editor { let buffer = location.buffer.read(cx); (location.buffer, location.range.to_point(buffer)) }) + // if special-casing the single-match case, remove ranges + // that intersect current selection + .filter(|(location_buffer, location)| { + if always_open_multibuffer || &buffer != location_buffer { + return true; + } + + !location.contains_inclusive(&selection_point.range()) + }) .into_group_map() })?; if locations.is_empty() { @@ -17392,6 +17713,60 @@ impl Editor { ranges.sort_by_key(|range| (range.start, Reverse(range.end))); ranges.dedup(); } + let mut num_locations = 0; + for ranges in locations.values_mut() { + ranges.sort_by_key(|range| (range.start, Reverse(range.end))); + ranges.dedup(); + num_locations += ranges.len(); + } + + if num_locations == 1 && !always_open_multibuffer { + let (target_buffer, target_ranges) = locations.into_iter().next().unwrap(); + let target_range = target_ranges.first().unwrap().clone(); + + return editor.update_in(cx, |editor, window, cx| { + let range = target_range.to_point(target_buffer.read(cx)); + let range = editor.range_for_match(&range); + let range = range.start..range.start; + + if Some(&target_buffer) == editor.buffer.read(cx).as_singleton().as_ref() { + editor.go_to_singleton_buffer_range(range, window, cx); + } else { + let pane = workspace.read(cx).active_pane().clone(); + window.defer(cx, move |window, cx| { + let target_editor: Entity = + workspace.update(cx, |workspace, cx| { + let pane = workspace.active_pane().clone(); + + let preview_tabs_settings = PreviewTabsSettings::get_global(cx); + let keep_old_preview = preview_tabs_settings + .enable_keep_preview_on_code_navigation; + let allow_new_preview = preview_tabs_settings + .enable_preview_file_from_code_navigation; + + workspace.open_project_item( + pane, + target_buffer.clone(), + true, + true, + keep_old_preview, + allow_new_preview, + window, + cx, + ) + }); + target_editor.update(cx, |target_editor, cx| { + // When selecting a definition in a different buffer, disable the nav history + // to avoid creating a history entry at the previous cursor location. + pane.update(cx, |pane, _| pane.disable_history()); + target_editor.go_to_singleton_buffer_range(range, window, cx); + pane.update(cx, |pane, _| pane.enable_history()); + }); + }); + } + Navigated::No + }); + } workspace.update_in(cx, |workspace, window, cx| { let target = locations @@ -17412,11 +17787,14 @@ impl Editor { } else { format!("References to {target}") }; + let allow_preview = PreviewTabsSettings::get_global(cx) + .enable_preview_multibuffer_from_code_navigation; Self::open_locations_in_multibuffer( workspace, locations, title, false, + allow_preview, MultibufferSelectionMode::First, window, cx, @@ -17426,12 +17804,13 @@ impl Editor { })) } - /// Opens a multibuffer with the given project locations in it + /// Opens a multibuffer with the given project locations in it. pub fn open_locations_in_multibuffer( workspace: &mut Workspace, locations: std::collections::HashMap, Vec>>, title: String, split: bool, + allow_preview: bool, multibuffer_selection_mode: MultibufferSelectionMode, window: &mut Window, cx: &mut Context, @@ -17479,6 +17858,7 @@ impl Editor { .is_some_and(|it| *it == key) }) }); + let was_existing = existing.is_some(); let editor = existing.unwrap_or_else(|| { cx.new(|cx| { let mut editor = Editor::for_multibuffer( @@ -17506,7 +17886,7 @@ impl Editor { } editor.highlight_background::( &ranges, - |theme| theme.colors().editor_highlighted_line_background, + |_, theme| theme.colors().editor_highlighted_line_background, cx, ); } @@ -17519,29 +17899,23 @@ impl Editor { }); let item = Box::new(editor); - let item_id = item.item_id(); - - if split { - let pane = workspace.adjacent_pane(window, cx); - workspace.add_item(pane, item, None, true, true, window, cx); - } else if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation { - let (preview_item_id, preview_item_idx) = - workspace.active_pane().read_with(cx, |pane, _| { - (pane.preview_item_id(), pane.preview_item_idx()) - }); - workspace.add_item_to_active_pane(item, preview_item_idx, true, window, cx); + let pane = if split { + workspace.adjacent_pane(window, cx) + } else { + workspace.active_pane().clone() + }; + let activate_pane = split; - if let Some(preview_item_id) = preview_item_id { - workspace.active_pane().update(cx, |pane, cx| { - pane.remove_item(preview_item_id, false, false, window, cx); - }); + let mut destination_index = None; + pane.update(cx, |pane, cx| { + if allow_preview && !was_existing { + destination_index = pane.replace_preview_item_id(item.item_id(), window, cx); } - } else { - workspace.add_item_to_active_pane(item, None, true, window, cx); - } - workspace.active_pane().update(cx, |pane, cx| { - pane.set_preview_item_id(Some(item_id), cx); + if was_existing && !allow_preview { + pane.unpreview_item_if_preview(item.item_id()); + } + pane.add_item(item, activate_pane, true, destination_index, window, cx); }); } @@ -17687,7 +18061,7 @@ impl Editor { cx, ); let rename_focus_handle = rename_editor.focus_handle(cx); - window.focus(&rename_focus_handle); + window.focus(&rename_focus_handle, cx); let block_id = this.insert_blocks( [BlockProperties { style: BlockStyle::Flex, @@ -17801,7 +18175,7 @@ impl Editor { ) -> Option { let rename = self.pending_rename.take()?; if rename.editor.focus_handle(cx).is_focused(window) { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } self.remove_blocks( @@ -18397,54 +18771,101 @@ impl Editor { return None; } let project = self.project()?.downgrade(); - let debounce = Duration::from_millis(pull_diagnostics_settings.debounce_ms); - let mut buffers = self.buffer.read(cx).all_buffers(); - buffers.retain(|buffer| { - let buffer_id_to_retain = buffer.read(cx).remote_id(); - buffer_id.is_none_or(|buffer_id| buffer_id == buffer_id_to_retain) - && self.registered_buffers.contains_key(&buffer_id_to_retain) - }); - if buffers.is_empty() { + + let mut edited_buffer_ids = HashSet::default(); + let mut edited_worktree_ids = HashSet::default(); + let edited_buffers = match buffer_id { + Some(buffer_id) => { + let buffer = self.buffer().read(cx).buffer(buffer_id)?; + let worktree_id = buffer.read(cx).file().map(|f| f.worktree_id(cx))?; + edited_buffer_ids.insert(buffer.read(cx).remote_id()); + edited_worktree_ids.insert(worktree_id); + vec![buffer] + } + None => self + .buffer() + .read(cx) + .all_buffers() + .into_iter() + .filter(|buffer| { + let buffer = buffer.read(cx); + match buffer.file().map(|f| f.worktree_id(cx)) { + Some(worktree_id) => { + edited_buffer_ids.insert(buffer.remote_id()); + edited_worktree_ids.insert(worktree_id); + true + } + None => false, + } + }) + .collect::>(), + }; + + if edited_buffers.is_empty() { self.pull_diagnostics_task = Task::ready(()); + self.pull_diagnostics_background_task = Task::ready(()); return None; } - self.pull_diagnostics_task = cx.spawn_in(window, async move |editor, cx| { - cx.background_executor().timer(debounce).await; + let mut already_used_buffers = HashSet::default(); + let related_open_buffers = self + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade()) + .into_iter() + .flat_map(|workspace| workspace.read(cx).panes()) + .flat_map(|pane| pane.read(cx).items_of_type::()) + .filter(|editor| editor != &cx.entity()) + .flat_map(|editor| editor.read(cx).buffer().read(cx).all_buffers()) + .filter(|buffer| { + let buffer = buffer.read(cx); + let buffer_id = buffer.remote_id(); + if already_used_buffers.insert(buffer_id) { + if let Some(worktree_id) = buffer.file().map(|f| f.worktree_id(cx)) { + return !edited_buffer_ids.contains(&buffer_id) + && !edited_worktree_ids.contains(&worktree_id); + } + } + false + }) + .collect::>(); + + let debounce = Duration::from_millis(pull_diagnostics_settings.debounce_ms); + let make_spawn = |buffers: Vec>, delay: Duration| { + if buffers.is_empty() { + return Task::ready(()); + } + let project_weak = project.clone(); + cx.spawn_in(window, async move |_, cx| { + cx.background_executor().timer(delay).await; - let Ok(mut pull_diagnostics_tasks) = cx.update(|_, cx| { - buffers - .into_iter() - .filter_map(|buffer| { - project - .update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.pull_diagnostics_for_buffer(buffer, cx) + let Ok(mut pull_diagnostics_tasks) = cx.update(|_, cx| { + buffers + .into_iter() + .filter_map(|buffer| { + project_weak + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }) }) - }) - .ok() - }) - .collect::>() - }) else { - return; - }; + .ok() + }) + .collect::>() + }) else { + return; + }; - while let Some(pull_task) = pull_diagnostics_tasks.next().await { - match pull_task { - Ok(()) => { - if editor - .update_in(cx, |editor, window, cx| { - editor.update_diagnostics_state(window, cx); - }) - .is_err() - { - return; - } + while let Some(pull_task) = pull_diagnostics_tasks.next().await { + if let Err(e) = pull_task { + log::error!("Failed to update project diagnostics: {e:#}"); } - Err(e) => log::error!("Failed to update project diagnostics: {e:#}"), } - } - }); + }) + }; + + self.pull_diagnostics_task = make_spawn(edited_buffers, debounce); + self.pull_diagnostics_background_task = make_spawn(related_open_buffers, debounce * 2); Some(()) } @@ -19945,8 +20366,11 @@ impl Editor { self.style = Some(style); } - pub fn style(&self) -> Option<&EditorStyle> { - self.style.as_ref() + pub fn style(&mut self, cx: &App) -> &EditorStyle { + if self.style.is_none() { + self.style = Some(self.create_style(cx)); + } + self.style.as_ref().unwrap() } // Called by the element. This method is not designed to be called outside of the editor @@ -20016,6 +20440,20 @@ impl Editor { self.show_indent_guides } + pub fn disable_indent_guides_for_buffer( + &mut self, + buffer_id: BufferId, + cx: &mut Context, + ) { + self.buffers_with_disabled_indent_guides.insert(buffer_id); + cx.notify(); + } + + pub fn has_indent_guides_disabled_for_buffer(&self, buffer_id: BufferId) -> bool { + self.buffers_with_disabled_indent_guides + .contains(&buffer_id) + } + pub fn toggle_line_numbers( &mut self, _: &ToggleLineNumbers, @@ -20034,7 +20472,7 @@ impl Editor { EditorSettings::get_global(cx).gutter.line_numbers } - pub fn relative_line_numbers(&self, cx: &mut App) -> RelativeLineNumbers { + pub fn relative_line_numbers(&self, cx: &App) -> RelativeLineNumbers { match ( self.use_relative_line_numbers, EditorSettings::get_global(cx).relative_line_numbers, @@ -20529,9 +20967,26 @@ impl Editor { buffer_ranges.last() }?; - let selection = text::ToPoint::to_point(&range.start, buffer).row - ..text::ToPoint::to_point(&range.end, buffer).row; - Some((multi_buffer.buffer(buffer.remote_id()).unwrap(), selection)) + let start_row_in_buffer = text::ToPoint::to_point(&range.start, buffer).row; + let end_row_in_buffer = text::ToPoint::to_point(&range.end, buffer).row; + + let Some(buffer_diff) = multi_buffer.diff_for(buffer.remote_id()) else { + let selection = start_row_in_buffer..end_row_in_buffer; + + return Some((multi_buffer.buffer(buffer.remote_id()).unwrap(), selection)); + }; + + let buffer_diff_snapshot = buffer_diff.read(cx).snapshot(cx); + + Some(( + multi_buffer.buffer(buffer.remote_id()).unwrap(), + buffer_diff_snapshot.row_to_base_text_row(start_row_in_buffer, Bias::Left, buffer) + ..buffer_diff_snapshot.row_to_base_text_row( + end_row_in_buffer, + Bias::Left, + buffer, + ), + )) }); let Some((buffer, selection)) = buffer_and_selection else { @@ -20728,6 +21183,7 @@ impl Editor { locations, format!("Selections for '{title}'"), false, + false, MultibufferSelectionMode::All, window, cx, @@ -20930,7 +21386,7 @@ impl Editor { pub fn set_search_within_ranges(&mut self, ranges: &[Range], cx: &mut Context) { self.highlight_background::( ranges, - |colors| colors.colors().editor_document_highlight_read_background, + |_, colors| colors.colors().editor_document_highlight_read_background, cx, ) } @@ -20946,12 +21402,12 @@ impl Editor { pub fn highlight_background( &mut self, ranges: &[Range], - color_fetcher: fn(&Theme) -> Hsla, + color_fetcher: impl Fn(&usize, &Theme) -> Hsla + Send + Sync + 'static, cx: &mut Context, ) { self.background_highlights.insert( HighlightKey::Type(TypeId::of::()), - (color_fetcher, Arc::from(ranges)), + (Arc::new(color_fetcher), Arc::from(ranges)), ); self.scrollbar_marker_state.dirty = true; cx.notify(); @@ -20961,12 +21417,12 @@ impl Editor { &mut self, key: usize, ranges: &[Range], - color_fetcher: fn(&Theme) -> Hsla, + color_fetcher: impl Fn(&usize, &Theme) -> Hsla + Send + Sync + 'static, cx: &mut Context, ) { self.background_highlights.insert( HighlightKey::TypePlus(TypeId::of::(), key), - (color_fetcher, Arc::from(ranges)), + (Arc::new(color_fetcher), Arc::from(ranges)), ); self.scrollbar_marker_state.dirty = true; cx.notify(); @@ -21191,7 +21647,6 @@ impl Editor { ) -> Vec<(Range, Hsla)> { let mut results = Vec::new(); for (color_fetcher, ranges) in self.background_highlights.values() { - let color = color_fetcher(theme); let start_ix = match ranges.binary_search_by(|probe| { let cmp = probe .end @@ -21204,7 +21659,7 @@ impl Editor { }) { Ok(i) | Err(i) => i, }; - for range in &ranges[start_ix..] { + for (index, range) in ranges[start_ix..].iter().enumerate() { if range .start .cmp(&search_range.end, &display_snapshot.buffer_snapshot()) @@ -21213,6 +21668,7 @@ impl Editor { break; } + let color = color_fetcher(&(start_ix + index), theme); let start = range.start.to_display_point(display_snapshot); let end = range.end.to_display_point(display_snapshot); results.push((start..end, color)) @@ -21581,8 +22037,10 @@ impl Editor { multi_buffer::Event::DiffHunksToggled => { self.tasks_update_task = Some(self.refresh_runnables(window, cx)); } - multi_buffer::Event::LanguageChanged(buffer_id) => { - self.registered_buffers.remove(&buffer_id); + multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => { + if !is_fresh_language { + self.registered_buffers.remove(&buffer_id); + } jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::Reparsed(*buffer_id)); cx.notify(); @@ -21644,16 +22102,18 @@ impl Editor { cx.notify(); } - fn fetch_accent_overrides(&self, cx: &App) -> Vec { + fn fetch_accent_data(&self, cx: &App) -> Option { if !self.mode.is_full() { - return Vec::new(); + return None; } let theme_settings = theme::ThemeSettings::get_global(cx); + let theme = cx.theme(); + let accent_colors = theme.accents().clone(); - theme_settings + let accent_overrides = theme_settings .theme_overrides - .get(cx.theme().name.as_ref()) + .get(theme.name.as_ref()) .map(|theme_style| &theme_style.accents) .into_iter() .flatten() @@ -21666,7 +22126,12 @@ impl Editor { .flatten(), ) .flat_map(|accent| accent.0.clone()) - .collect() + .collect(); + + Some(AccentData { + colors: accent_colors, + overrides: accent_overrides, + }) } fn fetch_applicable_language_settings( @@ -21696,9 +22161,9 @@ impl Editor { let language_settings_changed = new_language_settings != self.applicable_language_settings; self.applicable_language_settings = new_language_settings; - let new_accent_overrides = self.fetch_accent_overrides(cx); - let accent_overrides_changed = new_accent_overrides != self.accent_overrides; - self.accent_overrides = new_accent_overrides; + let new_accents = self.fetch_accent_data(cx); + let accents_changed = new_accents != self.accent_data; + self.accent_data = new_accents; if self.diagnostics_enabled() { let new_severity = EditorSettings::get_global(cx) @@ -21772,7 +22237,7 @@ impl Editor { } } - if language_settings_changed || accent_overrides_changed { + if language_settings_changed || accents_changed { self.colorize_brackets(true, cx); } @@ -21932,56 +22397,86 @@ impl Editor { }; for (buffer, (ranges, scroll_offset)) in new_selections_by_buffer { - let editor = buffer - .read(cx) - .file() - .is_none() + let buffer_read = buffer.read(cx); + let (has_file, is_project_file) = if let Some(file) = buffer_read.file() { + (true, project::File::from_dyn(Some(file)).is_some()) + } else { + (false, false) + }; + + // If project file is none workspace.open_project_item will fail to open the excerpt + // in a pre existing workspace item if one exists, because Buffer entity_id will be None + // so we check if there's a tab match in that case first + let editor = (!has_file || !is_project_file) .then(|| { // Handle file-less buffers separately: those are not really the project items, so won't have a project path or entity id, // so `workspace.open_project_item` will never find them, always opening a new editor. // Instead, we try to activate the existing editor in the pane first. - let (editor, pane_item_index) = + let (editor, pane_item_index, pane_item_id) = pane.read(cx).items().enumerate().find_map(|(i, item)| { let editor = item.downcast::()?; let singleton_buffer = editor.read(cx).buffer().read(cx).as_singleton()?; if singleton_buffer == buffer { - Some((editor, i)) + Some((editor, i, item.item_id())) } else { None } })?; pane.update(cx, |pane, cx| { - pane.activate_item(pane_item_index, true, true, window, cx) + pane.activate_item(pane_item_index, true, true, window, cx); + if !PreviewTabsSettings::get_global(cx) + .enable_preview_from_multibuffer + { + pane.unpreview_item_if_preview(pane_item_id); + } }); Some(editor) }) .flatten() .unwrap_or_else(|| { + let keep_old_preview = PreviewTabsSettings::get_global(cx) + .enable_keep_preview_on_code_navigation; + let allow_new_preview = + PreviewTabsSettings::get_global(cx).enable_preview_from_multibuffer; workspace.open_project_item::( pane.clone(), buffer, true, true, + keep_old_preview, + allow_new_preview, window, cx, ) }); editor.update(cx, |editor, cx| { + if has_file && !is_project_file { + editor.set_read_only(true); + } let autoscroll = match scroll_offset { Some(scroll_offset) => Autoscroll::top_relative(scroll_offset as usize), None => Autoscroll::newest(), }; let nav_history = editor.nav_history.take(); + let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let Some((&excerpt_id, _, buffer_snapshot)) = + multibuffer_snapshot.as_singleton() + else { + return; + }; editor.change_selections( SelectionEffects::scroll(autoscroll), window, cx, |s| { s.select_ranges(ranges.into_iter().map(|range| { - // we checked that the editor is a singleton editor so the offsets are valid - MultiBufferOffset(range.start.0)..MultiBufferOffset(range.end.0) + let range = buffer_snapshot.anchor_before(range.start) + ..buffer_snapshot.anchor_after(range.end); + multibuffer_snapshot + .anchor_range_in_excerpt(excerpt_id, range) + .unwrap() })); }, ); @@ -21992,10 +22487,11 @@ impl Editor { }); } - // For now, don't allow opening excerpts in buffers that aren't backed by - // regular project files. + // Allow opening excerpts for buffers that either belong to the current project + // or represent synthetic/non-local files (e.g., git blobs). File-less buffers + // are also supported so tests and other in-memory views keep working. fn can_open_excerpts_in_file(file: Option<&Arc>) -> bool { - file.is_none_or(|file| project::File::from_dyn(Some(file)).is_some()) + file.is_none_or(|file| project::File::from_dyn(Some(file)).is_some() || !file.is_local()) } fn marked_text_ranges(&self, cx: &App) -> Option>> { @@ -22249,7 +22745,7 @@ impl Editor { .take() .and_then(|descendant| descendant.upgrade()) { - window.focus(&descendant); + window.focus(&descendant, cx); } else { if let Some(blame) = self.blame.as_ref() { blame.update(cx, GitBlame::focus) @@ -22456,10 +22952,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let workspace = self.workspace(); - let project = self.project(); - let save_tasks = self.buffer().update(cx, |multi_buffer, cx| { - let mut tasks = Vec::new(); + self.buffer().update(cx, |multi_buffer, cx| { for (buffer_id, changes) in revert_changes { if let Some(buffer) = multi_buffer.buffer(buffer_id) { buffer.update(cx, |buffer, cx| { @@ -22471,66 +22964,33 @@ impl Editor { cx, ); }); - - if let Some(project) = - project.filter(|_| multi_buffer.all_diff_hunks_expanded()) - { - project.update(cx, |project, cx| { - tasks.push((buffer.clone(), project.save_buffer(buffer, cx))); - }) - } } } - tasks }); - cx.spawn_in(window, async move |_, cx| { - for (buffer, task) in save_tasks { - let result = task.await; - if result.is_err() { - let Some(path) = buffer - .read_with(cx, |buffer, cx| buffer.project_path(cx)) - .ok() - else { - continue; - }; - if let Some((workspace, path)) = workspace.as_ref().zip(path) { - let Some(task) = cx - .update_window_entity(workspace, |workspace, window, cx| { - workspace - .open_path_preview(path, None, false, false, false, window, cx) - }) - .ok() - else { - continue; - }; - task.await.log_err(); - } - } - } - }) - .detach(); self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.refresh() }); } pub fn to_pixel_point( - &self, + &mut self, source: multi_buffer::Anchor, editor_snapshot: &EditorSnapshot, window: &mut Window, + cx: &App, ) -> Option> { let source_point = source.to_display_point(editor_snapshot); - self.display_to_pixel_point(source_point, editor_snapshot, window) + self.display_to_pixel_point(source_point, editor_snapshot, window, cx) } pub fn display_to_pixel_point( - &self, + &mut self, source: DisplayPoint, editor_snapshot: &EditorSnapshot, window: &mut Window, + cx: &App, ) -> Option> { - let line_height = self.style()?.text.line_height_in_pixels(window.rem_size()); + let line_height = self.style(cx).text.line_height_in_pixels(window.rem_size()); let text_layout_details = self.text_layout_details(window); let scroll_top = text_layout_details .scroll_anchor @@ -22607,7 +23067,8 @@ impl Editor { ) { if self.buffer_kind(cx) == ItemBufferKind::Singleton && !self.mode.is_minimap() - && WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None + && WorkspaceSettings::get(None, cx).restore_on_startup + != RestoreOnStartupBehavior::EmptyTab { let buffer_snapshot = OnceCell::new(); @@ -22693,6 +23154,57 @@ impl Editor { // skip any LSP updates for it. self.active_diagnostics == ActiveDiagnostic::All || !self.mode().is_full() } + + fn create_style(&self, cx: &App) -> EditorStyle { + let settings = ThemeSettings::get_global(cx); + + let mut text_style = match self.mode { + EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), + font_size: rems(0.875).into(), + font_weight: settings.ui_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + EditorMode::Full { .. } | EditorMode::Minimap { .. } => TextStyle { + color: cx.theme().colors().editor_foreground, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }, + }; + if let Some(text_style_refinement) = &self.text_style_refinement { + text_style.refine(text_style_refinement) + } + + let background = match self.mode { + EditorMode::SingleLine => cx.theme().system().transparent, + EditorMode::AutoHeight { .. } => cx.theme().system().transparent, + EditorMode::Full { .. } => cx.theme().colors().editor_background, + EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7), + }; + + EditorStyle { + background, + border: cx.theme().colors().border, + local_player: cx.theme().players().local(), + text: text_style, + scrollbar_width: EditorElement::SCROLLBAR_WIDTH, + syntax: cx.theme().syntax().clone(), + status: cx.theme().status().clone(), + inlay_hints_style: make_inlay_hints_style(cx), + edit_prediction_styles: make_suggestion_styles(cx), + unnecessary_code_fade: settings.unnecessary_code_fade, + show_underlines: self.diagnostics_enabled(), + } + } } fn edit_for_markdown_paste<'a>( @@ -22864,76 +23376,460 @@ struct CompletionEdit { snippet: Option, } -fn insert_extra_newline_brackets( +fn comment_delimiter_for_newline( + start_point: &Point, buffer: &MultiBufferSnapshot, - range: Range, - language: &language::LanguageScope, -) -> bool { - let leading_whitespace_len = buffer - .reversed_chars_at(range.start) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); - let trailing_whitespace_len = buffer - .chars_at(range.end) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); - let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len; - - language.brackets().any(|(pair, enabled)| { - let pair_start = pair.start.trim_end(); - let pair_end = pair.end.trim_start(); - - enabled - && pair.newline - && buffer.contains_str_at(range.end, pair_end) - && buffer.contains_str_at( - range.start.saturating_sub_usize(pair_start.len()), - pair_start, - ) - }) + language: &LanguageScope, +) -> Option> { + let delimiters = language.line_comment_prefixes(); + let max_len_of_delimiter = delimiters.iter().map(|delimiter| delimiter.len()).max()?; + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + let comment_candidate = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_len_of_delimiter) + .collect::(); + let (delimiter, trimmed_len) = delimiters + .iter() + .filter_map(|delimiter| { + let prefix = delimiter.trim_end(); + if comment_candidate.starts_with(prefix) { + Some((delimiter, prefix.len())) + } else { + None + } + }) + .max_by_key(|(_, len)| *len)?; + + if let Some(BlockCommentConfig { + start: block_start, .. + }) = language.block_comment() + { + let block_start_trimmed = block_start.trim_end(); + if block_start_trimmed.starts_with(delimiter.trim_end()) { + let line_content = snapshot + .chars_for_range(range) + .skip(num_of_whitespaces) + .take(block_start_trimmed.len()) + .collect::(); + + if line_content.starts_with(block_start_trimmed) { + return None; + } + } + } + + let cursor_is_placed_after_comment_marker = + num_of_whitespaces + trimmed_len <= start_point.column as usize; + if cursor_is_placed_after_comment_marker { + Some(delimiter.clone()) + } else { + None + } } -fn insert_extra_newline_tree_sitter( +fn documentation_delimiter_for_newline( + start_point: &Point, buffer: &MultiBufferSnapshot, - range: Range, -) -> bool { - let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() { - [(buffer, range, _)] => (*buffer, range.clone()), - _ => return false, + language: &LanguageScope, + newline_config: &mut NewlineConfig, +) -> Option> { + let BlockCommentConfig { + start: start_tag, + end: end_tag, + prefix: delimiter, + tab_size: len, + } = language.documentation_comment()?; + let is_within_block_comment = buffer + .language_scope_at(*start_point) + .is_some_and(|scope| scope.override_name() == Some("comment")); + if !is_within_block_comment { + return None; + } + + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + // It is safe to use a column from MultiBufferPoint in context of a single buffer ranges, because we're only ever looking at a single line at a time. + let column = start_point.column; + let cursor_is_after_start_tag = { + let start_tag_len = start_tag.len(); + let start_tag_line = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(start_tag_len) + .collect::(); + if start_tag_line.starts_with(start_tag.as_ref()) { + num_of_whitespaces + start_tag_len <= column as usize + } else { + false + } }; - let pair = { - let mut result: Option> = None; - for pair in buffer - .all_bracket_ranges(range.start.0..range.end.0) - .filter(move |pair| { - pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0 - }) + let cursor_is_after_delimiter = { + let delimiter_trim = delimiter.trim_end(); + let delimiter_line = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(delimiter_trim.len()) + .collect::(); + if delimiter_line.starts_with(delimiter_trim) { + num_of_whitespaces + delimiter_trim.len() <= column as usize + } else { + false + } + }; + + let mut needs_extra_line = false; + let mut extra_line_additional_indent = IndentSize::spaces(0); + + let cursor_is_before_end_tag_if_exists = { + let mut char_position = 0u32; + let mut end_tag_offset = None; + + 'outer: for chunk in snapshot.text_for_range(range) { + if let Some(byte_pos) = chunk.find(&**end_tag) { + let chars_before_match = chunk[..byte_pos].chars().count() as u32; + end_tag_offset = Some(char_position + chars_before_match); + break 'outer; + } + char_position += chunk.chars().count() as u32; + } + + if let Some(end_tag_offset) = end_tag_offset { + let cursor_is_before_end_tag = column <= end_tag_offset; + if cursor_is_after_start_tag { + if cursor_is_before_end_tag { + needs_extra_line = true; + } + let cursor_is_at_start_of_end_tag = column == end_tag_offset; + if cursor_is_at_start_of_end_tag { + extra_line_additional_indent.len = *len; + } + } + cursor_is_before_end_tag + } else { + true + } + }; + + if (cursor_is_after_start_tag || cursor_is_after_delimiter) + && cursor_is_before_end_tag_if_exists + { + let additional_indent = if cursor_is_after_start_tag { + IndentSize::spaces(*len) + } else { + IndentSize::spaces(0) + }; + + *newline_config = NewlineConfig::Newline { + additional_indent, + extra_line_additional_indent: if needs_extra_line { + Some(extra_line_additional_indent) + } else { + None + }, + prevent_auto_indent: true, + }; + Some(delimiter.clone()) + } else { + None + } +} + +const ORDERED_LIST_MAX_MARKER_LEN: usize = 16; + +fn list_delimiter_for_newline( + start_point: &Point, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, + newline_config: &mut NewlineConfig, +) -> Option> { + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + let task_list_entries: Vec<_> = language + .task_list() + .into_iter() + .flat_map(|config| { + config + .prefixes + .iter() + .map(|prefix| (prefix.as_ref(), config.continuation.as_ref())) + }) + .collect(); + let unordered_list_entries: Vec<_> = language + .unordered_list() + .iter() + .map(|marker| (marker.as_ref(), marker.as_ref())) + .collect(); + + let all_entries: Vec<_> = task_list_entries + .into_iter() + .chain(unordered_list_entries) + .collect(); + + if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() { + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_prefix_len) + .collect(); + + if let Some((prefix, continuation)) = all_entries + .iter() + .filter(|(prefix, _)| candidate.starts_with(*prefix)) + .max_by_key(|(prefix, _)| prefix.len()) { - let len = pair.close_range.end - pair.open_range.start; + let end_of_prefix = num_of_whitespaces + prefix.len(); + let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; + let has_content_after_marker = snapshot + .chars_for_range(range) + .skip(end_of_prefix) + .any(|c| !c.is_whitespace()); - if let Some(existing) = &result { - let existing_len = existing.close_range.end - existing.open_range.start; - if len > existing_len { - continue; + if has_content_after_marker && cursor_is_after_prefix { + return Some((*continuation).into()); + } + + if start_point.column as usize == end_of_prefix { + if num_of_whitespaces == 0 { + *newline_config = NewlineConfig::ClearCurrentLine; + } else { + *newline_config = NewlineConfig::UnindentCurrentLine { + continuation: (*continuation).into(), + }; } } - result = Some(pair); + return None; } + } - result - }; - let Some(pair) = pair else { + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(ORDERED_LIST_MAX_MARKER_LEN) + .collect(); + + for ordered_config in language.ordered_list() { + let regex = match Regex::new(&ordered_config.pattern) { + Ok(r) => r, + Err(_) => continue, + }; + + if let Some(captures) = regex.captures(&candidate) { + let full_match = captures.get(0)?; + let marker_len = full_match.len(); + let end_of_prefix = num_of_whitespaces + marker_len; + let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; + + let has_content_after_marker = snapshot + .chars_for_range(range) + .skip(end_of_prefix) + .any(|c| !c.is_whitespace()); + + if has_content_after_marker && cursor_is_after_prefix { + let number: u32 = captures.get(1)?.as_str().parse().ok()?; + let continuation = ordered_config + .format + .replace("{1}", &(number + 1).to_string()); + return Some(continuation.into()); + } + + if start_point.column as usize == end_of_prefix { + let continuation = ordered_config.format.replace("{1}", "1"); + if num_of_whitespaces == 0 { + *newline_config = NewlineConfig::ClearCurrentLine; + } else { + *newline_config = NewlineConfig::UnindentCurrentLine { + continuation: continuation.into(), + }; + } + } + + return None; + } + } + + None +} + +fn is_list_prefix_row( + row: MultiBufferRow, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, +) -> bool { + let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else { return false; }; - pair.newline_only - && buffer - .chars_for_range(pair.open_range.end..range.start.0) - .chain(buffer.chars_for_range(range.end.0..pair.close_range.start)) - .all(|c| c.is_whitespace() && c != '\n') + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + let task_list_prefixes: Vec<_> = language + .task_list() + .into_iter() + .flat_map(|config| { + config + .prefixes + .iter() + .map(|p| p.as_ref()) + .collect::>() + }) + .collect(); + let unordered_list_markers: Vec<_> = language + .unordered_list() + .iter() + .map(|marker| marker.as_ref()) + .collect(); + let all_prefixes: Vec<_> = task_list_prefixes + .into_iter() + .chain(unordered_list_markers) + .collect(); + if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() { + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_prefix_len) + .collect(); + if all_prefixes + .iter() + .any(|prefix| candidate.starts_with(*prefix)) + { + return true; + } + } + + let ordered_list_candidate: String = snapshot + .chars_for_range(range) + .skip(num_of_whitespaces) + .take(ORDERED_LIST_MAX_MARKER_LEN) + .collect(); + for ordered_config in language.ordered_list() { + let regex = match Regex::new(&ordered_config.pattern) { + Ok(r) => r, + Err(_) => continue, + }; + if let Some(captures) = regex.captures(&ordered_list_candidate) { + return captures.get(0).is_some(); + } + } + + false +} + +#[derive(Debug)] +enum NewlineConfig { + /// Insert newline with optional additional indent and optional extra blank line + Newline { + additional_indent: IndentSize, + extra_line_additional_indent: Option, + prevent_auto_indent: bool, + }, + /// Clear the current line + ClearCurrentLine, + /// Unindent the current line and add continuation + UnindentCurrentLine { continuation: Arc }, +} + +impl NewlineConfig { + fn has_extra_line(&self) -> bool { + matches!( + self, + Self::Newline { + extra_line_additional_indent: Some(_), + .. + } + ) + } + + fn insert_extra_newline_brackets( + buffer: &MultiBufferSnapshot, + range: Range, + language: &language::LanguageScope, + ) -> bool { + let leading_whitespace_len = buffer + .reversed_chars_at(range.start) + .take_while(|c| c.is_whitespace() && *c != '\n') + .map(|c| c.len_utf8()) + .sum::(); + let trailing_whitespace_len = buffer + .chars_at(range.end) + .take_while(|c| c.is_whitespace() && *c != '\n') + .map(|c| c.len_utf8()) + .sum::(); + let range = range.start - leading_whitespace_len..range.end + trailing_whitespace_len; + + language.brackets().any(|(pair, enabled)| { + let pair_start = pair.start.trim_end(); + let pair_end = pair.end.trim_start(); + + enabled + && pair.newline + && buffer.contains_str_at(range.end, pair_end) + && buffer.contains_str_at( + range.start.saturating_sub_usize(pair_start.len()), + pair_start, + ) + }) + } + + fn insert_extra_newline_tree_sitter( + buffer: &MultiBufferSnapshot, + range: Range, + ) -> bool { + let (buffer, range) = match buffer.range_to_buffer_ranges(range).as_slice() { + [(buffer, range, _)] => (*buffer, range.clone()), + _ => return false, + }; + let pair = { + let mut result: Option> = None; + + for pair in buffer + .all_bracket_ranges(range.start.0..range.end.0) + .filter(move |pair| { + pair.open_range.start <= range.start.0 && pair.close_range.end >= range.end.0 + }) + { + let len = pair.close_range.end - pair.open_range.start; + + if let Some(existing) = &result { + let existing_len = existing.close_range.end - existing.open_range.start; + if len > existing_len { + continue; + } + } + + result = Some(pair); + } + + result + }; + let Some(pair) = pair else { + return false; + }; + pair.newline_only + && buffer + .chars_for_range(pair.open_range.end..range.start.0) + .chain(buffer.chars_for_range(range.end.0..pair.close_range.start)) + .all(|c| c.is_whitespace() && c != '\n') + } } fn update_uncommitted_diff_for_buffer( @@ -23483,7 +24379,6 @@ pub trait CompletionProvider { position: language::Anchor, text: &str, trigger_in_words: bool, - menu_is_open: bool, cx: &mut Context, ) -> bool; @@ -23862,7 +24757,6 @@ impl CompletionProvider for Entity { position: language::Anchor, text: &str, trigger_in_words: bool, - menu_is_open: bool, cx: &mut Context, ) -> bool { let mut chars = text.chars(); @@ -23877,9 +24771,6 @@ impl CompletionProvider for Entity { let buffer = buffer.read(cx); let snapshot = buffer.snapshot(); - if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input { - return false; - } let classifier = snapshot .char_classifier_at(position) .scope_context(Some(CharScopeContext::Completion)); @@ -24225,94 +25116,98 @@ impl EditorSnapshot { self.scroll_anchor.scroll_position(&self.display_snapshot) } - fn gutter_dimensions( + pub fn gutter_dimensions( &self, font_id: FontId, font_size: Pixels, - max_line_number_width: Pixels, + style: &EditorStyle, + window: &mut Window, cx: &App, - ) -> Option { - if !self.show_gutter { - return None; - } - - let ch_width = cx.text_system().ch_width(font_id, font_size).log_err()?; - let ch_advance = cx.text_system().ch_advance(font_id, font_size).log_err()?; + ) -> GutterDimensions { + if self.show_gutter + && let Some(ch_width) = cx.text_system().ch_width(font_id, font_size).log_err() + && let Some(ch_advance) = cx.text_system().ch_advance(font_id, font_size).log_err() + { + let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { + matches!( + ProjectSettings::get_global(cx).git.git_gutter, + GitGutterSetting::TrackedFiles + ) + }); + let gutter_settings = EditorSettings::get_global(cx).gutter; + let show_line_numbers = self + .show_line_numbers + .unwrap_or(gutter_settings.line_numbers); + let line_gutter_width = if show_line_numbers { + // Avoid flicker-like gutter resizes when the line number gains another digit by + // only resizing the gutter on files with > 10**min_line_number_digits lines. + let min_width_for_number_on_gutter = + ch_advance * gutter_settings.min_line_number_digits as f32; + self.max_line_number_width(style, window) + .max(min_width_for_number_on_gutter) + } else { + 0.0.into() + }; - let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { - matches!( - ProjectSettings::get_global(cx).git.git_gutter, - GitGutterSetting::TrackedFiles - ) - }); - let gutter_settings = EditorSettings::get_global(cx).gutter; - let show_line_numbers = self - .show_line_numbers - .unwrap_or(gutter_settings.line_numbers); - let line_gutter_width = if show_line_numbers { - // Avoid flicker-like gutter resizes when the line number gains another digit by - // only resizing the gutter on files with > 10**min_line_number_digits lines. - let min_width_for_number_on_gutter = - ch_advance * gutter_settings.min_line_number_digits as f32; - max_line_number_width.max(min_width_for_number_on_gutter) - } else { - 0.0.into() - }; + let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); + let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); - let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); - let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); + let git_blame_entries_width = + self.git_blame_gutter_max_author_length + .map(|max_author_length| { + let renderer = cx.global::().0.clone(); + const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; - let git_blame_entries_width = - self.git_blame_gutter_max_author_length - .map(|max_author_length| { - let renderer = cx.global::().0.clone(); - const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; + /// The number of characters to dedicate to gaps and margins. + const SPACING_WIDTH: usize = 4; - /// The number of characters to dedicate to gaps and margins. - const SPACING_WIDTH: usize = 4; + let max_char_count = max_author_length.min(renderer.max_author_length()) + + ::git::SHORT_SHA_LENGTH + + MAX_RELATIVE_TIMESTAMP.len() + + SPACING_WIDTH; - let max_char_count = max_author_length.min(renderer.max_author_length()) - + ::git::SHORT_SHA_LENGTH - + MAX_RELATIVE_TIMESTAMP.len() - + SPACING_WIDTH; + ch_advance * max_char_count + }); - ch_advance * max_char_count - }); + let is_singleton = self.buffer_snapshot().is_singleton(); + + let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); + left_padding += if !is_singleton { + ch_width * 4.0 + } else if show_runnables || show_breakpoints { + ch_width * 3.0 + } else if show_git_gutter && show_line_numbers { + ch_width * 2.0 + } else if show_git_gutter || show_line_numbers { + ch_width + } else { + px(0.) + }; - let is_singleton = self.buffer_snapshot().is_singleton(); - - let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); - left_padding += if !is_singleton { - ch_width * 4.0 - } else if show_runnables || show_breakpoints { - ch_width * 3.0 - } else if show_git_gutter && show_line_numbers { - ch_width * 2.0 - } else if show_git_gutter || show_line_numbers { - ch_width - } else { - px(0.) - }; + let shows_folds = is_singleton && gutter_settings.folds; - let shows_folds = is_singleton && gutter_settings.folds; + let right_padding = if shows_folds && show_line_numbers { + ch_width * 4.0 + } else if shows_folds || (!is_singleton && show_line_numbers) { + ch_width * 3.0 + } else if show_line_numbers { + ch_width + } else { + px(0.) + }; - let right_padding = if shows_folds && show_line_numbers { - ch_width * 4.0 - } else if shows_folds || (!is_singleton && show_line_numbers) { - ch_width * 3.0 - } else if show_line_numbers { - ch_width + GutterDimensions { + left_padding, + right_padding, + width: line_gutter_width + left_padding + right_padding, + margin: GutterDimensions::default_gutter_margin(font_id, font_size, cx), + git_blame_entries_width, + } + } else if self.offset_content { + GutterDimensions::default_with_margin(font_id, font_size, cx) } else { - px(0.) - }; - - Some(GutterDimensions { - left_padding, - right_padding, - width: line_gutter_width + left_padding + right_padding, - margin: GutterDimensions::default_gutter_margin(font_id, font_size, cx), - git_blame_entries_width, - }) + GutterDimensions::default() + } } pub fn render_crease_toggle( @@ -24395,6 +25290,92 @@ impl EditorSnapshot { None } } + + pub fn max_line_number_width(&self, style: &EditorStyle, window: &mut Window) -> Pixels { + let digit_count = self.widest_line_number().ilog10() + 1; + column_pixels(style, digit_count as usize, window) + } + + /// Returns the line delta from `base` to `line` in the multibuffer, ignoring wrapped lines. + /// + /// This is positive if `base` is before `line`. + fn relative_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 { + let point = DisplayPoint::new(line, 0).to_point(self); + self.relative_line_delta_to_point(base, point) + } + + /// Returns the line delta from `base` to `point` in the multibuffer, ignoring wrapped lines. + /// + /// This is positive if `base` is before `point`. + pub fn relative_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 { + let base_point = DisplayPoint::new(base, 0).to_point(self); + point.row as i64 - base_point.row as i64 + } + + /// Returns the line delta from `base` to `line` in the multibuffer, counting wrapped lines. + /// + /// This is positive if `base` is before `line`. + fn relative_wrapped_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 { + let point = DisplayPoint::new(line, 0).to_point(self); + self.relative_wrapped_line_delta_to_point(base, point) + } + + /// Returns the line delta from `base` to `point` in the multibuffer, counting wrapped lines. + /// + /// This is positive if `base` is before `point`. + pub fn relative_wrapped_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 { + let base_point = DisplayPoint::new(base, 0).to_point(self); + let wrap_snapshot = self.wrap_snapshot(); + let base_wrap_row = wrap_snapshot.make_wrap_point(base_point, Bias::Left).row(); + let wrap_row = wrap_snapshot.make_wrap_point(point, Bias::Left).row(); + wrap_row.0 as i64 - base_wrap_row.0 as i64 + } + + /// Returns the unsigned relative line number to display for each row in `rows`. + /// + /// Wrapped rows are excluded from the hashmap if `count_relative_lines` is `false`. + pub fn calculate_relative_line_numbers( + &self, + rows: &Range, + relative_to: DisplayRow, + count_wrapped_lines: bool, + ) -> HashMap { + let initial_offset = if count_wrapped_lines { + self.relative_wrapped_line_delta(relative_to, rows.start) + } else { + self.relative_line_delta(relative_to, rows.start) + }; + let display_row_infos = self + .row_infos(rows.start) + .take(rows.len()) + .enumerate() + .map(|(i, row_info)| (DisplayRow(rows.start.0 + i as u32), row_info)); + display_row_infos + .filter(|(_row, row_info)| { + row_info.buffer_row.is_some() + || (count_wrapped_lines && row_info.wrapped_buffer_row.is_some()) + }) + .enumerate() + .map(|(i, (row, _row_info))| (row, (initial_offset + i as i64).unsigned_abs() as u32)) + .collect() + } +} + +pub fn column_pixels(style: &EditorStyle, column: usize, window: &Window) -> Pixels { + let font_size = style.text.font_size.to_pixels(window.rem_size()); + let layout = window.text_system().shape_line( + SharedString::from(" ".repeat(column)), + font_size, + &[TextRun { + len: column, + font: style.text.font(), + color: Hsla::default(), + ..Default::default() + }], + None, + ); + + layout.width } impl Deref for EditorSnapshot { @@ -24475,57 +25456,7 @@ impl Focusable for Editor { impl Render for Editor { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - - let mut text_style = match self.mode { - EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - EditorMode::Full { .. } | EditorMode::Minimap { .. } => TextStyle { - color: cx.theme().colors().editor_foreground, - font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features.clone(), - font_fallbacks: settings.buffer_font.fallbacks.clone(), - font_size: settings.buffer_font_size(cx).into(), - font_weight: settings.buffer_font.weight, - line_height: relative(settings.buffer_line_height.value()), - ..Default::default() - }, - }; - if let Some(text_style_refinement) = &self.text_style_refinement { - text_style.refine(text_style_refinement) - } - - let background = match self.mode { - EditorMode::SingleLine => cx.theme().system().transparent, - EditorMode::AutoHeight { .. } => cx.theme().system().transparent, - EditorMode::Full { .. } => cx.theme().colors().editor_background, - EditorMode::Minimap { .. } => cx.theme().colors().editor_background.opacity(0.7), - }; - - EditorElement::new( - &cx.entity(), - EditorStyle { - background, - border: cx.theme().colors().border, - local_player: cx.theme().players().local(), - text: text_style, - scrollbar_width: EditorElement::SCROLLBAR_WIDTH, - syntax: cx.theme().syntax().clone(), - status: cx.theme().status().clone(), - inlay_hints_style: make_inlay_hints_style(cx), - edit_prediction_styles: make_suggestion_styles(cx), - unnecessary_code_fade: ThemeSettings::get_global(cx).unnecessary_code_fade, - show_underlines: self.diagnostics_enabled(), - }, - ) + EditorElement::new(&cx.entity(), self.create_style(cx)) } } @@ -25328,7 +26259,7 @@ impl BreakpointPromptEditor { self.editor .update(cx, |editor, cx| { editor.remove_blocks(self.block_ids.clone(), None, cx); - window.focus(&editor.focus_handle); + window.focus(&editor.focus_handle, cx); }) .log_err(); } diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index e1984311d4eb0ba9d989f77a707b22698b00c750..464157202f4821c8f05af479d2eff9f441a961ef 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -215,7 +215,8 @@ impl Settings for EditorSettings { }, scrollbar: Scrollbar { show: scrollbar.show.map(Into::into).unwrap(), - git_diff: scrollbar.git_diff.unwrap(), + git_diff: scrollbar.git_diff.unwrap() + && content.git.unwrap().enabled.unwrap().is_git_diff_enabled(), selected_text: scrollbar.selected_text.unwrap(), selected_symbol: scrollbar.selected_symbol.unwrap(), search_results: scrollbar.search_results.unwrap(), diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index cfb86924722ffcbdd5647357bc232ee1a584dcf3..47c1758adca6488c70f75a762e85fdb55f1662a5 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::{ JoinLines, code_context_menus::CodeContextMenu, - edit_prediction_tests::FakeEditPredictionProvider, + edit_prediction_tests::FakeEditPredictionDelegate, element::StickyHeader, linked_editing_ranges::LinkedEditingRanges, scroll::scroll_amount::ScrollAmount, @@ -35,18 +35,22 @@ use language_settings::Formatter; use languages::markdown_lang; use languages::rust_lang; use lsp::CompletionParams; -use multi_buffer::{IndentGuide, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey}; +use multi_buffer::{ + ExcerptRange, IndentGuide, MultiBuffer, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey, +}; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_ne}; use project::{ - FakeFs, + FakeFs, Project, debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}, project_settings::LspSettings, + trusted_worktrees::{PathTrust, TrustedWorktrees}, }; use serde_json::{self, json}; use settings::{ AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring, - IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent, + IndentGuideColoring, InlayHintSettingsContent, ProjectSettingsContent, SearchSettingsContent, + SettingsStore, }; use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; use std::{ @@ -65,7 +69,6 @@ use util::{ use workspace::{ CloseActiveItem, CloseAllItems, CloseOtherItems, MoveItemToPaneInDirection, NavigationEntry, OpenOptions, ViewId, - invalid_item_view::InvalidItemView, item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions}, register_project_item, }; @@ -2216,10 +2219,9 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2332,10 +2334,9 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut TestAppContext) async fn test_scroll_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2398,8 +2399,7 @@ async fn test_autoscroll(cx: &mut TestAppContext) { let line_height = cx.update_editor(|editor, window, cx| { editor.set_vertical_scroll_margin(2, cx); editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -2478,10 +2478,9 @@ async fn test_move_page_up_page_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _cx| { + let line_height = cx.update_editor(|editor, window, cx| { editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()) }); @@ -5775,6 +5774,116 @@ fn test_duplicate_line(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_rotate_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // Rotate text selections (horizontal) + cx.set_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state("x=«3ˇ», y=«1ˇ», z=«2ˇ»"); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state("x=«1ˇ», y=«2ˇ», z=«3ˇ»"); + + // Rotate text selections (vertical) + cx.set_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«3ˇ» + y=«1ˇ» + z=«2ˇ» + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=«1ˇ» + y=«2ˇ» + z=«3ˇ» + "}); + + // Rotate text selections (vertical, different lengths) + cx.set_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«aaˇ»\" + y=\"«ˇ»\" + z=\"«aˇ»\" + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + x=\"«ˇ»\" + y=\"«aˇ»\" + z=\"«aaˇ»\" + "}); + + // Rotate whole lines (cursor positions preserved) + cx.set_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + line3ˇ + ˇline123 + liˇne23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline123 + liˇne23 + line3ˇ + "}); + + // Rotate whole lines, multiple cursors per line (positions preserved) + cx.set_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_forward(&RotateSelectionsForward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇline3 + ˇliˇne123 + ˇline23 + "}); + cx.update_editor(|e, window, cx| { + e.rotate_selections_backward(&RotateSelectionsBackward, window, cx) + }); + cx.assert_editor_state(indoc! {" + ˇliˇne123 + ˇline23 + ˇline3 + "}); +} + #[gpui::test] fn test_move_line_up_down(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -7642,10 +7751,12 @@ fn test_select_line(cx: &mut TestAppContext) { ]) }); editor.select_line(&SelectLine, window, cx); + // Adjacent line selections should NOT merge (only overlapping ones do) assert_eq!( display_ranges(editor, cx), vec![ - DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(2), 0), + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(2), 0), DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 0), ] ); @@ -7664,9 +7775,13 @@ fn test_select_line(cx: &mut TestAppContext) { _ = editor.update(cx, |editor, window, cx| { editor.select_line(&SelectLine, window, cx); + // Adjacent but not overlapping, so they stay separate assert_eq!( display_ranges(editor, cx), - vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(5), 5)] + vec![ + DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(4), 0), + DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(5), 5), + ] ); }); } @@ -8634,7 +8749,7 @@ async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext) let mut cx = EditorTestContext::new(cx).await; - let provider = cx.new(|_| FakeEditPredictionProvider::default()); + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); cx.update_editor(|editor, window, cx| { editor.set_edit_prediction_provider(Some(provider.clone()), window, cx); }); @@ -8657,7 +8772,7 @@ async fn test_undo_edit_prediction_scrolls_to_edit_pos(cx: &mut TestAppContext) cx.update(|_, cx| { provider.update(cx, |provider, _| { - provider.set_edit_prediction(Some(edit_prediction::EditPrediction::Local { + provider.set_edit_prediction(Some(edit_prediction_types::EditPrediction::Local { id: None, edits: vec![(edit_position..edit_position, "X".into())], edit_preview: None, @@ -9968,7 +10083,7 @@ async fn test_autoindent_disabled_with_nested_language(cx: &mut TestAppContext) ], ..Default::default() }, - name: LanguageName::new("rust"), + name: LanguageName::new_static("rust"), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), @@ -10755,6 +10870,115 @@ async fn test_autoclose_with_overrides(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_autoclose_quotes_with_scope_awareness(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Double quote inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['"', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"', "ˇ"] + "#}); + + // Two double quotes inside single-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ['""', ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['""', "ˇ"] + "#}); + + // Single quote inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["'", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["'", 'ˇ'] + "#}); + + // Two single quotes inside double-quoted string + cx.set_state(indoc! {r#" + def main(): + items = ["''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["''", 'ˇ'] + "#}); + + // Mixed quotes on same line + cx.set_state(indoc! {r#" + def main(): + items = ['"""', "'''''", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "ˇ"] + "#}); + cx.update_editor(|editor, window, cx| { + editor.move_right(&MoveRight, window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input(", ", window, cx); + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("'", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ['"""', "'''''", "", 'ˇ'] + "#}); +} + +#[gpui::test] +async fn test_autoclose_quotes_with_multibyte_characters(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = languages::language("python", tree_sitter_python::LANGUAGE.into()); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + cx.set_state(indoc! {r#" + def main(): + items = ["🎉", ˇ] + "#}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.assert_editor_state(indoc! {r#" + def main(): + items = ["🎉", "ˇ"] + "#}); +} + #[gpui::test] async fn test_surround_with_pair(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -16087,7 +16311,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { «b(); - c(); + ˇ»«c(); ˇ» d(); } "}); @@ -16099,8 +16323,8 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { cx.assert_editor_state(indoc! {" fn a() { // «b(); - // c(); - ˇ»// d(); + ˇ»// «c(); + ˇ» // d(); } "}); @@ -16109,7 +16333,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { fn a() { // b(); «// c(); - ˇ» // d(); + ˇ» // d(); } "}); @@ -16119,7 +16343,7 @@ async fn test_toggle_comment(cx: &mut TestAppContext) { fn a() { // b(); «c(); - ˇ» // d(); + ˇ» // d(); } "}); @@ -16975,7 +17199,7 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) { anchor_range(Point::new(6, 3)..Point::new(6, 5)), anchor_range(Point::new(8, 4)..Point::new(8, 6)), ], - |_| Hsla::red(), + |_, _| Hsla::red(), cx, ); editor.highlight_background::( @@ -16985,7 +17209,7 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) { anchor_range(Point::new(7, 4)..Point::new(7, 7)), anchor_range(Point::new(9, 5)..Point::new(9, 8)), ], - |_| Hsla::green(), + |_, _| Hsla::green(), cx, ); @@ -17975,7 +18199,7 @@ async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) { ); editor_handle.update_in(cx, |editor, window, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(0, 21)..Point::new(0, 20)]) }); @@ -18120,7 +18344,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon ); update_test_project_settings(cx, |project_settings| { - project_settings.lsp.insert( + project_settings.lsp.0.insert( "Some other server name".into(), LspSettings { binary: None, @@ -18141,7 +18365,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon ); update_test_project_settings(cx, |project_settings| { - project_settings.lsp.insert( + project_settings.lsp.0.insert( language_server_name.into(), LspSettings { binary: None, @@ -18162,7 +18386,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon ); update_test_project_settings(cx, |project_settings| { - project_settings.lsp.insert( + project_settings.lsp.0.insert( language_server_name.into(), LspSettings { binary: None, @@ -18183,7 +18407,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon ); update_test_project_settings(cx, |project_settings| { - project_settings.lsp.insert( + project_settings.lsp.0.insert( language_server_name.into(), LspSettings { binary: None, @@ -19092,6 +19316,109 @@ async fn test_document_format_with_prettier(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_document_format_with_prettier_explicit_language(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.settings"), Default::default()) + .await; + + let project = Project::test(fs, [path!("/file.settings").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + let ts_lang = Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..LanguageMatcher::default() + }, + prettier_parser_name: Some("typescript".to_string()), + ..LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + )); + + language_registry.add(ts_lang.clone()); + + update_test_language_settings(cx, |settings| { + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); + }); + + let test_plugin = "test_plugin"; + let _ = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + prettier_plugins: vec![test_plugin], + ..Default::default() + }, + ); + + let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.settings"), cx) + }) + .await + .unwrap(); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, ts_lang, cx) + }); + + let buffer_text = "one\ntwo\nthree\n"; + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor.update_in(cx, |editor, window, cx| { + editor.set_text(buffer_text, window, cx) + }); + + 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; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + prettier_format_suffix + "\ntypescript", + "Test prettier formatting was not applied to the original buffer text", + ); + + update_test_language_settings(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::default()) + }); + let format = editor.update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }); + format.await.unwrap(); + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + + prettier_format_suffix + + "\ntypescript\n" + + prettier_format_suffix + + "\ntypescript", + "Autoformatting (via test prettier) was not applied to the original buffer text", + ); +} + #[gpui::test] async fn test_addition_reverts(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -20558,6 +20885,36 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) { .to_string(), ); + cx.update_editor(|editor, window, cx| { + editor.move_up(&MoveUp, window, cx); + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.assert_state_with_diff( + indoc! { " + ˇone + - two + three + five + "} + .to_string(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.move_down(&MoveDown, window, cx); + editor.toggle_selected_diff_hunks(&Default::default(), window, cx); + }); + cx.assert_state_with_diff( + indoc! { " + one + - two + ˇthree + - four + five + "} + .to_string(), + ); + cx.set_state(indoc! { " one ˇTWO @@ -20598,7 +20955,7 @@ async fn test_toggling_adjacent_diff_hunks(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_edits_around_expanded_deletion_hunks( +async fn test_toggling_adjacent_diff_hunks_2( executor: BackgroundExecutor, cx: &mut TestAppContext, ) { @@ -20607,15 +20964,75 @@ async fn test_edits_around_expanded_deletion_hunks( let mut cx = EditorTestContext::new(cx).await; let diff_base = r#" - use some::mod1; - use some::mod2; - - const A: u32 = 42; - const B: u32 = 42; - const C: u32 = 42; - + lineA + lineB + lineC + lineD + "# + .unindent(); - fn main() { + cx.set_state( + &r#" + ˇlineA1 + lineB + lineD + "# + .unindent(), + ); + cx.set_head_text(&diff_base); + executor.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - lineA + + ˇlineA1 + lineB + lineD + "# + .unindent(), + ); + + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + editor.move_right(&MoveRight, window, cx); + editor.toggle_selected_diff_hunks(&ToggleSelectedDiffHunks, window, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( + r#" + - lineA + + lineA1 + lˇineB + - lineC + lineD + "# + .unindent(), + ); +} + +#[gpui::test] +async fn test_edits_around_expanded_deletion_hunks( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + let diff_base = r#" + use some::mod1; + use some::mod2; + + const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; + + + fn main() { println!("hello"); println!("world"); @@ -21914,6 +22331,40 @@ async fn test_toggle_deletion_hunk_at_start_of_file( cx.assert_state_with_diff(hunk_expanded); } +#[gpui::test] +async fn test_expand_first_line_diff_hunk_keeps_deleted_lines_visible( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("ˇnew\nsecond\nthird\n"); + cx.set_head_text("old\nsecond\nthird\n"); + cx.update_editor(|editor, window, cx| { + editor.scroll(gpui::Point { x: 0., y: 0. }, None, window, cx); + }); + executor.run_until_parked(); + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); + + // Expanding a diff hunk at the first line inserts deleted lines above the first buffer line. + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + let excerpt_id = editor.buffer.read(cx).excerpt_ids()[0]; + let hunks = editor + .diff_hunks_in_ranges(&[Anchor::min()..Anchor::max()], &snapshot.buffer_snapshot()) + .collect::>(); + assert_eq!(hunks.len(), 1); + let hunk_range = Anchor::range_in_buffer(excerpt_id, hunks[0].buffer_range.clone()); + editor.toggle_single_diff_hunk(hunk_range, cx) + }); + executor.run_until_parked(); + cx.assert_state_with_diff("- old\n+ ˇnew\n second\n third\n".to_string()); + + // Keep the editor scrolled to the top so the full hunk remains visible. + assert_eq!(cx.update_editor(|e, _, cx| e.scroll_position(cx)).y, 0.0); +} + #[gpui::test] async fn test_display_diff_hunks(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -22481,7 +22932,7 @@ async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { }); let navigated = cx .update_editor(|editor, window, cx| { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) }) .unwrap() .await @@ -22517,7 +22968,7 @@ async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { ); let navigated = cx .update_editor(|editor, window, cx| { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) }) .unwrap() .await @@ -22569,7 +23020,7 @@ async fn test_find_all_references_editor_reuse(cx: &mut TestAppContext) { }); let navigated = cx .update_editor(|editor, window, cx| { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) }) .unwrap() .await @@ -23978,7 +24429,7 @@ async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); editor.highlight_background::( &[highlight_range], - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); @@ -24056,7 +24507,7 @@ async fn test_rename_without_prepare(cx: &mut TestAppContext) { let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); editor.highlight_background::( &[highlight_range], - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); @@ -25225,6 +25676,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp ˇ log('for else') "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): ˇfor item in items: @@ -25244,6 +25696,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp // test relative indent is preserved when tab // for `if`, `elif`, `else`, `while`, `with` and `for` cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): ˇfor item in items: @@ -25277,6 +25730,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp ˇ return 0 "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): ˇtry: @@ -25293,6 +25747,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp // test relative indent is preserved when tab // for `try`, `except`, `else`, `finally`, `match` and `def` cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): ˇtry: @@ -25326,6 +25781,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("else:", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): if i == 2: @@ -25343,6 +25799,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("except:", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: @@ -25362,6 +25819,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("else:", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: @@ -25385,6 +25843,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("finally:", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: @@ -25409,6 +25868,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("else:", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: @@ -25434,6 +25894,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("finally:", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: @@ -25459,6 +25920,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("except:", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: @@ -25482,6 +25944,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("except:", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): try: @@ -25503,6 +25966,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("else:", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def main(): for i in range(10): @@ -25519,6 +25983,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("a", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" def f() -> list[str]: aˇ @@ -25532,6 +25997,7 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input(":", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" match 1: case:ˇ @@ -25555,6 +26021,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" # COMMENT: ˇ @@ -25567,7 +26034,7 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); - cx.run_until_parked(); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" { ˇ @@ -25601,6 +26068,48 @@ async fn test_indent_on_newline_for_python(cx: &mut TestAppContext) { "}); } +#[gpui::test] +async fn test_python_indent_in_markdown(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor())); + let python_lang = languages::language("python", tree_sitter_python::LANGUAGE.into()); + language_registry.add(markdown_lang()); + language_registry.add(python_lang); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| { + buffer.set_language_registry(language_registry); + buffer.set_language(Some(markdown_lang()), cx); + }); + + // Test that `else:` correctly outdents to match `if:` inside the Python code block + cx.set_state(indoc! {" + # Heading + + ```python + def main(): + if condition: + pass + ˇ + ``` + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("else:", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + # Heading + + ```python + def main(): + if condition: + pass + else:ˇ + ``` + "}); +} + #[gpui::test] async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -25627,6 +26136,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo ˇ} "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function main() { ˇfor item in $items; do @@ -25644,6 +26154,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo "}); // test relative indent is preserved when tab cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function main() { ˇfor item in $items; do @@ -25678,6 +26189,7 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_bash(cx: &mut TestAppCo ˇ} "}); cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function handle() { ˇcase \"$1\" in @@ -25720,6 +26232,7 @@ async fn test_indent_after_input_for_bash(cx: &mut TestAppContext) { ˇ} "}); cx.update_editor(|e, window, cx| e.handle_input("#", window, cx)); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function main() { #ˇ for item in $items; do @@ -25754,6 +26267,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("else", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"foo bar\" @@ -25769,6 +26283,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("elif", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"foo bar\" @@ -25786,6 +26301,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("fi", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"foo bar\" @@ -25803,6 +26319,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("done", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" while read line; do echo \"$line\" @@ -25818,6 +26335,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("done", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" for file in *.txt; do cat \"$file\" @@ -25838,6 +26356,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("esac", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" case \"$1\" in start) @@ -25860,6 +26379,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("*)", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" case \"$1\" in start) @@ -25879,6 +26399,7 @@ async fn test_outdent_after_input_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.handle_input("fi", window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then echo \"outer if\" @@ -25905,6 +26426,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" # COMMENT: ˇ @@ -25918,7 +26440,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); - cx.run_until_parked(); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then @@ -25933,7 +26455,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); - cx.run_until_parked(); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then else @@ -25948,7 +26470,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); - cx.run_until_parked(); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" if [ \"$1\" = \"test\" ]; then elif @@ -25962,7 +26484,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); - cx.run_until_parked(); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" for file in *.txt; do ˇ @@ -25976,7 +26498,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); - cx.run_until_parked(); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" case \"$1\" in start) @@ -25993,7 +26515,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); - cx.run_until_parked(); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" case \"$1\" in start) @@ -26009,7 +26531,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); - cx.run_until_parked(); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" function test() { ˇ @@ -26023,7 +26545,7 @@ async fn test_indent_on_newline_for_bash(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.newline(&Newline, window, cx); }); - cx.run_until_parked(); + cx.wait_for_autoindent_applied().await; cx.assert_editor_state(indoc! {" echo \"test\"; ˇ @@ -26594,7 +27116,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { } }); - let ensure_result_id = |expected: Option, cx: &mut TestAppContext| { + let ensure_result_id = |expected: Option, cx: &mut TestAppContext| { project.update(cx, |project, cx| { let buffer_id = editor .read(cx) @@ -26607,7 +27129,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { let buffer_result_id = project .lsp_store() .read(cx) - .result_id(server_id, buffer_id, cx); + .result_id_for_buffer_pull(server_id, buffer_id, &None, cx); assert_eq!(expected, buffer_result_id); }); }; @@ -26624,7 +27146,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { .next() .await .expect("should have sent the first diagnostics pull request"); - ensure_result_id(Some("1".to_string()), cx); + ensure_result_id(Some(SharedString::new("1")), cx); // Editing should trigger diagnostics editor.update_in(cx, |editor, window, cx| { @@ -26637,7 +27159,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { 2, "Editing should trigger diagnostic request" ); - ensure_result_id(Some("2".to_string()), cx); + ensure_result_id(Some(SharedString::new("2")), cx); // Moving cursor should not trigger diagnostic request editor.update_in(cx, |editor, window, cx| { @@ -26652,7 +27174,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { 2, "Cursor movement should not trigger diagnostic request" ); - ensure_result_id(Some("2".to_string()), cx); + ensure_result_id(Some(SharedString::new("2")), cx); // Multiple rapid edits should be debounced for _ in 0..5 { editor.update_in(cx, |editor, window, cx| { @@ -26667,7 +27189,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { final_requests <= 4, "Multiple rapid edits should be debounced (got {final_requests} requests)", ); - ensure_result_id(Some(final_requests.to_string()), cx); + ensure_result_id(Some(SharedString::new(final_requests.to_string())), cx); } #[gpui::test] @@ -26797,6 +27319,82 @@ async fn test_add_selection_skip_soft_wrap_option(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_insert_snippet(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + let snippet = project::snippet_provider::Snippet { + prefix: vec![], // no prefix needed! + body: "an Unspecified".to_string(), + description: Some("shhhh it's a secret".to_string()), + name: "super secret snippet".to_string(), + }; + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + + let snippet = project::snippet_provider::Snippet { + prefix: vec![], // no prefix needed! + body: " Location".to_string(), + description: Some("the word 'location'".to_string()), + name: "location word".to_string(), + }; + snippets.add_snippet_for_test( + Some("Markdown".to_string()), + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + }); + }) + }); + + cx.set_state(indoc!(r#"First cursor at ˇ and second cursor at ˇ"#)); + + cx.update_editor(|editor, window, cx| { + editor.insert_snippet_at_selections( + &InsertSnippet { + language: None, + name: Some("super secret snippet".to_string()), + snippet: None, + }, + window, + cx, + ); + + // Language is specified in the action, + // so the buffer language does not need to match + editor.insert_snippet_at_selections( + &InsertSnippet { + language: Some("Markdown".to_string()), + name: Some("location word".to_string()), + snippet: None, + }, + window, + cx, + ); + + editor.insert_snippet_at_selections( + &InsertSnippet { + language: None, + name: None, + snippet: Some("$0 after".to_string()), + }, + window, + cx, + ); + }); + + cx.assert_editor_state( + r#"First cursor at an Unspecified Locationˇ after and second cursor at an Unspecified Locationˇ after"#, + ); +} + #[gpui::test(iterations = 10)] async fn test_document_colors(cx: &mut TestAppContext) { let expected_color = Rgba { @@ -27165,11 +27763,10 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) { }) .await .unwrap(); - - assert_eq!( - handle.to_any_view().entity_type(), - TypeId::of::() - ); + // The test file content `vec![0xff, 0xfe, ...]` starts with a UTF-16 LE BOM. + // Previously, this fell back to `InvalidItemView` because it wasn't valid UTF-8. + // With auto-detection enabled, this is now recognized as UTF-16 and opens in the Editor. + assert_eq!(handle.to_any_view().entity_type(), TypeId::of::()); } #[gpui::test] @@ -27304,7 +27901,7 @@ let result = variable * 2;", editor.highlight_background::( &anchor_ranges, - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); @@ -27400,6 +27997,134 @@ async fn test_paste_url_from_other_app_creates_markdown_link_over_selected_text( )); } +#[gpui::test] +async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Test if adding a character with multi cursors preserves nested list indents + cx.set_state(&indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [ˇ] Item 2 + - [ˇ] Item 2.a + - [ˇ] Item 2.b + " + }); + cx.update_editor(|editor, window, cx| { + editor.handle_input("x", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [xˇ] Item 2 + - [xˇ] Item 2.a + - [xˇ] Item 2.b + " + }); + + // Case 2: Test adding new line after nested list continues the list with unchecked task + cx.set_state(&indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.bˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.b + - [ ] ˇ" + }); + + // Case 3: Test adding content to continued list item + cx.update_editor(|editor, window, cx| { + editor.handle_input("Item 2.c", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - [ ] Item 1 + - [ ] Item 1.a + - [x] Item 2 + - [x] Item 2.a + - [x] Item 2.b + - [ ] Item 2.cˇ" + }); + + // Case 4: Test adding new line after nested ordered list continues with next number + cx.set_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.bˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.assert_editor_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + 3. ˇ" + }); + + // Case 5: Adding content to continued ordered list item + cx.update_editor(|editor, window, cx| { + editor.handle_input("Item 2.c", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + 1. Item 1 + 1. Item 1.a + 2. Item 2 + 1. Item 2.a + 2. Item 2.b + 3. Item 2.cˇ" + }); + + // Case 6: Test adding new line after nested ordered list preserves indent of previous line + cx.set_state(indoc! {" + - Item 1 + - Item 1.a + - Item 1.a + ˇ"}); + cx.update_editor(|editor, window, cx| { + editor.handle_input("-", window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc! {" + - Item 1 + - Item 1.a + - Item 1.a + -ˇ"}); + + // Case 7: Test blockquote newline preserves something + cx.set_state(indoc! {" + > Item 1ˇ" + }); + cx.update_editor(|editor, window, cx| { + editor.newline(&Newline, window, cx); + }); + cx.assert_editor_state(indoc! {" + > Item 1 + ˇ" + }); +} + #[gpui::test] async fn test_paste_url_from_zed_copy_creates_markdown_link_over_selected_text( cx: &mut gpui::TestAppContext, @@ -27874,7 +28599,8 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) { let mut sticky_headers = |offset: ScrollOffset| { cx.update_editor(|e, window, cx| { e.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); - EditorElement::sticky_headers(&e, &e.snapshot(window, cx), cx) + let style = e.style(cx).clone(); + EditorElement::sticky_headers(&e, &e.snapshot(window, cx), &style, cx) .into_iter() .map( |StickyHeader { @@ -27915,31 +28641,154 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) { } #[gpui::test] -async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { +fn test_relative_line_numbers(cx: &mut TestAppContext) { init_test(cx, |_| {}); - cx.update(|cx| { - SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |settings| { - settings.editor.sticky_scroll = Some(settings::StickyScrollContent { - enabled: Some(true), - }) - }); - }); - }); - let mut cx = EditorTestContext::new(cx).await; - let line_height = cx.editor(|editor, window, _cx| { - editor - .style() - .unwrap() - .text - .line_height_in_pixels(window.rem_size()) + let buffer_1 = cx.new(|cx| Buffer::local("aaaaaaaaaa\nbbb\n", cx)); + let buffer_2 = cx.new(|cx| Buffer::local("cccccccccc\nddd\n", cx)); + let buffer_3 = cx.new(|cx| Buffer::local("eee\nffffffffff\n", cx)); + + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(ReadWrite); + multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))], + cx, + ); + multibuffer.push_excerpts( + buffer_3.clone(), + [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))], + cx, + ); + multibuffer }); - let buffer = indoc! {" - ˇfn foo() { - let abc = 123; - } + // wrapped contents of multibuffer: + // aaa + // aaa + // aaa + // a + // bbb + // + // ccc + // ccc + // ccc + // c + // ddd + // + // eee + // fff + // fff + // fff + // f + + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(multibuffer, window, cx)); + editor.update_in(cx, |editor, window, cx| { + editor.set_wrap_width(Some(30.0.into()), cx); // every 3 characters + + // includes trailing newlines. + let expected_line_numbers = [2, 6, 7, 10, 14, 15, 18, 19, 23]; + let expected_wrapped_line_numbers = [ + 2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, + ]; + + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + Point::new(7, 0)..Point::new(7, 1), // second row of `ccc` + ]); + }); + + let snapshot = editor.snapshot(window, cx); + + // these are all 0-indexed + let base_display_row = DisplayRow(11); + let base_row = 3; + let wrapped_base_row = 7; + + // test not counting wrapped lines + let expected_relative_numbers = expected_line_numbers + .into_iter() + .enumerate() + .map(|(i, row)| (DisplayRow(row), i.abs_diff(base_row) as u32)) + .collect_vec(); + let actual_relative_numbers = snapshot + .calculate_relative_line_numbers( + &(DisplayRow(0)..DisplayRow(24)), + base_display_row, + false, + ) + .into_iter() + .sorted() + .collect_vec(); + assert_eq!(expected_relative_numbers, actual_relative_numbers); + // check `calculate_relative_line_numbers()` against `relative_line_delta()` for each line + for (display_row, relative_number) in expected_relative_numbers { + assert_eq!( + relative_number, + snapshot + .relative_line_delta(display_row, base_display_row) + .unsigned_abs() as u32, + ); + } + + // test counting wrapped lines + let expected_wrapped_relative_numbers = expected_wrapped_line_numbers + .into_iter() + .enumerate() + .map(|(i, row)| (DisplayRow(row), i.abs_diff(wrapped_base_row) as u32)) + .collect_vec(); + let actual_relative_numbers = snapshot + .calculate_relative_line_numbers( + &(DisplayRow(0)..DisplayRow(24)), + base_display_row, + true, + ) + .into_iter() + .sorted() + .collect_vec(); + assert_eq!(expected_wrapped_relative_numbers, actual_relative_numbers); + // check `calculate_relative_line_numbers()` against `relative_wrapped_line_delta()` for each line + for (display_row, relative_number) in expected_wrapped_relative_numbers { + assert_eq!( + relative_number, + snapshot + .relative_wrapped_line_delta(display_row, base_display_row) + .unsigned_abs() as u32, + ); + } + }); +} + +#[gpui::test] +async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.editor.sticky_scroll = Some(settings::StickyScrollContent { + enabled: Some(true), + }) + }); + }); + }); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, window, cx| { + editor + .style(cx) + .text + .line_height_in_pixels(window.rem_size()) + }); + + let buffer = indoc! {" + ˇfn foo() { + let abc = 123; + } struct Bar; impl Bar { fn new() -> Self { @@ -28348,33 +29197,32 @@ async fn test_multibuffer_selections_with_folding(cx: &mut TestAppContext) { 3 "}); - // Edge case scenario: fold all buffers, then try to insert + // Test correct folded header is selected upon fold cx.update_editor(|editor, _, cx| { editor.fold_buffer(buffer_ids[0], cx); editor.fold_buffer(buffer_ids[1], cx); }); cx.assert_excerpts_with_selections(indoc! {" - [EXCERPT] - ˇ[FOLDED] [EXCERPT] [FOLDED] + [EXCERPT] + ˇ[FOLDED] "}); - // Insert should work via default selection + // Test selection inside folded buffer unfolds it on type cx.update_editor(|editor, window, cx| { editor.handle_input("W", window, cx); }); cx.update_editor(|editor, _, cx| { editor.unfold_buffer(buffer_ids[0], cx); - editor.unfold_buffer(buffer_ids[1], cx); }); cx.assert_excerpts_with_selections(indoc! {" [EXCERPT] - Wˇ1 + 1 2 3 [EXCERPT] - 1 + Wˇ1 Z 3 "}); @@ -28463,3 +29311,793 @@ async fn test_multibuffer_scroll_cursor_top_margin(cx: &mut TestAppContext) { ); }); } + +#[gpui::test] +async fn test_find_references_single_case(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + references_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let before = indoc!( + r#" + fn main() { + let aˇbc = 123; + let xyz = abc; + } + "# + ); + let after = indoc!( + r#" + fn main() { + let abc = 123; + let xyz = ˇabc; + } + "# + ); + + cx.lsp + .set_request_handler::(async move |params, _| { + Ok(Some(vec![ + lsp::Location { + uri: params.text_document_position.text_document.uri.clone(), + range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 11)), + }, + lsp::Location { + uri: params.text_document_position.text_document.uri, + range: lsp::Range::new(lsp::Position::new(2, 14), lsp::Position::new(2, 17)), + }, + ])) + }); + + cx.set_state(before); + + let action = FindAllReferences { + always_open_multibuffer: false, + }; + + let navigated = cx + .update_editor(|editor, window, cx| editor.find_all_references(&action, window, cx)) + .expect("should have spawned a task") + .await + .unwrap(); + + assert_eq!(navigated, Navigated::No); + + cx.run_until_parked(); + + cx.assert_editor_state(after); +} + +#[gpui::test] +async fn test_newline_task_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker + cx.set_state(indoc! {" + - [ ] taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] ˇ + "}); + + // Case 2: Works with checked task items too + cx.set_state(indoc! {" + - [x] completed taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [x] completed task + - [ ] ˇ + "}); + + // Case 2.1: Works with uppercase checked marker too + cx.set_state(indoc! {" + - [X] completed taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [X] completed task + - [ ] ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + - [ ] taˇsk + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] ta + - [ ] ˇsk + "}); + + // Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker + cx.set_state(indoc! {" + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + - [ ]$$ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + - [ ] task + - [ ] indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] indented + - [ ] ˇ + "}); + + // Case 6: Adding newline with cursor right after prefix, unindents + cx.set_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after prefix, removes marker + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ- [ ] task + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ- [ ] task + "}); + + cx.set_state(indoc! {" + - [ˇ ] task + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ + ˇ + ] task + "}); +} + +#[gpui::test] +async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker + cx.set_state(indoc! {" + - itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - ˇ + "}); + + // Case 2: Works with different markers + cx.set_state(indoc! {" + * starred itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + * starred item + * ˇ + "}); + + cx.set_state(indoc! {" + + plus itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + plus item + + ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + - itˇem + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - it + - ˇem + "}); + + // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker + cx.set_state(indoc! {" + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + - $ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + - item + - indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - indented + - ˇ + "}); + + // Case 6: Adding newline with cursor right after marker, unindents + cx.set_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after marker, removes marker + cx.assert_editor_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - sub item + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ- item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ- item + "}); + + cx.set_state(indoc! {" + -ˇ item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - + ˇitem + "}); +} + +#[gpui::test] +async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number + cx.set_state(indoc! {" + 1. first itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. first item + 2. ˇ + "}); + + // Case 2: Works with larger numbers + cx.set_state(indoc! {" + 10. tenth itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 10. tenth item + 11. ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + 1. itˇem + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. it + 2. ˇem + "}); + + // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker + cx.set_state(indoc! {" + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + 1. $ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + 1. item + 2. indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. indented + 3. ˇ + "}); + + // Case 6: Adding newline with cursor right after marker, unindents + cx.set_state(indoc! {" + 1. item + 2. sub item + 3. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after marker, removes marker + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ1. item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ1. item + "}); + + cx.set_state(indoc! {" + 1ˇ. item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1 + ˇ. item + "}); +} + +#[gpui::test] +async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number + cx.set_state(indoc! {" + 1. first item + 1. sub first item + 2. sub second item + 3. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. first item + 1. sub first item + 2. sub second item + 1. ˇ + "}); +} + +#[gpui::test] +async fn test_tab_list_indent(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Unordered list - cursor after prefix, adds indent before prefix + cx.set_state(indoc! {" + - ˇitem + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- ˇitem + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 2: Task list - cursor after prefix + cx.set_state(indoc! {" + - [ ] ˇtask + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- [ ] ˇtask + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 3: Ordered list - cursor after prefix + cx.set_state(indoc! {" + 1. ˇfirst + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$1. ˇfirst + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 4: With existing indentation - adds more indent + let initial = indoc! {" + $$- ˇitem + "}; + cx.set_state(initial.replace("$", " ").as_str()); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$$$- ˇitem + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 5: Empty list item + cx.set_state(indoc! {" + - ˇ + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- ˇ + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 6: Cursor at end of line with content + cx.set_state(indoc! {" + - itemˇ + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- itemˇ + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 7: Cursor at start of list item, indents it + cx.set_state(indoc! {" + - item + ˇ - sub item + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + - item + ˇ - sub item + "}; + cx.assert_editor_state(expected); + + // Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false + cx.update_editor(|_, _, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.indent_list_on_tab = Some(false); + }); + }); + }); + cx.set_state(indoc! {" + - item + ˇ - sub item + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + - item + ˇ- sub item + "}; + cx.assert_editor_state(expected); +} + +#[gpui::test] +async fn test_local_worktree_trust(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + cx.update(|cx| project::trusted_worktrees::init(HashMap::default(), None, None, cx)); + + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.inlay_hints = + Some(InlayHintSettingsContent { + enabled: Some(true), + ..InlayHintSettingsContent::default() + }); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".zed": { + "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"# + }, + "main.rs": "fn main() {}" + }), + ) + .await; + + let lsp_inlay_hint_request_count = Arc::new(AtomicUsize::new(0)); + let server_name = "override-rust-analyzer"; + let project = Project::test_with_worktree_trust(fs, [path!("/project").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + + let capabilities = lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: server_name, + capabilities, + initializer: Some(Box::new({ + let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); + move |fake_server| { + let lsp_inlay_hint_request_count = lsp_inlay_hint_request_count.clone(); + fake_server.set_request_handler::( + move |_params, _| { + lsp_inlay_hint_request_count.fetch_add(1, atomic::Ordering::Release); + async move { + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, 0), + label: lsp::InlayHintLabel::String("hint".to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }, + ); + } + })), + ..FakeLspAdapter::default() + }, + ); + + cx.run_until_parked(); + + let worktree_id = project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .next() + .map(|wt| wt.read(cx).id()) + .expect("should have a worktree") + }); + + let trusted_worktrees = + cx.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should exist")); + + let can_trust = trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(!can_trust, "worktree should be restricted initially"); + + let buffer_before_approval = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("main.rs")), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::full(), + cx.new(|cx| MultiBuffer::singleton(buffer_before_approval.clone(), cx)), + Some(project.clone()), + window, + cx, + ) + }); + cx.run_until_parked(); + let fake_language_server = fake_language_servers.next(); + + cx.read(|cx| { + let file = buffer_before_approval.read(cx).file(); + assert_eq!( + language::language_settings::language_settings(Some("Rust".into()), file, cx) + .language_servers, + ["...".to_string()], + "local .zed/settings.json must not apply before trust approval" + ) + }); + + editor.update_in(cx, |editor, window, cx| { + editor.handle_input("1", window, cx); + }); + cx.run_until_parked(); + cx.executor() + .advance_clock(std::time::Duration::from_secs(1)); + assert_eq!( + lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire), + 0, + "inlay hints must not be queried before trust approval" + ); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + std::collections::HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + None, + cx, + ); + }); + cx.run_until_parked(); + + cx.read(|cx| { + let file = buffer_before_approval.read(cx).file(); + assert_eq!( + language::language_settings::language_settings(Some("Rust".into()), file, cx) + .language_servers, + ["override-rust-analyzer".to_string()], + "local .zed/settings.json should apply after trust approval" + ) + }); + let _fake_language_server = fake_language_server.await.unwrap(); + editor.update_in(cx, |editor, window, cx| { + editor.handle_input("1", window, cx); + }); + cx.run_until_parked(); + cx.executor() + .advance_clock(std::time::Duration::from_secs(1)); + assert!( + lsp_inlay_hint_request_count.load(atomic::Ordering::Acquire) > 0, + "inlay hints should be queried after trust approval" + ); + + let can_trust_after = + trusted_worktrees.update(cx, |store, cx| store.can_trust(worktree_id, cx)); + assert!(can_trust_after, "worktree should be trusted after trust()"); +} + +#[gpui::test] +fn test_editor_rendering_when_positioned_above_viewport(cx: &mut TestAppContext) { + // This test reproduces a bug where drawing an editor at a position above the viewport + // (simulating what happens when an AutoHeight editor inside a List is scrolled past) + // causes an infinite loop in blocks_in_range. + // + // The issue: when the editor's bounds.origin.y is very negative (above the viewport), + // the content mask intersection produces visible_bounds with origin at the viewport top. + // This makes clipped_top_in_lines very large, causing start_row to exceed max_row. + // When blocks_in_range is called with start_row > max_row, the cursor seeks to the end + // but the while loop after seek never terminates because cursor.next() is a no-op at end. + init_test(cx, |_| {}); + + let window = cx.add_window(|_, _| gpui::Empty); + let mut cx = VisualTestContext::from_window(*window, cx); + + let buffer = cx.update(|_, cx| MultiBuffer::build_simple("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n", cx)); + let editor = cx.new_window_entity(|window, cx| build_editor(buffer, window, cx)); + + // Simulate a small viewport (500x500 pixels at origin 0,0) + cx.simulate_resize(gpui::size(px(500.), px(500.))); + + // Draw the editor at a very negative Y position, simulating an editor that's been + // scrolled way above the visible viewport (like in a List that has scrolled past it). + // The editor is 3000px tall but positioned at y=-10000, so it's entirely above the viewport. + // This should NOT hang - it should just render nothing. + cx.draw( + gpui::point(px(0.), px(-10000.)), + gpui::size(px(500.), px(3000.)), + |_, _| editor.clone(), + ); + + // If we get here without hanging, the test passes +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5e23b41547b6720271d156473900b642779aa48d..263d0a86ff2985a3dde8bcb223d7492e764c1557 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -11,6 +11,7 @@ use crate::{ SelectedTextHighlight, Selection, SelectionDragState, SelectionEffects, SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, + column_pixels, display_map::{ Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins, HighlightKey, HighlightedChunk, ToDisplayPoint, @@ -36,22 +37,18 @@ use crate::{ use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use collections::{BTreeMap, HashMap}; use file_icons::FileIcons; -use git::{ - Oid, - blame::{BlameEntry, ParsedCommitMessage}, - status::FileStatus, -}; +use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatus}; use gpui::{ Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle, Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, - ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, - Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, - linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background, - transparent_black, + MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, + Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, + Size, StatefulInteractiveElement, Style, Styled, TextAlign, TextRun, TextStyleRefinement, + WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, + point, px, quad, relative, size, solid_background, transparent_black, }; use itertools::Itertools; use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting}; @@ -61,6 +58,7 @@ use multi_buffer::{ MultiBufferRow, RowInfo, }; +use edit_prediction_types::EditPredictionGranularity; use project::{ Entry, ProjectPath, debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, @@ -68,7 +66,7 @@ use project::{ }; use settings::{ GitGutterSetting, GitHunkStyleSetting, IndentGuideBackgroundColoring, IndentGuideColoring, - Settings, + RelativeLineNumbers, Settings, }; use smallvec::{SmallVec, smallvec}; use std::{ @@ -131,6 +129,7 @@ impl SelectionLayout { fn new( selection: Selection, line_mode: bool, + cursor_offset: bool, cursor_shape: CursorShape, map: &DisplaySnapshot, is_newest: bool, @@ -151,12 +150,9 @@ impl SelectionLayout { } // any vim visual mode (including line mode) - if (cursor_shape == CursorShape::Block || cursor_shape == CursorShape::Hollow) - && !range.is_empty() - && !selection.reversed - { + if cursor_offset && !range.is_empty() && !selection.reversed { if head.column() > 0 { - head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left) + head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left); } else if head.row().0 > 0 && head != map.max_point() { head = map.clip_point( DisplayPoint::new( @@ -198,8 +194,6 @@ pub struct EditorElement { style: EditorStyle, } -type DisplayRowDelta = u32; - impl EditorElement { pub(crate) const SCROLLBAR_WIDTH: Pixels = px(15.); @@ -253,6 +247,8 @@ impl EditorElement { register_action(editor, window, Editor::sort_lines_case_insensitive); register_action(editor, window, Editor::reverse_lines); register_action(editor, window, Editor::shuffle_lines); + register_action(editor, window, Editor::rotate_selections_forward); + register_action(editor, window, Editor::rotate_selections_backward); register_action(editor, window, Editor::convert_indentation_to_spaces); register_action(editor, window, Editor::convert_indentation_to_tabs); register_action(editor, window, Editor::convert_to_upper_case); @@ -365,6 +361,7 @@ impl EditorElement { register_action(editor, window, Editor::split_selection_into_lines); register_action(editor, window, Editor::add_selection_above); register_action(editor, window, Editor::add_selection_below); + register_action(editor, window, Editor::insert_snippet_at_selections); register_action(editor, window, |editor, action, window, cx| { editor.select_next(action, window, cx).log_err(); }); @@ -591,8 +588,6 @@ impl EditorElement { register_action(editor, window, Editor::show_signature_help); register_action(editor, window, Editor::signature_help_prev); register_action(editor, window, Editor::signature_help_next); - register_action(editor, window, Editor::next_edit_prediction); - register_action(editor, window, Editor::previous_edit_prediction); register_action(editor, window, Editor::show_edit_prediction); register_action(editor, window, Editor::context_menu_first); register_action(editor, window, Editor::context_menu_prev); @@ -601,7 +596,8 @@ impl EditorElement { register_action(editor, window, Editor::display_cursor_names); register_action(editor, window, Editor::unique_lines_case_insensitive); register_action(editor, window, Editor::unique_lines_case_sensitive); - register_action(editor, window, Editor::accept_partial_edit_prediction); + register_action(editor, window, Editor::accept_next_word_edit_prediction); + register_action(editor, window, Editor::accept_next_line_edit_prediction); register_action(editor, window, Editor::accept_edit_prediction); register_action(editor, window, Editor::restore_file); register_action(editor, window, Editor::git_restore); @@ -1013,10 +1009,16 @@ impl EditorElement { let pending_nonempty_selections = editor.has_pending_nonempty_selection(); let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&event.modifiers(), cx); + let mouse_down_hovered_link_modifier = if let ClickEvent::Mouse(mouse_event) = event { + Editor::is_cmd_or_ctrl_pressed(&mouse_event.down.modifiers, cx) + } else { + true + }; if let Some(mouse_position) = event.mouse_position() && !pending_nonempty_selections && hovered_link_modifier + && mouse_down_hovered_link_modifier && text_hitbox.is_hovered(window) { let point = position_map.point_for_position(mouse_position); @@ -1027,6 +1029,28 @@ impl EditorElement { } } + fn pressure_click( + editor: &mut Editor, + event: &MousePressureEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let force_click_possible = + matches!(editor.prev_pressure_stage, Some(PressureStage::Normal)) + && event.stage == PressureStage::Force; + + editor.prev_pressure_stage = Some(event.stage); + + if force_click_possible && text_hitbox.is_hovered(window) { + let point = position_map.point_for_position(event.position); + editor.handle_click_hovered_link(point, event.modifiers, window, cx); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + } + } + fn mouse_dragged( editor: &mut Editor, event: &MouseMoveEvent, @@ -1227,7 +1251,13 @@ impl EditorElement { editor.hide_blame_popover(false, cx); } } else { - editor.hide_blame_popover(false, cx); + let keyboard_grace = editor + .inline_blame_popover + .as_ref() + .is_some_and(|state| state.keyboard_grace); + if !keyboard_grace { + editor.hide_blame_popover(false, cx); + } } let breakpoint_indicator = if gutter_hovered { @@ -1425,6 +1455,7 @@ impl EditorElement { let layout = SelectionLayout::new( selection, editor.selections.line_mode(), + editor.cursor_offset_on_selection, editor.cursor_shape, &snapshot.display_snapshot, is_newest, @@ -1471,6 +1502,7 @@ impl EditorElement { let drag_cursor_layout = SelectionLayout::new( drop_cursor.clone(), false, + editor.cursor_offset_on_selection, CursorShape::Bar, &snapshot.display_snapshot, false, @@ -1534,6 +1566,7 @@ impl EditorElement { .push(SelectionLayout::new( selection.selection, selection.line_mode, + editor.cursor_offset_on_selection, selection.cursor_shape, &snapshot.display_snapshot, false, @@ -1544,6 +1577,8 @@ impl EditorElement { selections.extend(remote_selections.into_values()); } else if !editor.is_focused(window) && editor.show_cursor_when_unfocused { + let cursor_offset_on_selection = editor.cursor_offset_on_selection; + let layouts = snapshot .buffer_snapshot() .selections_in_range(&(start_anchor..end_anchor), true) @@ -1551,6 +1586,7 @@ impl EditorElement { SelectionLayout::new( selection, line_mode, + cursor_offset_on_selection, cursor_shape, &snapshot.display_snapshot, false, @@ -1659,9 +1695,13 @@ impl EditorElement { [cursor_position.row().minus(visible_display_row_range.start) as usize]; let cursor_column = cursor_position.column() as usize; - let cursor_character_x = cursor_row_layout.x_for_index(cursor_column); - let mut block_width = - cursor_row_layout.x_for_index(cursor_column + 1) - cursor_character_x; + let cursor_character_x = cursor_row_layout.x_for_index(cursor_column) + + cursor_row_layout + .alignment_offset(self.style.text.text_align, text_hitbox.size.width); + let cursor_next_x = cursor_row_layout.x_for_index(cursor_column + 1) + + cursor_row_layout + .alignment_offset(self.style.text.text_align, text_hitbox.size.width); + let mut block_width = cursor_next_x - cursor_character_x; if block_width == Pixels::ZERO { block_width = em_advance; } @@ -2260,7 +2300,8 @@ impl EditorElement { }; let padding = ProjectSettings::get_global(cx).diagnostics.inline.padding as f32 * em_width; - let min_x = self.column_pixels( + let min_x = column_pixels( + &self.style, ProjectSettings::get_global(cx) .diagnostics .inline @@ -2334,7 +2375,7 @@ impl EditorElement { .opacity(0.05)) .text_color(severity_to_color(&diagnostic_to_render.severity).color(cx)) .text_sm() - .font_family(style.text.font().family) + .font(style.text.font()) .child(diagnostic_to_render.message.clone()) .into_any(); @@ -2511,7 +2552,6 @@ impl EditorElement { scroll_position: gpui::Point, scroll_pixel_position: gpui::Point, line_height: Pixels, - text_hitbox: &Hitbox, window: &mut Window, cx: &mut App, ) -> Option { @@ -2564,7 +2604,8 @@ impl EditorElement { let padded_line_end = line_end + padding; - let min_column_in_pixels = self.column_pixels( + let min_column_in_pixels = column_pixels( + &self.style, ProjectSettings::get_global(cx).git.inline_blame.min_column as usize, window, ); @@ -2580,16 +2621,6 @@ impl EditorElement { let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); let bounds = Bounds::new(absolute_offset, size); - self.layout_blame_entry_popover( - entry.clone(), - blame, - line_height, - text_hitbox, - row_info.buffer_id?, - window, - cx, - ); - element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), window, cx); Some(InlineBlameLayout { @@ -2600,16 +2631,48 @@ impl EditorElement { }) } - fn layout_blame_entry_popover( + fn layout_blame_popover( &self, - blame_entry: BlameEntry, - blame: Entity, - line_height: Pixels, + editor_snapshot: &EditorSnapshot, text_hitbox: &Hitbox, - buffer: BufferId, + line_height: Pixels, window: &mut Window, cx: &mut App, ) { + if !self.editor.read(cx).inline_blame_popover.is_some() { + return; + } + + let Some(blame) = self.editor.read(cx).blame.clone() else { + return; + }; + let cursor_point = self + .editor + .read(cx) + .selections + .newest::(&editor_snapshot.display_snapshot) + .head(); + + let Some((buffer, buffer_point, _)) = editor_snapshot + .buffer_snapshot() + .point_to_buffer_point(cursor_point) + else { + return; + }; + + let row_info = RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(buffer_point.row), + ..Default::default() + }; + + let Some((buffer_id, blame_entry)) = blame + .update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next()) + .flatten() + else { + return; + }; + let Some((popover_state, target_point)) = self.editor.read_with(cx, |editor, _| { editor .inline_blame_popover @@ -2631,7 +2694,7 @@ impl EditorElement { popover_state.markdown, workspace, &blame, - buffer, + buffer_id, window, cx, ) @@ -2766,7 +2829,7 @@ impl EditorElement { .enumerate() .filter_map(|(i, indent_guide)| { let single_indent_width = - self.column_pixels(indent_guide.tab_size as usize, window); + column_pixels(&self.style, indent_guide.tab_size as usize, window); let total_width = single_indent_width * indent_guide.depth as f32; let start_x = Pixels::from( ScrollOffset::from(content_origin.x + total_width) @@ -2823,7 +2886,7 @@ impl EditorElement { .wrap_guides(cx) .into_iter() .flat_map(|(guide, active)| { - let wrap_position = self.column_pixels(guide, window); + let wrap_position = column_pixels(&self.style, guide, window); let wrap_guide_x = wrap_position + horizontal_offset; let display_wrap_guide = wrap_guide_x >= content_origin && wrap_guide_x <= hitbox.bounds.right() - vertical_scrollbar_width; @@ -3164,64 +3227,6 @@ impl EditorElement { .collect() } - fn calculate_relative_line_numbers( - &self, - snapshot: &EditorSnapshot, - rows: &Range, - relative_to: Option, - count_wrapped_lines: bool, - ) -> HashMap { - let mut relative_rows: HashMap = Default::default(); - let Some(relative_to) = relative_to else { - return relative_rows; - }; - - let start = rows.start.min(relative_to); - let end = rows.end.max(relative_to); - - let buffer_rows = snapshot - .row_infos(start) - .take(1 + end.minus(start) as usize) - .collect::>(); - - let head_idx = relative_to.minus(start); - let mut delta = 1; - let mut i = head_idx + 1; - let should_count_line = |row_info: &RowInfo| { - if count_wrapped_lines { - row_info.buffer_row.is_some() || row_info.wrapped_buffer_row.is_some() - } else { - row_info.buffer_row.is_some() - } - }; - while i < buffer_rows.len() as u32 { - if should_count_line(&buffer_rows[i as usize]) { - if rows.contains(&DisplayRow(i + start.0)) { - relative_rows.insert(DisplayRow(i + start.0), delta); - } - delta += 1; - } - i += 1; - } - delta = 1; - i = head_idx.min(buffer_rows.len().saturating_sub(1) as u32); - while i > 0 && buffer_rows[i as usize].buffer_row.is_none() && !count_wrapped_lines { - i -= 1; - } - - while i > 0 { - i -= 1; - if should_count_line(&buffer_rows[i as usize]) { - if rows.contains(&DisplayRow(i + start.0)) { - relative_rows.insert(DisplayRow(i + start.0), delta); - } - delta += 1; - } - } - - relative_rows - } - fn layout_line_numbers( &self, gutter_hitbox: Option<&Hitbox>, @@ -3231,7 +3236,7 @@ impl EditorElement { rows: Range, buffer_rows: &[RowInfo], active_rows: &BTreeMap, - newest_selection_head: Option, + relative_line_base: Option, snapshot: &EditorSnapshot, window: &mut Window, cx: &mut App, @@ -3243,31 +3248,16 @@ impl EditorElement { return Arc::default(); } - let (newest_selection_head, relative) = self.editor.update(cx, |editor, cx| { - let newest_selection_head = newest_selection_head.unwrap_or_else(|| { - let newest = editor - .selections - .newest::(&editor.display_snapshot(cx)); - SelectionLayout::new( - newest, - editor.selections.line_mode(), - editor.cursor_shape, - &snapshot.display_snapshot, - true, - true, - None, - ) - .head - }); - let relative = editor.relative_line_numbers(cx); - (newest_selection_head, relative) - }); + let relative = self.editor.read(cx).relative_line_numbers(cx); let relative_line_numbers_enabled = relative.enabled(); - let relative_to = relative_line_numbers_enabled.then(|| newest_selection_head.row()); + let relative_rows = if relative_line_numbers_enabled && let Some(base) = relative_line_base + { + snapshot.calculate_relative_line_numbers(&rows, base, relative.wrapped()) + } else { + Default::default() + }; - let relative_rows = - self.calculate_relative_line_numbers(snapshot, &rows, relative_to, relative.wrapped()); let mut line_number = String::new(); let segments = buffer_rows.iter().enumerate().flat_map(|(ix, row_info)| { let display_row = DisplayRow(rows.start.0 + ix as u32); @@ -3886,6 +3876,8 @@ impl EditorElement { ) -> impl IntoElement { let editor = self.editor.read(cx); let multi_buffer = editor.buffer.read(cx); + let is_read_only = self.editor.read(cx).read_only(cx); + let file_status = multi_buffer .all_diff_hunks_expanded() .then(|| editor.status_for_buffer_id(for_excerpt.buffer_id, cx)) @@ -3938,7 +3930,7 @@ impl EditorElement { .gap_1p5() .when(is_sticky, |el| el.shadow_md()) .border_1() - .map(|div| { + .map(|border| { let border_color = if is_selected && is_folded && focus_handle.contains_focused(window, cx) @@ -3947,7 +3939,7 @@ impl EditorElement { } else { colors.border }; - div.border_color(border_color) + border.border_color(border_color) }) .bg(colors.editor_subheader_background) .hover(|style| style.bg(colors.element_hover)) @@ -4027,13 +4019,15 @@ impl EditorElement { }) .take(1), ) - .child( - h_flex() - .size_3() - .justify_center() - .flex_shrink_0() - .children(indicator), - ) + .when(!is_read_only, |this| { + this.child( + h_flex() + .size_3() + .justify_center() + .flex_shrink_0() + .children(indicator), + ) + }) .child( h_flex() .cursor_pointer() @@ -4583,6 +4577,9 @@ impl EditorElement { gutter_dimensions: &GutterDimensions, gutter_hitbox: &Hitbox, text_hitbox: &Hitbox, + style: &EditorStyle, + relative_line_numbers: RelativeLineNumbers, + relative_to: Option, window: &mut Window, cx: &mut App, ) -> Option { @@ -4590,7 +4587,7 @@ impl EditorElement { .show_line_numbers .unwrap_or_else(|| EditorSettings::get_global(cx).gutter.line_numbers); - let rows = Self::sticky_headers(self.editor.read(cx), snapshot, cx); + let rows = Self::sticky_headers(self.editor.read(cx), snapshot, style, cx); let mut lines = Vec::::new(); @@ -4612,9 +4609,21 @@ impl EditorElement { ); let line_number = show_line_numbers.then(|| { - let number = (start_point.row + 1).to_string(); + let relative_number = relative_to.and_then(|base| match relative_line_numbers { + RelativeLineNumbers::Disabled => None, + RelativeLineNumbers::Enabled => { + Some(snapshot.relative_line_delta_to_point(base, start_point)) + } + RelativeLineNumbers::Wrapped => { + Some(snapshot.relative_wrapped_line_delta_to_point(base, start_point)) + } + }); + let number = relative_number + .filter(|&delta| delta != 0) + .map(|delta| delta.unsigned_abs() as u32) + .unwrap_or(start_point.row + 1); let color = cx.theme().colors().editor_line_number; - self.shape_line_number(SharedString::from(number), color, window) + self.shape_line_number(SharedString::from(number.to_string()), color, window) }); lines.push(StickyHeaderLine::new( @@ -4649,6 +4658,7 @@ impl EditorElement { pub(crate) fn sticky_headers( editor: &Editor, snapshot: &EditorSnapshot, + style: &EditorStyle, cx: &App, ) -> Vec { let scroll_top = snapshot.scroll_position().y; @@ -4656,7 +4666,7 @@ impl EditorElement { let mut end_rows = Vec::::new(); let mut rows = Vec::::new(); - let items = editor.sticky_headers(cx).unwrap_or_default(); + let items = editor.sticky_headers(style, cx).unwrap_or_default(); for item in items { let start_point = item.range.start.to_point(snapshot.buffer_snapshot()); @@ -4826,8 +4836,11 @@ impl EditorElement { let edit_prediction = if edit_prediction_popover_visible { self.editor.update(cx, move |editor, cx| { - let accept_binding = - editor.accept_edit_prediction_keybind(false, window, cx); + let accept_binding = editor.accept_edit_prediction_keybind( + EditPredictionGranularity::Full, + window, + cx, + ); let mut element = editor.render_edit_prediction_cursor_popover( min_width, max_width, @@ -5219,7 +5232,7 @@ impl EditorElement { ) -> Option { let max_height_in_lines = ((height - POPOVER_Y_PADDING) / line_height).floor() as u32; self.editor.update(cx, |editor, cx| { - editor.render_context_menu(&self.style, max_height_in_lines, window, cx) + editor.render_context_menu(max_height_in_lines, window, cx) }) } @@ -5246,16 +5259,18 @@ impl EditorElement { window: &mut Window, cx: &mut App, ) -> Option { - let position = self.editor.update(cx, |editor, _cx| { + let position = self.editor.update(cx, |editor, cx| { let visible_start_point = editor.display_to_pixel_point( DisplayPoint::new(visible_range.start, 0), editor_snapshot, window, + cx, )?; let visible_end_point = editor.display_to_pixel_point( DisplayPoint::new(visible_range.end, 0), editor_snapshot, window, + cx, )?; let mouse_context_menu = editor.mouse_context_menu.as_ref()?; @@ -5263,7 +5278,8 @@ impl EditorElement { MenuPosition::PinnedToScreen(point) => (None, point), MenuPosition::PinnedToEditor { source, offset } => { let source_display_point = source.to_display_point(editor_snapshot); - let source_point = editor.to_pixel_point(source, editor_snapshot, window)?; + let source_point = + editor.to_pixel_point(source, editor_snapshot, window, cx)?; let position = content_origin + source_point + offset; (Some(source_display_point), position) } @@ -5341,6 +5357,12 @@ impl EditorElement { .max(MIN_POPOVER_LINE_HEIGHT * line_height), // Apply minimum height of 4 lines ); + // Don't show hover popovers when context menu is open to avoid overlap + let has_context_menu = self.editor.read(cx).mouse_context_menu.is_some(); + if has_context_menu { + return; + } + let hover_popovers = self.editor.update(cx, |editor, cx| { editor.hover_state.render( snapshot, @@ -6140,10 +6162,25 @@ impl EditorElement { let color = cx.theme().colors().editor_hover_line_number; let line = self.shape_line_number(shaped_line.text.clone(), color, window); - line.paint(hitbox.origin, line_height, window, cx).log_err() + line.paint( + hitbox.origin, + line_height, + TextAlign::Left, + None, + window, + cx, + ) + .log_err() } else { shaped_line - .paint(hitbox.origin, line_height, window, cx) + .paint( + hitbox.origin, + line_height, + TextAlign::Left, + None, + window, + cx, + ) .log_err() }) else { continue; @@ -7232,23 +7269,27 @@ impl EditorElement { .map(|row| { let line_layout = &layout.position_map.line_layouts[row.minus(start_row) as usize]; + let alignment_offset = + line_layout.alignment_offset(layout.text_align, layout.content_width); HighlightedRangeLine { start_x: if row == range.start.row() { layout.content_origin.x + Pixels::from( ScrollPixelOffset::from( - line_layout.x_for_index(range.start.column() as usize), + line_layout.x_for_index(range.start.column() as usize) + + alignment_offset, ) - layout.position_map.scroll_pixel_position.x, ) } else { - layout.content_origin.x + layout.content_origin.x + alignment_offset - Pixels::from(layout.position_map.scroll_pixel_position.x) }, end_x: if row == range.end.row() { layout.content_origin.x + Pixels::from( ScrollPixelOffset::from( - line_layout.x_for_index(range.end.column() as usize), + line_layout.x_for_index(range.end.column() as usize) + + alignment_offset, ) - layout.position_map.scroll_pixel_position.x, ) } else { @@ -7256,6 +7297,7 @@ impl EditorElement { ScrollPixelOffset::from( layout.content_origin.x + line_layout.width + + alignment_offset + line_end_overshoot, ) - layout.position_map.scroll_pixel_position.x, ) @@ -7714,6 +7756,19 @@ impl EditorElement { } }); + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + + move |event: &MousePressureEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + Self::pressure_click(editor, &event, &position_map, window, cx); + }) + } + } + }); + window.on_mouse_event({ let position_map = layout.position_map.clone(); let editor = self.editor.clone(); @@ -7737,29 +7792,6 @@ impl EditorElement { }); } - fn column_pixels(&self, column: usize, window: &Window) -> Pixels { - let style = &self.style; - let font_size = style.text.font_size.to_pixels(window.rem_size()); - let layout = window.text_system().shape_line( - SharedString::from(" ".repeat(column)), - font_size, - &[TextRun { - len: column, - font: style.text.font(), - color: Hsla::default(), - ..Default::default() - }], - None, - ); - - layout.width - } - - fn max_line_number_width(&self, snapshot: &EditorSnapshot, window: &mut Window) -> Pixels { - let digit_count = snapshot.widest_line_number().ilog10() + 1; - self.column_pixels(digit_count as usize, window) - } - fn shape_line_number( &self, text: SharedString, @@ -8506,8 +8538,15 @@ impl LineWithInvisibles { for fragment in &self.fragments { match fragment { LineFragment::Text(line) => { - line.paint(fragment_origin, line_height, window, cx) - .log_err(); + line.paint( + fragment_origin, + line_height, + layout.text_align, + Some(layout.content_width), + window, + cx, + ) + .log_err(); fragment_origin.x += line.width; } LineFragment::Element { size, .. } => { @@ -8549,8 +8588,15 @@ impl LineWithInvisibles { for fragment in &self.fragments { match fragment { LineFragment::Text(line) => { - line.paint_background(fragment_origin, line_height, window, cx) - .log_err(); + line.paint_background( + fragment_origin, + line_height, + layout.text_align, + Some(layout.content_width), + window, + cx, + ) + .log_err(); fragment_origin.x += line.width; } LineFragment::Element { size, .. } => { @@ -8599,7 +8645,7 @@ impl LineWithInvisibles { [token_offset, token_end_offset], Box::new(move |window: &mut Window, cx: &mut App| { invisible_symbol - .paint(origin, line_height, window, cx) + .paint(origin, line_height, TextAlign::Left, None, window, cx) .log_err(); }), ) @@ -8760,6 +8806,15 @@ impl LineWithInvisibles { None } + + pub fn alignment_offset(&self, text_align: TextAlign, content_width: Pixels) -> Pixels { + let line_width = self.width; + match text_align { + TextAlign::Left => px(0.0), + TextAlign::Center => (content_width - line_width) / 2.0, + TextAlign::Right => content_width - line_width, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -8907,8 +8962,6 @@ impl Element for EditorElement { max_lines, } => { let editor_handle = cx.entity(); - let max_line_number_width = - self.max_line_number_width(&editor.snapshot(window, cx), window); window.request_measured_layout( Style::default(), move |known_dimensions, available_space, window, cx| { @@ -8918,7 +8971,6 @@ impl Element for EditorElement { editor, min_lines, max_lines, - max_line_number_width, known_dimensions, available_space.width, window, @@ -9005,15 +9057,10 @@ impl Element for EditorElement { .gutter_dimensions( font_id, font_size, - self.max_line_number_width(&snapshot, window), + style, + window, cx, - ) - .or_else(|| { - self.editor.read(cx).offset_content.then(|| { - GutterDimensions::default_with_margin(font_id, font_size, cx) - }) - }) - .unwrap_or_default(); + ); let text_width = bounds.size.width - gutter_dimensions.width; let settings = EditorSettings::get_global(cx); @@ -9100,6 +9147,15 @@ impl Element for EditorElement { let height_in_lines = f64::from(bounds.size.height / line_height); let max_row = snapshot.max_point().row().as_f64(); + // Calculate how much of the editor is clipped by parent containers (e.g., List). + // This allows us to only render lines that are actually visible, which is + // critical for performance when large AutoHeight editors are inside Lists. + let visible_bounds = window.content_mask().bounds; + let clipped_top = (visible_bounds.origin.y - bounds.origin.y).max(px(0.)); + let clipped_top_in_lines = f64::from(clipped_top / line_height); + let visible_height_in_lines = + f64::from(visible_bounds.size.height / line_height); + // The max scroll position for the top of the window let max_scroll_top = if matches!( snapshot.mode, @@ -9156,10 +9212,16 @@ impl Element for EditorElement { let mut scroll_position = snapshot.scroll_position(); // The scroll position is a fractional point, the whole number of which represents // the top of the window in terms of display rows. - let start_row = DisplayRow(scroll_position.y as u32); + // We add clipped_top_in_lines to skip rows that are clipped by parent containers, + // but we don't modify scroll_position itself since the parent handles positioning. let max_row = snapshot.max_point().row(); + let start_row = cmp::min( + DisplayRow((scroll_position.y + clipped_top_in_lines).floor() as u32), + max_row, + ); let end_row = cmp::min( - (scroll_position.y + height_in_lines).ceil() as u32, + (scroll_position.y + clipped_top_in_lines + visible_height_in_lines).ceil() + as u32, max_row.next_row().0, ); let end_row = DisplayRow(end_row); @@ -9357,6 +9419,28 @@ impl Element for EditorElement { window, cx, ); + + // relative rows are based on newest selection, even outside the visible area + let relative_row_base = self.editor.update(cx, |editor, cx| { + if editor.selections.count()==0 { + return None; + } + let newest = editor + .selections + .newest::(&editor.display_snapshot(cx)); + Some(SelectionLayout::new( + newest, + editor.selections.line_mode(), + editor.cursor_offset_on_selection, + editor.cursor_shape, + &snapshot.display_snapshot, + true, + true, + None, + ) + .head.row()) + }); + let mut breakpoint_rows = self.editor.update(cx, |editor, cx| { editor.active_breakpoints(start_row..end_row, window, cx) }); @@ -9374,7 +9458,7 @@ impl Element for EditorElement { start_row..end_row, &row_infos, &active_rows, - newest_selection_head, + relative_row_base, &snapshot, window, cx, @@ -9694,6 +9778,7 @@ impl Element for EditorElement { && is_singleton && EditorSettings::get_global(cx).sticky_scroll.enabled { + let relative = self.editor.read(cx).relative_line_numbers(cx); self.layout_sticky_headers( &snapshot, editor_width, @@ -9704,6 +9789,9 @@ impl Element for EditorElement { &gutter_dimensions, &gutter_hitbox, &text_hitbox, + &style, + relative, + relative_row_base, window, cx, ) @@ -9811,7 +9899,6 @@ impl Element for EditorElement { scroll_position, scroll_pixel_position, line_height, - &text_hitbox, window, cx, ) { @@ -10009,6 +10096,8 @@ impl Element for EditorElement { window, cx, ); + + self.layout_blame_popover(&snapshot, &hitbox, line_height, window, cx); } let mouse_context_menu = self.layout_mouse_context_menu( @@ -10128,6 +10217,8 @@ impl Element for EditorElement { em_width, em_advance, snapshot, + text_align: self.style.text.text_align, + content_width: text_hitbox.size.width, gutter_hitbox: gutter_hitbox.clone(), text_hitbox: text_hitbox.clone(), inline_blame_bounds: inline_blame_layout @@ -10181,6 +10272,8 @@ impl Element for EditorElement { sticky_buffer_header, sticky_headers, expand_toggles, + text_align: self.style.text.text_align, + content_width: text_hitbox.size.width, } }) }) @@ -10361,6 +10454,8 @@ pub struct EditorLayout { sticky_buffer_header: Option, sticky_headers: Option, document_colors: Option<(DocumentColorsRenderMode, Vec<(Range, Hsla)>)>, + text_align: TextAlign, + content_width: Pixels, } struct StickyHeaders { @@ -10528,7 +10623,9 @@ impl StickyHeaderLine { gutter_origin.x + gutter_width - gutter_right_padding - line_number.width, gutter_origin.y, ); - line_number.paint(origin, line_height, window, cx).log_err(); + line_number + .paint(origin, line_height, TextAlign::Left, None, window, cx) + .log_err(); } } } @@ -10967,6 +11064,8 @@ pub(crate) struct PositionMap { pub visible_row_range: Range, pub line_layouts: Vec, pub snapshot: EditorSnapshot, + pub text_align: TextAlign, + pub content_width: Pixels, pub text_hitbox: Hitbox, pub gutter_hitbox: Hitbox, pub inline_blame_bounds: Option<(Bounds, BufferId, BlameEntry)>, @@ -11032,10 +11131,12 @@ impl PositionMap { .line_layouts .get(row as usize - scroll_position.y as usize) { - if let Some(ix) = line.index_for_x(x) { + let alignment_offset = line.alignment_offset(self.text_align, self.content_width); + let x_relative_to_text = x - alignment_offset; + if let Some(ix) = line.index_for_x(x_relative_to_text) { (ix as u32, px(0.)) } else { - (line.len as u32, px(0.).max(x - line.width)) + (line.len as u32, px(0.).max(x_relative_to_text - line.width)) } } else { (0, x) @@ -11224,7 +11325,14 @@ impl CursorLayout { if let Some(block_text) = &self.block_text { block_text - .paint(self.origin + origin, self.line_height, window, cx) + .paint( + self.origin + origin, + self.line_height, + TextAlign::Left, + None, + window, + cx, + ) .log_err(); } } @@ -11419,7 +11527,6 @@ fn compute_auto_height_layout( editor: &mut Editor, min_lines: usize, max_lines: Option, - max_line_number_width: Pixels, known_dimensions: Size>, available_width: AvailableSpace, window: &mut Window, @@ -11443,14 +11550,7 @@ fn compute_auto_height_layout( let em_width = window.text_system().em_width(font_id, font_size).unwrap(); let mut snapshot = editor.snapshot(window, cx); - let gutter_dimensions = snapshot - .gutter_dimensions(font_id, font_size, max_line_number_width, cx) - .or_else(|| { - editor - .offset_content - .then(|| GutterDimensions::default_with_margin(font_id, font_size, cx)) - }) - .unwrap_or_default(); + let gutter_dimensions = snapshot.gutter_dimensions(font_id, font_size, style, window, cx); editor.gutter_dimensions = gutter_dimensions; let text_width = width - gutter_dimensions.width; @@ -11513,7 +11613,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); for x in 1..=100 { let (_, state) = cx.draw( @@ -11541,7 +11641,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); for x in 1..=100 { let (_, state) = cx.draw( @@ -11558,7 +11658,7 @@ mod tests { } #[gpui::test] - fn test_shape_line_numbers(cx: &mut TestAppContext) { + fn test_layout_line_numbers(cx: &mut TestAppContext) { init_test(cx, |_| {}); let window = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx); @@ -11566,7 +11666,7 @@ mod tests { }); let editor = window.root(cx).unwrap(); - let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); let line_height = window .update(cx, |_, window, _| { style.text.line_height_in_pixels(window.rem_size()) @@ -11598,7 +11698,7 @@ mod tests { }) .collect::>(), &BTreeMap::default(), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11610,10 +11710,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(6)), - Some(DisplayRow(3)), + DisplayRow(3), false, ) }) @@ -11629,10 +11728,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(3)..DisplayRow(6)), - Some(DisplayRow(1)), + DisplayRow(1), false, ) }) @@ -11646,10 +11744,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(3)), - Some(DisplayRow(6)), + DisplayRow(6), false, ) }) @@ -11686,7 +11783,7 @@ mod tests { }) .collect::>(), &BTreeMap::default(), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11701,7 +11798,7 @@ mod tests { } #[gpui::test] - fn test_shape_line_numbers_wrapping(cx: &mut TestAppContext) { + fn test_layout_line_numbers_wrapping(cx: &mut TestAppContext) { init_test(cx, |_| {}); let window = cx.add_window(|window, cx| { let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx); @@ -11714,7 +11811,7 @@ mod tests { }); let editor = window.root(cx).unwrap(); - let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); let line_height = window .update(cx, |_, window, _| { style.text.line_height_in_pixels(window.rem_size()) @@ -11746,7 +11843,7 @@ mod tests { }) .collect::>(), &BTreeMap::default(), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11758,10 +11855,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(6)), - Some(DisplayRow(3)), + DisplayRow(3), true, ) }) @@ -11798,7 +11894,7 @@ mod tests { }) .collect::>(), &BTreeMap::from_iter([(DisplayRow(0), LineHighlightSpec::default())]), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11813,10 +11909,9 @@ mod tests { let relative_rows = window .update(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); - element.calculate_relative_line_numbers( - &snapshot, + snapshot.calculate_relative_line_numbers( &(DisplayRow(0)..DisplayRow(6)), - Some(DisplayRow(3)), + DisplayRow(3), true, ) }) @@ -11841,11 +11936,11 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); window .update(cx, |editor, window, cx| { - editor.cursor_shape = CursorShape::Block; + editor.cursor_offset_on_selection = true; editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(0, 0)..Point::new(1, 0), @@ -11912,7 +12007,7 @@ mod tests { }); let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = cx.update(|_, cx| editor.update(cx, |editor, cx| editor.style(cx).clone())); window .update(cx, |editor, window, cx| { editor.set_placeholder_text("hello", window, cx); @@ -12152,7 +12247,7 @@ mod tests { let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); - let style = cx.update(|_, cx| editor.read(cx).style().unwrap().clone()); + let style = editor.update(cx, |editor, cx| editor.style(cx).clone()); window .update(cx, |editor, _, cx| { editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 008630faef7cc1ccb3b9703e4b11c0b88b7cf17c..d1338c3cbd3540914b23a53410fd5c823e1285c8 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -1,11 +1,11 @@ use crate::Editor; -use anyhow::Result; +use anyhow::{Context as _, Result}; use collections::HashMap; -use futures::StreamExt; + use git::{ - GitHostingProviderRegistry, GitRemote, Oid, - blame::{Blame, BlameEntry, ParsedCommitMessage}, - parse_git_remote_url, + GitHostingProviderRegistry, Oid, + blame::{Blame, BlameEntry}, + commit::ParsedCommitMessage, }; use gpui::{ AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task, @@ -494,76 +494,103 @@ impl GitBlame { self.changed_while_blurred = true; return; } - let blame = self.project.update(cx, |project, cx| { - let Some(multi_buffer) = self.multi_buffer.upgrade() else { - return Vec::new(); - }; - multi_buffer - .read(cx) - .all_buffer_ids() - .into_iter() - .filter_map(|id| { - let buffer = multi_buffer.read(cx).buffer(id)?; - let snapshot = buffer.read(cx).snapshot(); - let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); - - let blame_buffer = project.blame_buffer(&buffer, None, cx); - Some(async move { (id, snapshot, buffer_edits, blame_buffer.await) }) - }) - .collect::>() - }); - let provider_registry = GitHostingProviderRegistry::default_global(cx); + let buffers_to_blame = self + .multi_buffer + .update(cx, |multi_buffer, _| { + multi_buffer + .all_buffer_ids() + .into_iter() + .filter_map(|id| Some(multi_buffer.buffer(id)?.downgrade())) + .collect::>() + }) + .unwrap_or_default(); + let project = self.project.downgrade(); self.task = cx.spawn(async move |this, cx| { - let (result, errors) = cx - .background_spawn({ - async move { - let blame = futures::stream::iter(blame) - .buffered(4) - .collect::>() - .await; - let mut res = vec![]; - let mut errors = vec![]; - for (id, snapshot, buffer_edits, blame) in blame { - match blame { - Ok(Some(Blame { - entries, - messages, - remote_url, - })) => { - let entries = build_blame_entry_sum_tree( - entries, - snapshot.max_point().row, - ); - let commit_details = parse_commit_messages( - messages, - remote_url, - provider_registry.clone(), - ) - .await; - - res.push(( + let mut all_results = Vec::new(); + let mut all_errors = Vec::new(); + + for buffers in buffers_to_blame.chunks(4) { + let blame = cx.update(|cx| { + buffers + .iter() + .map(|buffer| { + let buffer = buffer.upgrade().context("buffer was dropped")?; + let project = project.upgrade().context("project was dropped")?; + let id = buffer.read(cx).remote_id(); + let snapshot = buffer.read(cx).snapshot(); + let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe()); + let remote_url = project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) + .and_then(|(repo, _)| repo.read(cx).default_remote_url()); + let blame_buffer = project + .update(cx, |project, cx| project.blame_buffer(&buffer, None, cx)); + Ok(async move { + (id, snapshot, buffer_edits, blame_buffer.await, remote_url) + }) + }) + .collect::>>() + })??; + let provider_registry = + cx.update(|cx| GitHostingProviderRegistry::default_global(cx))?; + let (results, errors) = cx + .background_spawn({ + async move { + let blame = futures::future::join_all(blame).await; + let mut res = vec![]; + let mut errors = vec![]; + for (id, snapshot, buffer_edits, blame, remote_url) in blame { + match blame { + Ok(Some(Blame { entries, messages })) => { + let entries = build_blame_entry_sum_tree( + entries, + snapshot.max_point().row, + ); + let commit_details = messages + .into_iter() + .map(|(oid, message)| { + let parsed_commit_message = + ParsedCommitMessage::parse( + oid.to_string(), + message, + remote_url.as_deref(), + Some(provider_registry.clone()), + ); + (oid, parsed_commit_message) + }) + .collect(); + res.push(( + id, + snapshot, + buffer_edits, + Some(entries), + commit_details, + )); + } + Ok(None) => res.push(( id, snapshot, buffer_edits, - Some(entries), - commit_details, - )); + None, + Default::default(), + )), + Err(e) => errors.push(e), } - Ok(None) => { - res.push((id, snapshot, buffer_edits, None, Default::default())) - } - Err(e) => errors.push(e), } + (res, errors) } - (res, errors) - } - }) - .await; + }) + .await; + all_results.extend(results); + all_errors.extend(errors) + } this.update(cx, |this, cx| { this.buffers.clear(); - for (id, snapshot, buffer_edits, entries, commit_details) in result { + for (id, snapshot, buffer_edits, entries, commit_details) in all_results { let Some(entries) = entries else { continue; }; @@ -578,11 +605,11 @@ impl GitBlame { ); } cx.notify(); - if !errors.is_empty() { + if !all_errors.is_empty() { this.project.update(cx, |_, cx| { if this.user_triggered { - log::error!("failed to get git blame data: {errors:?}"); - let notification = errors + log::error!("failed to get git blame data: {all_errors:?}"); + let notification = all_errors .into_iter() .format_with(",", |e, f| f(&format_args!("{:#}", e))) .to_string(); @@ -593,7 +620,7 @@ impl GitBlame { } else { // If we weren't triggered by a user, we just log errors in the background, instead of sending // notifications. - log::debug!("failed to get git blame data: {errors:?}"); + log::debug!("failed to get git blame data: {all_errors:?}"); } }) } @@ -654,55 +681,6 @@ fn build_blame_entry_sum_tree(entries: Vec, max_row: u32) -> SumTree entries } -async fn parse_commit_messages( - messages: impl IntoIterator, - remote_url: Option, - provider_registry: Arc, -) -> HashMap { - let mut commit_details = HashMap::default(); - - let parsed_remote_url = remote_url - .as_deref() - .and_then(|remote_url| parse_git_remote_url(provider_registry, remote_url)); - - for (oid, message) in messages { - let permalink = if let Some((provider, git_remote)) = parsed_remote_url.as_ref() { - Some(provider.build_commit_permalink( - git_remote, - git::BuildCommitPermalinkParams { - sha: oid.to_string().as_str(), - }, - )) - } else { - None - }; - - let remote = parsed_remote_url - .as_ref() - .map(|(provider, remote)| GitRemote { - host: provider.clone(), - owner: remote.owner.clone().into(), - repo: remote.repo.clone().into(), - }); - - let pull_request = parsed_remote_url - .as_ref() - .and_then(|(provider, remote)| provider.extract_pull_request(remote, &message)); - - commit_details.insert( - oid, - ParsedCommitMessage { - message: message.into(), - permalink, - remote, - pull_request, - }, - ); - } - - commit_details -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/editor/src/highlight_matching_bracket.rs b/crates/editor/src/highlight_matching_bracket.rs index eaef28bed21bf480a32c3abd3440a6c41e42d5f1..3ead3e2a11348b0f262926bbfe4fb880f0dff663 100644 --- a/crates/editor/src/highlight_matching_bracket.rs +++ b/crates/editor/src/highlight_matching_bracket.rs @@ -7,6 +7,7 @@ use theme::ActiveTheme; enum MatchingBracketHighlight {} impl Editor { + #[ztracing::instrument(skip_all)] pub fn refresh_matching_bracket_highlights( &mut self, window: &Window, diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 5ef52f36dfd609a49d93eb77b74b2df3287df30a..1c00acbfa9f1a69cbe01c45758db5a0cd4fee757 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -9,8 +9,10 @@ use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; use project::{InlayId, LocationLink, Project, ResolvedPath}; +use regex::Regex; use settings::Settings; -use std::ops::Range; +use std::{ops::Range, sync::LazyLock}; +use text::OffsetRangeExt; use theme::ActiveTheme as _; use util::{ResultExt, TryFutureExt as _, maybe}; @@ -168,7 +170,7 @@ impl Editor { match EditorSettings::get_global(cx).go_to_definition_fallback { GoToDefinitionFallback::None => None, GoToDefinitionFallback::FindAllReferences => { - editor.find_all_references(&FindAllReferences, window, cx) + editor.find_all_references(&FindAllReferences::default(), window, cx) } } }) @@ -216,7 +218,7 @@ impl Editor { self.hide_hovered_link(cx); if !hovered_link_state.links.is_empty() { if !self.focus_handle.is_focused(window) { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } // exclude links pointing back to the current anchor @@ -595,7 +597,8 @@ pub(crate) async fn find_file( let project = project?; let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()).ok()?; let scope = snapshot.language_scope_at(position); - let (range, candidate_file_path) = surrounding_filename(snapshot, position)?; + let (range, candidate_file_path) = surrounding_filename(&snapshot, position)?; + let candidate_len = candidate_file_path.len(); async fn check_path( candidate_file_path: &str, @@ -612,29 +615,66 @@ pub(crate) async fn find_file( .filter(|s| s.is_file()) } - if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await { - return Some((range, existing_path)); + let pattern_candidates = link_pattern_file_candidates(&candidate_file_path); + + for (pattern_candidate, pattern_range) in &pattern_candidates { + if let Some(existing_path) = check_path(&pattern_candidate, &project, buffer, cx).await { + let offset_range = range.to_offset(&snapshot); + let actual_start = offset_range.start + pattern_range.start; + let actual_end = offset_range.end - (candidate_len - pattern_range.end); + return Some(( + snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), + existing_path, + )); + } } - if let Some(scope) = scope { - for suffix in scope.path_suffixes() { - if candidate_file_path.ends_with(format!(".{suffix}").as_str()) { - continue; - } + for (pattern_candidate, pattern_range) in pattern_candidates { + for suffix in scope.path_suffixes() { + if pattern_candidate.ends_with(format!(".{suffix}").as_str()) { + continue; + } - let suffixed_candidate = format!("{candidate_file_path}.{suffix}"); - if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await - { - return Some((range, existing_path)); + let suffixed_candidate = format!("{pattern_candidate}.{suffix}"); + if let Some(existing_path) = + check_path(&suffixed_candidate, &project, buffer, cx).await + { + let offset_range = range.to_offset(&snapshot); + let actual_start = offset_range.start + pattern_range.start; + let actual_end = offset_range.end - (candidate_len - pattern_range.end); + return Some(( + snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), + existing_path, + )); + } } } } - None } +// Tries to capture potentially inlined links, like those found in markdown, +// e.g. [LinkTitle](link_file.txt) +// Since files can have parens, we should always return the full string +// (literally, [LinkTitle](link_file.txt)) as a candidate. +fn link_pattern_file_candidates(candidate: &str) -> Vec<(String, Range)> { + static MD_LINK_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\(([^)]*)\)").expect("Failed to create REGEX")); + + let candidate_len = candidate.len(); + + let mut candidates = vec![(candidate.to_string(), 0..candidate_len)]; + + if let Some(captures) = MD_LINK_REGEX.captures(candidate) { + if let Some(link) = captures.get(1) { + candidates.push((link.as_str().to_string(), link.range())); + } + } + candidates +} + fn surrounding_filename( - snapshot: language::BufferSnapshot, + snapshot: &language::BufferSnapshot, position: text::Anchor, ) -> Option<(Range, String)> { const LIMIT: usize = 2048; @@ -735,7 +775,7 @@ mod tests { test::editor_lsp_test_context::EditorLspTestContext, }; use futures::StreamExt; - use gpui::Modifiers; + use gpui::{Modifiers, MousePressureEvent, PressureStage}; use indoc::indoc; use lsp::request::{GotoDefinition, GotoTypeDefinition}; use multi_buffer::MultiBufferOffset; @@ -1316,6 +1356,58 @@ mod tests { assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into())); } + #[test] + fn test_link_pattern_file_candidates() { + let candidates: Vec = link_pattern_file_candidates("[LinkTitle](link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + assert_eq!( + candidates, + vec!["[LinkTitle](link_file.txt)", "link_file.txt",] + ); + // Link title with spaces in it + let candidates: Vec = link_pattern_file_candidates("LinkTitle](link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + assert_eq!( + candidates, + vec!["LinkTitle](link_file.txt)", "link_file.txt",] + ); + + // Link with spaces + let candidates: Vec = link_pattern_file_candidates("LinkTitle](link\\ _file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!( + candidates, + vec!["LinkTitle](link\\ _file.txt)", "link\\ _file.txt",] + ); + // + // Square brackets not strictly necessary + let candidates: Vec = link_pattern_file_candidates("(link_file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!(candidates, vec!["(link_file.txt)", "link_file.txt",]); + + // No nesting + let candidates: Vec = + link_pattern_file_candidates("LinkTitle](link_(link_file)file.txt)") + .into_iter() + .map(|(c, _)| c) + .collect(); + + assert_eq!( + candidates, + vec!["LinkTitle](link_(link_file)file.txt)", "link_(link_file",] + ) + } + #[gpui::test] async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -1374,7 +1466,7 @@ mod tests { (positions, snapshot) }); - let result = surrounding_filename(snapshot, position); + let result = surrounding_filename(&snapshot, position); if let Some(expected) = expected { assert!(result.is_some(), "Failed to find file path: {}", input); @@ -1706,4 +1798,77 @@ mod tests { cx.simulate_click(screen_coord, Modifiers::secondary_key()); cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1)); } + + #[gpui::test] + async fn test_pressure_links(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + definition_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + fn ˇtest() { do_work(); } + fn do_work() { test(); } + "}); + + // Position the mouse over a symbol that has a definition + let hover_point = cx.pixel_position(indoc! {" + fn test() { do_wˇork(); } + fn do_work() { test(); } + "}); + let symbol_range = cx.lsp_range(indoc! {" + fn test() { «do_work»(); } + fn do_work() { test(); } + "}); + let target_range = cx.lsp_range(indoc! {" + fn test() { do_work(); } + fn «do_work»() { test(); } + "}); + + let mut requests = + cx.set_request_handler::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: Some(symbol_range), + target_uri: url.clone(), + target_range, + target_selection_range: target_range, + }, + ]))) + }); + + cx.simulate_mouse_move(hover_point, None, Modifiers::none()); + + // First simulate Normal pressure to set up the previous stage + cx.simulate_event(MousePressureEvent { + pressure: 0.5, + stage: PressureStage::Normal, + position: hover_point, + modifiers: Modifiers::none(), + }); + cx.background_executor.run_until_parked(); + + // Now simulate Force pressure to trigger the force click and go-to definition + cx.simulate_event(MousePressureEvent { + pressure: 1.0, + stage: PressureStage::Force, + position: hover_point, + modifiers: Modifiers::none(), + }); + requests.next().await; + cx.background_executor.run_until_parked(); + + // Assert that we navigated to the definition + cx.assert_editor_state(indoc! {" + fn test() { do_work(); } + fn «do_workˇ»() { test(); } + "}); + } } diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 0b9a25d3ee0fcb1cb67497bf51fe41ed73a3692e..64415005ec61b1ce942e4fbedaabc70919f5e61d 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -151,7 +151,7 @@ pub fn hover_at_inlay( false }) { - hide_hover(editor, cx); + return; } let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; @@ -518,7 +518,7 @@ fn show_hover( // Highlight the selected symbol using a background highlight editor.highlight_background::( &hover_highlights, - |theme| theme.colors().element_hover, // todo update theme + |_, theme| theme.colors().element_hover, // todo update theme cx, ); } @@ -607,23 +607,30 @@ async fn parse_blocks( pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { let settings = ThemeSettings::get_global(cx); let ui_font_family = settings.ui_font.family.clone(); + let ui_font_features = settings.ui_font.features.clone(); let ui_font_fallbacks = settings.ui_font.fallbacks.clone(); let buffer_font_family = settings.buffer_font.family.clone(); + let buffer_font_features = settings.buffer_font.features.clone(); let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone(); let mut base_text_style = window.text_style(); base_text_style.refine(&TextStyleRefinement { font_family: Some(ui_font_family), + font_features: Some(ui_font_features), font_fallbacks: ui_font_fallbacks, color: Some(cx.theme().colors().editor_foreground), ..Default::default() }); MarkdownStyle { base_text_style, - code_block: StyleRefinement::default().my(rems(1.)).font_buffer(cx), + code_block: StyleRefinement::default() + .my(rems(1.)) + .font_buffer(cx) + .font_features(buffer_font_features.clone()), inline_code: TextStyleRefinement { background_color: Some(cx.theme().colors().background), font_family: Some(buffer_font_family), + font_features: Some(buffer_font_features), font_fallbacks: buffer_font_fallbacks, ..Default::default() }, @@ -649,6 +656,7 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { .text_base() .mt(rems(1.)) .mb_0(), + table_columns_min_size: true, ..Default::default() } } @@ -657,12 +665,15 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { let settings = ThemeSettings::get_global(cx); let ui_font_family = settings.ui_font.family.clone(); let ui_font_fallbacks = settings.ui_font.fallbacks.clone(); + let ui_font_features = settings.ui_font.features.clone(); let buffer_font_family = settings.buffer_font.family.clone(); + let buffer_font_features = settings.buffer_font.features.clone(); let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone(); let mut base_text_style = window.text_style(); base_text_style.refine(&TextStyleRefinement { font_family: Some(ui_font_family), + font_features: Some(ui_font_features), font_fallbacks: ui_font_fallbacks, color: Some(cx.theme().colors().editor_foreground), ..Default::default() @@ -673,6 +684,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { inline_code: TextStyleRefinement { background_color: Some(cx.theme().colors().editor_background.opacity(0.5)), font_family: Some(buffer_font_family), + font_features: Some(buffer_font_features), font_fallbacks: buffer_font_fallbacks, ..Default::default() }, @@ -698,6 +710,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { .font_weight(FontWeight::BOLD) .text_base() .mb_0(), + table_columns_min_size: true, ..Default::default() } } diff --git a/crates/editor/src/indent_guides.rs b/crates/editor/src/indent_guides.rs index 7c392d27531472a413ce4d32d09cce4eb722e462..f186f9da77aca5a0d34cdc05272032f93862b1d2 100644 --- a/crates/editor/src/indent_guides.rs +++ b/crates/editor/src/indent_guides.rs @@ -181,6 +181,10 @@ pub fn indent_guides_in_range( .buffer_snapshot() .indent_guides_in_range(start_anchor..end_anchor, ignore_disabled_for_language, cx) .filter(|indent_guide| { + if editor.has_indent_guides_disabled_for_buffer(indent_guide.buffer_id) { + return false; + } + if editor.is_buffer_folded(indent_guide.buffer_id, cx) { return false; } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 8111c837e2ee5c35fdfb120999c2be49b09c468c..34b54795455a9612da04b54f43101f3dcf00efd9 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -17,8 +17,8 @@ use gpui::{ ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point, }; use language::{ - Bias, Buffer, BufferRow, CharKind, CharScopeContext, DiskState, LocalFile, Point, - SelectionGoal, proto::serialize_anchor as serialize_text_anchor, + Bias, Buffer, BufferRow, CharKind, CharScopeContext, LocalFile, Point, SelectionGoal, + proto::serialize_anchor as serialize_text_anchor, }; use lsp::DiagnosticSeverity; use multi_buffer::MultiBufferOffset; @@ -722,7 +722,7 @@ impl Item for Editor { .read(cx) .as_singleton() .and_then(|buffer| buffer.read(cx).file()) - .is_some_and(|file| file.disk_state() == DiskState::Deleted); + .is_some_and(|file| file.disk_state().is_deleted()); h_flex() .gap_2() @@ -842,7 +842,6 @@ impl Item for Editor { .map(|handle| handle.read(cx).base_buffer().unwrap_or(handle.clone())) .collect::>(); - // let mut buffers_to_save = let buffers_to_save = if self.buffer.read(cx).is_singleton() && !options.autosave { buffers } else { @@ -1487,6 +1486,7 @@ impl SearchableItem for Editor { fn update_matches( &mut self, matches: &[Range], + active_match_index: Option, _: &mut Window, cx: &mut Context, ) { @@ -1497,7 +1497,13 @@ impl SearchableItem for Editor { let updated = existing_range != Some(matches); self.highlight_background::( matches, - |theme| theme.colors().search_match_background, + move |index, theme| { + if active_match_index == Some(*index) { + theme.colors().search_active_match_background + } else { + theme.colors().search_match_background + } + }, cx, ); if updated { @@ -1891,15 +1897,20 @@ fn path_for_buffer<'a>( cx: &'a App, ) -> Option> { let file = buffer.read(cx).as_singleton()?.read(cx).file()?; - path_for_file(file.as_ref(), height, include_filename, cx) + path_for_file(file, height, include_filename, cx) } fn path_for_file<'a>( - file: &'a dyn language::File, + file: &'a Arc, mut height: usize, include_filename: bool, cx: &'a App, ) -> Option> { + if project::File::from_dyn(Some(file)).is_none() { + return None; + } + + let file = file.as_ref(); // Ensure we always render at least the filename. height += 1; @@ -1939,18 +1950,18 @@ mod tests { use super::*; use fs::MTime; use gpui::{App, VisualTestContext}; - use language::{LanguageMatcher, TestFile}; + use language::TestFile; use project::FakeFs; use std::path::{Path, PathBuf}; use util::{path, rel_path::RelPath}; #[gpui::test] fn test_path_for_file(cx: &mut App) { - let file = TestFile { + let file: Arc = Arc::new(TestFile { path: RelPath::empty().into(), root_name: String::new(), local_root: None, - }; + }); assert_eq!(path_for_file(&file, 0, false, cx), None); } @@ -1979,20 +1990,6 @@ mod tests { .unwrap() } - fn rust_language() -> Arc { - Arc::new(language::Language::new( - language::LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )) - } - #[gpui::test] async fn test_deserialize(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -2074,7 +2071,9 @@ mod tests { { let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await; // Add Rust to the language, so that we can restore the language of the buffer - project.read_with(cx, |project, _| project.languages().add(rust_language())); + project.read_with(cx, |project, _| { + project.languages().add(languages::rust_lang()) + }); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); diff --git a/crates/editor/src/jsx_tag_auto_close.rs b/crates/editor/src/jsx_tag_auto_close.rs index e22fde313df4b99b7b650775ad7e7397e3c4f813..1d808c968d579569fb595a5a1a0ddaa4dbc718b3 100644 --- a/crates/editor/src/jsx_tag_auto_close.rs +++ b/crates/editor/src/jsx_tag_auto_close.rs @@ -19,7 +19,7 @@ pub struct JsxTagCompletionState { /// that corresponds to the tag name /// Note that this is not configurable, i.e. we assume the first /// named child of a tag node is the tag name -const TS_NODE_TAG_NAME_CHILD_INDEX: usize = 0; +const TS_NODE_TAG_NAME_CHILD_INDEX: u32 = 0; /// Maximum number of parent elements to walk back when checking if an open tag /// is already closed. diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 39ad8a3672511724d69ba59a366513a24edb2198..1eaaff416ed2415ae147bac361261dc8a3b8bf06 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -59,7 +59,7 @@ impl MouseContextMenu { x: editor.gutter_dimensions.width, y: Pixels::ZERO, }; - let source_position = editor.to_pixel_point(source, &editor_snapshot, window)?; + let source_position = editor.to_pixel_point(source, &editor_snapshot, window, cx)?; let menu_position = MenuPosition::PinnedToEditor { source, offset: position - (source_position + content_origin), @@ -90,8 +90,8 @@ impl MouseContextMenu { // `true` when the `ContextMenu` is focused. let focus_handle = context_menu_focus.clone(); cx.on_next_frame(window, move |_, window, cx| { - cx.on_next_frame(window, move |_, window, _cx| { - window.focus(&focus_handle); + cx.on_next_frame(window, move |_, window, cx| { + window.focus(&focus_handle, cx); }); }); @@ -100,7 +100,7 @@ impl MouseContextMenu { move |editor, _, _event: &DismissEvent, window, cx| { editor.mouse_context_menu.take(); if context_menu_focus.contains_focused(window, cx) { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } } }); @@ -127,7 +127,7 @@ impl MouseContextMenu { } editor.mouse_context_menu.take(); if context_menu_focus.contains_focused(window, cx) { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); } }, ); @@ -161,12 +161,7 @@ pub fn deploy_context_menu( cx: &mut Context, ) { if !editor.is_focused(window) { - window.focus(&editor.focus_handle(cx)); - } - - // Don't show context menu for inline editors - if !editor.mode().is_full() { - return; + window.focus(&editor.focus_handle(cx), cx); } let display_map = editor.display_snapshot(cx); @@ -179,6 +174,11 @@ pub fn deploy_context_menu( }; menu } else { + // Don't show context menu for inline editors (only applies to default menu) + if !editor.mode().is_full() { + return; + } + // Don't show the context menu if there isn't a project associated with this editor let Some(project) = editor.project.clone() else { return; @@ -235,7 +235,10 @@ pub fn deploy_context_menu( .action("Go to Declaration", Box::new(GoToDeclaration)) .action("Go to Type Definition", Box::new(GoToTypeDefinition)) .action("Go to Implementation", Box::new(GoToImplementation)) - .action("Find All References", Box::new(FindAllReferences)) + .action( + "Find All References", + Box::new(FindAllReferences::default()), + ) .separator() .action("Rename Symbol", Box::new(Rename)) .action("Format Buffer", Box::new(Format)) @@ -277,7 +280,11 @@ pub fn deploy_context_menu( "Copy Permalink", Box::new(CopyPermalinkToLine), ) - .action_disabled_when(!has_git_repo, "File History", Box::new(git::FileHistory)); + .action_disabled_when( + !has_git_repo, + "View File History", + Box::new(git::FileHistory), + ); match focus { Some(focus) => builder.context(focus), None => builder, diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index a92735d18617057ddd10f049e5a22525827e1874..422be9a54e7cfcc40484e4093eeab6c94ce7d8ee 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -251,7 +251,11 @@ impl ScrollManager { Bias::Left, ) .to_point(map); - let top_anchor = map.buffer_snapshot().anchor_after(scroll_top_buffer_point); + // Anchor the scroll position to the *left* of the first visible buffer point. + // + // This prevents the viewport from shifting down when blocks (e.g. expanded diff hunk + // deletions) are inserted *above* the first buffer character in the file. + let top_anchor = map.buffer_snapshot().anchor_before(scroll_top_buffer_point); self.set_anchor( ScrollAnchor { diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 28fd9442193bbec663d3f72eaa805214375dd8ca..fc2ecb9205109532da2b43c97821b5352f27aff2 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -5,7 +5,7 @@ use crate::{ }; use gpui::{Bounds, Context, Pixels, Window}; use language::Point; -use multi_buffer::Anchor; +use multi_buffer::{Anchor, ToPoint}; use std::cmp; #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -186,6 +186,19 @@ impl Editor { } } + let style = self.style(cx).clone(); + let sticky_headers = self.sticky_headers(&style, cx).unwrap_or_default(); + let visible_sticky_headers = sticky_headers + .iter() + .filter(|h| { + let buffer_snapshot = display_map.buffer_snapshot(); + let buffer_range = + h.range.start.to_point(buffer_snapshot)..h.range.end.to_point(buffer_snapshot); + + buffer_range.contains(&Point::new(target_top as u32, 0)) + }) + .count(); + let margin = if matches!(self.mode, EditorMode::AutoHeight { .. }) { 0. } else { @@ -218,7 +231,7 @@ impl Editor { let was_autoscrolled = match strategy { AutoscrollStrategy::Fit | AutoscrollStrategy::Newest => { let margin = margin.min(self.scroll_manager.vertical_scroll_margin); - let target_top = (target_top - margin).max(0.0); + let target_top = (target_top - margin - visible_sticky_headers as f64).max(0.0); let target_bottom = target_bottom + margin; let start_row = scroll_position.y; let end_row = start_row + visible_lines; diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index f8ff9da763403b0946e99a4e39c934ff43ad6634..54bb7ceec1d035fbefb0c229c4e537e8277b67cd 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -136,7 +136,13 @@ impl SelectionsCollection { iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { - if pending.start <= next_selection.end && pending.end >= next_selection.start { + if should_merge( + pending.start, + pending.end, + next_selection.start, + next_selection.end, + false, + ) { let next_selection = disjoint.next().unwrap(); if next_selection.start < pending.start { pending.start = next_selection.start; @@ -236,7 +242,13 @@ impl SelectionsCollection { iter::from_fn(move || { if let Some(pending) = pending_opt.as_mut() { while let Some(next_selection) = disjoint.peek() { - if pending.start <= next_selection.end && pending.end >= next_selection.start { + if should_merge( + pending.start, + pending.end, + next_selection.start, + next_selection.end, + false, + ) { let next_selection = disjoint.next().unwrap(); if next_selection.start < pending.start { pending.start = next_selection.start; @@ -419,22 +431,30 @@ impl SelectionsCollection { mutable_collection.disjoint.iter().for_each(|selection| { assert!( snapshot.can_resolve(&selection.start), - "disjoint selection start is not resolvable for the given snapshot:\n{selection:?}", + "disjoint selection start is not resolvable for the given snapshot:\n{selection:?}, {excerpt:?}", + excerpt = snapshot.buffer_for_excerpt(selection.start.excerpt_id).map(|snapshot| snapshot.remote_id()), ); assert!( snapshot.can_resolve(&selection.end), - "disjoint selection end is not resolvable for the given snapshot: {selection:?}", + "disjoint selection end is not resolvable for the given snapshot: {selection:?}, {excerpt:?}", + excerpt = snapshot.buffer_for_excerpt(selection.end.excerpt_id).map(|snapshot| snapshot.remote_id()), ); }); if let Some(pending) = &mutable_collection.pending { let selection = &pending.selection; assert!( snapshot.can_resolve(&selection.start), - "pending selection start is not resolvable for the given snapshot: {pending:?}", + "pending selection start is not resolvable for the given snapshot: {pending:?}, {excerpt:?}", + excerpt = snapshot + .buffer_for_excerpt(selection.start.excerpt_id) + .map(|snapshot| snapshot.remote_id()), ); assert!( snapshot.can_resolve(&selection.end), - "pending selection end is not resolvable for the given snapshot: {pending:?}", + "pending selection end is not resolvable for the given snapshot: {pending:?}, {excerpt:?}", + excerpt = snapshot + .buffer_for_excerpt(selection.end.excerpt_id) + .map(|snapshot| snapshot.remote_id()), ); } } @@ -532,11 +552,18 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { }; if filtered_selections.is_empty() { - let default_anchor = self.snapshot.anchor_before(MultiBufferOffset(0)); + let buffer_snapshot = self.snapshot.buffer_snapshot(); + let anchor = buffer_snapshot + .excerpts() + .find(|(_, buffer, _)| buffer.remote_id() == buffer_id) + .and_then(|(excerpt_id, _, range)| { + buffer_snapshot.anchor_in_excerpt(excerpt_id, range.context.start) + }) + .unwrap_or_else(|| self.snapshot.anchor_before(MultiBufferOffset(0))); self.collection.disjoint = Arc::from([Selection { id: post_inc(&mut self.collection.next_selection_id), - start: default_anchor, - end: default_anchor, + start: anchor, + end: anchor, reversed: false, goal: SelectionGoal::None, }]); @@ -651,10 +678,13 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> { }) .collect::>(); selections.sort_unstable_by_key(|s| s.start); - // Merge overlapping selections. + let mut i = 1; while i < selections.len() { - if selections[i].start <= selections[i - 1].end { + let prev = &selections[i - 1]; + let current = &selections[i]; + + if should_merge(prev.start, prev.end, current.start, current.end, true) { let removed = selections.remove(i); if removed.start < selections[i - 1].start { selections[i - 1].start = removed.start; @@ -1124,7 +1154,13 @@ fn coalesce_selections( iter::from_fn(move || { let mut selection = selections.next()?; while let Some(next_selection) = selections.peek() { - if selection.end >= next_selection.start { + if should_merge( + selection.start, + selection.end, + next_selection.start, + next_selection.end, + true, + ) { if selection.reversed == next_selection.reversed { selection.end = cmp::max(selection.end, next_selection.end); selections.next(); @@ -1146,3 +1182,35 @@ fn coalesce_selections( Some(selection) }) } + +/// Determines whether two selections should be merged into one. +/// +/// Two selections should be merged when: +/// 1. They overlap: the selections share at least one position +/// 2. They have the same start position: one contains or equals the other +/// 3. A cursor touches a selection boundary: a zero-width selection (cursor) at the +/// start or end of another selection should be absorbed into it +/// +/// Note: two selections that merely touch (one ends exactly where the other begins) +/// but don't share any positions remain separate, see: https://github.com/zed-industries/zed/issues/24748 +fn should_merge(a_start: T, a_end: T, b_start: T, b_end: T, sorted: bool) -> bool { + let is_overlapping = if sorted { + // When sorted, `a` starts before or at `b`, so overlap means `b` starts before `a` ends + b_start < a_end + } else { + a_start < b_end && b_start < a_end + }; + + // Selections starting at the same position should always merge (one contains the other) + let same_start = a_start == b_start; + + // A cursor (zero-width selection) touching another selection's boundary should merge. + // This handles cases like a cursor at position X merging with a selection that + // starts or ends at X. + let is_cursor_a = a_start == a_end; + let is_cursor_b = b_start == b_end; + let cursor_at_boundary = (is_cursor_a && (a_start == b_start || a_end == b_end)) + || (is_cursor_b && (b_start == a_start || b_end == a_end)); + + is_overlapping || same_start || cursor_at_boundary +} diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index e99f56b8803ee2931fed2406c18cb5bb003be37b..389c3527025156d331bb05ac08c9f147f63f052b 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -211,7 +211,7 @@ impl SplittableEditor { self.primary_editor.update(cx, |editor, cx| { editor.buffer().update(cx, |primary_multibuffer, cx| { primary_multibuffer.set_show_deleted_hunks(false, cx); - let paths = primary_multibuffer.paths().collect::>(); + let paths = primary_multibuffer.paths().cloned().collect::>(); for path in paths { let Some(excerpt_id) = primary_multibuffer.excerpts_for_path(&path).next() else { @@ -220,7 +220,7 @@ impl SplittableEditor { let snapshot = primary_multibuffer.snapshot(cx); let buffer = snapshot.buffer_for_excerpt(excerpt_id).unwrap(); let diff = primary_multibuffer.diff_for(buffer.remote_id()).unwrap(); - secondary.sync_path_excerpts(path, primary_multibuffer, diff, cx); + secondary.sync_path_excerpts(path.clone(), primary_multibuffer, diff, cx); } }) }); @@ -228,7 +228,7 @@ impl SplittableEditor { let primary_pane = self.panes.first_pane(); self.panes - .split(&primary_pane, &secondary_pane, SplitDirection::Left) + .split(&primary_pane, &secondary_pane, SplitDirection::Left, cx) .unwrap(); cx.notify(); } @@ -237,7 +237,7 @@ impl SplittableEditor { let Some(secondary) = self.secondary.take() else { return; }; - self.panes.remove(&secondary.pane).unwrap(); + self.panes.remove(&secondary.pane, cx).unwrap(); self.primary_editor.update(cx, |primary, cx| { primary.buffer().update(cx, |buffer, cx| { buffer.set_show_deleted_hunks(true, cx); @@ -308,7 +308,7 @@ impl SplittableEditor { corresponding_paths = excerpt_ids .clone() .map(|excerpt_id| { - let path = multibuffer.path_for_excerpt(excerpt_id).cloned().unwrap(); + let path = multibuffer.path_for_excerpt(excerpt_id).unwrap(); let buffer = snapshot.buffer_for_excerpt(excerpt_id).unwrap(); let diff = multibuffer.diff_for(buffer.remote_id()).unwrap(); (path, diff) @@ -465,6 +465,7 @@ impl SplittableEditor { .primary_multibuffer .read(cx) .paths() + .cloned() .collect::>(); let excerpt_ids = self.primary_multibuffer.read(cx).excerpt_ids(); @@ -519,7 +520,7 @@ impl SplittableEditor { .cloned() .collect::>(); for path in paths_to_remove { - self.remove_excerpts_for_path(path, cx); + self.remove_excerpts_for_path(path.clone(), cx); } } } diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index 5a0652bdd199a638f92234b1d50232071db18e07..1cc619385446502db6a3a0dceb6e70fa4b4e8416 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -176,11 +176,9 @@ pub fn block_content_for_tests( } pub fn editor_content_with_blocks(editor: &Entity, cx: &mut VisualTestContext) -> String { - cx.draw( - gpui::Point::default(), - size(px(3000.0), px(3000.0)), - |_, _| editor.clone(), - ); + let draw_size = size(px(3000.0), px(3000.0)); + cx.simulate_resize(draw_size); + cx.draw(gpui::Point::default(), draw_size, |_, _| editor.clone()); let (snapshot, mut lines, blocks) = editor.update_in(cx, |editor, window, cx| { let snapshot = editor.snapshot(window, cx); let text = editor.display_text(cx); diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 3afe0e6134221fc69837abd30618f2b74ae069f5..3e7c47c2ac5efeedde51f180bcfcb424aec31c86 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -126,7 +126,7 @@ impl EditorLspTestContext { .read(cx) .nav_history_for_item(&cx.entity()); editor.set_nav_history(Some(nav_history)); - window.focus(&editor.focus_handle(cx)) + window.focus(&editor.focus_handle(cx), cx) }); let lsp = fake_servers.next().await.unwrap(); @@ -205,6 +205,49 @@ impl EditorLspTestContext { (_ "{" "}" @end) @indent (_ "(" ")" @end) @indent "#})), + text_objects: Some(Cow::from(indoc! {r#" + (function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + + (method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + + ; Arrow function in variable declaration - capture the full declaration + ([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + ]) @function.around + + ([ + (lexical_declaration + (variable_declarator + value: (arrow_function))) + (variable_declaration + (variable_declarator + value: (arrow_function))) + ]) @function.around + + ; Catch-all for arrow functions in other contexts (callbacks, etc.) + ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator)) + "#})), ..Default::default() }) .expect("Could not parse queries"); @@ -276,6 +319,49 @@ impl EditorLspTestContext { (jsx_opening_element) @start (jsx_closing_element)? @end) @indent "#})), + text_objects: Some(Cow::from(indoc! {r#" + (function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + + (method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + + ; Arrow function in variable declaration - capture the full declaration + ([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")))) + ]) @function.around + + ([ + (lexical_declaration + (variable_declarator + value: (arrow_function))) + (variable_declaration + (variable_declarator + value: (arrow_function))) + ]) @function.around + + ; Catch-all for arrow functions in other contexts (callbacks, etc.) + ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator)) + "#})), ..Default::default() }) .expect("Could not parse queries"); diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index cd45a6ec47ad7631404189194a6a0291a6240647..267058691d0070678830ba9d7c40f54a9363737b 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -78,7 +78,7 @@ impl EditorTestContext { cx, ); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); let editor_view = editor.root(cx).unwrap(); @@ -139,7 +139,7 @@ impl EditorTestContext { let editor = cx.add_window(|window, cx| { let editor = build_editor(buffer, window, cx); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor }); @@ -283,8 +283,7 @@ impl EditorTestContext { .head(); let pixel_position = editor.pixel_position_of_newest_cursor.unwrap(); let line_height = editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()); let snapshot = editor.snapshot(window, cx); @@ -306,6 +305,12 @@ impl EditorTestContext { snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end) } + pub async fn wait_for_autoindent_applied(&mut self) { + if let Some(fut) = self.update_buffer(|buffer, _| buffer.wait_for_autoindent_applied()) { + fut.await.ok(); + } + } + pub fn set_head_text(&mut self, diff_base: &str) { self.cx.run_until_parked(); let fs = diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index 056e36f8330dd3c2c0d01c26653014637f7a2732..b6f93bcc875a849bbdec60c60b595fbcd1b8c5d8 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -261,7 +261,7 @@ impl ExampleContext { .expect("Unknown tool_name content in meta"); tool_uses_by_id.insert( - tool_call.id, + tool_call.tool_call_id, ToolUse { name: tool_name.to_string(), value: tool_call.raw_input.unwrap_or_default(), @@ -277,7 +277,9 @@ impl ExampleContext { ThreadEvent::ToolCallUpdate(tool_call_update) => { if let acp_thread::ToolCallUpdate::UpdateFields(update) = tool_call_update { if let Some(raw_input) = update.fields.raw_input { - if let Some(tool_use) = tool_uses_by_id.get_mut(&update.id) { + if let Some(tool_use) = + tool_uses_by_id.get_mut(&update.tool_call_id) + { tool_use.value = raw_input; } } @@ -290,7 +292,7 @@ impl ExampleContext { update.fields.status == Some(acp::ToolCallStatus::Completed); let tool_use = tool_uses_by_id - .remove(&update.id) + .remove(&update.tool_call_id) .expect("Unrecognized tool call completed"); let log_message = if succeeded { @@ -337,10 +339,7 @@ impl ExampleContext { acp::StopReason::MaxTurnRequests => { return Err(anyhow!("Exceeded maximum turn requests")); } - acp::StopReason::Refusal => { - return Err(anyhow!("Refusal")); - } - acp::StopReason::Cancelled => return Err(anyhow!("Cancelled")), + stop_reason => return Err(anyhow!("{stop_reason:?}")), }, } } diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index 99a8af053609b98efe29a179964a38137c4ba021..8c9da3eefab61e4fa5897f9d76123c3fe1d5fa8b 100644 --- a/crates/eval/src/instance.rs +++ b/crates/eval/src/instance.rs @@ -202,6 +202,7 @@ impl ExampleInstance { app_state.languages.clone(), app_state.fs.clone(), None, + false, cx, ); @@ -303,13 +304,12 @@ impl ExampleInstance { let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let thread = if let Some(json) = &meta.existing_thread_json { - let session_id = acp::SessionId( + let session_id = acp::SessionId::new( rand::rng() .sample_iter(&distr::Alphanumeric) .take(7) .map(char::from) - .collect::() - .into(), + .collect::(), ); let db_thread = agent::DbThread::from_json(json.as_bytes()).expect("Can't read serialized thread"); @@ -626,6 +626,15 @@ impl agent::TerminalHandle for EvalTerminalHandle { self.terminal .read_with(cx, |term, cx| term.current_output(cx)) } + + fn kill(&self, cx: &AsyncApp) -> Result<()> { + cx.update(|cx| { + self.terminal.update(cx, |terminal, cx| { + terminal.kill(cx); + }); + })?; + Ok(()) + } } impl agent::ThreadEnvironment for EvalThreadEnvironment { @@ -640,7 +649,7 @@ impl agent::ThreadEnvironment for EvalThreadEnvironment { cx.spawn(async move |cx| { let language_registry = project.read_with(cx, |project, _cx| project.languages().clone())?; - let id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let terminal = acp_thread::create_terminal_entity(command, &[], vec![], cwd.clone(), &project, cx) .await?; @@ -893,7 +902,7 @@ pub fn wait_for_lang_server( .update(cx, |buffer, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store - .language_servers_for_local_buffer(buffer, cx) + .running_language_servers_for_local_buffer(buffer, cx) .next() .is_some() }) diff --git a/crates/eval_utils/Cargo.toml b/crates/eval_utils/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a512035f5d1754f0f6f942faa27d063e169a22ef --- /dev/null +++ b/crates/eval_utils/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "eval_utils" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/eval_utils.rs" +doctest = false + +[dependencies] +gpui.workspace = true +serde.workspace = true +smol.workspace = true diff --git a/crates/zeta_cli/LICENSE-GPL b/crates/eval_utils/LICENSE-GPL similarity index 100% rename from crates/zeta_cli/LICENSE-GPL rename to crates/eval_utils/LICENSE-GPL diff --git a/crates/eval_utils/README.md b/crates/eval_utils/README.md new file mode 100644 index 0000000000000000000000000000000000000000..617077a81524ff918e8b9b93aa970d636504479c --- /dev/null +++ b/crates/eval_utils/README.md @@ -0,0 +1,3 @@ +# eval_utils + +Utilities for evals of agents. diff --git a/crates/eval_utils/src/eval_utils.rs b/crates/eval_utils/src/eval_utils.rs new file mode 100644 index 0000000000000000000000000000000000000000..be3294ed1490d6a602c3a5282d25dbba7d065443 --- /dev/null +++ b/crates/eval_utils/src/eval_utils.rs @@ -0,0 +1,146 @@ +//! Utilities for evaluation and benchmarking. + +use std::{ + collections::HashMap, + sync::{Arc, mpsc}, +}; + +fn report_progress(evaluated_count: usize, failed_count: usize, iterations: usize) { + let passed_count = evaluated_count - failed_count; + let passed_ratio = if evaluated_count == 0 { + 0.0 + } else { + passed_count as f64 / evaluated_count as f64 + }; + println!( + "\r\x1b[KEvaluated {}/{} ({:.2}% passed)", + evaluated_count, + iterations, + passed_ratio * 100.0 + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OutcomeKind { + Passed, + Failed, + Error, +} + +pub trait EvalOutputProcessor { + type Metadata: 'static + Send; + fn process(&mut self, output: &EvalOutput); + fn assert(&mut self); +} + +#[derive(Clone, Debug)] +pub struct EvalOutput { + pub outcome: OutcomeKind, + pub data: String, + pub metadata: M, +} + +impl EvalOutput { + pub fn passed(message: impl Into) -> Self { + EvalOutput { + outcome: OutcomeKind::Passed, + data: message.into(), + metadata: M::default(), + } + } + + pub fn failed(message: impl Into) -> Self { + EvalOutput { + outcome: OutcomeKind::Failed, + data: message.into(), + metadata: M::default(), + } + } +} + +pub struct NoProcessor; +impl EvalOutputProcessor for NoProcessor { + type Metadata = (); + + fn process(&mut self, _output: &EvalOutput) {} + + fn assert(&mut self) {} +} + +pub fn eval

( + iterations: usize, + expected_pass_ratio: f32, + mut processor: P, + evalf: impl Fn() -> EvalOutput + Send + Sync + 'static, +) where + P: EvalOutputProcessor, +{ + let mut evaluated_count = 0; + let mut failed_count = 0; + let evalf = Arc::new(evalf); + report_progress(evaluated_count, failed_count, iterations); + + let (tx, rx) = mpsc::channel(); + + let executor = gpui::background_executor(); + let semaphore = Arc::new(smol::lock::Semaphore::new(32)); + let evalf = Arc::new(evalf); + // Warm the cache once + let first_output = evalf(); + tx.send(first_output).ok(); + + for _ in 1..iterations { + let tx = tx.clone(); + let semaphore = semaphore.clone(); + let evalf = evalf.clone(); + executor + .spawn(async move { + let _guard = semaphore.acquire().await; + let output = evalf(); + tx.send(output).ok(); + }) + .detach(); + } + drop(tx); + + let mut failed_evals = Vec::new(); + let mut errored_evals = HashMap::new(); + while let Ok(output) = rx.recv() { + processor.process(&output); + + match output.outcome { + OutcomeKind::Passed => {} + OutcomeKind::Failed => { + failed_count += 1; + failed_evals.push(output); + } + OutcomeKind::Error => { + failed_count += 1; + *errored_evals.entry(output.data).or_insert(0) += 1; + } + } + + evaluated_count += 1; + report_progress(evaluated_count, failed_count, iterations); + } + + let actual_pass_ratio = (iterations - failed_count) as f32 / iterations as f32; + println!("Actual pass ratio: {}\n", actual_pass_ratio); + if actual_pass_ratio < expected_pass_ratio { + for (error, count) in errored_evals { + println!("Eval errored {} times. Error: {}", count, error); + } + + for failed in failed_evals { + println!("Eval failed"); + println!("{}", failed.data); + } + + panic!( + "Actual pass ratio: {}\nExpected pass ratio: {}", + actual_pass_ratio, expected_pass_ratio + ); + } + + processor.assert(); +} diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index ed084a0c1c0d6ea237dd06391330d8e41917b574..307a3a19bd5ec6502270ae2f579cbd6b6f378746 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -37,4 +37,8 @@ wasm-encoder.workspace = true wasmparser.workspace = true [dev-dependencies] +fs = { workspace = true, "features" = ["test-support"] } +gpui = { workspace = true, "features" = ["test-support"] } +indoc.workspace = true pretty_assertions.workspace = true +tempfile.workspace = true diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index 1385ec488a2de36f5894ab91ac0ae45881aa625f..8b9bf994d17e0594c719bed29907630fedf11497 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -2,8 +2,9 @@ use crate::{ ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, build_debug_adapter_schema_path, parse_wasm_extension_version, }; +use ::fs::Fs; use anyhow::{Context as _, Result, bail}; -use futures::AsyncReadExt; +use futures::{AsyncReadExt, StreamExt}; use heck::ToSnakeCase; use http_client::{self, AsyncBody, HttpClient}; use serde::Deserialize; @@ -77,8 +78,9 @@ impl ExtensionBuilder { extension_dir: &Path, extension_manifest: &mut ExtensionManifest, options: CompileExtensionOptions, + fs: Arc, ) -> Result<()> { - populate_defaults(extension_manifest, extension_dir)?; + populate_defaults(extension_manifest, extension_dir, fs).await?; if extension_dir.is_relative() { bail!( @@ -247,26 +249,34 @@ impl ExtensionBuilder { let parser_path = src_path.join("parser.c"); let scanner_path = src_path.join("scanner.c"); - log::info!("compiling {grammar_name} parser"); - let clang_output = util::command::new_smol_command(&clang_path) - .args(["-fPIC", "-shared", "-Os"]) - .arg(format!("-Wl,--export=tree_sitter_{grammar_name}")) - .arg("-o") - .arg(&grammar_wasm_path) - .arg("-I") - .arg(&src_path) - .arg(&parser_path) - .args(scanner_path.exists().then_some(scanner_path)) - .output() - .await - .context("failed to run clang")?; - - if !clang_output.status.success() { - bail!( - "failed to compile {} parser with clang: {}", - grammar_name, - String::from_utf8_lossy(&clang_output.stderr), + // Skip recompiling if the WASM object is already newer than the source files + if file_newer_than_deps(&grammar_wasm_path, &[&parser_path, &scanner_path]).unwrap_or(false) + { + log::info!( + "skipping compilation of {grammar_name} parser because the existing compiled grammar is up to date" ); + } else { + log::info!("compiling {grammar_name} parser"); + let clang_output = util::command::new_smol_command(&clang_path) + .args(["-fPIC", "-shared", "-Os"]) + .arg(format!("-Wl,--export=tree_sitter_{grammar_name}")) + .arg("-o") + .arg(&grammar_wasm_path) + .arg("-I") + .arg(&src_path) + .arg(&parser_path) + .args(scanner_path.exists().then_some(scanner_path)) + .output() + .await + .context("failed to run clang")?; + + if !clang_output.status.success() { + bail!( + "failed to compile {} parser with clang: {}", + grammar_name, + String::from_utf8_lossy(&clang_output.stderr), + ); + } } Ok(()) @@ -538,7 +548,11 @@ impl ExtensionBuilder { } } -fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> Result<()> { +async fn populate_defaults( + manifest: &mut ExtensionManifest, + extension_path: &Path, + fs: Arc, +) -> Result<()> { // For legacy extensions on the v0 schema (aka, using `extension.json`), clear out any existing // contents of the computed fields, since we don't care what the existing values are. if manifest.schema_version.is_v0() { @@ -553,12 +567,16 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> } let languages_dir = extension_path.join("languages"); - if languages_dir.exists() { - for entry in fs::read_dir(&languages_dir).context("failed to list languages dir")? { - let entry = entry?; - let language_dir = entry.path(); + if fs.is_dir(&languages_dir).await { + let mut language_dir_entries = fs + .read_dir(&languages_dir) + .await + .context("failed to list languages dir")?; + + while let Some(language_dir) = language_dir_entries.next().await { + let language_dir = language_dir?; let config_path = language_dir.join("config.toml"); - if config_path.exists() { + if fs.is_file(config_path.as_path()).await { let relative_language_dir = language_dir.strip_prefix(extension_path)?.to_path_buf(); if !manifest.languages.contains(&relative_language_dir) { @@ -569,10 +587,14 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> } let themes_dir = extension_path.join("themes"); - if themes_dir.exists() { - for entry in fs::read_dir(&themes_dir).context("failed to list themes dir")? { - let entry = entry?; - let theme_path = entry.path(); + if fs.is_dir(&themes_dir).await { + let mut theme_dir_entries = fs + .read_dir(&themes_dir) + .await + .context("failed to list themes dir")?; + + while let Some(theme_path) = theme_dir_entries.next().await { + let theme_path = theme_path?; if theme_path.extension() == Some("json".as_ref()) { let relative_theme_path = theme_path.strip_prefix(extension_path)?.to_path_buf(); if !manifest.themes.contains(&relative_theme_path) { @@ -583,10 +605,14 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> } let icon_themes_dir = extension_path.join("icon_themes"); - if icon_themes_dir.exists() { - for entry in fs::read_dir(&icon_themes_dir).context("failed to list icon themes dir")? { - let entry = entry?; - let icon_theme_path = entry.path(); + if fs.is_dir(&icon_themes_dir).await { + let mut icon_theme_dir_entries = fs + .read_dir(&icon_themes_dir) + .await + .context("failed to list icon themes dir")?; + + while let Some(icon_theme_path) = icon_theme_dir_entries.next().await { + let icon_theme_path = icon_theme_path?; if icon_theme_path.extension() == Some("json".as_ref()) { let relative_icon_theme_path = icon_theme_path.strip_prefix(extension_path)?.to_path_buf(); @@ -595,21 +621,26 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> } } } - } - - let snippets_json_path = extension_path.join("snippets.json"); - if snippets_json_path.exists() { - manifest.snippets = Some(snippets_json_path); + }; + if manifest.snippets.is_none() + && let snippets_json_path = extension_path.join("snippets.json") + && fs.is_file(&snippets_json_path).await + { + manifest.snippets = Some("snippets.json".into()); } // For legacy extensions on the v0 schema (aka, using `extension.json`), we want to populate the grammars in // the manifest using the contents of the `grammars` directory. if manifest.schema_version.is_v0() { let grammars_dir = extension_path.join("grammars"); - if grammars_dir.exists() { - for entry in fs::read_dir(&grammars_dir).context("failed to list grammars dir")? { - let entry = entry?; - let grammar_path = entry.path(); + if fs.is_dir(&grammars_dir).await { + let mut grammar_dir_entries = fs + .read_dir(&grammars_dir) + .await + .context("failed to list grammars dir")?; + + while let Some(grammar_path) = grammar_dir_entries.next().await { + let grammar_path = grammar_path?; if grammar_path.extension() == Some("toml".as_ref()) { #[derive(Deserialize)] struct GrammarConfigToml { @@ -619,7 +650,7 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> pub path: Option, } - let grammar_config = fs::read_to_string(&grammar_path)?; + let grammar_config = fs.load(&grammar_path).await?; let grammar_config: GrammarConfigToml = toml::from_str(&grammar_config)?; let grammar_name = grammar_path @@ -643,3 +674,153 @@ fn populate_defaults(manifest: &mut ExtensionManifest, extension_path: &Path) -> Ok(()) } + +/// Returns `true` if the target exists and its last modified time is greater than that +/// of each dependency which exists (i.e., dependency paths which do not exist are ignored). +/// +/// # Errors +/// +/// Returns `Err` if any of the underlying file I/O operations fail. +fn file_newer_than_deps(target: &Path, dependencies: &[&Path]) -> Result { + if !target.try_exists()? { + return Ok(false); + } + let target_modified = target.metadata()?.modified()?; + for dependency in dependencies { + if !dependency.try_exists()? { + continue; + } + let dep_modified = dependency.metadata()?.modified()?; + if target_modified < dep_modified { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use std::{ + path::{Path, PathBuf}, + str::FromStr, + thread::sleep, + time::Duration, + }; + + use gpui::TestAppContext; + use indoc::indoc; + + use crate::{ + ExtensionManifest, + extension_builder::{file_newer_than_deps, populate_defaults}, + }; + + #[test] + fn test_file_newer_than_deps() { + // Don't use TempTree because we need to guarantee the order + let tmpdir = tempfile::tempdir().unwrap(); + let target = tmpdir.path().join("target.wasm"); + let dep1 = tmpdir.path().join("parser.c"); + let dep2 = tmpdir.path().join("scanner.c"); + + assert!( + !file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(), + "target doesn't exist" + ); + std::fs::write(&target, "foo").unwrap(); // Create target + assert!( + file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(), + "dependencies don't exist; target is newer" + ); + sleep(Duration::from_secs(1)); + std::fs::write(&dep1, "foo").unwrap(); // Create dep1 (newer than target) + // Dependency is newer + assert!( + !file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(), + "a dependency is newer (target {:?}, dep1 {:?})", + target.metadata().unwrap().modified().unwrap(), + dep1.metadata().unwrap().modified().unwrap(), + ); + sleep(Duration::from_secs(1)); + std::fs::write(&dep2, "foo").unwrap(); // Create dep2 + sleep(Duration::from_secs(1)); + std::fs::write(&target, "foobar").unwrap(); // Update target + assert!( + file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(), + "target is newer than dependencies (target {:?}, dep2 {:?})", + target.metadata().unwrap().modified().unwrap(), + dep2.metadata().unwrap().modified().unwrap(), + ); + } + + #[gpui::test] + async fn test_snippet_location_is_kept(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let extension_path = Path::new("/extension"); + + fs.insert_tree( + extension_path, + serde_json::json!({ + "extension.toml": indoc! {r#" + id = "test-manifest" + name = "Test Manifest" + version = "0.0.1" + schema_version = 1 + + snippets = "./snippets/snippets.json" + "# + }, + "snippets.json": "", + }), + ) + .await; + + let mut manifest = ExtensionManifest::load(fs.clone(), extension_path) + .await + .unwrap(); + + populate_defaults(&mut manifest, extension_path, fs.clone()) + .await + .unwrap(); + + assert_eq!( + manifest.snippets, + Some(PathBuf::from_str("./snippets/snippets.json").unwrap()) + ) + } + + #[gpui::test] + async fn test_automatic_snippet_location_is_relative(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); + let extension_path = Path::new("/extension"); + + fs.insert_tree( + extension_path, + serde_json::json!({ + "extension.toml": indoc! {r#" + id = "test-manifest" + name = "Test Manifest" + version = "0.0.1" + schema_version = 1 + + "# + }, + "snippets.json": "", + }), + ) + .await; + + let mut manifest = ExtensionManifest::load(fs.clone(), extension_path) + .await + .unwrap(); + + populate_defaults(&mut manifest, extension_path, fs.clone()) + .await + .unwrap(); + + assert_eq!( + manifest.snippets, + Some(PathBuf::from_str("snippets.json").unwrap()) + ) + } +} diff --git a/crates/extension/src/extension_host_proxy.rs b/crates/extension/src/extension_host_proxy.rs index 6a24e3ba3f496bd0f0b89d61e9125b29ecae0204..b445878389015d4b3b8c3e25a0d103586462fd86 100644 --- a/crates/extension/src/extension_host_proxy.rs +++ b/crates/extension/src/extension_host_proxy.rs @@ -19,6 +19,9 @@ impl Global for GlobalExtensionHostProxy {} /// /// This object implements each of the individual proxy types so that their /// methods can be called directly on it. +/// Registration function for language model providers. +pub type LanguageModelProviderRegistration = Box; + #[derive(Default)] pub struct ExtensionHostProxy { theme_proxy: RwLock>>, @@ -29,6 +32,7 @@ pub struct ExtensionHostProxy { slash_command_proxy: RwLock>>, context_server_proxy: RwLock>>, debug_adapter_provider_proxy: RwLock>>, + language_model_provider_proxy: RwLock>>, } impl ExtensionHostProxy { @@ -54,6 +58,7 @@ impl ExtensionHostProxy { slash_command_proxy: RwLock::default(), context_server_proxy: RwLock::default(), debug_adapter_provider_proxy: RwLock::default(), + language_model_provider_proxy: RwLock::default(), } } @@ -90,6 +95,15 @@ impl ExtensionHostProxy { .write() .replace(Arc::new(proxy)); } + + pub fn register_language_model_provider_proxy( + &self, + proxy: impl ExtensionLanguageModelProviderProxy, + ) { + self.language_model_provider_proxy + .write() + .replace(Arc::new(proxy)); + } } pub trait ExtensionThemeProxy: Send + Sync + 'static { @@ -446,3 +460,37 @@ impl ExtensionDebugAdapterProviderProxy for ExtensionHostProxy { proxy.unregister_debug_locator(locator_name) } } + +pub trait ExtensionLanguageModelProviderProxy: Send + Sync + 'static { + fn register_language_model_provider( + &self, + provider_id: Arc, + register_fn: LanguageModelProviderRegistration, + cx: &mut App, + ); + + fn unregister_language_model_provider(&self, provider_id: Arc, cx: &mut App); +} + +impl ExtensionLanguageModelProviderProxy for ExtensionHostProxy { + fn register_language_model_provider( + &self, + provider_id: Arc, + register_fn: LanguageModelProviderRegistration, + cx: &mut App, + ) { + let Some(proxy) = self.language_model_provider_proxy.read().clone() else { + return; + }; + + proxy.register_language_model_provider(provider_id, register_fn, cx) + } + + fn unregister_language_model_provider(&self, provider_id: Arc, cx: &mut App) { + let Some(proxy) = self.language_model_provider_proxy.read().clone() else { + return; + }; + + proxy.unregister_language_model_provider(provider_id, cx) + } +} diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index 4ecdd378ca86dbee263e439e13fa4776dab9e316..39b629db30d0d1cee3374dafc317bdeb0f368146 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -93,6 +93,8 @@ pub struct ExtensionManifest { pub debug_adapters: BTreeMap, DebugAdapterManifestEntry>, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub debug_locators: BTreeMap, DebugLocatorManifestEntry>, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub language_model_providers: BTreeMap, LanguageModelProviderManifestEntry>, } impl ExtensionManifest { @@ -288,6 +290,16 @@ pub struct DebugAdapterManifestEntry { #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] pub struct DebugLocatorManifestEntry {} +/// Manifest entry for a language model provider. +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct LanguageModelProviderManifestEntry { + /// Display name for the provider. + pub name: String, + /// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg"). + #[serde(default)] + pub icon: Option, +} + impl ExtensionManifest { pub async fn load(fs: Arc, extension_dir: &Path) -> Result { let extension_name = extension_dir @@ -358,6 +370,7 @@ fn manifest_from_old_manifest( capabilities: Vec::new(), debug_adapters: Default::default(), debug_locators: Default::default(), + language_model_providers: Default::default(), } } @@ -391,6 +404,7 @@ mod tests { capabilities: vec![], debug_adapters: Default::default(), debug_locators: Default::default(), + language_model_providers: BTreeMap::default(), } } diff --git a/crates/extension_api/Cargo.toml b/crates/extension_api/Cargo.toml index 318a0024bf4d9bae76af888b6668d7c21f37f804..829455e62912883bea85f429a1a8917e6360d0fb 100644 --- a/crates/extension_api/Cargo.toml +++ b/crates/extension_api/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "zed_extension_api" -version = "0.7.0" +version = "0.8.0" description = "APIs for creating Zed extensions in Rust" repository = "https://github.com/zed-industries/zed" documentation = "https://docs.rs/zed_extension_api" keywords = ["zed", "extension"] edition.workspace = true -publish = true +# Change back to `true` when we're ready to publish v0.8.0. +publish = false license = "Apache-2.0" [lints] diff --git a/crates/extension_api/src/extension_api.rs b/crates/extension_api/src/extension_api.rs index 723e5442098f1a66b78b86fa7ed980a18944778b..acd1cba47b0150b85ddec8baafa8b5f341460a39 100644 --- a/crates/extension_api/src/extension_api.rs +++ b/crates/extension_api/src/extension_api.rs @@ -331,10 +331,9 @@ static mut EXTENSION: Option> = None; pub static ZED_API_VERSION: [u8; 6] = *include_bytes!(concat!(env!("OUT_DIR"), "/version_bytes")); mod wit { - wit_bindgen::generate!({ skip: ["init-extension"], - path: "./wit/since_v0.6.0", + path: "./wit/since_v0.8.0", }); } @@ -524,6 +523,12 @@ impl wit::Guest for Component { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] pub struct LanguageServerId(String); +impl LanguageServerId { + pub fn new(value: String) -> Self { + Self(value) + } +} + impl AsRef for LanguageServerId { fn as_ref(&self) -> &str { &self.0 @@ -540,6 +545,12 @@ impl fmt::Display for LanguageServerId { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)] pub struct ContextServerId(String); +impl ContextServerId { + pub fn new(value: String) -> Self { + Self(value) + } +} + impl AsRef for ContextServerId { fn as_ref(&self) -> &str { &self.0 diff --git a/crates/extension_api/wit/since_v0.8.0/common.wit b/crates/extension_api/wit/since_v0.8.0/common.wit new file mode 100644 index 0000000000000000000000000000000000000000..139e7ba0ca4d1cc5ac78ccd23673ca749d6e46b2 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/common.wit @@ -0,0 +1,12 @@ +interface common { + /// A (half-open) range (`[start, end)`). + record range { + /// The start of the range (inclusive). + start: u32, + /// The end of the range (exclusive). + end: u32, + } + + /// A list of environment variables. + type env-vars = list>; +} diff --git a/crates/extension_api/wit/since_v0.8.0/context-server.wit b/crates/extension_api/wit/since_v0.8.0/context-server.wit new file mode 100644 index 0000000000000000000000000000000000000000..7234e0e6d0f6d444e92a056a92f6c90c7dc053b4 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/context-server.wit @@ -0,0 +1,11 @@ +interface context-server { + /// Configuration for context server setup and installation. + record context-server-configuration { + /// Installation instructions in Markdown format. + installation-instructions: string, + /// JSON schema for settings validation. + settings-schema: string, + /// Default settings template. + default-settings: string, + } +} diff --git a/crates/extension_api/wit/since_v0.8.0/dap.wit b/crates/extension_api/wit/since_v0.8.0/dap.wit new file mode 100644 index 0000000000000000000000000000000000000000..693befe02f9c313455facd4839572528c3408fd1 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/dap.wit @@ -0,0 +1,123 @@ +interface dap { + use common.{env-vars}; + + /// Resolves a specified TcpArgumentsTemplate into TcpArguments + resolve-tcp-template: func(template: tcp-arguments-template) -> result; + + record launch-request { + program: string, + cwd: option, + args: list, + envs: env-vars, + } + + record attach-request { + process-id: option, + } + + variant debug-request { + launch(launch-request), + attach(attach-request) + } + + record tcp-arguments { + port: u16, + host: u32, + timeout: option, + } + + record tcp-arguments-template { + port: option, + host: option, + timeout: option, + } + + /// Debug Config is the "highest-level" configuration for a debug session. + /// It comes from a new process modal UI; thus, it is essentially debug-adapter-agnostic. + /// It is expected of the extension to translate this generic configuration into something that can be debugged by the adapter (debug scenario). + record debug-config { + /// Name of the debug task + label: string, + /// The debug adapter to use + adapter: string, + request: debug-request, + stop-on-entry: option, + } + + record task-template { + /// Human readable name of the task to display in the UI. + label: string, + /// Executable command to spawn. + command: string, + args: list, + env: env-vars, + cwd: option, + } + + /// A task template with substituted task variables. + type resolved-task = task-template; + + /// A task template for building a debug target. + type build-task-template = task-template; + + variant build-task-definition { + by-name(string), + template(build-task-definition-template-payload ) + } + record build-task-definition-template-payload { + locator-name: option, + template: build-task-template + } + + /// Debug Scenario is the user-facing configuration type (used in debug.json). It is still concerned with what to debug and not necessarily how to do it (except for any + /// debug-adapter-specific configuration options). + record debug-scenario { + /// Unsubstituted label for the task.DebugAdapterBinary + label: string, + /// Name of the Debug Adapter this configuration is intended for. + adapter: string, + /// An optional build step to be ran prior to starting a debug session. Build steps are used by Zed's locators to locate the executable to debug. + build: option, + /// JSON-encoded configuration for a given debug adapter. + config: string, + /// TCP connection parameters (if they were specified by user) + tcp-connection: option, + } + + enum start-debugging-request-arguments-request { + launch, + attach, + } + + record debug-task-definition { + /// Unsubstituted label for the task.DebugAdapterBinary + label: string, + /// Name of the Debug Adapter this configuration is intended for. + adapter: string, + /// JSON-encoded configuration for a given debug adapter. + config: string, + /// TCP connection parameters (if they were specified by user) + tcp-connection: option, + } + + record start-debugging-request-arguments { + /// JSON-encoded configuration for a given debug adapter. It is specific to each debug adapter. + /// `configuration` will have it's Zed variable references substituted prior to being passed to the debug adapter. + configuration: string, + request: start-debugging-request-arguments-request, + } + + /// The lowest-level representation of a debug session, which specifies: + /// - How to start a debug adapter process + /// - How to start a debug session with it (using DAP protocol) + /// for a given debug scenario. + record debug-adapter-binary { + command: option, + arguments: list, + envs: env-vars, + cwd: option, + /// Zed will use TCP transport if `connection` is specified. + connection: option, + request-args: start-debugging-request-arguments + } +} diff --git a/crates/extension_api/wit/since_v0.8.0/extension.wit b/crates/extension_api/wit/since_v0.8.0/extension.wit new file mode 100644 index 0000000000000000000000000000000000000000..8195162b89a420d322970bf894bd9ec824119087 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/extension.wit @@ -0,0 +1,167 @@ +package zed:extension; + +world extension { + import context-server; + import dap; + import github; + import http-client; + import platform; + import process; + import nodejs; + + use common.{env-vars, range}; + use context-server.{context-server-configuration}; + use dap.{attach-request, build-task-template, debug-config, debug-adapter-binary, debug-task-definition, debug-request, debug-scenario, launch-request, resolved-task, start-debugging-request-arguments-request}; + use lsp.{completion, symbol}; + use process.{command}; + use slash-command.{slash-command, slash-command-argument-completion, slash-command-output}; + + /// Initializes the extension. + export init-extension: func(); + + /// The type of a downloaded file. + enum downloaded-file-type { + /// A gzipped file (`.gz`). + gzip, + /// A gzipped tar archive (`.tar.gz`). + gzip-tar, + /// A ZIP file (`.zip`). + zip, + /// An uncompressed file. + uncompressed, + } + + /// The installation status for a language server. + variant language-server-installation-status { + /// The language server has no installation status. + none, + /// The language server is being downloaded. + downloading, + /// The language server is checking for updates. + checking-for-update, + /// The language server installation failed for specified reason. + failed(string), + } + + record settings-location { + worktree-id: u64, + path: string, + } + + import get-settings: func(path: option, category: string, key: option) -> result; + + /// Downloads a file from the given URL and saves it to the given path within the extension's + /// working directory. + /// + /// The file will be extracted according to the given file type. + import download-file: func(url: string, file-path: string, file-type: downloaded-file-type) -> result<_, string>; + + /// Makes the file at the given path executable. + import make-file-executable: func(filepath: string) -> result<_, string>; + + /// Updates the installation status for the given language server. + import set-language-server-installation-status: func(language-server-name: string, status: language-server-installation-status); + + /// A Zed worktree. + resource worktree { + /// Returns the ID of the worktree. + id: func() -> u64; + /// Returns the root path of the worktree. + root-path: func() -> string; + /// Returns the textual contents of the specified file in the worktree. + read-text-file: func(path: string) -> result; + /// Returns the path to the given binary name, if one is present on the `$PATH`. + which: func(binary-name: string) -> option; + /// Returns the current shell environment. + shell-env: func() -> env-vars; + } + + /// A Zed project. + resource project { + /// Returns the IDs of all of the worktrees in this project. + worktree-ids: func() -> list; + } + + /// A key-value store. + resource key-value-store { + /// Inserts an entry under the specified key. + insert: func(key: string, value: string) -> result<_, string>; + } + + /// Returns the command used to start up the language server. + export language-server-command: func(language-server-id: string, worktree: borrow) -> result; + + /// Returns the initialization options to pass to the language server on startup. + /// + /// The initialization options are represented as a JSON string. + export language-server-initialization-options: func(language-server-id: string, worktree: borrow) -> result, string>; + + /// Returns the workspace configuration options to pass to the language server. + export language-server-workspace-configuration: func(language-server-id: string, worktree: borrow) -> result, string>; + + /// Returns the initialization options to pass to the other language server. + export language-server-additional-initialization-options: func(language-server-id: string, target-language-server-id: string, worktree: borrow) -> result, string>; + + /// Returns the workspace configuration options to pass to the other language server. + export language-server-additional-workspace-configuration: func(language-server-id: string, target-language-server-id: string, worktree: borrow) -> result, string>; + + /// A label containing some code. + record code-label { + /// The source code to parse with Tree-sitter. + code: string, + /// The spans to display in the label. + spans: list, + /// The range of the displayed label to include when filtering. + filter-range: range, + } + + /// A span within a code label. + variant code-label-span { + /// A range into the parsed code. + code-range(range), + /// A span containing a code literal. + literal(code-label-span-literal), + } + + /// A span containing a code literal. + record code-label-span-literal { + /// The literal text. + text: string, + /// The name of the highlight to use for this literal. + highlight-name: option, + } + + export labels-for-completions: func(language-server-id: string, completions: list) -> result>, string>; + export labels-for-symbols: func(language-server-id: string, symbols: list) -> result>, string>; + + + /// Returns the completions that should be shown when completing the provided slash command with the given query. + export complete-slash-command-argument: func(command: slash-command, args: list) -> result, string>; + + /// Returns the output from running the provided slash command. + export run-slash-command: func(command: slash-command, args: list, worktree: option>) -> result; + + /// Returns the command used to start up a context server. + export context-server-command: func(context-server-id: string, project: borrow) -> result; + + /// Returns the configuration for a context server. + export context-server-configuration: func(context-server-id: string, project: borrow) -> result, string>; + + /// Returns a list of packages as suggestions to be included in the `/docs` + /// search results. + /// + /// This can be used to provide completions for known packages (e.g., from the + /// local project or a registry) before a package has been indexed. + export suggest-docs-packages: func(provider-name: string) -> result, string>; + + /// Indexes the docs for the specified package. + export index-docs: func(provider-name: string, package-name: string, database: borrow) -> result<_, string>; + + /// Returns a configured debug adapter binary for a given debug task. + export get-dap-binary: func(adapter-name: string, config: debug-task-definition, user-installed-path: option, worktree: borrow) -> result; + /// Returns the kind of a debug scenario (launch or attach). + export dap-request-kind: func(adapter-name: string, config: string) -> result; + export dap-config-to-scenario: func(config: debug-config) -> result; + export dap-locator-create-scenario: func(locator-name: string, build-config-template: build-task-template, resolved-label: string, debug-adapter-name: string) -> option; + export run-dap-locator: func(locator-name: string, config: resolved-task) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/github.wit b/crates/extension_api/wit/since_v0.8.0/github.wit new file mode 100644 index 0000000000000000000000000000000000000000..21cd5d48056af08441d3bb5aa8547edd97a874d7 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/github.wit @@ -0,0 +1,35 @@ +interface github { + /// A GitHub release. + record github-release { + /// The version of the release. + version: string, + /// The list of assets attached to the release. + assets: list, + } + + /// An asset from a GitHub release. + record github-release-asset { + /// The name of the asset. + name: string, + /// The download URL for the asset. + download-url: string, + } + + /// The options used to filter down GitHub releases. + record github-release-options { + /// Whether releases without assets should be included. + require-assets: bool, + /// Whether pre-releases should be included. + pre-release: bool, + } + + /// Returns the latest release for the given GitHub repository. + /// + /// Takes repo as a string in the form "/", for example: "zed-industries/zed". + latest-github-release: func(repo: string, options: github-release-options) -> result; + + /// Returns the GitHub release with the specified tag name for the given GitHub repository. + /// + /// Returns an error if a release with the given tag name does not exist. + github-release-by-tag-name: func(repo: string, tag: string) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/http-client.wit b/crates/extension_api/wit/since_v0.8.0/http-client.wit new file mode 100644 index 0000000000000000000000000000000000000000..bb0206c17a52d4d20b99f445dca4ac606e0485f7 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/http-client.wit @@ -0,0 +1,67 @@ +interface http-client { + /// An HTTP request. + record http-request { + /// The HTTP method for the request. + method: http-method, + /// The URL to which the request should be made. + url: string, + /// The headers for the request. + headers: list>, + /// The request body. + body: option>, + /// The policy to use for redirects. + redirect-policy: redirect-policy, + } + + /// HTTP methods. + enum http-method { + /// `GET` + get, + /// `HEAD` + head, + /// `POST` + post, + /// `PUT` + put, + /// `DELETE` + delete, + /// `OPTIONS` + options, + /// `PATCH` + patch, + } + + /// The policy for dealing with redirects received from the server. + variant redirect-policy { + /// Redirects from the server will not be followed. + /// + /// This is the default behavior. + no-follow, + /// Redirects from the server will be followed up to the specified limit. + follow-limit(u32), + /// All redirects from the server will be followed. + follow-all, + } + + /// An HTTP response. + record http-response { + /// The response headers. + headers: list>, + /// The response body. + body: list, + } + + /// Performs an HTTP request and returns the response. + fetch: func(req: http-request) -> result; + + /// An HTTP response stream. + resource http-response-stream { + /// Retrieves the next chunk of data from the response stream. + /// + /// Returns `Ok(None)` if the stream has ended. + next-chunk: func() -> result>, string>; + } + + /// Performs an HTTP request and returns a response stream. + fetch-stream: func(req: http-request) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/lsp.wit b/crates/extension_api/wit/since_v0.8.0/lsp.wit new file mode 100644 index 0000000000000000000000000000000000000000..91a36c93a66467ea7dc7d78932d3821dae79d864 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/lsp.wit @@ -0,0 +1,90 @@ +interface lsp { + /// An LSP completion. + record completion { + label: string, + label-details: option, + detail: option, + kind: option, + insert-text-format: option, + } + + /// The kind of an LSP completion. + variant completion-kind { + text, + method, + function, + %constructor, + field, + variable, + class, + %interface, + module, + property, + unit, + value, + %enum, + keyword, + snippet, + color, + file, + reference, + folder, + enum-member, + constant, + struct, + event, + operator, + type-parameter, + other(s32), + } + + /// Label details for an LSP completion. + record completion-label-details { + detail: option, + description: option, + } + + /// Defines how to interpret the insert text in a completion item. + variant insert-text-format { + plain-text, + snippet, + other(s32), + } + + /// An LSP symbol. + record symbol { + kind: symbol-kind, + name: string, + } + + /// The kind of an LSP symbol. + variant symbol-kind { + file, + module, + namespace, + %package, + class, + method, + property, + field, + %constructor, + %enum, + %interface, + function, + variable, + constant, + %string, + number, + boolean, + array, + object, + key, + null, + enum-member, + struct, + event, + operator, + type-parameter, + other(s32), + } +} diff --git a/crates/extension_api/wit/since_v0.8.0/nodejs.wit b/crates/extension_api/wit/since_v0.8.0/nodejs.wit new file mode 100644 index 0000000000000000000000000000000000000000..c814548314162c862e81a98b3fba6950dc2a7f41 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/nodejs.wit @@ -0,0 +1,13 @@ +interface nodejs { + /// Returns the path to the Node binary used by Zed. + node-binary-path: func() -> result; + + /// Returns the latest version of the given NPM package. + npm-package-latest-version: func(package-name: string) -> result; + + /// Returns the installed version of the given NPM package, if it exists. + npm-package-installed-version: func(package-name: string) -> result, string>; + + /// Installs the specified NPM package. + npm-install-package: func(package-name: string, version: string) -> result<_, string>; +} diff --git a/crates/extension_api/wit/since_v0.8.0/platform.wit b/crates/extension_api/wit/since_v0.8.0/platform.wit new file mode 100644 index 0000000000000000000000000000000000000000..48472a99bc175fdc24231a690db021433d5a2505 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/platform.wit @@ -0,0 +1,24 @@ +interface platform { + /// An operating system. + enum os { + /// macOS. + mac, + /// Linux. + linux, + /// Windows. + windows, + } + + /// A platform architecture. + enum architecture { + /// AArch64 (e.g., Apple Silicon). + aarch64, + /// x86. + x86, + /// x86-64. + x8664, + } + + /// Gets the current operating system and architecture. + current-platform: func() -> tuple; +} diff --git a/crates/extension_api/wit/since_v0.8.0/process.wit b/crates/extension_api/wit/since_v0.8.0/process.wit new file mode 100644 index 0000000000000000000000000000000000000000..d9a5728a3d8f5bdaa578d9dd9fc087610688cf27 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/process.wit @@ -0,0 +1,29 @@ +interface process { + use common.{env-vars}; + + /// A command. + record command { + /// The command to execute. + command: string, + /// The arguments to pass to the command. + args: list, + /// The environment variables to set for the command. + env: env-vars, + } + + /// The output of a finished process. + record output { + /// The status (exit code) of the process. + /// + /// On Unix, this will be `None` if the process was terminated by a signal. + status: option, + /// The data that the process wrote to stdout. + stdout: list, + /// The data that the process wrote to stderr. + stderr: list, + } + + /// Executes the given command as a child process, waiting for it to finish + /// and collecting all of its output. + run-command: func(command: command) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/settings.rs b/crates/extension_api/wit/since_v0.8.0/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..19e28c1ba955a998fe7b97f3eacb57c4b1104154 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/settings.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, num::NonZeroU32}; + +/// The settings for a particular language. +#[derive(Debug, Serialize, Deserialize)] +pub struct LanguageSettings { + /// How many columns a tab should occupy. + pub tab_size: NonZeroU32, +} + +/// The settings for a particular language server. +#[derive(Default, Debug, Serialize, Deserialize)] +pub struct LspSettings { + /// The settings for the language server binary. + pub binary: Option, + /// The initialization options to pass to the language server. + pub initialization_options: Option, + /// The settings to pass to language server. + pub settings: Option, +} + +/// The settings for a particular context server. +#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextServerSettings { + /// The settings for the context server binary. + pub command: Option, + /// The settings to pass to the context server. + pub settings: Option, +} + +/// The settings for a command. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommandSettings { + /// The path to the command. + pub path: Option, + /// The arguments to pass to the command. + pub arguments: Option>, + /// The environment variables. + pub env: Option>, +} diff --git a/crates/extension_api/wit/since_v0.8.0/slash-command.wit b/crates/extension_api/wit/since_v0.8.0/slash-command.wit new file mode 100644 index 0000000000000000000000000000000000000000..f52561c2ef412be071820f3a71621c3c4f3f9da3 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/slash-command.wit @@ -0,0 +1,41 @@ +interface slash-command { + use common.{range}; + + /// A slash command for use in the Assistant. + record slash-command { + /// The name of the slash command. + name: string, + /// The description of the slash command. + description: string, + /// The tooltip text to display for the run button. + tooltip-text: string, + /// Whether this slash command requires an argument. + requires-argument: bool, + } + + /// The output of a slash command. + record slash-command-output { + /// The text produced by the slash command. + text: string, + /// The list of sections to show in the slash command placeholder. + sections: list, + } + + /// A section in the slash command output. + record slash-command-output-section { + /// The range this section occupies. + range: range, + /// The label to display in the placeholder for this section. + label: string, + } + + /// A completion for a slash command argument. + record slash-command-argument-completion { + /// The label to display for this completion. + label: string, + /// The new text that should be inserted into the command when this completion is accepted. + new-text: string, + /// Whether the command should be run when accepting this completion. + run-command: bool, + } +} diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 524e14b0cedcebef259948d73b530236525180c0..699a6b014322833a15fd593d7e5ac613a221ed24 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -71,6 +71,7 @@ async fn main() -> Result<()> { &extension_path, &mut manifest, CompileExtensionOptions { release: true }, + fs.clone(), ) .await .context("failed to compile extension")?; diff --git a/crates/extension_host/benches/extension_compilation_benchmark.rs b/crates/extension_host/benches/extension_compilation_benchmark.rs index c3459cf116b5510bc98fd167ea32c242bd48ad9a..605b98c67071155d8444639ef7043b9c8901161d 100644 --- a/crates/extension_host/benches/extension_compilation_benchmark.rs +++ b/crates/extension_host/benches/extension_compilation_benchmark.rs @@ -7,7 +7,7 @@ use extension::{ extension_builder::{CompileExtensionOptions, ExtensionBuilder}, }; use extension_host::wasm_host::WasmHost; -use fs::RealFs; +use fs::{Fs, RealFs}; use gpui::{TestAppContext, TestDispatcher}; use http_client::{FakeHttpClient, Response}; use node_runtime::NodeRuntime; @@ -24,7 +24,11 @@ fn extension_benchmarks(c: &mut Criterion) { let mut group = c.benchmark_group("load"); let mut manifest = manifest(); - let wasm_bytes = wasm_bytes(&cx, &mut manifest); + let wasm_bytes = wasm_bytes( + &cx, + &mut manifest, + Arc::new(RealFs::new(None, cx.executor())), + ); let manifest = Arc::new(manifest); let extensions_dir = TempTree::new(json!({ "installed": {}, @@ -60,7 +64,7 @@ fn init() -> TestAppContext { cx } -fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest) -> Vec { +fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest, fs: Arc) -> Vec { let extension_builder = extension_builder(); let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() @@ -73,6 +77,7 @@ fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest) -> Vec &path, manifest, CompileExtensionOptions { release: true }, + fs, )) .unwrap(); std::fs::read(path.join("extension.wasm")).unwrap() @@ -143,6 +148,7 @@ fn manifest() -> ExtensionManifest { )], debug_adapters: Default::default(), debug_locators: Default::default(), + language_model_providers: BTreeMap::default(), } } diff --git a/crates/extension_host/src/capability_granter.rs b/crates/extension_host/src/capability_granter.rs index 9f27b5e480bc3c22faefe67cd49a06af21614096..6278deef0a7d41e40d4444ddbe992f007cd5e53e 100644 --- a/crates/extension_host/src/capability_granter.rs +++ b/crates/extension_host/src/capability_granter.rs @@ -113,6 +113,7 @@ mod tests { capabilities: vec![], debug_adapters: Default::default(), debug_locators: Default::default(), + language_model_providers: BTreeMap::default(), } } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index c1c598f1895f6897fd2989752f74fde077af97b3..09e8259771668346c237c1cc05e6074ca3b37797 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -980,12 +980,14 @@ impl ExtensionStore { cx.background_spawn({ let extension_source_path = extension_source_path.clone(); + let fs = fs.clone(); async move { builder .compile_extension( &extension_source_path, &mut extension_manifest, CompileExtensionOptions { release: false }, + fs, ) .await } @@ -1042,12 +1044,13 @@ impl ExtensionStore { cx.notify(); let compile = cx.background_spawn(async move { - let mut manifest = ExtensionManifest::load(fs, &path).await?; + let mut manifest = ExtensionManifest::load(fs.clone(), &path).await?; builder .compile_extension( &path, &mut manifest, CompileExtensionOptions { release: true }, + fs, ) .await }); diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 85a3a720ce8c62fc4317756ec264926c981864c4..c17484f26a06b3392cdbcd8f3c1578eb43c7b213 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -165,6 +165,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { capabilities: Vec::new(), debug_adapters: Default::default(), debug_locators: Default::default(), + language_model_providers: BTreeMap::default(), }), dev: false, }, @@ -196,6 +197,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { capabilities: Vec::new(), debug_adapters: Default::default(), debug_locators: Default::default(), + language_model_providers: BTreeMap::default(), }), dev: false, }, @@ -307,9 +309,9 @@ async fn test_extension_store(cx: &mut TestAppContext) { assert_eq!( language_registry.language_names(), [ - LanguageName::new("ERB"), - LanguageName::new("Plain Text"), - LanguageName::new("Ruby"), + LanguageName::new_static("ERB"), + LanguageName::new_static("Plain Text"), + LanguageName::new_static("Ruby"), ] ); assert_eq!( @@ -376,6 +378,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { capabilities: Vec::new(), debug_adapters: Default::default(), debug_locators: Default::default(), + language_model_providers: BTreeMap::default(), }), dev: false, }, @@ -463,9 +466,9 @@ async fn test_extension_store(cx: &mut TestAppContext) { assert_eq!( language_registry.language_names(), [ - LanguageName::new("ERB"), - LanguageName::new("Plain Text"), - LanguageName::new("Ruby"), + LanguageName::new_static("ERB"), + LanguageName::new_static("Plain Text"), + LanguageName::new_static("Ruby"), ] ); assert_eq!( @@ -523,7 +526,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { assert_eq!( language_registry.language_names(), - [LanguageName::new("Plain Text")] + [LanguageName::new_static("Plain Text")] ); assert_eq!(language_registry.grammar_names(), []); }); @@ -705,7 +708,7 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { .await .unwrap(); - let mut fake_servers = language_registry.register_fake_language_server( + let mut fake_servers = language_registry.register_fake_lsp_server( LanguageServerName("gleam".into()), lsp::ServerCapabilities { completion_provider: Some(Default::default()), diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index cecaf2039bc6dc049ece1177700a14eead3d86bc..a6e5768f16243ce6c6a4d250002e29d5db06a071 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -45,7 +45,7 @@ use wasmtime::{ CacheStore, Engine, Store, component::{Component, ResourceTable}, }; -use wasmtime_wasi::{self as wasi, WasiView}; +use wasmtime_wasi::p2::{self as wasi, IoView as _}; use wit::Extension; pub struct WasmHost { @@ -685,8 +685,8 @@ impl WasmHost { .await .context("failed to create extension work dir")?; - let file_perms = wasi::FilePerms::all(); - let dir_perms = wasi::DirPerms::all(); + let file_perms = wasmtime_wasi::FilePerms::all(); + let dir_perms = wasmtime_wasi::DirPerms::all(); let path = SanitizedPath::new(&extension_work_dir).to_string(); #[cfg(target_os = "windows")] let path = path.replace('\\', "/"); @@ -856,11 +856,13 @@ impl WasmState { } } -impl wasi::WasiView for WasmState { +impl wasi::IoView for WasmState { fn table(&mut self) -> &mut ResourceTable { &mut self.table } +} +impl wasi::WasiView for WasmState { fn ctx(&mut self) -> &mut wasi::WasiCtx { &mut self.ctx } diff --git a/crates/extension_host/src/wasm_host/wit.rs b/crates/extension_host/src/wasm_host/wit.rs index 4c88af1b0a023441b237a26e5c14f1e6f0d0102d..e080915b4fe1f18325843961db36e2fbc16bd418 100644 --- a/crates/extension_host/src/wasm_host/wit.rs +++ b/crates/extension_host/src/wasm_host/wit.rs @@ -7,6 +7,7 @@ mod since_v0_3_0; mod since_v0_4_0; mod since_v0_5_0; mod since_v0_6_0; +mod since_v0_8_0; use dap::DebugRequest; use extension::{DebugTaskDefinition, KeyValueStoreDelegate, WorktreeDelegate}; use gpui::BackgroundExecutor; @@ -20,7 +21,7 @@ use crate::wasm_host::wit::since_v0_6_0::dap::StartDebuggingRequestArgumentsRequ use super::{WasmState, wasm_engine}; use anyhow::{Context as _, Result, anyhow}; use semver::Version; -use since_v0_6_0 as latest; +use since_v0_8_0 as latest; use std::{ops::RangeInclusive, path::PathBuf, sync::Arc}; use wasmtime::{ Store, @@ -44,7 +45,7 @@ pub fn new_linker( f: impl Fn(&mut Linker, fn(&mut WasmState) -> &mut WasmState) -> Result<()>, ) -> Linker { let mut linker = Linker::new(&wasm_engine(executor)); - wasmtime_wasi::add_to_linker_async(&mut linker).unwrap(); + wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap(); f(&mut linker, wasi_view).unwrap(); linker } @@ -66,7 +67,7 @@ pub fn wasm_api_version_range(release_channel: ReleaseChannel) -> RangeInclusive let max_version = match release_channel { ReleaseChannel::Dev | ReleaseChannel::Nightly => latest::MAX_VERSION, - ReleaseChannel::Stable | ReleaseChannel::Preview => latest::MAX_VERSION, + ReleaseChannel::Stable | ReleaseChannel::Preview => since_v0_6_0::MAX_VERSION, }; since_v0_0_1::MIN_VERSION..=max_version @@ -95,6 +96,7 @@ pub fn authorize_access_to_unreleased_wasm_api_version( } pub enum Extension { + V0_8_0(since_v0_8_0::Extension), V0_6_0(since_v0_6_0::Extension), V0_5_0(since_v0_5_0::Extension), V0_4_0(since_v0_4_0::Extension), @@ -118,10 +120,21 @@ impl Extension { let _ = release_channel; if version >= latest::MIN_VERSION { + authorize_access_to_unreleased_wasm_api_version(release_channel)?; + let extension = latest::Extension::instantiate_async(store, component, latest::linker(executor)) .await .context("failed to instantiate wasm extension")?; + Ok(Self::V0_8_0(extension)) + } else if version >= since_v0_6_0::MIN_VERSION { + let extension = since_v0_6_0::Extension::instantiate_async( + store, + component, + since_v0_6_0::linker(executor), + ) + .await + .context("failed to instantiate wasm extension")?; Ok(Self::V0_6_0(extension)) } else if version >= since_v0_5_0::MIN_VERSION { let extension = since_v0_5_0::Extension::instantiate_async( @@ -200,6 +213,7 @@ impl Extension { pub async fn call_init_extension(&self, store: &mut Store) -> Result<()> { match self { + Extension::V0_8_0(ext) => ext.call_init_extension(store).await, Extension::V0_6_0(ext) => ext.call_init_extension(store).await, Extension::V0_5_0(ext) => ext.call_init_extension(store).await, Extension::V0_4_0(ext) => ext.call_init_extension(store).await, @@ -220,6 +234,10 @@ impl Extension { resource: Resource>, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_command(store, &language_server_id.0, resource) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_command(store, &language_server_id.0, resource) .await @@ -282,6 +300,14 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_initialization_options( + store, + &language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_initialization_options( store, @@ -371,6 +397,14 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_workspace_configuration( + store, + &language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_workspace_configuration( store, @@ -439,6 +473,15 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_additional_initialization_options( + store, + &language_server_id.0, + &target_language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_additional_initialization_options( store, @@ -483,6 +526,15 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_additional_workspace_configuration( + store, + &language_server_id.0, + &target_language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_additional_workspace_configuration( store, @@ -526,10 +578,23 @@ impl Extension { completions: Vec, ) -> Result>, String>> { match self { - Extension::V0_6_0(ext) => { + Extension::V0_8_0(ext) => { ext.call_labels_for_completions(store, &language_server_id.0, &completions) .await } + Extension::V0_6_0(ext) => Ok(ext + .call_labels_for_completions( + store, + &language_server_id.0, + &completions.into_iter().collect::>(), + ) + .await? + .map(|labels| { + labels + .into_iter() + .map(|label| label.map(Into::into)) + .collect() + })), Extension::V0_5_0(ext) => Ok(ext .call_labels_for_completions( store, @@ -619,10 +684,23 @@ impl Extension { symbols: Vec, ) -> Result>, String>> { match self { - Extension::V0_6_0(ext) => { + Extension::V0_8_0(ext) => { ext.call_labels_for_symbols(store, &language_server_id.0, &symbols) .await } + Extension::V0_6_0(ext) => Ok(ext + .call_labels_for_symbols( + store, + &language_server_id.0, + &symbols.into_iter().collect::>(), + ) + .await? + .map(|labels| { + labels + .into_iter() + .map(|label| label.map(Into::into)) + .collect() + })), Extension::V0_5_0(ext) => Ok(ext .call_labels_for_symbols( store, @@ -712,6 +790,10 @@ impl Extension { arguments: &[String], ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_complete_slash_command_argument(store, command, arguments) + .await + } Extension::V0_6_0(ext) => { ext.call_complete_slash_command_argument(store, command, arguments) .await @@ -750,6 +832,10 @@ impl Extension { resource: Option>>, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_run_slash_command(store, command, arguments, resource) + .await + } Extension::V0_6_0(ext) => { ext.call_run_slash_command(store, command, arguments, resource) .await @@ -787,6 +873,10 @@ impl Extension { project: Resource, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_context_server_command(store, &context_server_id, project) + .await + } Extension::V0_6_0(ext) => { ext.call_context_server_command(store, &context_server_id, project) .await @@ -823,6 +913,10 @@ impl Extension { project: Resource, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_context_server_configuration(store, &context_server_id, project) + .await + } Extension::V0_6_0(ext) => { ext.call_context_server_configuration(store, &context_server_id, project) .await @@ -849,6 +943,7 @@ impl Extension { provider: &str, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => ext.call_suggest_docs_packages(store, provider).await, Extension::V0_6_0(ext) => ext.call_suggest_docs_packages(store, provider).await, Extension::V0_5_0(ext) => ext.call_suggest_docs_packages(store, provider).await, Extension::V0_4_0(ext) => ext.call_suggest_docs_packages(store, provider).await, @@ -869,6 +964,10 @@ impl Extension { kv_store: Resource>, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_index_docs(store, provider, package_name, kv_store) + .await + } Extension::V0_6_0(ext) => { ext.call_index_docs(store, provider, package_name, kv_store) .await @@ -898,6 +997,7 @@ impl Extension { } } } + pub async fn call_get_dap_binary( &self, store: &mut Store, @@ -924,6 +1024,7 @@ impl Extension { _ => anyhow::bail!("`get_dap_binary` not available prior to v0.6.0"), } } + pub async fn call_dap_request_kind( &self, store: &mut Store, @@ -944,6 +1045,7 @@ impl Extension { _ => anyhow::bail!("`dap_request_kind` not available prior to v0.6.0"), } } + pub async fn call_dap_config_to_scenario( &self, store: &mut Store, @@ -962,6 +1064,7 @@ impl Extension { _ => anyhow::bail!("`dap_config_to_scenario` not available prior to v0.6.0"), } } + pub async fn call_dap_locator_create_scenario( &self, store: &mut Store, @@ -988,6 +1091,7 @@ impl Extension { _ => anyhow::bail!("`dap_locator_create_scenario` not available prior to v0.6.0"), } } + pub async fn call_run_dap_locator( &self, store: &mut Store, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index c96e5216c4703df2a73e1a0bc27c90d13adbb782..8595c278b95a433f782ea5c53e2c97c75aa353da 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -1,41 +1,13 @@ -use crate::wasm_host::wit::since_v0_6_0::{ - dap::{ - AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest, - StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate, - }, - slash_command::SlashCommandOutputSection, -}; -use crate::wasm_host::wit::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind}; -use crate::wasm_host::{WasmState, wit::ToWasmtimeResult}; -use ::http_client::{AsyncBody, HttpRequestExt}; -use ::settings::{Settings, WorktreeId}; -use anyhow::{Context as _, Result, bail}; -use async_compression::futures::bufread::GzipDecoder; -use async_tar::Archive; -use async_trait::async_trait; -use extension::{ - ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate, -}; -use futures::{AsyncReadExt, lock::Mutex}; -use futures::{FutureExt as _, io::BufReader}; -use gpui::{BackgroundExecutor, SharedString}; -use language::{BinaryStatus, LanguageName, language_settings::AllLanguageSettings}; -use project::project_settings::ProjectSettings; +use crate::wasm_host::WasmState; +use anyhow::Result; +use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate}; +use gpui::BackgroundExecutor; use semver::Version; -use std::{ - env, - net::Ipv4Addr, - path::{Path, PathBuf}, - str::FromStr, - sync::{Arc, OnceLock}, -}; -use task::{SpawnInTerminal, ZedDebugConfig}; -use url::Url; -use util::{ - archive::extract_zip, fs::make_file_executable, maybe, paths::PathStyle, rel_path::RelPath, -}; +use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; +use super::latest; + pub const MIN_VERSION: Version = Version::new(0, 6, 0); pub const MAX_VERSION: Version = Version::new(0, 7, 0); @@ -44,10 +16,19 @@ wasmtime::component::bindgen!({ trappable_imports: true, path: "../extension_api/wit/since_v0.6.0", with: { - "worktree": ExtensionWorktree, - "project": ExtensionProject, - "key-value-store": ExtensionKeyValueStore, - "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream + "worktree": ExtensionWorktree, + "project": ExtensionProject, + "key-value-store": ExtensionKeyValueStore, + "zed:extension/common": latest::zed::extension::common, + "zed:extension/github": latest::zed::extension::github, + "zed:extension/http-client": latest::zed::extension::http_client, + "zed:extension/lsp": latest::zed::extension::lsp, + "zed:extension/nodejs": latest::zed::extension::nodejs, + "zed:extension/platform": latest::zed::extension::platform, + "zed:extension/process": latest::zed::extension::process, + "zed:extension/slash-command": latest::zed::extension::slash_command, + "zed:extension/context-server": latest::zed::extension::context_server, + "zed:extension/dap": latest::zed::extension::dap, }, }); @@ -61,289 +42,32 @@ mod settings { pub type ExtensionWorktree = Arc; pub type ExtensionProject = Arc; pub type ExtensionKeyValueStore = Arc; -pub type ExtensionHttpResponseStream = Arc>>; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) } -impl From for std::ops::Range { - fn from(range: Range) -> Self { - let start = range.start as usize; - let end = range.end as usize; - start..end - } -} - -impl From for extension::Command { - fn from(value: Command) -> Self { - Self { - command: value.command.into(), - args: value.args, - env: value.env, - } - } -} - -impl From - for extension::StartDebuggingRequestArgumentsRequest -{ - fn from(value: StartDebuggingRequestArgumentsRequest) -> Self { - match value { - StartDebuggingRequestArgumentsRequest::Launch => Self::Launch, - StartDebuggingRequestArgumentsRequest::Attach => Self::Attach, - } - } -} -impl TryFrom for extension::StartDebuggingRequestArguments { - type Error = anyhow::Error; - - fn try_from(value: StartDebuggingRequestArguments) -> Result { - Ok(Self { - configuration: serde_json::from_str(&value.configuration)?, - request: value.request.into(), - }) - } -} -impl From for extension::TcpArguments { - fn from(value: TcpArguments) -> Self { - Self { - host: value.host.into(), - port: value.port, - timeout: value.timeout, - } - } -} - -impl From for TcpArgumentsTemplate { - fn from(value: extension::TcpArgumentsTemplate) -> Self { - Self { - host: value.host.map(Ipv4Addr::to_bits), - port: value.port, - timeout: value.timeout, - } - } -} - -impl From for extension::TcpArgumentsTemplate { - fn from(value: TcpArgumentsTemplate) -> Self { - Self { - host: value.host.map(Ipv4Addr::from_bits), - port: value.port, - timeout: value.timeout, - } - } -} - -impl TryFrom for DebugTaskDefinition { - type Error = anyhow::Error; - fn try_from(value: extension::DebugTaskDefinition) -> Result { - Ok(Self { - label: value.label.to_string(), - adapter: value.adapter.to_string(), - config: value.config.to_string(), - tcp_connection: value.tcp_connection.map(Into::into), - }) - } -} - -impl From for DebugRequest { - fn from(value: task::DebugRequest) -> Self { - match value { - task::DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), - task::DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), - } - } -} - -impl From for task::DebugRequest { - fn from(value: DebugRequest) -> Self { - match value { - DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), - DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), - } - } -} - -impl From for LaunchRequest { - fn from(value: task::LaunchRequest) -> Self { - Self { - program: value.program, - cwd: value.cwd.map(|p| p.to_string_lossy().into_owned()), - args: value.args, - envs: value.env.into_iter().collect(), - } - } -} - -impl From for AttachRequest { - fn from(value: task::AttachRequest) -> Self { - Self { - process_id: value.process_id, - } - } -} - -impl From for task::LaunchRequest { - fn from(value: LaunchRequest) -> Self { - Self { - program: value.program, - cwd: value.cwd.map(|p| p.into()), - args: value.args, - env: value.envs.into_iter().collect(), - } - } -} -impl From for task::AttachRequest { - fn from(value: AttachRequest) -> Self { - Self { - process_id: value.process_id, - } - } -} - -impl From for DebugConfig { - fn from(value: ZedDebugConfig) -> Self { - Self { - label: value.label.into(), - adapter: value.adapter.into(), - request: value.request.into(), - stop_on_entry: value.stop_on_entry, - } - } -} -impl TryFrom for extension::DebugAdapterBinary { - type Error = anyhow::Error; - fn try_from(value: DebugAdapterBinary) -> Result { - Ok(Self { - command: value.command, - arguments: value.arguments, - envs: value.envs.into_iter().collect(), - cwd: value.cwd.map(|s| s.into()), - connection: value.connection.map(Into::into), - request_args: value.request_args.try_into()?, - }) - } -} - -impl From for extension::BuildTaskDefinition { - fn from(value: BuildTaskDefinition) -> Self { - match value { - BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), - BuildTaskDefinition::Template(build_task_template) => Self::Template { - task_template: build_task_template.template.into(), - locator_name: build_task_template.locator_name.map(SharedString::from), - }, - } - } -} - -impl From for BuildTaskDefinition { - fn from(value: extension::BuildTaskDefinition) -> Self { - match value { - extension::BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), - extension::BuildTaskDefinition::Template { - task_template, - locator_name, - } => Self::Template(BuildTaskDefinitionTemplatePayload { - template: task_template.into(), - locator_name: locator_name.map(String::from), - }), - } - } -} -impl From for extension::BuildTaskTemplate { - fn from(value: BuildTaskTemplate) -> Self { - Self { - label: value.label, - command: value.command, - args: value.args, - env: value.env.into_iter().collect(), - cwd: value.cwd, - ..Default::default() - } - } -} -impl From for BuildTaskTemplate { - fn from(value: extension::BuildTaskTemplate) -> Self { - Self { - label: value.label, - command: value.command, - args: value.args, - env: value.env.into_iter().collect(), - cwd: value.cwd, - } - } -} - -impl TryFrom for extension::DebugScenario { - type Error = anyhow::Error; - - fn try_from(value: DebugScenario) -> std::result::Result { - Ok(Self { - adapter: value.adapter.into(), - label: value.label.into(), - build: value.build.map(Into::into), - config: serde_json::Value::from_str(&value.config)?, - tcp_connection: value.tcp_connection.map(Into::into), - }) - } -} - -impl From for DebugScenario { - fn from(value: extension::DebugScenario) -> Self { - Self { - adapter: value.adapter.into(), - label: value.label.into(), - build: value.build.map(Into::into), - config: value.config.to_string(), - tcp_connection: value.tcp_connection.map(Into::into), - } - } -} - -impl TryFrom for ResolvedTask { - type Error = anyhow::Error; - - fn try_from(value: SpawnInTerminal) -> Result { - Ok(Self { - label: value.label, - command: value.command.context("missing command")?, - args: value.args, - env: value.env.into_iter().collect(), - cwd: value.cwd.map(|s| { - let s = s.to_string_lossy(); - if cfg!(target_os = "windows") { - s.replace('\\', "/") - } else { - s.into_owned() - } - }), - }) - } -} - -impl From for extension::CodeLabel { +impl From for latest::CodeLabel { fn from(value: CodeLabel) -> Self { Self { code: value.code, spans: value.spans.into_iter().map(Into::into).collect(), - filter_range: value.filter_range.into(), + filter_range: value.filter_range, } } } -impl From for extension::CodeLabelSpan { +impl From for latest::CodeLabelSpan { fn from(value: CodeLabelSpan) -> Self { match value { - CodeLabelSpan::CodeRange(range) => Self::CodeRange(range.into()), + CodeLabelSpan::CodeRange(range) => Self::CodeRange(range), CodeLabelSpan::Literal(literal) => Self::Literal(literal.into()), } } } -impl From for extension::CodeLabelSpanLiteral { +impl From for latest::CodeLabelSpanLiteral { fn from(value: CodeLabelSpanLiteral) -> Self { Self { text: value.text, @@ -352,167 +76,37 @@ impl From for extension::CodeLabelSpanLiteral { } } -impl From for Completion { - fn from(value: extension::Completion) -> Self { +impl From for latest::SettingsLocation { + fn from(value: SettingsLocation) -> Self { Self { - label: value.label, - label_details: value.label_details.map(Into::into), - detail: value.detail, - kind: value.kind.map(Into::into), - insert_text_format: value.insert_text_format.map(Into::into), + worktree_id: value.worktree_id, + path: value.path, } } } -impl From for CompletionLabelDetails { - fn from(value: extension::CompletionLabelDetails) -> Self { - Self { - detail: value.detail, - description: value.description, - } - } -} - -impl From for CompletionKind { - fn from(value: extension::CompletionKind) -> Self { +impl From for latest::LanguageServerInstallationStatus { + fn from(value: LanguageServerInstallationStatus) -> Self { match value { - extension::CompletionKind::Text => Self::Text, - extension::CompletionKind::Method => Self::Method, - extension::CompletionKind::Function => Self::Function, - extension::CompletionKind::Constructor => Self::Constructor, - extension::CompletionKind::Field => Self::Field, - extension::CompletionKind::Variable => Self::Variable, - extension::CompletionKind::Class => Self::Class, - extension::CompletionKind::Interface => Self::Interface, - extension::CompletionKind::Module => Self::Module, - extension::CompletionKind::Property => Self::Property, - extension::CompletionKind::Unit => Self::Unit, - extension::CompletionKind::Value => Self::Value, - extension::CompletionKind::Enum => Self::Enum, - extension::CompletionKind::Keyword => Self::Keyword, - extension::CompletionKind::Snippet => Self::Snippet, - extension::CompletionKind::Color => Self::Color, - extension::CompletionKind::File => Self::File, - extension::CompletionKind::Reference => Self::Reference, - extension::CompletionKind::Folder => Self::Folder, - extension::CompletionKind::EnumMember => Self::EnumMember, - extension::CompletionKind::Constant => Self::Constant, - extension::CompletionKind::Struct => Self::Struct, - extension::CompletionKind::Event => Self::Event, - extension::CompletionKind::Operator => Self::Operator, - extension::CompletionKind::TypeParameter => Self::TypeParameter, - extension::CompletionKind::Other(value) => Self::Other(value), + LanguageServerInstallationStatus::None => Self::None, + LanguageServerInstallationStatus::Downloading => Self::Downloading, + LanguageServerInstallationStatus::CheckingForUpdate => Self::CheckingForUpdate, + LanguageServerInstallationStatus::Failed(message) => Self::Failed(message), } } } -impl From for InsertTextFormat { - fn from(value: extension::InsertTextFormat) -> Self { +impl From for latest::DownloadedFileType { + fn from(value: DownloadedFileType) -> Self { match value { - extension::InsertTextFormat::PlainText => Self::PlainText, - extension::InsertTextFormat::Snippet => Self::Snippet, - extension::InsertTextFormat::Other(value) => Self::Other(value), + DownloadedFileType::Gzip => Self::Gzip, + DownloadedFileType::GzipTar => Self::GzipTar, + DownloadedFileType::Zip => Self::Zip, + DownloadedFileType::Uncompressed => Self::Uncompressed, } } } -impl From for Symbol { - fn from(value: extension::Symbol) -> Self { - Self { - kind: value.kind.into(), - name: value.name, - } - } -} - -impl From for SymbolKind { - fn from(value: extension::SymbolKind) -> Self { - match value { - extension::SymbolKind::File => Self::File, - extension::SymbolKind::Module => Self::Module, - extension::SymbolKind::Namespace => Self::Namespace, - extension::SymbolKind::Package => Self::Package, - extension::SymbolKind::Class => Self::Class, - extension::SymbolKind::Method => Self::Method, - extension::SymbolKind::Property => Self::Property, - extension::SymbolKind::Field => Self::Field, - extension::SymbolKind::Constructor => Self::Constructor, - extension::SymbolKind::Enum => Self::Enum, - extension::SymbolKind::Interface => Self::Interface, - extension::SymbolKind::Function => Self::Function, - extension::SymbolKind::Variable => Self::Variable, - extension::SymbolKind::Constant => Self::Constant, - extension::SymbolKind::String => Self::String, - extension::SymbolKind::Number => Self::Number, - extension::SymbolKind::Boolean => Self::Boolean, - extension::SymbolKind::Array => Self::Array, - extension::SymbolKind::Object => Self::Object, - extension::SymbolKind::Key => Self::Key, - extension::SymbolKind::Null => Self::Null, - extension::SymbolKind::EnumMember => Self::EnumMember, - extension::SymbolKind::Struct => Self::Struct, - extension::SymbolKind::Event => Self::Event, - extension::SymbolKind::Operator => Self::Operator, - extension::SymbolKind::TypeParameter => Self::TypeParameter, - extension::SymbolKind::Other(value) => Self::Other(value), - } - } -} - -impl From for SlashCommand { - fn from(value: extension::SlashCommand) -> Self { - Self { - name: value.name, - description: value.description, - tooltip_text: value.tooltip_text, - requires_argument: value.requires_argument, - } - } -} - -impl From for extension::SlashCommandOutput { - fn from(value: SlashCommandOutput) -> Self { - Self { - text: value.text, - sections: value.sections.into_iter().map(Into::into).collect(), - } - } -} - -impl From for extension::SlashCommandOutputSection { - fn from(value: SlashCommandOutputSection) -> Self { - Self { - range: value.range.start as usize..value.range.end as usize, - label: value.label, - } - } -} - -impl From for extension::SlashCommandArgumentCompletion { - fn from(value: SlashCommandArgumentCompletion) -> Self { - Self { - label: value.label, - new_text: value.new_text, - run_command: value.run_command, - } - } -} - -impl TryFrom for extension::ContextServerConfiguration { - type Error = anyhow::Error; - - fn try_from(value: ContextServerConfiguration) -> Result { - let settings_schema: serde_json::Value = serde_json::from_str(&value.settings_schema) - .context("Failed to parse settings_schema")?; - - Ok(Self { - installation_instructions: value.installation_instructions, - default_settings: value.default_settings, - settings_schema, - }) - } -} - impl HostKeyValueStore for WasmState { async fn insert( &mut self, @@ -520,8 +114,7 @@ impl HostKeyValueStore for WasmState { key: String, value: String, ) -> wasmtime::Result> { - let kv_store = self.table.get(&kv_store)?; - kv_store.insert(key, value).await.to_wasmtime_result() + latest::HostKeyValueStore::insert(self, kv_store, key, value).await } async fn drop(&mut self, _worktree: Resource) -> Result<()> { @@ -535,8 +128,7 @@ impl HostProject for WasmState { &mut self, project: Resource, ) -> wasmtime::Result> { - let project = self.table.get(&project)?; - Ok(project.worktree_ids()) + latest::HostProject::worktree_ids(self, project).await } async fn drop(&mut self, _project: Resource) -> Result<()> { @@ -547,16 +139,14 @@ impl HostProject for WasmState { impl HostWorktree for WasmState { async fn id(&mut self, delegate: Resource>) -> wasmtime::Result { - let delegate = self.table.get(&delegate)?; - Ok(delegate.id()) + latest::HostWorktree::id(self, delegate).await } async fn root_path( &mut self, delegate: Resource>, ) -> wasmtime::Result { - let delegate = self.table.get(&delegate)?; - Ok(delegate.root_path()) + latest::HostWorktree::root_path(self, delegate).await } async fn read_text_file( @@ -564,19 +154,14 @@ impl HostWorktree for WasmState { delegate: Resource>, path: String, ) -> wasmtime::Result> { - let delegate = self.table.get(&delegate)?; - Ok(delegate - .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Posix)?) - .await - .map_err(|error| error.to_string())) + latest::HostWorktree::read_text_file(self, delegate, path).await } async fn shell_env( &mut self, delegate: Resource>, ) -> wasmtime::Result { - let delegate = self.table.get(&delegate)?; - Ok(delegate.shell_env().await.into_iter().collect()) + latest::HostWorktree::shell_env(self, delegate).await } async fn which( @@ -584,8 +169,7 @@ impl HostWorktree for WasmState { delegate: Resource>, binary_name: String, ) -> wasmtime::Result> { - let delegate = self.table.get(&delegate)?; - Ok(delegate.which(binary_name).await) + latest::HostWorktree::which(self, delegate, binary_name).await } async fn drop(&mut self, _worktree: Resource) -> Result<()> { @@ -594,319 +178,6 @@ impl HostWorktree for WasmState { } } -impl common::Host for WasmState {} - -impl http_client::Host for WasmState { - async fn fetch( - &mut self, - request: http_client::HttpRequest, - ) -> wasmtime::Result> { - maybe!(async { - let url = &request.url; - let request = convert_request(&request)?; - let mut response = self.host.http_client.send(request).await?; - - if response.status().is_client_error() || response.status().is_server_error() { - bail!("failed to fetch '{url}': status code {}", response.status()) - } - convert_response(&mut response).await - }) - .await - .to_wasmtime_result() - } - - async fn fetch_stream( - &mut self, - request: http_client::HttpRequest, - ) -> wasmtime::Result, String>> { - let request = convert_request(&request)?; - let response = self.host.http_client.send(request); - maybe!(async { - let response = response.await?; - let stream = Arc::new(Mutex::new(response)); - let resource = self.table.push(stream)?; - Ok(resource) - }) - .await - .to_wasmtime_result() - } -} - -impl http_client::HostHttpResponseStream for WasmState { - async fn next_chunk( - &mut self, - resource: Resource, - ) -> wasmtime::Result>, String>> { - let stream = self.table.get(&resource)?.clone(); - maybe!(async move { - let mut response = stream.lock().await; - let mut buffer = vec![0; 8192]; // 8KB buffer - let bytes_read = response.body_mut().read(&mut buffer).await?; - if bytes_read == 0 { - Ok(None) - } else { - buffer.truncate(bytes_read); - Ok(Some(buffer)) - } - }) - .await - .to_wasmtime_result() - } - - async fn drop(&mut self, _resource: Resource) -> Result<()> { - Ok(()) - } -} - -impl From for ::http_client::Method { - fn from(value: http_client::HttpMethod) -> Self { - match value { - http_client::HttpMethod::Get => Self::GET, - http_client::HttpMethod::Post => Self::POST, - http_client::HttpMethod::Put => Self::PUT, - http_client::HttpMethod::Delete => Self::DELETE, - http_client::HttpMethod::Head => Self::HEAD, - http_client::HttpMethod::Options => Self::OPTIONS, - http_client::HttpMethod::Patch => Self::PATCH, - } - } -} - -fn convert_request( - extension_request: &http_client::HttpRequest, -) -> anyhow::Result<::http_client::Request> { - let mut request = ::http_client::Request::builder() - .method(::http_client::Method::from(extension_request.method)) - .uri(&extension_request.url) - .follow_redirects(match extension_request.redirect_policy { - http_client::RedirectPolicy::NoFollow => ::http_client::RedirectPolicy::NoFollow, - http_client::RedirectPolicy::FollowLimit(limit) => { - ::http_client::RedirectPolicy::FollowLimit(limit) - } - http_client::RedirectPolicy::FollowAll => ::http_client::RedirectPolicy::FollowAll, - }); - for (key, value) in &extension_request.headers { - request = request.header(key, value); - } - let body = extension_request - .body - .clone() - .map(AsyncBody::from) - .unwrap_or_default(); - request.body(body).map_err(anyhow::Error::from) -} - -async fn convert_response( - response: &mut ::http_client::Response, -) -> anyhow::Result { - let mut extension_response = http_client::HttpResponse { - body: Vec::new(), - headers: Vec::new(), - }; - - for (key, value) in response.headers() { - extension_response - .headers - .push((key.to_string(), value.to_str().unwrap_or("").to_string())); - } - - response - .body_mut() - .read_to_end(&mut extension_response.body) - .await?; - - Ok(extension_response) -} - -impl nodejs::Host for WasmState { - async fn node_binary_path(&mut self) -> wasmtime::Result> { - self.host - .node_runtime - .binary_path() - .await - .map(|path| path.to_string_lossy().into_owned()) - .to_wasmtime_result() - } - - async fn npm_package_latest_version( - &mut self, - package_name: String, - ) -> wasmtime::Result> { - self.host - .node_runtime - .npm_package_latest_version(&package_name) - .await - .to_wasmtime_result() - } - - async fn npm_package_installed_version( - &mut self, - package_name: String, - ) -> wasmtime::Result, String>> { - self.host - .node_runtime - .npm_package_installed_version(&self.work_dir(), &package_name) - .await - .to_wasmtime_result() - } - - async fn npm_install_package( - &mut self, - package_name: String, - version: String, - ) -> wasmtime::Result> { - self.capability_granter - .grant_npm_install_package(&package_name)?; - - self.host - .node_runtime - .npm_install_packages(&self.work_dir(), &[(&package_name, &version)]) - .await - .to_wasmtime_result() - } -} - -#[async_trait] -impl lsp::Host for WasmState {} - -impl From<::http_client::github::GithubRelease> for github::GithubRelease { - fn from(value: ::http_client::github::GithubRelease) -> Self { - Self { - version: value.tag_name, - assets: value.assets.into_iter().map(Into::into).collect(), - } - } -} - -impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAsset { - fn from(value: ::http_client::github::GithubReleaseAsset) -> Self { - Self { - name: value.name, - download_url: value.browser_download_url, - } - } -} - -impl github::Host for WasmState { - async fn latest_github_release( - &mut self, - repo: String, - options: github::GithubReleaseOptions, - ) -> wasmtime::Result> { - maybe!(async { - let release = ::http_client::github::latest_github_release( - &repo, - options.require_assets, - options.pre_release, - self.host.http_client.clone(), - ) - .await?; - Ok(release.into()) - }) - .await - .to_wasmtime_result() - } - - async fn github_release_by_tag_name( - &mut self, - repo: String, - tag: String, - ) -> wasmtime::Result> { - maybe!(async { - let release = ::http_client::github::get_release_by_tag_name( - &repo, - &tag, - self.host.http_client.clone(), - ) - .await?; - Ok(release.into()) - }) - .await - .to_wasmtime_result() - } -} - -impl platform::Host for WasmState { - async fn current_platform(&mut self) -> Result<(platform::Os, platform::Architecture)> { - Ok(( - match env::consts::OS { - "macos" => platform::Os::Mac, - "linux" => platform::Os::Linux, - "windows" => platform::Os::Windows, - _ => panic!("unsupported os"), - }, - match env::consts::ARCH { - "aarch64" => platform::Architecture::Aarch64, - "x86" => platform::Architecture::X86, - "x86_64" => platform::Architecture::X8664, - _ => panic!("unsupported architecture"), - }, - )) - } -} - -impl From for process::Output { - fn from(output: std::process::Output) -> Self { - Self { - status: output.status.code(), - stdout: output.stdout, - stderr: output.stderr, - } - } -} - -impl process::Host for WasmState { - async fn run_command( - &mut self, - command: process::Command, - ) -> wasmtime::Result> { - maybe!(async { - self.capability_granter - .grant_exec(&command.command, &command.args)?; - - let output = util::command::new_smol_command(command.command.as_str()) - .args(&command.args) - .envs(command.env) - .output() - .await?; - - Ok(output.into()) - }) - .await - .to_wasmtime_result() - } -} - -#[async_trait] -impl slash_command::Host for WasmState {} - -#[async_trait] -impl context_server::Host for WasmState {} - -impl dap::Host for WasmState { - async fn resolve_tcp_template( - &mut self, - template: TcpArgumentsTemplate, - ) -> wasmtime::Result> { - maybe!(async { - let (host, port, timeout) = - ::dap::configure_tcp_connection(task::TcpArgumentsTemplate { - port: template.port, - host: template.host.map(Ipv4Addr::from_bits), - timeout: template.timeout, - }) - .await?; - Ok(TcpArguments { - port, - host: host.to_bits(), - timeout, - }) - }) - .await - .to_wasmtime_result() - } -} - impl ExtensionImports for WasmState { async fn get_settings( &mut self, @@ -914,96 +185,13 @@ impl ExtensionImports for WasmState { category: String, key: Option, ) -> wasmtime::Result> { - self.on_main_thread(|cx| { - async move { - let path = location.as_ref().and_then(|location| { - RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() - }); - let location = path - .as_ref() - .zip(location.as_ref()) - .map(|(path, location)| ::settings::SettingsLocation { - worktree_id: WorktreeId::from_proto(location.worktree_id), - path, - }); - - cx.update(|cx| match category.as_str() { - "language" => { - let key = key.map(|k| LanguageName::new(&k)); - let settings = AllLanguageSettings::get(location, cx).language( - location, - key.as_ref(), - cx, - ); - Ok(serde_json::to_string(&settings::LanguageSettings { - tab_size: settings.tab_size, - })?) - } - "lsp" => { - let settings = key - .and_then(|key| { - ProjectSettings::get(location, cx) - .lsp - .get(&::lsp::LanguageServerName::from_proto(key)) - }) - .cloned() - .unwrap_or_default(); - Ok(serde_json::to_string(&settings::LspSettings { - binary: settings.binary.map(|binary| settings::CommandSettings { - path: binary.path, - arguments: binary.arguments, - env: binary.env.map(|env| env.into_iter().collect()), - }), - settings: settings.settings, - initialization_options: settings.initialization_options, - })?) - } - "context_servers" => { - let settings = key - .and_then(|key| { - ProjectSettings::get(location, cx) - .context_servers - .get(key.as_str()) - }) - .cloned() - .unwrap_or_else(|| { - project::project_settings::ContextServerSettings::default_extension( - ) - }); - - match settings { - project::project_settings::ContextServerSettings::Stdio { - enabled: _, - command, - } => Ok(serde_json::to_string(&settings::ContextServerSettings { - command: Some(settings::CommandSettings { - path: command.path.to_str().map(|path| path.to_string()), - arguments: Some(command.args), - env: command.env.map(|env| env.into_iter().collect()), - }), - settings: None, - })?), - project::project_settings::ContextServerSettings::Extension { - enabled: _, - settings, - } => Ok(serde_json::to_string(&settings::ContextServerSettings { - command: None, - settings: Some(settings), - })?), - project::project_settings::ContextServerSettings::Http { .. } => { - bail!("remote context server settings not supported in 0.6.0") - } - } - } - _ => { - bail!("Unknown settings category: {}", category); - } - }) - } - .boxed_local() - }) - .await? - .to_wasmtime_result() + latest::ExtensionImports::get_settings( + self, + location.map(|location| location.into()), + category, + key, + ) + .await } async fn set_language_server_installation_status( @@ -1011,18 +199,12 @@ impl ExtensionImports for WasmState { server_name: String, status: LanguageServerInstallationStatus, ) -> wasmtime::Result<()> { - let status = match status { - LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, - LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, - LanguageServerInstallationStatus::None => BinaryStatus::None, - LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, - }; - - self.host - .proxy - .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status); - - Ok(()) + latest::ExtensionImports::set_language_server_installation_status( + self, + server_name, + status.into(), + ) + .await } async fn download_file( @@ -1031,79 +213,10 @@ impl ExtensionImports for WasmState { path: String, file_type: DownloadedFileType, ) -> wasmtime::Result> { - maybe!(async { - let parsed_url = Url::parse(&url)?; - self.capability_granter.grant_download_file(&parsed_url)?; - - let path = PathBuf::from(path); - let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref()); - - self.host.fs.create_dir(&extension_work_dir).await?; - - let destination_path = self - .host - .writeable_path_from_extension(&self.manifest.id, &path)?; - - let mut response = self - .host - .http_client - .get(&url, Default::default(), true) - .await - .context("downloading release")?; - - anyhow::ensure!( - response.status().is_success(), - "download failed with status {}", - response.status() - ); - let body = BufReader::new(response.body_mut()); - - match file_type { - DownloadedFileType::Uncompressed => { - futures::pin_mut!(body); - self.host - .fs - .create_file_with(&destination_path, body) - .await?; - } - DownloadedFileType::Gzip => { - let body = GzipDecoder::new(body); - futures::pin_mut!(body); - self.host - .fs - .create_file_with(&destination_path, body) - .await?; - } - DownloadedFileType::GzipTar => { - let body = GzipDecoder::new(body); - futures::pin_mut!(body); - self.host - .fs - .extract_tar_file(&destination_path, Archive::new(body)) - .await?; - } - DownloadedFileType::Zip => { - futures::pin_mut!(body); - extract_zip(&destination_path, body) - .await - .with_context(|| format!("unzipping {path:?} archive"))?; - } - } - - Ok(()) - }) - .await - .to_wasmtime_result() + latest::ExtensionImports::download_file(self, url, path, file_type.into()).await } async fn make_file_executable(&mut self, path: String) -> wasmtime::Result> { - let path = self - .host - .writeable_path_from_extension(&self.manifest.id, Path::new(&path))?; - - make_file_executable(&path) - .await - .with_context(|| format!("setting permissions for path {path:?}")) - .to_wasmtime_result() + latest::ExtensionImports::make_file_executable(self, path).await } } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs new file mode 100644 index 0000000000000000000000000000000000000000..8b3f8e86b71e959eade1e5d3710ce66b5b2d3008 --- /dev/null +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -0,0 +1,1111 @@ +use crate::wasm_host::wit::since_v0_6_0::{ + dap::{ + BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, StartDebuggingRequestArguments, + TcpArguments, TcpArgumentsTemplate, + }, + slash_command::SlashCommandOutputSection, +}; +use crate::wasm_host::wit::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind}; +use crate::wasm_host::{WasmState, wit::ToWasmtimeResult}; +use ::http_client::{AsyncBody, HttpRequestExt}; +use ::settings::{Settings, WorktreeId}; +use anyhow::{Context as _, Result, bail}; +use async_compression::futures::bufread::GzipDecoder; +use async_tar::Archive; +use async_trait::async_trait; +use extension::{ + ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate, +}; +use futures::{AsyncReadExt, lock::Mutex}; +use futures::{FutureExt as _, io::BufReader}; +use gpui::{BackgroundExecutor, SharedString}; +use language::{BinaryStatus, LanguageName, language_settings::AllLanguageSettings}; +use project::project_settings::ProjectSettings; +use semver::Version; +use std::{ + env, + net::Ipv4Addr, + path::{Path, PathBuf}, + str::FromStr, + sync::{Arc, OnceLock}, +}; +use task::{SpawnInTerminal, ZedDebugConfig}; +use url::Url; +use util::{ + archive::extract_zip, fs::make_file_executable, maybe, paths::PathStyle, rel_path::RelPath, +}; +use wasmtime::component::{Linker, Resource}; + +pub const MIN_VERSION: Version = Version::new(0, 8, 0); +pub const MAX_VERSION: Version = Version::new(0, 8, 0); + +wasmtime::component::bindgen!({ + async: true, + trappable_imports: true, + path: "../extension_api/wit/since_v0.8.0", + with: { + "worktree": ExtensionWorktree, + "project": ExtensionProject, + "key-value-store": ExtensionKeyValueStore, + "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream + }, +}); + +pub use self::zed::extension::*; + +mod settings { + #![allow(dead_code)] + include!(concat!(env!("OUT_DIR"), "/since_v0.8.0/settings.rs")); +} + +pub type ExtensionWorktree = Arc; +pub type ExtensionProject = Arc; +pub type ExtensionKeyValueStore = Arc; +pub type ExtensionHttpResponseStream = Arc>>; + +pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { + static LINKER: OnceLock> = OnceLock::new(); + LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) +} + +impl From for std::ops::Range { + fn from(range: Range) -> Self { + let start = range.start as usize; + let end = range.end as usize; + start..end + } +} + +impl From for extension::Command { + fn from(value: Command) -> Self { + Self { + command: value.command.into(), + args: value.args, + env: value.env, + } + } +} + +impl From + for extension::StartDebuggingRequestArgumentsRequest +{ + fn from(value: StartDebuggingRequestArgumentsRequest) -> Self { + match value { + StartDebuggingRequestArgumentsRequest::Launch => Self::Launch, + StartDebuggingRequestArgumentsRequest::Attach => Self::Attach, + } + } +} +impl TryFrom for extension::StartDebuggingRequestArguments { + type Error = anyhow::Error; + + fn try_from(value: StartDebuggingRequestArguments) -> Result { + Ok(Self { + configuration: serde_json::from_str(&value.configuration)?, + request: value.request.into(), + }) + } +} +impl From for extension::TcpArguments { + fn from(value: TcpArguments) -> Self { + Self { + host: value.host.into(), + port: value.port, + timeout: value.timeout, + } + } +} + +impl From for TcpArgumentsTemplate { + fn from(value: extension::TcpArgumentsTemplate) -> Self { + Self { + host: value.host.map(Ipv4Addr::to_bits), + port: value.port, + timeout: value.timeout, + } + } +} + +impl From for extension::TcpArgumentsTemplate { + fn from(value: TcpArgumentsTemplate) -> Self { + Self { + host: value.host.map(Ipv4Addr::from_bits), + port: value.port, + timeout: value.timeout, + } + } +} + +impl TryFrom for DebugTaskDefinition { + type Error = anyhow::Error; + fn try_from(value: extension::DebugTaskDefinition) -> Result { + Ok(Self { + label: value.label.to_string(), + adapter: value.adapter.to_string(), + config: value.config.to_string(), + tcp_connection: value.tcp_connection.map(Into::into), + }) + } +} + +impl From for DebugRequest { + fn from(value: task::DebugRequest) -> Self { + match value { + task::DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), + task::DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), + } + } +} + +impl From for task::DebugRequest { + fn from(value: DebugRequest) -> Self { + match value { + DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), + DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), + } + } +} + +impl From for LaunchRequest { + fn from(value: task::LaunchRequest) -> Self { + Self { + program: value.program, + cwd: value.cwd.map(|p| p.to_string_lossy().into_owned()), + args: value.args, + envs: value.env.into_iter().collect(), + } + } +} + +impl From for AttachRequest { + fn from(value: task::AttachRequest) -> Self { + Self { + process_id: value.process_id, + } + } +} + +impl From for task::LaunchRequest { + fn from(value: LaunchRequest) -> Self { + Self { + program: value.program, + cwd: value.cwd.map(|p| p.into()), + args: value.args, + env: value.envs.into_iter().collect(), + } + } +} +impl From for task::AttachRequest { + fn from(value: AttachRequest) -> Self { + Self { + process_id: value.process_id, + } + } +} + +impl From for DebugConfig { + fn from(value: ZedDebugConfig) -> Self { + Self { + label: value.label.into(), + adapter: value.adapter.into(), + request: value.request.into(), + stop_on_entry: value.stop_on_entry, + } + } +} +impl TryFrom for extension::DebugAdapterBinary { + type Error = anyhow::Error; + fn try_from(value: DebugAdapterBinary) -> Result { + Ok(Self { + command: value.command, + arguments: value.arguments, + envs: value.envs.into_iter().collect(), + cwd: value.cwd.map(|s| s.into()), + connection: value.connection.map(Into::into), + request_args: value.request_args.try_into()?, + }) + } +} + +impl From for extension::BuildTaskDefinition { + fn from(value: BuildTaskDefinition) -> Self { + match value { + BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), + BuildTaskDefinition::Template(build_task_template) => Self::Template { + task_template: build_task_template.template.into(), + locator_name: build_task_template.locator_name.map(SharedString::from), + }, + } + } +} + +impl From for BuildTaskDefinition { + fn from(value: extension::BuildTaskDefinition) -> Self { + match value { + extension::BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), + extension::BuildTaskDefinition::Template { + task_template, + locator_name, + } => Self::Template(BuildTaskDefinitionTemplatePayload { + template: task_template.into(), + locator_name: locator_name.map(String::from), + }), + } + } +} +impl From for extension::BuildTaskTemplate { + fn from(value: BuildTaskTemplate) -> Self { + Self { + label: value.label, + command: value.command, + args: value.args, + env: value.env.into_iter().collect(), + cwd: value.cwd, + ..Default::default() + } + } +} +impl From for BuildTaskTemplate { + fn from(value: extension::BuildTaskTemplate) -> Self { + Self { + label: value.label, + command: value.command, + args: value.args, + env: value.env.into_iter().collect(), + cwd: value.cwd, + } + } +} + +impl TryFrom for extension::DebugScenario { + type Error = anyhow::Error; + + fn try_from(value: DebugScenario) -> std::result::Result { + Ok(Self { + adapter: value.adapter.into(), + label: value.label.into(), + build: value.build.map(Into::into), + config: serde_json::Value::from_str(&value.config)?, + tcp_connection: value.tcp_connection.map(Into::into), + }) + } +} + +impl From for DebugScenario { + fn from(value: extension::DebugScenario) -> Self { + Self { + adapter: value.adapter.into(), + label: value.label.into(), + build: value.build.map(Into::into), + config: value.config.to_string(), + tcp_connection: value.tcp_connection.map(Into::into), + } + } +} + +impl TryFrom for ResolvedTask { + type Error = anyhow::Error; + + fn try_from(value: SpawnInTerminal) -> Result { + Ok(Self { + label: value.label, + command: value.command.context("missing command")?, + args: value.args, + env: value.env.into_iter().collect(), + cwd: value.cwd.map(|s| { + let s = s.to_string_lossy(); + if cfg!(target_os = "windows") { + s.replace('\\', "/") + } else { + s.into_owned() + } + }), + }) + } +} + +impl From for extension::CodeLabel { + fn from(value: CodeLabel) -> Self { + Self { + code: value.code, + spans: value.spans.into_iter().map(Into::into).collect(), + filter_range: value.filter_range.into(), + } + } +} + +impl From for extension::CodeLabelSpan { + fn from(value: CodeLabelSpan) -> Self { + match value { + CodeLabelSpan::CodeRange(range) => Self::CodeRange(range.into()), + CodeLabelSpan::Literal(literal) => Self::Literal(literal.into()), + } + } +} + +impl From for extension::CodeLabelSpanLiteral { + fn from(value: CodeLabelSpanLiteral) -> Self { + Self { + text: value.text, + highlight_name: value.highlight_name, + } + } +} + +impl From for Completion { + fn from(value: extension::Completion) -> Self { + Self { + label: value.label, + label_details: value.label_details.map(Into::into), + detail: value.detail, + kind: value.kind.map(Into::into), + insert_text_format: value.insert_text_format.map(Into::into), + } + } +} + +impl From for CompletionLabelDetails { + fn from(value: extension::CompletionLabelDetails) -> Self { + Self { + detail: value.detail, + description: value.description, + } + } +} + +impl From for CompletionKind { + fn from(value: extension::CompletionKind) -> Self { + match value { + extension::CompletionKind::Text => Self::Text, + extension::CompletionKind::Method => Self::Method, + extension::CompletionKind::Function => Self::Function, + extension::CompletionKind::Constructor => Self::Constructor, + extension::CompletionKind::Field => Self::Field, + extension::CompletionKind::Variable => Self::Variable, + extension::CompletionKind::Class => Self::Class, + extension::CompletionKind::Interface => Self::Interface, + extension::CompletionKind::Module => Self::Module, + extension::CompletionKind::Property => Self::Property, + extension::CompletionKind::Unit => Self::Unit, + extension::CompletionKind::Value => Self::Value, + extension::CompletionKind::Enum => Self::Enum, + extension::CompletionKind::Keyword => Self::Keyword, + extension::CompletionKind::Snippet => Self::Snippet, + extension::CompletionKind::Color => Self::Color, + extension::CompletionKind::File => Self::File, + extension::CompletionKind::Reference => Self::Reference, + extension::CompletionKind::Folder => Self::Folder, + extension::CompletionKind::EnumMember => Self::EnumMember, + extension::CompletionKind::Constant => Self::Constant, + extension::CompletionKind::Struct => Self::Struct, + extension::CompletionKind::Event => Self::Event, + extension::CompletionKind::Operator => Self::Operator, + extension::CompletionKind::TypeParameter => Self::TypeParameter, + extension::CompletionKind::Other(value) => Self::Other(value), + } + } +} + +impl From for InsertTextFormat { + fn from(value: extension::InsertTextFormat) -> Self { + match value { + extension::InsertTextFormat::PlainText => Self::PlainText, + extension::InsertTextFormat::Snippet => Self::Snippet, + extension::InsertTextFormat::Other(value) => Self::Other(value), + } + } +} + +impl From for Symbol { + fn from(value: extension::Symbol) -> Self { + Self { + kind: value.kind.into(), + name: value.name, + } + } +} + +impl From for SymbolKind { + fn from(value: extension::SymbolKind) -> Self { + match value { + extension::SymbolKind::File => Self::File, + extension::SymbolKind::Module => Self::Module, + extension::SymbolKind::Namespace => Self::Namespace, + extension::SymbolKind::Package => Self::Package, + extension::SymbolKind::Class => Self::Class, + extension::SymbolKind::Method => Self::Method, + extension::SymbolKind::Property => Self::Property, + extension::SymbolKind::Field => Self::Field, + extension::SymbolKind::Constructor => Self::Constructor, + extension::SymbolKind::Enum => Self::Enum, + extension::SymbolKind::Interface => Self::Interface, + extension::SymbolKind::Function => Self::Function, + extension::SymbolKind::Variable => Self::Variable, + extension::SymbolKind::Constant => Self::Constant, + extension::SymbolKind::String => Self::String, + extension::SymbolKind::Number => Self::Number, + extension::SymbolKind::Boolean => Self::Boolean, + extension::SymbolKind::Array => Self::Array, + extension::SymbolKind::Object => Self::Object, + extension::SymbolKind::Key => Self::Key, + extension::SymbolKind::Null => Self::Null, + extension::SymbolKind::EnumMember => Self::EnumMember, + extension::SymbolKind::Struct => Self::Struct, + extension::SymbolKind::Event => Self::Event, + extension::SymbolKind::Operator => Self::Operator, + extension::SymbolKind::TypeParameter => Self::TypeParameter, + extension::SymbolKind::Other(value) => Self::Other(value), + } + } +} + +impl From for SlashCommand { + fn from(value: extension::SlashCommand) -> Self { + Self { + name: value.name, + description: value.description, + tooltip_text: value.tooltip_text, + requires_argument: value.requires_argument, + } + } +} + +impl From for extension::SlashCommandOutput { + fn from(value: SlashCommandOutput) -> Self { + Self { + text: value.text, + sections: value.sections.into_iter().map(Into::into).collect(), + } + } +} + +impl From for extension::SlashCommandOutputSection { + fn from(value: SlashCommandOutputSection) -> Self { + Self { + range: value.range.start as usize..value.range.end as usize, + label: value.label, + } + } +} + +impl From for extension::SlashCommandArgumentCompletion { + fn from(value: SlashCommandArgumentCompletion) -> Self { + Self { + label: value.label, + new_text: value.new_text, + run_command: value.run_command, + } + } +} + +impl TryFrom for extension::ContextServerConfiguration { + type Error = anyhow::Error; + + fn try_from(value: ContextServerConfiguration) -> Result { + let settings_schema: serde_json::Value = serde_json::from_str(&value.settings_schema) + .context("Failed to parse settings_schema")?; + + Ok(Self { + installation_instructions: value.installation_instructions, + default_settings: value.default_settings, + settings_schema, + }) + } +} + +impl HostKeyValueStore for WasmState { + async fn insert( + &mut self, + kv_store: Resource, + key: String, + value: String, + ) -> wasmtime::Result> { + let kv_store = self.table.get(&kv_store)?; + kv_store.insert(key, value).await.to_wasmtime_result() + } + + async fn drop(&mut self, _worktree: Resource) -> Result<()> { + // We only ever hand out borrows of key-value stores. + Ok(()) + } +} + +impl HostProject for WasmState { + async fn worktree_ids( + &mut self, + project: Resource, + ) -> wasmtime::Result> { + let project = self.table.get(&project)?; + Ok(project.worktree_ids()) + } + + async fn drop(&mut self, _project: Resource) -> Result<()> { + // We only ever hand out borrows of projects. + Ok(()) + } +} + +impl HostWorktree for WasmState { + async fn id(&mut self, delegate: Resource>) -> wasmtime::Result { + let delegate = self.table.get(&delegate)?; + Ok(delegate.id()) + } + + async fn root_path( + &mut self, + delegate: Resource>, + ) -> wasmtime::Result { + let delegate = self.table.get(&delegate)?; + Ok(delegate.root_path()) + } + + async fn read_text_file( + &mut self, + delegate: Resource>, + path: String, + ) -> wasmtime::Result> { + let delegate = self.table.get(&delegate)?; + Ok(delegate + .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Posix)?) + .await + .map_err(|error| error.to_string())) + } + + async fn shell_env( + &mut self, + delegate: Resource>, + ) -> wasmtime::Result { + let delegate = self.table.get(&delegate)?; + Ok(delegate.shell_env().await.into_iter().collect()) + } + + async fn which( + &mut self, + delegate: Resource>, + binary_name: String, + ) -> wasmtime::Result> { + let delegate = self.table.get(&delegate)?; + Ok(delegate.which(binary_name).await) + } + + async fn drop(&mut self, _worktree: Resource) -> Result<()> { + // We only ever hand out borrows of worktrees. + Ok(()) + } +} + +impl common::Host for WasmState {} + +impl http_client::Host for WasmState { + async fn fetch( + &mut self, + request: http_client::HttpRequest, + ) -> wasmtime::Result> { + maybe!(async { + let url = &request.url; + let request = convert_request(&request)?; + let mut response = self.host.http_client.send(request).await?; + + if response.status().is_client_error() || response.status().is_server_error() { + bail!("failed to fetch '{url}': status code {}", response.status()) + } + convert_response(&mut response).await + }) + .await + .to_wasmtime_result() + } + + async fn fetch_stream( + &mut self, + request: http_client::HttpRequest, + ) -> wasmtime::Result, String>> { + let request = convert_request(&request)?; + let response = self.host.http_client.send(request); + maybe!(async { + let response = response.await?; + let stream = Arc::new(Mutex::new(response)); + let resource = self.table.push(stream)?; + Ok(resource) + }) + .await + .to_wasmtime_result() + } +} + +impl http_client::HostHttpResponseStream for WasmState { + async fn next_chunk( + &mut self, + resource: Resource, + ) -> wasmtime::Result>, String>> { + let stream = self.table.get(&resource)?.clone(); + maybe!(async move { + let mut response = stream.lock().await; + let mut buffer = vec![0; 8192]; // 8KB buffer + let bytes_read = response.body_mut().read(&mut buffer).await?; + if bytes_read == 0 { + Ok(None) + } else { + buffer.truncate(bytes_read); + Ok(Some(buffer)) + } + }) + .await + .to_wasmtime_result() + } + + async fn drop(&mut self, _resource: Resource) -> Result<()> { + Ok(()) + } +} + +impl From for ::http_client::Method { + fn from(value: http_client::HttpMethod) -> Self { + match value { + http_client::HttpMethod::Get => Self::GET, + http_client::HttpMethod::Post => Self::POST, + http_client::HttpMethod::Put => Self::PUT, + http_client::HttpMethod::Delete => Self::DELETE, + http_client::HttpMethod::Head => Self::HEAD, + http_client::HttpMethod::Options => Self::OPTIONS, + http_client::HttpMethod::Patch => Self::PATCH, + } + } +} + +fn convert_request( + extension_request: &http_client::HttpRequest, +) -> anyhow::Result<::http_client::Request> { + let mut request = ::http_client::Request::builder() + .method(::http_client::Method::from(extension_request.method)) + .uri(&extension_request.url) + .follow_redirects(match extension_request.redirect_policy { + http_client::RedirectPolicy::NoFollow => ::http_client::RedirectPolicy::NoFollow, + http_client::RedirectPolicy::FollowLimit(limit) => { + ::http_client::RedirectPolicy::FollowLimit(limit) + } + http_client::RedirectPolicy::FollowAll => ::http_client::RedirectPolicy::FollowAll, + }); + for (key, value) in &extension_request.headers { + request = request.header(key, value); + } + let body = extension_request + .body + .clone() + .map(AsyncBody::from) + .unwrap_or_default(); + request.body(body).map_err(anyhow::Error::from) +} + +async fn convert_response( + response: &mut ::http_client::Response, +) -> anyhow::Result { + let mut extension_response = http_client::HttpResponse { + body: Vec::new(), + headers: Vec::new(), + }; + + for (key, value) in response.headers() { + extension_response + .headers + .push((key.to_string(), value.to_str().unwrap_or("").to_string())); + } + + response + .body_mut() + .read_to_end(&mut extension_response.body) + .await?; + + Ok(extension_response) +} + +impl nodejs::Host for WasmState { + async fn node_binary_path(&mut self) -> wasmtime::Result> { + self.host + .node_runtime + .binary_path() + .await + .map(|path| path.to_string_lossy().into_owned()) + .to_wasmtime_result() + } + + async fn npm_package_latest_version( + &mut self, + package_name: String, + ) -> wasmtime::Result> { + self.host + .node_runtime + .npm_package_latest_version(&package_name) + .await + .map(|v| v.to_string()) + .to_wasmtime_result() + } + + async fn npm_package_installed_version( + &mut self, + package_name: String, + ) -> wasmtime::Result, String>> { + self.host + .node_runtime + .npm_package_installed_version(&self.work_dir(), &package_name) + .await + .map(|option| option.map(|version| version.to_string())) + .to_wasmtime_result() + } + + async fn npm_install_package( + &mut self, + package_name: String, + version: String, + ) -> wasmtime::Result> { + self.capability_granter + .grant_npm_install_package(&package_name)?; + + self.host + .node_runtime + .npm_install_packages(&self.work_dir(), &[(&package_name, &version)]) + .await + .to_wasmtime_result() + } +} + +#[async_trait] +impl lsp::Host for WasmState {} + +impl From<::http_client::github::GithubRelease> for github::GithubRelease { + fn from(value: ::http_client::github::GithubRelease) -> Self { + Self { + version: value.tag_name, + assets: value.assets.into_iter().map(Into::into).collect(), + } + } +} + +impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAsset { + fn from(value: ::http_client::github::GithubReleaseAsset) -> Self { + Self { + name: value.name, + download_url: value.browser_download_url, + } + } +} + +impl github::Host for WasmState { + async fn latest_github_release( + &mut self, + repo: String, + options: github::GithubReleaseOptions, + ) -> wasmtime::Result> { + maybe!(async { + let release = ::http_client::github::latest_github_release( + &repo, + options.require_assets, + options.pre_release, + self.host.http_client.clone(), + ) + .await?; + Ok(release.into()) + }) + .await + .to_wasmtime_result() + } + + async fn github_release_by_tag_name( + &mut self, + repo: String, + tag: String, + ) -> wasmtime::Result> { + maybe!(async { + let release = ::http_client::github::get_release_by_tag_name( + &repo, + &tag, + self.host.http_client.clone(), + ) + .await?; + Ok(release.into()) + }) + .await + .to_wasmtime_result() + } +} + +impl platform::Host for WasmState { + async fn current_platform(&mut self) -> Result<(platform::Os, platform::Architecture)> { + Ok(( + match env::consts::OS { + "macos" => platform::Os::Mac, + "linux" => platform::Os::Linux, + "windows" => platform::Os::Windows, + _ => panic!("unsupported os"), + }, + match env::consts::ARCH { + "aarch64" => platform::Architecture::Aarch64, + "x86" => platform::Architecture::X86, + "x86_64" => platform::Architecture::X8664, + _ => panic!("unsupported architecture"), + }, + )) + } +} + +impl From for process::Output { + fn from(output: std::process::Output) -> Self { + Self { + status: output.status.code(), + stdout: output.stdout, + stderr: output.stderr, + } + } +} + +impl process::Host for WasmState { + async fn run_command( + &mut self, + command: process::Command, + ) -> wasmtime::Result> { + maybe!(async { + self.capability_granter + .grant_exec(&command.command, &command.args)?; + + let output = util::command::new_smol_command(command.command.as_str()) + .args(&command.args) + .envs(command.env) + .output() + .await?; + + Ok(output.into()) + }) + .await + .to_wasmtime_result() + } +} + +#[async_trait] +impl slash_command::Host for WasmState {} + +#[async_trait] +impl context_server::Host for WasmState {} + +impl dap::Host for WasmState { + async fn resolve_tcp_template( + &mut self, + template: TcpArgumentsTemplate, + ) -> wasmtime::Result> { + maybe!(async { + let (host, port, timeout) = + ::dap::configure_tcp_connection(task::TcpArgumentsTemplate { + port: template.port, + host: template.host.map(Ipv4Addr::from_bits), + timeout: template.timeout, + }) + .await?; + Ok(TcpArguments { + port, + host: host.to_bits(), + timeout, + }) + }) + .await + .to_wasmtime_result() + } +} + +impl ExtensionImports for WasmState { + async fn get_settings( + &mut self, + location: Option, + category: String, + key: Option, + ) -> wasmtime::Result> { + self.on_main_thread(|cx| { + async move { + let path = location.as_ref().and_then(|location| { + RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() + }); + let location = path + .as_ref() + .zip(location.as_ref()) + .map(|(path, location)| ::settings::SettingsLocation { + worktree_id: WorktreeId::from_proto(location.worktree_id), + path, + }); + + cx.update(|cx| match category.as_str() { + "language" => { + let key = key.map(|k| LanguageName::new(&k)); + let settings = AllLanguageSettings::get(location, cx).language( + location, + key.as_ref(), + cx, + ); + Ok(serde_json::to_string(&settings::LanguageSettings { + tab_size: settings.tab_size, + })?) + } + "lsp" => { + let settings = key + .and_then(|key| { + ProjectSettings::get(location, cx) + .lsp + .get(&::lsp::LanguageServerName::from_proto(key)) + }) + .cloned() + .unwrap_or_default(); + Ok(serde_json::to_string(&settings::LspSettings { + binary: settings.binary.map(|binary| settings::CommandSettings { + path: binary.path, + arguments: binary.arguments, + env: binary.env.map(|env| env.into_iter().collect()), + }), + settings: settings.settings, + initialization_options: settings.initialization_options, + })?) + } + "context_servers" => { + let settings = key + .and_then(|key| { + ProjectSettings::get(location, cx) + .context_servers + .get(key.as_str()) + }) + .cloned() + .unwrap_or_else(|| { + project::project_settings::ContextServerSettings::default_extension( + ) + }); + + match settings { + project::project_settings::ContextServerSettings::Stdio { + enabled: _, + command, + } => Ok(serde_json::to_string(&settings::ContextServerSettings { + command: Some(settings::CommandSettings { + path: command.path.to_str().map(|path| path.to_string()), + arguments: Some(command.args), + env: command.env.map(|env| env.into_iter().collect()), + }), + settings: None, + })?), + project::project_settings::ContextServerSettings::Extension { + enabled: _, + settings, + } => Ok(serde_json::to_string(&settings::ContextServerSettings { + command: None, + settings: Some(settings), + })?), + project::project_settings::ContextServerSettings::Http { .. } => { + bail!("remote context server settings not supported in 0.6.0") + } + } + } + _ => { + bail!("Unknown settings category: {}", category); + } + }) + } + .boxed_local() + }) + .await? + .to_wasmtime_result() + } + + async fn set_language_server_installation_status( + &mut self, + server_name: String, + status: LanguageServerInstallationStatus, + ) -> wasmtime::Result<()> { + let status = match status { + LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, + LanguageServerInstallationStatus::None => BinaryStatus::None, + LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, + }; + + self.host + .proxy + .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status); + + Ok(()) + } + + async fn download_file( + &mut self, + url: String, + path: String, + file_type: DownloadedFileType, + ) -> wasmtime::Result> { + maybe!(async { + let parsed_url = Url::parse(&url)?; + self.capability_granter.grant_download_file(&parsed_url)?; + + let path = PathBuf::from(path); + let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref()); + + self.host.fs.create_dir(&extension_work_dir).await?; + + let destination_path = self + .host + .writeable_path_from_extension(&self.manifest.id, &path)?; + + let mut response = self + .host + .http_client + .get(&url, Default::default(), true) + .await + .context("downloading release")?; + + anyhow::ensure!( + response.status().is_success(), + "download failed with status {}", + response.status() + ); + let body = BufReader::new(response.body_mut()); + + match file_type { + DownloadedFileType::Uncompressed => { + futures::pin_mut!(body); + self.host + .fs + .create_file_with(&destination_path, body) + .await?; + } + DownloadedFileType::Gzip => { + let body = GzipDecoder::new(body); + futures::pin_mut!(body); + self.host + .fs + .create_file_with(&destination_path, body) + .await?; + } + DownloadedFileType::GzipTar => { + let body = GzipDecoder::new(body); + futures::pin_mut!(body); + self.host + .fs + .extract_tar_file(&destination_path, Archive::new(body)) + .await?; + } + DownloadedFileType::Zip => { + futures::pin_mut!(body); + extract_zip(&destination_path, body) + .await + .with_context(|| format!("unzipping {path:?} archive"))?; + } + } + + Ok(()) + }) + .await + .to_wasmtime_result() + } + + async fn make_file_executable(&mut self, path: String) -> wasmtime::Result> { + let path = self + .host + .writeable_path_from_extension(&self.manifest.id, Path::new(&path))?; + + make_file_executable(&path) + .await + .with_context(|| format!("setting permissions for path {path:?}")) + .to_wasmtime_result() + } +} diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index e6d30527e0d7672255bf8f61cfd56fe06b409920..3dd4803ce17adb053f86f29e3724d58d479136c6 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -229,8 +229,10 @@ enum Feature { AgentClaude, AgentCodex, AgentGemini, + ExtensionBasedpyright, ExtensionRuff, ExtensionTailwind, + ExtensionTy, Git, LanguageBash, LanguageC, @@ -251,8 +253,13 @@ fn keywords_by_feature() -> &'static BTreeMap> { (Feature::AgentClaude, vec!["claude", "claude code"]), (Feature::AgentCodex, vec!["codex", "codex cli"]), (Feature::AgentGemini, vec!["gemini", "gemini cli"]), + ( + Feature::ExtensionBasedpyright, + vec!["basedpyright", "pyright"], + ), (Feature::ExtensionRuff, vec!["ruff"]), (Feature::ExtensionTailwind, vec!["tail", "tailwind"]), + (Feature::ExtensionTy, vec!["ty"]), (Feature::Git, vec!["git"]), (Feature::LanguageBash, vec!["sh", "bash"]), (Feature::LanguageC, vec!["c", "clang"]), @@ -732,7 +739,7 @@ impl ExtensionsPage { extension: &ExtensionMetadata, cx: &mut Context, ) -> ExtensionCard { - let this = cx.entity(); + let this = cx.weak_entity(); let status = Self::extension_status(&extension.id, cx); let has_dev_extension = Self::dev_extension_exists(&extension.id, cx); @@ -882,13 +889,15 @@ impl ExtensionsPage { y: px(2.0), }) .menu(move |window, cx| { - Some(Self::render_remote_extension_context_menu( - &this, - extension_id.clone(), - authors.clone(), - window, - cx, - )) + this.upgrade().map(|this| { + Self::render_remote_extension_context_menu( + &this, + extension_id.clone(), + authors.clone(), + window, + cx, + ) + }) }), ), ), @@ -1364,6 +1373,23 @@ impl ExtensionsPage { return; }; + if let Some(id) = search.strip_prefix("id:") { + self.upsells.clear(); + + let upsell = match id.to_lowercase().as_str() { + "ruff" => Some(Feature::ExtensionRuff), + "basedpyright" => Some(Feature::ExtensionBasedpyright), + "ty" => Some(Feature::ExtensionTy), + _ => None, + }; + + if let Some(upsell) = upsell { + self.upsells.insert(upsell); + } + + return; + } + let search = search.to_lowercase(); let search_terms = search .split_whitespace() @@ -1446,8 +1472,7 @@ impl ExtensionsPage { }, ); }, - )) - .color(ui::SwitchColor::Accent), + )), ), ), ) @@ -1482,6 +1507,12 @@ impl ExtensionsPage { false, cx, ), + Feature::ExtensionBasedpyright => self.render_feature_upsell_banner( + "Basedpyright (Python language server) support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python#basedpyright".into(), + false, + cx, + ), Feature::ExtensionRuff => self.render_feature_upsell_banner( "Ruff (linter for Python) support is built-in to Zed!".into(), "https://zed.dev/docs/languages/python#code-formatting--linting".into(), @@ -1494,6 +1525,12 @@ impl ExtensionsPage { false, cx, ), + Feature::ExtensionTy => self.render_feature_upsell_banner( + "Ty (Python language server) support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python".into(), + false, + cx, + ), Feature::Git => self.render_feature_upsell_banner( "Zed comes with basic Git support—more features are coming in the future." .into(), diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 47b6f1230ac747c2633327d1be923d33388cf179..1768e43d1d0a88433d61c6390f912377c2ba55e3 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -1,11 +1,5 @@ use crate::FeatureFlag; -pub struct PredictEditsRateCompletionsFeatureFlag; - -impl FeatureFlag for PredictEditsRateCompletionsFeatureFlag { - const NAME: &'static str = "predict-edits-rate-completions"; -} - pub struct NotebookFeatureFlag; impl FeatureFlag for NotebookFeatureFlag { @@ -17,3 +11,15 @@ pub struct PanicFeatureFlag; impl FeatureFlag for PanicFeatureFlag { const NAME: &'static str = "panic"; } + +pub struct InlineAssistantUseToolFeatureFlag; + +impl FeatureFlag for InlineAssistantUseToolFeatureFlag { + const NAME: &'static str = "inline-assistant-use-tool"; +} + +pub struct AgentV2FeatureFlag; + +impl FeatureFlag for AgentV2FeatureFlag { + const NAME: &'static str = "agent-v2"; +} diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 050d7a45a1b46e94a195f88e49fd6795ce37f09f..1bfd41fa2709e4c46b5177bee6851d91dc86bccb 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1713,7 +1713,7 @@ impl PickerDelegate for FileFinderDelegate { ui::IconPosition::End, Some(ToggleIncludeIgnored.boxed_clone()), move |window, cx| { - window.focus(&focus_handle); + window.focus(&focus_handle, cx); window.dispatch_action( ToggleIncludeIgnored.boxed_clone(), cx, @@ -1760,16 +1760,19 @@ impl PickerDelegate for FileFinderDelegate { menu.context(focus_handle) .action( "Split Left", - pane::SplitLeft.boxed_clone(), + pane::SplitLeft::default().boxed_clone(), ) .action( "Split Right", - pane::SplitRight.boxed_clone(), + pane::SplitRight::default().boxed_clone(), + ) + .action( + "Split Up", + pane::SplitUp::default().boxed_clone(), ) - .action("Split Up", pane::SplitUp.boxed_clone()) .action( "Split Down", - pane::SplitDown.boxed_clone(), + pane::SplitDown::default().boxed_clone(), ) } })) diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 2ae0c47776acb5c58b7d0919aa7522fb64d923d0..f75d0ee99dc32bc1a1ab812328bba3d36fcb2953 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -44,8 +44,9 @@ impl OpenPathDelegate { tx: oneshot::Sender>>, lister: DirectoryLister, creating_path: bool, - path_style: PathStyle, + cx: &App, ) -> Self { + let path_style = lister.path_style(cx); Self { tx: Some(tx), lister, @@ -216,8 +217,7 @@ impl OpenPathPrompt { cx: &mut Context, ) { workspace.toggle_modal(window, cx, |window, cx| { - let delegate = - OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::local()); + let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx); let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.)); let query = lister.default_query(cx); picker.set_query(query, window, cx); diff --git a/crates/file_finder/src/open_path_prompt_tests.rs b/crates/file_finder/src/open_path_prompt_tests.rs index dea188034bfa7ae46f5b17c50424b40331fadb75..9af18c8a6bd82b389d4d18a997c3b5fe4a088730 100644 --- a/crates/file_finder/src/open_path_prompt_tests.rs +++ b/crates/file_finder/src/open_path_prompt_tests.rs @@ -5,7 +5,7 @@ use picker::{Picker, PickerDelegate}; use project::Project; use serde_json::json; use ui::rems; -use util::{path, paths::PathStyle}; +use util::path; use workspace::{AppState, Workspace}; use crate::OpenPathDelegate; @@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); insert_query(path!("sadjaoislkdjasldj"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), Vec::::new()); @@ -119,7 +119,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash. let query = path!("/root"); @@ -227,7 +227,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); // Support both forward and backward slashes. let query = "C:/root/"; @@ -295,56 +295,6 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { ); } -#[gpui::test] -#[cfg_attr(not(target_os = "windows"), ignore)] -async fn test_open_path_prompt_on_windows_with_remote(cx: &mut TestAppContext) { - let app_state = init_test(cx); - app_state - .fs - .as_fake() - .insert_tree( - "/root", - json!({ - "a": "A", - "dir1": {}, - "dir2": {} - }), - ) - .await; - - let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::Posix, cx); - - let query = "/root/"; - insert_query(query, &picker, cx).await; - assert_eq!( - collect_match_candidates(&picker, cx), - vec!["./", "a", "dir1", "dir2"] - ); - assert_eq!( - confirm_completion(query, 1, &picker, cx).unwrap(), - "/root/a" - ); - - // Confirm completion for the query "/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash. - let query = "/root/d"; - insert_query(query, &picker, cx).await; - assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!( - confirm_completion(query, 1, &picker, cx).unwrap(), - "/root/dir2/" - ); - - let query = "/root/d"; - insert_query(query, &picker, cx).await; - assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!( - confirm_completion(query, 0, &picker, cx).unwrap(), - "/root/dir1/" - ); -} - #[gpui::test] async fn test_new_path_prompt(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -372,7 +322,7 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, true, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, true, cx); insert_query(path!("/root"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]); @@ -406,16 +356,15 @@ fn init_test(cx: &mut TestAppContext) -> Arc { fn build_open_path_prompt( project: Entity, creating_path: bool, - path_style: PathStyle, cx: &mut TestAppContext, ) -> (Entity>, &mut VisualTestContext) { let (tx, _) = futures::channel::oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, path_style); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); ( workspace.update_in(cx, |_, window, cx| { + let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx); cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) .width(rems(34.)) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index c641988ab891889b8ebb63c7e9414d69d3107558..576eb34341dca78f0a9de2d8ad4ce28e4eaff7db 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -23,6 +23,7 @@ use std::{ path::PathBuf, sync::{Arc, LazyLock}, }; +use text::LineEnding; use util::{paths::PathStyle, rel_path::RelPath}; pub static LOAD_INDEX_TEXT_TASK: LazyLock = LazyLock::new(TaskLabel::new); @@ -50,6 +51,8 @@ pub struct FakeGitRepositoryState { pub blames: HashMap, pub current_branch_name: Option, pub branches: HashSet, + /// List of remotes, keys are names and values are URLs + pub remotes: HashMap, pub simulated_index_write_error_message: Option, pub refs: HashMap, } @@ -68,6 +71,7 @@ impl FakeGitRepositoryState { refs: HashMap::from_iter([("HEAD".into(), "abc".into())]), merge_base_contents: Default::default(), oids: Default::default(), + remotes: HashMap::default(), } } } @@ -152,8 +156,16 @@ impl GitRepository for FakeGitRepository { }) } - fn remote_url(&self, _name: &str) -> Option { - None + fn remote_url(&self, name: &str) -> BoxFuture<'_, Option> { + let name = name.to_string(); + let fut = self.with_state_async(false, move |state| { + state + .remotes + .get(&name) + .context("remote not found") + .cloned() + }); + async move { fut.await.ok() }.boxed() } fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result> { @@ -197,6 +209,7 @@ impl GitRepository for FakeGitRepository { async { Ok(CommitDetails { sha: commit.into(), + message: "initial commit".into(), ..Default::default() }) } @@ -378,11 +391,18 @@ impl GitRepository for FakeGitRepository { Ok(state .branches .iter() - .map(|branch_name| Branch { - is_head: Some(branch_name) == current_branch.as_ref(), - ref_name: branch_name.into(), - most_recent_commit: None, - upstream: None, + .map(|branch_name| { + let ref_name = if branch_name.starts_with("refs/") { + branch_name.into() + } else { + format!("refs/heads/{branch_name}").into() + }; + Branch { + is_head: Some(branch_name) == current_branch.as_ref(), + ref_name, + most_recent_commit: None, + upstream: None, + } }) .collect()) }) @@ -432,11 +452,21 @@ impl GitRepository for FakeGitRepository { }) } - fn delete_branch(&self, _name: String) -> BoxFuture<'_, Result<()>> { - unimplemented!() + fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + if !state.branches.remove(&name) { + bail!("no such branch: {name}"); + } + Ok(()) + }) } - fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result> { + fn blame( + &self, + path: RepoPath, + _content: Rope, + _line_ending: LineEnding, + ) -> BoxFuture<'_, Result> { self.with_state_async(false, move |state| { state .blames @@ -553,7 +583,7 @@ impl GitRepository for FakeGitRepository { _askpass: AskPassDelegate, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { - unimplemented!() + async { Ok(()) }.boxed() } fn run_hook( @@ -561,7 +591,7 @@ impl GitRepository for FakeGitRepository { _hook: RunHook, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { - unimplemented!() + async { Ok(()) }.boxed() } fn push( @@ -598,15 +628,24 @@ impl GitRepository for FakeGitRepository { unimplemented!() } - fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { - unimplemented!() + fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { + self.with_state_async(false, move |state| { + let remotes = state + .remotes + .keys() + .map(|r| Remote { + name: r.clone().into(), + }) + .collect::>(); + Ok(remotes) + }) } - fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { + fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { unimplemented!() } - fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { + fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { unimplemented!() } @@ -683,6 +722,20 @@ impl GitRepository for FakeGitRepository { fn default_branch(&self) -> BoxFuture<'_, Result>> { async { Ok(Some("main".into())) }.boxed() } + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.remotes.insert(name, url); + Ok(()) + }) + } + + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.remotes.remove(&name); + Ok(()) + }) + } } #[cfg(test)] diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 5be94ab6302b0a950b91e32dc43da374f0c62f29..3b2f318119fc3e1ff75bf19e30798aaf6630dfa7 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -434,7 +434,18 @@ impl RealFs { for component in path.components() { match component { std::path::Component::Prefix(_) => { - let canonicalized = std::fs::canonicalize(component)?; + let component = component.as_os_str(); + let canonicalized = if component + .to_str() + .map(|e| e.ends_with("\\")) + .unwrap_or(false) + { + std::fs::canonicalize(component) + } else { + let mut component = component.to_os_string(); + component.push("\\"); + std::fs::canonicalize(component) + }?; let mut strip = PathBuf::new(); for component in canonicalized.components() { @@ -641,6 +652,8 @@ impl Fs for RealFs { use objc::{class, msg_send, sel, sel_impl}; unsafe { + /// Allow NSString::alloc use here because it sets autorelease + #[allow(clippy::disallowed_methods)] unsafe fn ns_string(string: &str) -> id { unsafe { NSString::alloc(nil).init_str(string).autorelease() } } @@ -803,7 +816,7 @@ impl Fs for RealFs { } let file = smol::fs::File::create(path).await?; let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file); - for chunk in chunks(text, line_ending) { + for chunk in text::chunks_with_line_ending(text, line_ending) { writer.write_all(chunk.as_bytes()).await?; } writer.flush().await?; @@ -1844,6 +1857,18 @@ impl FakeFs { .unwrap(); } + pub fn set_remote_for_repo( + &self, + dot_git: &Path, + name: impl Into, + url: impl Into, + ) { + self.with_git_state(dot_git, true, |state| { + state.remotes.insert(name.into(), url.into()); + }) + .unwrap(); + } + pub fn insert_branches(&self, dot_git: &Path, branches: &[&str]) { self.with_git_state(dot_git, true, |state| { if let Some(first) = branches.first() @@ -2555,7 +2580,7 @@ impl Fs for FakeFs { async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> { self.simulate_random_delay().await; let path = normalize_path(path); - let content = chunks(text, line_ending).collect::(); + let content = text::chunks_with_line_ending(text, line_ending).collect::(); if let Some(path) = path.parent() { self.create_dir(path).await?; } @@ -2773,25 +2798,6 @@ impl Fs for FakeFs { } } -fn chunks(rope: &Rope, line_ending: LineEnding) -> impl Iterator { - rope.chunks().flat_map(move |chunk| { - let mut newline = false; - let end_with_newline = chunk.ends_with('\n').then_some(line_ending.as_str()); - chunk - .lines() - .flat_map(move |line| { - let ending = if newline { - Some(line_ending.as_str()) - } else { - None - }; - newline = true; - ending.into_iter().chain([line]) - }) - .chain(end_with_newline) - }) -} - pub fn normalize_path(path: &Path) -> PathBuf { let mut components = path.components().peekable(); let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { @@ -3411,6 +3417,26 @@ mod tests { assert_eq!(content, "Hello"); } + #[gpui::test] + #[cfg(target_os = "windows")] + async fn test_realfs_canonicalize(executor: BackgroundExecutor) { + use util::paths::SanitizedPath; + + let fs = RealFs { + bundled_git_binary_path: None, + executor, + next_job_id: Arc::new(AtomicUsize::new(0)), + job_event_subscribers: Arc::new(Mutex::new(Vec::new())), + }; + let temp_dir = TempDir::new().unwrap(); + let file = temp_dir.path().join("test (1).txt"); + let file = SanitizedPath::new(&file); + std::fs::write(&file, "test").unwrap(); + + let canonicalized = fs.canonicalize(file.as_path()).await; + assert!(canonicalized.is_ok()); + } + #[gpui::test] async fn test_rename(executor: BackgroundExecutor) { let fs = FakeFs::new(executor.clone()); diff --git a/crates/fuzzy/src/matcher.rs b/crates/fuzzy/src/matcher.rs index eb844e349821394785bb61a34600f04a6fa985eb..782c9caca832d81fb6e4bce8f49b4f310664b292 100644 --- a/crates/fuzzy/src/matcher.rs +++ b/crates/fuzzy/src/matcher.rs @@ -96,7 +96,8 @@ impl<'a> Matcher<'a> { continue; } - let matrix_len = self.query.len() * (prefix.len() + candidate_chars.len()); + let matrix_len = + self.query.len() * (lowercase_prefix.len() + lowercase_candidate_chars.len()); self.score_matrix.clear(); self.score_matrix.resize(matrix_len, None); self.best_position_matrix.clear(); @@ -596,4 +597,15 @@ mod tests { }) .collect() } + + /// Test for https://github.com/zed-industries/zed/issues/44324 + #[test] + fn test_recursive_score_match_index_out_of_bounds() { + let paths = vec!["İ/İ/İ/İ"]; + let query = "İ/İ"; + + // This panicked with "index out of bounds: the len is 21 but the index is 22" + let result = match_single_path_query(query, false, &paths); + let _ = result; + } } diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index e58b9cb7e0427bf3af1c88f473debba0b6f94f59..d6011de98b8c69837d16bf2a2211fc7632726230 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -1,14 +1,13 @@ +use crate::Oid; use crate::commit::get_messages; use crate::repository::RepoPath; -use crate::{GitRemote, Oid}; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; use futures::AsyncWriteExt; -use gpui::SharedString; use serde::{Deserialize, Serialize}; use std::process::Stdio; use std::{ops::Range, path::Path}; -use text::Rope; +use text::{LineEnding, Rope}; use time::OffsetDateTime; use time::UtcOffset; use time::macros::format_description; @@ -19,15 +18,6 @@ pub use git2 as libgit; pub struct Blame { pub entries: Vec, pub messages: HashMap, - pub remote_url: Option, -} - -#[derive(Clone, Debug, Default)] -pub struct ParsedCommitMessage { - pub message: SharedString, - pub permalink: Option, - pub pull_request: Option, - pub remote: Option, } impl Blame { @@ -36,9 +26,10 @@ impl Blame { working_directory: &Path, path: &RepoPath, content: &Rope, - remote_url: Option, + line_ending: LineEnding, ) -> Result { - let output = run_git_blame(git_binary, working_directory, path, content).await?; + let output = + run_git_blame(git_binary, working_directory, path, content, line_ending).await?; let mut entries = parse_git_blame(&output)?; entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); @@ -53,11 +44,7 @@ impl Blame { .await .context("failed to get commit messages")?; - Ok(Self { - entries, - messages, - remote_url, - }) + Ok(Self { entries, messages }) } } @@ -69,12 +56,12 @@ async fn run_git_blame( working_directory: &Path, path: &RepoPath, contents: &Rope, + line_ending: LineEnding, ) -> Result { let mut child = util::command::new_smol_command(git_binary) .current_dir(working_directory) .arg("blame") .arg("--incremental") - .arg("-w") .arg("--contents") .arg("-") .arg(path.as_unix_str()) @@ -89,7 +76,7 @@ async fn run_git_blame( .as_mut() .context("failed to get pipe to stdin of git blame command")?; - for chunk in contents.chunks() { + for chunk in text::chunks_with_line_ending(contents, line_ending) { stdin.write_all(chunk.as_bytes()).await?; } stdin.flush().await?; diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index ece1d76b8ae9c9f40f27178da1ef13fe1a78e659..1b450a3dffb9e9956e5b43aa2797ae02f90e731c 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -1,7 +1,52 @@ -use crate::{Oid, status::StatusCode}; +use crate::{ + BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url, + status::StatusCode, +}; use anyhow::{Context as _, Result}; use collections::HashMap; -use std::path::Path; +use gpui::SharedString; +use std::{path::Path, sync::Arc}; + +#[derive(Clone, Debug, Default)] +pub struct ParsedCommitMessage { + pub message: SharedString, + pub permalink: Option, + pub pull_request: Option, + pub remote: Option, +} + +impl ParsedCommitMessage { + pub fn parse( + sha: String, + message: String, + remote_url: Option<&str>, + provider_registry: Option>, + ) -> Self { + if let Some((hosting_provider, remote)) = provider_registry + .and_then(|reg| remote_url.and_then(|url| parse_git_remote_url(reg, url))) + { + let pull_request = hosting_provider.extract_pull_request(&remote, &message); + Self { + message: message.into(), + permalink: Some( + hosting_provider + .build_commit_permalink(&remote, BuildCommitPermalinkParams { sha: &sha }), + ), + pull_request, + remote: Some(GitRemote { + host: hosting_provider, + owner: remote.owner.into(), + repo: remote.repo.into(), + }), + } + } else { + Self { + message: message.into(), + ..Default::default() + } + } + } +} pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result> { if shas.is_empty() { diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 197ce4d6fb3bb3a41dd0be67e542d91d87561736..805d8d181ab7a434b565d38bdb2f802a8a3cda1a 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -23,6 +23,7 @@ pub const FSMONITOR_DAEMON: &str = "fsmonitor--daemon"; pub const LFS_DIR: &str = "lfs"; pub const COMMIT_MESSAGE: &str = "COMMIT_EDITMSG"; pub const INDEX_LOCK: &str = "index.lock"; +pub const REPO_EXCLUDE: &str = "info/exclude"; actions!( git, @@ -232,14 +233,12 @@ impl From for usize { #[derive(Copy, Clone, Debug)] pub enum RunHook { PreCommit, - PrePush, } impl RunHook { pub fn as_str(&self) -> &str { match self { Self::PreCommit => "pre-commit", - Self::PrePush => "pre-push", } } @@ -250,7 +249,6 @@ impl RunHook { pub fn from_proto(value: i32) -> Option { match value { 0 => Some(Self::PreCommit), - 1 => Some(Self::PrePush), _ => None, } } diff --git a/crates/git/src/remote.rs b/crates/git/src/remote.rs index e9814afc51a4a24fd154d74d0be2387c28c59fa3..8fb44839848278a3a698d7f2562741f682f38e24 100644 --- a/crates/git/src/remote.rs +++ b/crates/git/src/remote.rs @@ -1,3 +1,4 @@ +use std::str::FromStr; use std::sync::LazyLock; use derive_more::Deref; @@ -11,7 +12,7 @@ pub struct RemoteUrl(Url); static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z\-_]+@").expect("Failed to create USERNAME_REGEX")); -impl std::str::FromStr for RemoteUrl { +impl FromStr for RemoteUrl { type Err = url::ParseError; fn from_str(input: &str) -> Result { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 4f11819f1097617a6b416fa8e991072d595db38a..c3dd0995ff83d4bfdd494e4b5c192ff5999c21f8 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -7,13 +7,16 @@ use collections::HashMap; use futures::future::BoxFuture; use futures::io::BufWriter; use futures::{AsyncWriteExt, FutureExt as _, select_biased}; -use git2::BranchType; +use git2::{BranchType, ErrorCode}; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task}; use parking_lot::Mutex; use rope::Rope; use schemars::JsonSchema; use serde::Deserialize; use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; +use text::LineEnding; + +use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::process::{ExitStatus, Stdio}; use std::{ @@ -55,6 +58,12 @@ impl Branch { self.ref_name.starts_with("refs/remotes/") } + pub fn remote_name(&self) -> Option<&str> { + self.ref_name + .strip_prefix("refs/remotes/") + .and_then(|stripped| stripped.split("/").next()) + } + pub fn tracking_status(&self) -> Option { self.upstream .as_ref() @@ -420,7 +429,7 @@ pub trait GitRepository: Send + Sync { ) -> BoxFuture<'_, anyhow::Result<()>>; /// Returns the URL of the remote with the given name. - fn remote_url(&self, name: &str) -> Option; + fn remote_url(&self, name: &str) -> BoxFuture<'_, Option>; /// Resolve a list of refs to SHAs. fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>>; @@ -479,7 +488,12 @@ pub trait GitRepository: Send + Sync { fn show(&self, commit: String) -> BoxFuture<'_, Result>; fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result>; - fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result>; + fn blame( + &self, + path: RepoPath, + content: Rope, + line_ending: LineEnding, + ) -> BoxFuture<'_, Result>; fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result>; fn file_history_paginated( &self, @@ -590,6 +604,10 @@ pub trait GitRepository: Send + Sync { fn get_all_remotes(&self) -> BoxFuture<'_, Result>>; + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>; + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>; + /// returns a list of remote branches that contain HEAD fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>>; @@ -640,6 +658,7 @@ pub struct RealGitRepository { pub repository: Arc>, pub system_git_binary_path: Option, pub any_git_binary_path: PathBuf, + any_git_binary_help_output: Arc>>, executor: BackgroundExecutor, } @@ -658,6 +677,7 @@ impl RealGitRepository { system_git_binary_path, any_git_binary_path, executor, + any_git_binary_help_output: Arc::new(Mutex::new(None)), }) } @@ -668,6 +688,27 @@ impl RealGitRepository { .context("failed to read git work directory") .map(Path::to_path_buf) } + + async fn any_git_binary_help_output(&self) -> SharedString { + if let Some(output) = self.any_git_binary_help_output.lock().clone() { + return output; + } + let git_binary_path = self.any_git_binary_path.clone(); + let executor = self.executor.clone(); + let working_directory = self.working_directory(); + let output: SharedString = self + .executor + .spawn(async move { + GitBinary::new(git_binary_path, working_directory?, executor) + .run(["help", "-a"]) + .await + }) + .await + .unwrap_or_default() + .into(); + *self.any_git_binary_help_output.lock() = Some(output.clone()); + output + } } #[derive(Clone, Debug)] @@ -967,7 +1008,15 @@ impl GitRepository for RealGitRepository { index.read(false)?; const STAGE_NORMAL: i32 = 0; - let oid = match index.get_path(path.as_std_path(), STAGE_NORMAL) { + let path = path.as_std_path(); + // `RepoPath` contains a `RelPath` which normalizes `.` into an empty path + // `get_path` unwraps on empty paths though, so undo that normalization here + let path = if path.components().next().is_none() { + ".".as_ref() + } else { + path + }; + let oid = match index.get_path(path, STAGE_NORMAL) { Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id, _ => return Ok(None), }; @@ -1077,10 +1126,16 @@ impl GitRepository for RealGitRepository { .boxed() } - fn remote_url(&self, name: &str) -> Option { - let repo = self.repository.lock(); - let remote = repo.find_remote(name).ok()?; - remote.url().map(|url| url.to_string()) + fn remote_url(&self, name: &str) -> BoxFuture<'_, Option> { + let repo = self.repository.clone(); + let name = name.to_owned(); + self.executor + .spawn(async move { + let repo = repo.lock(); + let remote = repo.find_remote(&name).ok()?; + remote.url().map(|url| url.to_string()) + }) + .boxed() } fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { @@ -1371,9 +1426,19 @@ impl GitRepository for RealGitRepository { branch } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) { let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?; + let revision = revision.get(); let branch_commit = revision.peel_to_commit()?; - let mut branch = repo.branch(&branch_name, &branch_commit, false)?; + let mut branch = match repo.branch(&branch_name, &branch_commit, false) { + Ok(branch) => branch, + Err(err) if err.code() == ErrorCode::Exists => { + repo.find_branch(&branch_name, BranchType::Local)? + } + Err(err) => { + return Err(err.into()); + } + }; + branch.set_upstream(Some(&name))?; branch } else { @@ -1389,7 +1454,6 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let branch = branch.await?; - GitBinary::new(git_binary_path, working_directory?, executor) .run(&["checkout", &branch]) .await?; @@ -1454,22 +1518,24 @@ impl GitRepository for RealGitRepository { .boxed() } - fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result> { + fn blame( + &self, + path: RepoPath, + content: Rope, + line_ending: LineEnding, + ) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); + let executor = self.executor.clone(); - let remote_url = self - .remote_url("upstream") - .or_else(|| self.remote_url("origin")); - - self.executor + executor .spawn(async move { crate::blame::Blame::for_path( &git_binary_path, &working_directory?, &path, &content, - remote_url, + line_ending, ) .await }) @@ -1972,7 +2038,7 @@ impl GitRepository for RealGitRepository { let working_directory = working_directory?; let output = new_smol_command(&git_binary_path) .current_dir(&working_directory) - .args(["remote"]) + .args(["remote", "-v"]) .output() .await?; @@ -1981,14 +2047,43 @@ impl GitRepository for RealGitRepository { "Failed to get all remotes:\n{}", String::from_utf8_lossy(&output.stderr) ); - let remote_names = String::from_utf8_lossy(&output.stdout) - .split('\n') - .filter(|name| !name.is_empty()) - .map(|name| Remote { - name: name.trim().to_string().into(), + let remote_names: HashSet = String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let mut split_line = line.split_whitespace(); + let remote_name = split_line.next()?; + + Some(Remote { + name: remote_name.trim().to_string().into(), + }) }) .collect(); - Ok(remote_names) + + Ok(remote_names.into_iter().collect()) + }) + .boxed() + } + + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + repo.remote_delete(&name)?; + + Ok(()) + }) + .boxed() + } + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + repo.remote(&name, url.as_ref())?; + Ok(()) }) .boxed() } @@ -2230,18 +2325,47 @@ impl GitRepository for RealGitRepository { env: Arc>, ) -> BoxFuture<'_, Result<()>> { let working_directory = self.working_directory(); + let repository = self.repository.clone(); let git_binary_path = self.any_git_binary_path.clone(); let executor = self.executor.clone(); - self.executor - .spawn(async move { - let working_directory = working_directory?; - let git = GitBinary::new(git_binary_path, working_directory, executor) - .envs(HashMap::clone(&env)); - git.run(&["hook", "run", "--ignore-missing", hook.as_str()]) - .await?; - Ok(()) - }) - .boxed() + let help_output = self.any_git_binary_help_output(); + + // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang. + async move { + let working_directory = working_directory?; + if !help_output + .await + .lines() + .any(|line| line.trim().starts_with("hook ")) + { + let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str()); + if hook_abs_path.is_file() { + let output = new_smol_command(&hook_abs_path) + .envs(env.iter()) + .current_dir(&working_directory) + .output() + .await?; + + if !output.status.success() { + return Err(GitBinaryCommandError { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + status: output.status, + } + .into()); + } + } + + return Ok(()); + } + + let git = GitBinary::new(git_binary_path, working_directory, executor) + .envs(HashMap::clone(&env)); + git.run(&["hook", "run", "--ignore-missing", hook.as_str()]) + .await?; + Ok(()) + } + .boxed() } } diff --git a/crates/git_hosting_providers/Cargo.toml b/crates/git_hosting_providers/Cargo.toml index 851556151e285975cb1eb7d3d33244d7e11b5663..9480e0ec28c0ffa61c1126b2a627f22dc445d7d3 100644 --- a/crates/git_hosting_providers/Cargo.toml +++ b/crates/git_hosting_providers/Cargo.toml @@ -18,6 +18,7 @@ futures.workspace = true git.workspace = true gpui.workspace = true http_client.workspace = true +itertools.workspace = true regex.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/git_hosting_providers/src/git_hosting_providers.rs b/crates/git_hosting_providers/src/git_hosting_providers.rs index 6940ea382a1a21dbb3e97b55d74ee2489a1691ba..37cf5882059d7a274661f5a083a23e3f25e676ff 100644 --- a/crates/git_hosting_providers/src/git_hosting_providers.rs +++ b/crates/git_hosting_providers/src/git_hosting_providers.rs @@ -26,18 +26,18 @@ pub fn init(cx: &mut App) { provider_registry.register_hosting_provider(Arc::new(Gitee)); provider_registry.register_hosting_provider(Arc::new(Github::public_instance())); provider_registry.register_hosting_provider(Arc::new(Gitlab::public_instance())); - provider_registry.register_hosting_provider(Arc::new(Sourcehut)); + provider_registry.register_hosting_provider(Arc::new(SourceHut::public_instance())); } /// Registers additional Git hosting providers. /// /// These require information from the Git repository to construct, so their /// registration is deferred until we have a Git repository initialized. -pub fn register_additional_providers( +pub async fn register_additional_providers( provider_registry: Arc, repository: Arc, ) { - let Some(origin_url) = repository.remote_url("origin") else { + let Some(origin_url) = repository.remote_url("origin").await else { return; }; @@ -51,6 +51,8 @@ pub fn register_additional_providers( provider_registry.register_hosting_provider(Arc::new(gitea_self_hosted)); } else if let Ok(bitbucket_self_hosted) = Bitbucket::from_remote_url(&origin_url) { provider_registry.register_hosting_provider(Arc::new(bitbucket_self_hosted)); + } else if let Ok(sourcehut_self_hosted) = SourceHut::from_remote_url(&origin_url) { + provider_registry.register_hosting_provider(Arc::new(sourcehut_self_hosted)); } } diff --git a/crates/git_hosting_providers/src/providers/bitbucket.rs b/crates/git_hosting_providers/src/providers/bitbucket.rs index 0c30a13758a8339087ebb146f0029baee0d3ea7e..07c6898d4e0affc3bdde4d8290607897cf2cd5be 100644 --- a/crates/git_hosting_providers/src/providers/bitbucket.rs +++ b/crates/git_hosting_providers/src/providers/bitbucket.rs @@ -1,8 +1,14 @@ -use std::str::FromStr; use std::sync::LazyLock; - -use anyhow::{Result, bail}; +use std::{str::FromStr, sync::Arc}; + +use anyhow::{Context as _, Result, bail}; +use async_trait::async_trait; +use futures::AsyncReadExt; +use gpui::SharedString; +use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request}; +use itertools::Itertools as _; use regex::Regex; +use serde::Deserialize; use url::Url; use git::{ @@ -20,6 +26,42 @@ fn pull_request_regex() -> &'static Regex { &PULL_REQUEST_REGEX } +#[derive(Debug, Deserialize)] +struct CommitDetails { + author: Author, +} + +#[derive(Debug, Deserialize)] +struct Author { + user: Account, +} + +#[derive(Debug, Deserialize)] +struct Account { + links: AccountLinks, +} + +#[derive(Debug, Deserialize)] +struct AccountLinks { + avatar: Option, +} + +#[derive(Debug, Deserialize)] +struct Link { + href: String, +} + +#[derive(Debug, Deserialize)] +struct CommitDetailsSelfHosted { + author: AuthorSelfHosted, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AuthorSelfHosted { + avatar_url: Option, +} + pub struct Bitbucket { name: String, base_url: Url, @@ -61,8 +103,60 @@ impl Bitbucket { .host_str() .is_some_and(|host| host != "bitbucket.org") } + + async fn fetch_bitbucket_commit_author( + &self, + repo_owner: &str, + repo: &str, + commit: &str, + client: &Arc, + ) -> Result> { + let Some(host) = self.base_url.host_str() else { + bail!("failed to get host from bitbucket base url"); + }; + let is_self_hosted = self.is_self_hosted(); + let url = if is_self_hosted { + format!( + "https://{host}/rest/api/latest/projects/{repo_owner}/repos/{repo}/commits/{commit}?avatarSize=128" + ) + } else { + format!("https://api.{host}/2.0/repositories/{repo_owner}/{repo}/commit/{commit}") + }; + + let request = Request::get(&url) + .header("Content-Type", "application/json") + .follow_redirects(http_client::RedirectPolicy::FollowAll); + + let mut response = client + .send(request.body(AsyncBody::default())?) + .await + .with_context(|| format!("error fetching BitBucket commit details at {:?}", url))?; + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let body_str = std::str::from_utf8(&body)?; + + if is_self_hosted { + serde_json::from_str::(body_str) + .map(|commit| commit.author.avatar_url) + } else { + serde_json::from_str::(body_str) + .map(|commit| commit.author.user.links.avatar.map(|link| link.href)) + } + .context("failed to deserialize BitBucket commit details") + } } +#[async_trait] impl GitHostingProvider for Bitbucket { fn name(&self) -> String { self.name.clone() @@ -73,7 +167,7 @@ impl GitHostingProvider for Bitbucket { } fn supports_avatars(&self) -> bool { - false + true } fn format_line_number(&self, line: u32) -> String { @@ -98,9 +192,16 @@ impl GitHostingProvider for Bitbucket { return None; } - let mut path_segments = url.path_segments()?; - let owner = path_segments.next()?; - let repo = path_segments.next()?.trim_end_matches(".git"); + let mut path_segments = url.path_segments()?.collect::>(); + let repo = path_segments.pop()?.trim_end_matches(".git"); + let owner = if path_segments.get(0).is_some_and(|v| *v == "scm") && path_segments.len() > 1 + { + // Skip the "scm" segment if it's not the only segment + // https://github.com/gitkraken/vscode-gitlens/blob/a6e3c6fbb255116507eaabaa9940c192ed7bb0e1/src/git/remotes/bitbucket-server.ts#L72-L74 + path_segments.into_iter().skip(1).join("/") + } else { + path_segments.into_iter().join("/") + }; Some(ParsedGitRemote { owner: owner.into(), @@ -176,6 +277,22 @@ impl GitHostingProvider for Bitbucket { Some(PullRequest { number, url }) } + + async fn commit_author_avatar_url( + &self, + repo_owner: &str, + repo: &str, + commit: SharedString, + http_client: Arc, + ) -> Result> { + let commit = commit.to_string(); + let avatar_url = self + .fetch_bitbucket_commit_author(repo_owner, repo, &commit, &http_client) + .await? + .map(|avatar_url| Url::parse(&avatar_url)) + .transpose()?; + Ok(avatar_url) + } } #[cfg(test)] @@ -264,6 +381,38 @@ mod tests { repo: "zed".into(), } ); + + // Test with "scm" in the path + let remote_url = "https://bitbucket.company.com/scm/zed-industries/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + + // Test with only "scm" as owner + let remote_url = "https://bitbucket.company.com/scm/zed.git"; + + let parsed_remote = Bitbucket::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "scm".into(), + repo: "zed".into(), + } + ); } #[test] diff --git a/crates/git_hosting_providers/src/providers/sourcehut.rs b/crates/git_hosting_providers/src/providers/sourcehut.rs index 55bff551846b5f69bad8ccaeaccf3ad55868303f..41011b023bef01a06138ead26d93ba447d6a4ba1 100644 --- a/crates/git_hosting_providers/src/providers/sourcehut.rs +++ b/crates/git_hosting_providers/src/providers/sourcehut.rs @@ -1,5 +1,6 @@ use std::str::FromStr; +use anyhow::{Result, bail}; use url::Url; use git::{ @@ -7,15 +8,52 @@ use git::{ RemoteUrl, }; -pub struct Sourcehut; +use crate::get_host_from_git_remote_url; -impl GitHostingProvider for Sourcehut { +pub struct SourceHut { + name: String, + base_url: Url, +} + +impl SourceHut { + pub fn new(name: &str, base_url: Url) -> Self { + Self { + name: name.to_string(), + base_url, + } + } + + pub fn public_instance() -> Self { + Self::new("SourceHut", Url::parse("https://git.sr.ht").unwrap()) + } + + pub fn from_remote_url(remote_url: &str) -> Result { + let host = get_host_from_git_remote_url(remote_url)?; + if host == "git.sr.ht" { + bail!("the SourceHut instance is not self-hosted"); + } + + // TODO: detecting self hosted instances by checking whether "sourcehut" is in the url or not + // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more + // information. + if !host.contains("sourcehut") { + bail!("not a SourceHut URL"); + } + + Ok(Self::new( + "SourceHut Self-Hosted", + Url::parse(&format!("https://{}", host))?, + )) + } +} + +impl GitHostingProvider for SourceHut { fn name(&self) -> String { - "SourceHut".to_string() + self.name.clone() } fn base_url(&self) -> Url { - Url::parse("https://git.sr.ht").unwrap() + self.base_url.clone() } fn supports_avatars(&self) -> bool { @@ -34,7 +72,7 @@ impl GitHostingProvider for Sourcehut { let url = RemoteUrl::from_str(url).ok()?; let host = url.host_str()?; - if host != "git.sr.ht" { + if host != self.base_url.host_str()? { return None; } @@ -96,7 +134,7 @@ mod tests { #[test] fn test_parse_remote_url_given_ssh_url() { - let parsed_remote = Sourcehut + let parsed_remote = SourceHut::public_instance() .parse_remote_url("git@git.sr.ht:~zed-industries/zed") .unwrap(); @@ -111,7 +149,7 @@ mod tests { #[test] fn test_parse_remote_url_given_ssh_url_with_git_suffix() { - let parsed_remote = Sourcehut + let parsed_remote = SourceHut::public_instance() .parse_remote_url("git@git.sr.ht:~zed-industries/zed.git") .unwrap(); @@ -126,7 +164,7 @@ mod tests { #[test] fn test_parse_remote_url_given_https_url() { - let parsed_remote = Sourcehut + let parsed_remote = SourceHut::public_instance() .parse_remote_url("https://git.sr.ht/~zed-industries/zed") .unwrap(); @@ -139,9 +177,63 @@ mod tests { ); } + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url() { + let remote_url = "git@sourcehut.org:~zed-industries/zed"; + + let parsed_remote = SourceHut::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_ssh_url_with_git_suffix() { + let remote_url = "git@sourcehut.org:~zed-industries/zed.git"; + + let parsed_remote = SourceHut::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed.git".into(), + } + ); + } + + #[test] + fn test_parse_remote_url_given_self_hosted_https_url() { + let remote_url = "https://sourcehut.org/~zed-industries/zed"; + + let parsed_remote = SourceHut::from_remote_url(remote_url) + .unwrap() + .parse_remote_url(remote_url) + .unwrap(); + + assert_eq!( + parsed_remote, + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + } + ); + } + #[test] fn test_build_sourcehut_permalink() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), @@ -159,7 +251,7 @@ mod tests { #[test] fn test_build_sourcehut_permalink_with_git_suffix() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed.git".into(), @@ -175,9 +267,49 @@ mod tests { assert_eq!(permalink.to_string(), expected_url.to_string()) } + #[test] + fn test_build_sourcehut_self_hosted_permalink() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_sourcehut_self_hosted_permalink_with_git_suffix() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed.git") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed.git".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + None, + ), + ); + + let expected_url = "https://sourcehut.org/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + #[test] fn test_build_sourcehut_permalink_with_single_line_selection() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), @@ -195,7 +327,7 @@ mod tests { #[test] fn test_build_sourcehut_permalink_with_multi_line_selection() { - let permalink = Sourcehut.build_permalink( + let permalink = SourceHut::public_instance().build_permalink( ParsedGitRemote { owner: "zed-industries".into(), repo: "zed".into(), @@ -210,4 +342,44 @@ mod tests { let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48"; assert_eq!(permalink.to_string(), expected_url.to_string()) } + + #[test] + fn test_build_sourcehut_self_hosted_permalink_with_single_line_selection() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(6..6), + ), + ); + + let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_sourcehut_self_hosted_permalink_with_multi_line_selection() { + let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed") + .unwrap() + .build_permalink( + ParsedGitRemote { + owner: "zed-industries".into(), + repo: "zed".into(), + }, + BuildPermalinkParams::new( + "faa6f979be417239b2e070dbbf6392b909224e0b", + &repo_path("crates/editor/src/git/permalink.rs"), + Some(23..47), + ), + ); + + let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } } diff --git a/crates/git_hosting_providers/src/settings.rs b/crates/git_hosting_providers/src/settings.rs index 9bf6c1022b04cc60b0fbaead5177f9fe8e6e0280..95243cbe4e4ba68249ba89b948a5ed2644909364 100644 --- a/crates/git_hosting_providers/src/settings.rs +++ b/crates/git_hosting_providers/src/settings.rs @@ -8,7 +8,7 @@ use settings::{ use url::Url; use util::ResultExt as _; -use crate::{Bitbucket, Github, Gitlab}; +use crate::{Bitbucket, Forgejo, Gitea, Github, Gitlab, SourceHut}; pub(crate) fn init(cx: &mut App) { init_git_hosting_provider_settings(cx); @@ -46,6 +46,11 @@ fn update_git_hosting_providers_from_settings(cx: &mut App) { } GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _, GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _, + GitHostingProviderKind::Gitea => Arc::new(Gitea::new(&provider.name, url)) as _, + GitHostingProviderKind::Forgejo => Arc::new(Forgejo::new(&provider.name, url)) as _, + GitHostingProviderKind::SourceHut => { + Arc::new(SourceHut::new(&provider.name, url)) as _ + } }) }); diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 5e96cd3529b48bb401ee14e1a704b9bec485e356..c88244a036767be0ef862e74faa2113d54125443 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -13,7 +13,6 @@ name = "git_ui" path = "src/git_ui.rs" [features] -default = [] test-support = ["multi_buffer/test-support"] [dependencies] @@ -44,6 +43,7 @@ notifications.workspace = true panel.workspace = true picker.workspace = true project.workspace = true +prompt_store.workspace = true recent_projects.workspace = true remote.workspace = true schemars.workspace = true @@ -62,7 +62,8 @@ watch.workspace = true workspace.workspace = true zed_actions.workspace = true zeroize.workspace = true - +ztracing.workspace = true +tracing.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true @@ -74,7 +75,11 @@ gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } +rand.workspace = true settings = { workspace = true, features = ["test-support"] } unindent.workspace = true workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true + +[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 d3f89831898c4ef3e3fa5c088d0094c0efa6e8b5..d4d8750a18ee6efbd90a38722043450c6ec61358 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -3,10 +3,7 @@ use crate::{ commit_view::CommitView, }; use editor::{BlameRenderer, Editor, hover_markdown_style}; -use git::{ - blame::{BlameEntry, ParsedCommitMessage}, - repository::CommitSummary, -}; +use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary}; use gpui::{ ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*, @@ -47,14 +44,17 @@ impl BlameRenderer for GitBlameRenderer { let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED); let avatar = if ProjectSettings::get_global(cx).git.blame.show_avatar { - CommitAvatar::new( - &blame_entry.sha.to_string().into(), - details.as_ref().and_then(|it| it.remote.as_ref()), + Some( + CommitAvatar::new( + &blame_entry.sha.to_string().into(), + details.as_ref().and_then(|it| it.remote.as_ref()), + ) + .render(window, cx), ) - .render(window, cx) } else { None }; + Some( div() .mr_2() @@ -64,7 +64,7 @@ impl BlameRenderer for GitBlameRenderer { .w_full() .gap_2() .justify_between() - .font_family(style.font().family) + .font(style.font()) .line_height(style.line_height) .text_color(cx.theme().status().hint) .child( @@ -80,7 +80,10 @@ impl BlameRenderer for GitBlameRenderer { .on_mouse_down(MouseButton::Right, { let blame_entry = blame_entry.clone(); let details = details.clone(); + let editor = editor.clone(); move |event, window, cx| { + cx.stop_propagation(); + deploy_blame_entry_context_menu( &blame_entry, details.as_ref(), @@ -107,17 +110,19 @@ impl BlameRenderer for GitBlameRenderer { ) } }) - .hoverable_tooltip(move |_window, cx| { - cx.new(|cx| { - CommitTooltip::blame_entry( - &blame_entry, - details.clone(), - repository.clone(), - workspace.clone(), - cx, - ) + .when(!editor.read(cx).has_mouse_context_menu(), |el| { + el.hoverable_tooltip(move |_window, cx| { + cx.new(|cx| { + CommitTooltip::blame_entry( + &blame_entry, + details.clone(), + repository.clone(), + workspace.clone(), + cx, + ) + }) + .into() }) - .into() }), ) .into_any(), @@ -148,7 +153,7 @@ impl BlameRenderer for GitBlameRenderer { h_flex() .id("inline-blame") .w_full() - .font_family(style.font().family) + .font(style.font()) .text_color(cx.theme().status().hint) .line_height(style.line_height) .child(Icon::new(IconName::FileGit).color(Color::Hint)) @@ -198,9 +203,6 @@ impl BlameRenderer for GitBlameRenderer { let link_color = cx.theme().colors().text_accent; let markdown_style = { let mut style = hover_markdown_style(window, cx); - if let Some(code_block) = &style.code_block.text { - style.base_text_style.refine(code_block); - } style.link.refine(&TextStyleRefinement { color: Some(link_color), underline: Some(UnderlineStyle { @@ -261,7 +263,7 @@ impl BlameRenderer for GitBlameRenderer { .flex_wrap() .border_b_1() .border_color(cx.theme().colors().border_variant) - .children(avatar) + .child(avatar) .child(author) .when(!author_email.is_empty(), |this| { this.child( @@ -399,6 +401,7 @@ fn deploy_blame_entry_context_menu( }); editor.update(cx, move |editor, cx| { + editor.hide_blame_popover(false, cx); editor.deploy_mouse_context_menu(position, context_menu, window, cx); cx.notify(); }); diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 92c2f92ca342be270aa25f9e1a7ee96f5e06a585..4db37e91b8720e51ff0416cc471842483ab1d0ca 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1,8 +1,10 @@ use anyhow::Context as _; +use editor::Editor; use fuzzy::StringMatchCandidate; use collections::HashSet; use git::repository::Branch; +use gpui::http_client::Url; use gpui::{ Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, @@ -14,7 +16,10 @@ use project::project_settings::ProjectSettings; use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; -use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use ui::{ + Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, ListItemSpacing, Tooltip, + prelude::*, +}; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; use workspace::{ModalView, Workspace}; @@ -24,8 +29,10 @@ use crate::{branch_picker, git_panel::show_error_toast}; actions!( branch_picker, [ - /// Deletes the selected git branch. - DeleteBranch + /// Deletes the selected git branch or remote. + DeleteBranch, + /// Filter the list of remotes + FilterRemotes ] ); @@ -65,32 +72,26 @@ pub fn open( let repository = workspace.project().read(cx).active_repository(cx); let style = BranchListStyle::Modal; workspace.toggle_modal(window, cx, |window, cx| { - BranchList::new( - Some(workspace_handle), - repository, - style, - rems(34.), - window, - cx, - ) + BranchList::new(workspace_handle, repository, style, rems(34.), window, cx) }) } pub fn popover( + workspace: WeakEntity, repository: Option>, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| { let list = BranchList::new( - None, + workspace, repository, BranchListStyle::Popover, rems(20.), window, cx, ); - list.focus_handle(cx).focus(window); + list.focus_handle(cx).focus(window, cx); list }) } @@ -110,7 +111,7 @@ pub struct BranchList { impl BranchList { fn new( - workspace: Option>, + workspace: WeakEntity, repository: Option>, style: BranchListStyle, width: Rems, @@ -206,7 +207,7 @@ impl BranchList { .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) } - fn handle_delete_branch( + fn handle_delete( &mut self, _: &branch_picker::DeleteBranch, window: &mut Window, @@ -215,9 +216,23 @@ impl BranchList { self.picker.update(cx, |picker, cx| { picker .delegate - .delete_branch_at(picker.delegate.selected_index, window, cx) + .delete_at(picker.delegate.selected_index, window, cx) }) } + + fn handle_filter( + &mut self, + _: &branch_picker::FilterRemotes, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker.delegate.branch_filter = picker.delegate.branch_filter.invert(); + picker.update_matches(picker.query(cx), window, cx); + picker.refresh_placeholder(window, cx); + cx.notify(); + }); + } } impl ModalView for BranchList {} impl EventEmitter for BranchList {} @@ -234,7 +249,8 @@ impl Render for BranchList { .key_context("GitBranchSelector") .w(self.width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) - .on_action(cx.listener(Self::handle_delete_branch)) + .on_action(cx.listener(Self::handle_delete)) + .on_action(cx.listener(Self::handle_filter)) .child(self.picker.clone()) .on_mouse_down_out({ cx.listener(move |this, _, window, cx| { @@ -246,16 +262,72 @@ impl Render for BranchList { } } -#[derive(Debug, Clone)] -struct BranchEntry { - branch: Branch, - positions: Vec, - is_new: bool, +#[derive(Debug, Clone, PartialEq)] +enum Entry { + Branch { + branch: Branch, + positions: Vec, + }, + NewUrl { + url: String, + }, + NewBranch { + name: String, + }, + NewRemoteName { + name: String, + url: SharedString, + }, +} + +impl Entry { + fn as_branch(&self) -> Option<&Branch> { + match self { + Entry::Branch { branch, .. } => Some(branch), + _ => None, + } + } + + fn name(&self) -> &str { + match self { + Entry::Branch { branch, .. } => branch.name(), + Entry::NewUrl { url, .. } => url.as_str(), + Entry::NewBranch { name, .. } => name.as_str(), + Entry::NewRemoteName { name, .. } => name.as_str(), + } + } + + #[cfg(test)] + fn is_new_url(&self) -> bool { + matches!(self, Self::NewUrl { .. }) + } + + #[cfg(test)] + fn is_new_branch(&self) -> bool { + matches!(self, Self::NewBranch { .. }) + } +} + +#[derive(Clone, Copy, PartialEq)] +enum BranchFilter { + /// Show both local and remote branches. + All, + /// Only show remote branches. + Remote, +} + +impl BranchFilter { + fn invert(&self) -> Self { + match self { + BranchFilter::All => BranchFilter::Remote, + BranchFilter::Remote => BranchFilter::All, + } + } } pub struct BranchListDelegate { - workspace: Option>, - matches: Vec, + workspace: WeakEntity, + matches: Vec, all_branches: Option>, default_branch: Option, repo: Option>, @@ -263,12 +335,26 @@ pub struct BranchListDelegate { selected_index: usize, last_query: String, modifiers: Modifiers, + branch_filter: BranchFilter, + state: PickerState, focus_handle: FocusHandle, } +#[derive(Debug)] +enum PickerState { + /// When we display list of branches/remotes + List, + /// When we set an url to create a new remote + NewRemote, + /// When we confirm the new remote url (after NewRemote) + CreateRemote(SharedString), + /// When we set a new branch to create + NewBranch, +} + impl BranchListDelegate { fn new( - workspace: Option>, + workspace: WeakEntity, repo: Option>, style: BranchListStyle, cx: &mut Context, @@ -283,6 +369,8 @@ impl BranchListDelegate { selected_index: 0, last_query: Default::default(), modifiers: Default::default(), + branch_filter: BranchFilter::All, + state: PickerState::List, focus_handle: cx.focus_handle(), } } @@ -313,8 +401,28 @@ impl BranchListDelegate { cx.emit(DismissEvent); } - fn delete_branch_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { - let Some(branch_entry) = self.matches.get(idx) else { + fn create_remote( + &self, + remote_name: String, + remote_url: String, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(repo) = self.repo.clone() else { + return; + }; + + let receiver = repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url)); + + cx.background_spawn(async move { receiver.await? }) + .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| { + Some(e.to_string()) + }); + cx.emit(DismissEvent); + } + + fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry) = self.matches.get(idx).cloned() else { return; }; let Some(repo) = self.repo.clone() else { @@ -322,20 +430,51 @@ impl BranchListDelegate { }; let workspace = self.workspace.clone(); - let branch_name = branch_entry.branch.name().to_string(); - let branch_ref = branch_entry.branch.ref_name.clone(); cx.spawn_in(window, async move |picker, cx| { - let result = repo - .update(cx, |repo, _| repo.delete_branch(branch_name.clone()))? - .await?; + let mut is_remote = false; + let result = match &entry { + Entry::Branch { branch, .. } => match branch.remote_name() { + Some(remote_name) => { + is_remote = true; + repo.update(cx, |repo, _| repo.remove_remote(remote_name.to_string()))? + .await? + } + None => { + repo.update(cx, |repo, _| repo.delete_branch(branch.name().to_string()))? + .await? + } + }, + _ => { + log::error!("Failed to delete remote: wrong entry to delete"); + return Ok(()); + } + }; if let Err(e) = result { - log::error!("Failed to delete branch: {}", e); + if is_remote { + log::error!("Failed to delete remote: {}", e); + } else { + log::error!("Failed to delete branch: {}", e); + } - if let Some(workspace) = workspace.and_then(|w| w.upgrade()) { + if let Some(workspace) = workspace.upgrade() { cx.update(|_window, cx| { - show_error_toast(workspace, format!("branch -d {branch_name}"), e, cx) + if is_remote { + show_error_toast( + workspace, + format!("remote remove {}", entry.name()), + e, + cx, + ) + } else { + show_error_toast( + workspace, + format!("branch -d {}", entry.name()), + e, + cx, + ) + } })?; } @@ -343,13 +482,12 @@ impl BranchListDelegate { } picker.update_in(cx, |picker, _, cx| { - picker - .delegate - .matches - .retain(|entry| entry.branch.ref_name != branch_ref); + picker.delegate.matches.retain(|e| e != &entry); - if let Some(all_branches) = &mut picker.delegate.all_branches { - all_branches.retain(|branch| branch.ref_name != branch_ref); + if let Entry::Branch { branch, .. } = &entry { + if let Some(all_branches) = &mut picker.delegate.all_branches { + all_branches.retain(|e| e.ref_name != branch.ref_name); + } } if picker.delegate.matches.is_empty() { @@ -371,7 +509,80 @@ impl PickerDelegate for BranchListDelegate { type ListItem = ListItem; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select branch…".into() + match self.state { + PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { + match self.branch_filter { + BranchFilter::All => "Select branch or remote…", + BranchFilter::Remote => "Select remote…", + } + } + PickerState::CreateRemote(_) => "Enter a name for this remote…", + } + .into() + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + match self.state { + PickerState::CreateRemote(_) => { + Some(SharedString::new_static("Remote name can't be empty")) + } + _ => None, + } + } + + fn render_editor( + &self, + editor: &Entity, + _window: &mut Window, + _cx: &mut Context>, + ) -> Div { + let focus_handle = self.focus_handle.clone(); + + 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(editor.clone()) + .when( + self.editor_position() == PickerEditorPosition::End, + |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) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + tooltip_label, + &branch_picker::FilterRemotes, + &focus_handle, + cx, + ) + }) + .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()), + ) } fn editor_position(&self) -> PickerEditorPosition { @@ -408,26 +619,38 @@ impl PickerDelegate for BranchListDelegate { return Task::ready(()); }; - const RECENT_BRANCHES_COUNT: usize = 10; + let branch_filter = self.branch_filter; cx.spawn_in(window, async move |picker, cx| { - let mut matches: Vec = if query.is_empty() { - all_branches + let branch_matches_filter = |branch: &Branch| match branch_filter { + BranchFilter::All => true, + BranchFilter::Remote => branch.is_remote(), + }; + + let mut matches: Vec = if query.is_empty() { + let mut matches: Vec = all_branches .into_iter() - .filter(|branch| !branch.is_remote()) - .take(RECENT_BRANCHES_COUNT) - .map(|branch| BranchEntry { + .filter(|branch| branch_matches_filter(branch)) + .map(|branch| Entry::Branch { branch, positions: Vec::new(), - is_new: false, }) - .collect() + .collect(); + + // Keep the existing recency sort within each group, but show local branches first. + matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote())); + + matches } else { - let candidates = all_branches + let branches = all_branches + .iter() + .filter(|branch| branch_matches_filter(branch)) + .collect::>(); + let candidates = branches .iter() .enumerate() .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name())) .collect::>(); - fuzzy::match_strings( + let mut matches: Vec = fuzzy::match_strings( &candidates, &query, true, @@ -438,31 +661,59 @@ impl PickerDelegate for BranchListDelegate { ) .await .into_iter() - .map(|candidate| BranchEntry { - branch: all_branches[candidate.candidate_id].clone(), + .map(|candidate| Entry::Branch { + branch: branches[candidate.candidate_id].clone(), positions: candidate.positions, - is_new: false, }) - .collect() + .collect(); + + // Keep fuzzy-relevance ordering within local/remote groups, but show locals first. + matches.sort_by_key(|entry| entry.as_branch().is_some_and(|b| b.is_remote())); + + matches }; picker .update(cx, |picker, _| { + if let PickerState::CreateRemote(url) = &picker.delegate.state { + let query = query.replace(' ', "-"); + if !query.is_empty() { + picker.delegate.matches = vec![Entry::NewRemoteName { + name: query.clone(), + url: url.clone(), + }]; + picker.delegate.selected_index = 0; + } else { + picker.delegate.matches = Vec::new(); + picker.delegate.selected_index = 0; + } + picker.delegate.last_query = query; + return; + } + if !query.is_empty() - && !matches - .first() - .is_some_and(|entry| entry.branch.name() == query) + && !matches.first().is_some_and(|entry| entry.name() == query) { let query = query.replace(' ', "-"); - matches.push(BranchEntry { - branch: Branch { - ref_name: format!("refs/heads/{query}").into(), - is_head: false, - upstream: None, - most_recent_commit: None, - }, - positions: Vec::new(), - is_new: true, - }) + let is_url = query.trim_start_matches("git@").parse::().is_ok(); + let entry = if is_url { + Entry::NewUrl { url: query } + } else { + Entry::NewBranch { name: query } + }; + // Only transition to NewBranch/NewRemote states when we only show their list item + // Otherwise, stay in List state so footer buttons remain visible + picker.delegate.state = if matches.is_empty() { + if is_url { + PickerState::NewRemote + } else { + PickerState::NewBranch + } + } else { + PickerState::List + }; + matches.push(entry); + } else { + picker.delegate.state = PickerState::List; } let delegate = &mut picker.delegate; delegate.matches = matches; @@ -483,52 +734,73 @@ impl PickerDelegate for BranchListDelegate { return; }; - if entry.is_new { - let from_branch = if secondary { - self.default_branch.clone() - } else { - None - }; - self.create_branch( - from_branch, - entry.branch.name().to_owned().into(), - window, - cx, - ); - return; - } + match entry { + Entry::Branch { branch, .. } => { + let current_branch = self.repo.as_ref().map(|repo| { + repo.read_with(cx, |repo, _| { + repo.branch.as_ref().map(|branch| branch.ref_name.clone()) + }) + }); + + if current_branch + .flatten() + .is_some_and(|current_branch| current_branch == branch.ref_name) + { + cx.emit(DismissEvent); + return; + } - let current_branch = self.repo.as_ref().map(|repo| { - repo.read_with(cx, |repo, _| { - repo.branch.as_ref().map(|branch| branch.ref_name.clone()) - }) - }); + let Some(repo) = self.repo.clone() else { + return; + }; - if current_branch - .flatten() - .is_some_and(|current_branch| current_branch == entry.branch.ref_name) - { - cx.emit(DismissEvent); - return; - } + let branch = branch.clone(); + cx.spawn(async move |_, cx| { + repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))? + .await??; - let Some(repo) = self.repo.clone() else { - return; - }; - - let branch = entry.branch.clone(); - cx.spawn(async move |_, cx| { - repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))? - .await??; - - anyhow::Ok(()) - }) - .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None); + anyhow::Ok(()) + }) + .detach_and_prompt_err( + "Failed to change branch", + window, + cx, + |_, _, _| None, + ); + } + Entry::NewUrl { url } => { + self.state = PickerState::CreateRemote(url.clone().into()); + self.matches = Vec::new(); + self.selected_index = 0; + + cx.defer_in(window, |picker, window, cx| { + picker.refresh_placeholder(window, cx); + picker.set_query("", window, cx); + cx.notify(); + }); + + // returning early to prevent dismissing the modal, so a user can enter + // a remote name first. + return; + } + Entry::NewRemoteName { name, url } => { + self.create_remote(name.clone(), url.to_string(), window, cx); + } + Entry::NewBranch { name } => { + let from_branch = if secondary { + self.default_branch.clone() + } else { + None + }; + self.create_branch(from_branch, name.into(), window, cx); + } + } cx.emit(DismissEvent); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + self.state = PickerState::List; cx.emit(DismissEvent); } @@ -542,174 +814,1134 @@ impl PickerDelegate for BranchListDelegate { let entry = &self.matches.get(ix)?; let (commit_time, author_name, subject) = entry - .branch - .most_recent_commit - .as_ref() - .map(|commit| { - let subject = commit.subject.clone(); - let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) - .unwrap_or_else(|_| OffsetDateTime::now_utc()); - let local_offset = - time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); - let formatted_time = time_format::format_localized_timestamp( - commit_time, - OffsetDateTime::now_utc(), - local_offset, - time_format::TimestampFormat::Relative, - ); - let author = commit.author_name.clone(); - (Some(formatted_time), Some(author), Some(subject)) + .as_branch() + .and_then(|branch| { + branch.most_recent_commit.as_ref().map(|commit| { + let subject = commit.subject.clone(); + let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) + .unwrap_or_else(|_| OffsetDateTime::now_utc()); + let local_offset = + time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let formatted_time = time_format::format_localized_timestamp( + commit_time, + OffsetDateTime::now_utc(), + local_offset, + time_format::TimestampFormat::Relative, + ); + let author = commit.author_name.clone(); + (Some(formatted_time), Some(author), Some(subject)) + }) }) .unwrap_or_else(|| (None, None, None)); - let icon = if let Some(default_branch) = self.default_branch.clone() - && entry.is_new - { - Some( - IconButton::new("branch-from-default", IconName::GitBranchAlt) - .on_click(cx.listener(move |this, _, window, cx| { - this.delegate.set_selected_index(ix, window, cx); - this.delegate.confirm(true, window, cx); - })) - .tooltip(move |_window, cx| { - Tooltip::for_action( - format!("Create branch based off default: {default_branch}"), - &menu::SecondaryConfirm, - cx, - ) - }), - ) - } else { - None + let entry_icon = match entry { + Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => { + Icon::new(IconName::Plus).color(Color::Muted) + } + Entry::Branch { branch, .. } => { + if branch.is_remote() { + Icon::new(IconName::Screen).color(Color::Muted) + } else { + Icon::new(IconName::GitBranchAlt).color(Color::Muted) + } + } }; - let branch_name = if entry.is_new { - h_flex() - .gap_1() - .child( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(format!("Create branch \"{}\"…", entry.branch.name())) - .single_line() - .truncate(), - ) - .into_any_element() - } else { - h_flex() - .max_w_48() - .child( - HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone()) - .truncate(), - ) - .into_any_element() + let entry_title = match entry { + Entry::NewUrl { .. } => Label::new("Create Remote Repository") + .single_line() + .truncate() + .into_any_element(), + Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{name}\"…")) + .single_line() + .truncate() + .into_any_element(), + Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\"")) + .single_line() + .truncate() + .into_any_element(), + Entry::Branch { branch, positions } => { + HighlightedLabel::new(branch.name().to_string(), positions.clone()) + .single_line() + .truncate() + .into_any_element() + } + }; + + let focus_handle = self.focus_handle.clone(); + let is_new_items = matches!( + entry, + Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } + ); + + let deleted_branch_icon = |entry_ix: usize, is_head_branch: bool| { + IconButton::new(("delete", entry_ix), IconName::Trash) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Delete Branch", + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + }) + .disabled(is_head_branch) + .on_click(cx.listener(move |this, _, window, cx| { + this.delegate.delete_at(entry_ix, window, cx); + })) }; + let create_from_default_button = self.default_branch.as_ref().map(|default_branch| { + let tooltip_label: SharedString = format!("Create New From: {default_branch}").into(); + let focus_handle = self.focus_handle.clone(); + + IconButton::new("create_from_default", IconName::GitBranchPlus) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + tooltip_label.clone(), + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(true, window, cx); + })) + .into_any_element() + }); + Some( - ListItem::new(SharedString::from(format!("vcs-menu-{ix}"))) + ListItem::new(format!("vcs-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) - .tooltip({ - let branch_name = entry.branch.name().to_string(); - if entry.is_new { - Tooltip::text(format!("Create branch \"{}\"", branch_name)) - } else { - Tooltip::text(branch_name) - } - }) .child( - v_flex() + h_flex() .w_full() - .overflow_hidden() + .gap_3() + .flex_grow() + .child(entry_icon) .child( - h_flex() - .gap_6() - .justify_between() - .overflow_x_hidden() - .child(branch_name) - .when_some(commit_time, |label, commit_time| { - label.child( - Label::new(commit_time) - .size(LabelSize::Small) - .color(Color::Muted) - .into_element(), - ) - }), - ) - .when(self.style == BranchListStyle::Modal, |el| { - el.child(div().max_w_96().child({ - let message = if entry.is_new { - if let Some(current_branch) = - self.repo.as_ref().and_then(|repo| { - repo.read(cx).branch.as_ref().map(|b| b.name()) + v_flex() + .id("info_container") + .w_full() + .child(entry_title) + .child( + h_flex() + .w_full() + .justify_between() + .gap_1p5() + .when(self.style == BranchListStyle::Modal, |el| { + el.child(div().max_w_96().child({ + let message = match entry { + Entry::NewUrl { url } => { + format!("Based off {url}") + } + Entry::NewRemoteName { url, .. } => { + format!("Based off {url}") + } + Entry::NewBranch { .. } => { + if let Some(current_branch) = + self.repo.as_ref().and_then(|repo| { + repo.read(cx) + .branch + .as_ref() + .map(|b| b.name()) + }) + { + format!("Based off {}", current_branch) + } else { + "Based off the current branch" + .to_string() + } + } + Entry::Branch { .. } => { + let show_author_name = + ProjectSettings::get_global(cx) + .git + .branch_picker + .show_author_name; + + subject.map_or( + "No commits found".into(), + |subject| { + if show_author_name + && let Some(author) = + author_name + { + format!( + "{} • {}", + author, subject + ) + } else { + subject.to_string() + } + }, + ) + } + }; + + Label::new(message) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate() + })) }) - { - format!("based off {}", current_branch) - } else { - "based off the current branch".to_string() - } - } else { - let show_author_name = ProjectSettings::get_global(cx) - .git - .branch_picker - .show_author_name; - - subject.map_or("no commits found".into(), |subject| { - if show_author_name && author_name.is_some() { - format!("{} • {}", author_name.unwrap(), subject) - } else { - subject.to_string() - } - }) - }; - Label::new(message) - .size(LabelSize::Small) - .truncate() - .color(Color::Muted) - })) - }), + .when_some(commit_time, |label, commit_time| { + label.child( + Label::new(commit_time) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + .when_some( + entry.as_branch().map(|b| b.name().to_string()), + |this, branch_name| this.tooltip(Tooltip::text(branch_name)), + ), + ), + ) + .when( + self.editor_position() == PickerEditorPosition::End && !is_new_items, + |this| { + this.map(|this| { + let is_head_branch = + entry.as_branch().is_some_and(|branch| branch.is_head); + if self.selected_index() == ix { + this.end_slot(deleted_branch_icon(ix, is_head_branch)) + } else { + this.end_hover_slot(deleted_branch_icon(ix, is_head_branch)) + } + }) + }, ) - .end_slot::(icon), + .when_some( + if self.editor_position() == PickerEditorPosition::End && is_new_items { + create_from_default_button + } else { + None + }, + |this, create_from_default_button| { + this.map(|this| { + if self.selected_index() == ix { + this.end_slot(create_from_default_button) + } else { + this.end_hover_slot(create_from_default_button) + } + }) + }, + ), ) } - fn render_footer( + fn render_header( &self, _window: &mut Window, - cx: &mut Context>, + _cx: &mut Context>, ) -> Option { + matches!(self.state, PickerState::List).then(|| { + let label = match self.branch_filter { + BranchFilter::All => "Branches", + BranchFilter::Remote => "Remotes", + }; + + ListHeader::new(label).inset(true).into_any_element() + }) + } + + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + if self.editor_position() == PickerEditorPosition::End { + return None; + } let focus_handle = self.focus_handle.clone(); - Some( + let footer_container = || { h_flex() .w_full() .p_1p5() - .gap_0p5() - .justify_end() .border_t_1() .border_color(cx.theme().colors().border_variant) - .child( - Button::new("delete-branch", "Delete") - .key_binding( - KeyBinding::for_action_in( - &branch_picker::DeleteBranch, - &focus_handle, - cx, + }; + + match self.state { + PickerState::List => { + let selected_entry = self.matches.get(self.selected_index); + + let branch_from_default_button = self + .default_branch + .as_ref() + .filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. }))) + .map(|default_branch| { + let button_label = format!("Create New From: {default_branch}"); + + Button::new("branch-from-default", button_label) + .key_binding( + KeyBinding::for_action_in( + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(true, window, cx); + })) + }); + + let delete_and_select_btns = h_flex() + .gap_1() + .child( + Button::new("delete-branch", "Delete") + .key_binding( + KeyBinding::for_action_in( + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window + .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); + }), + ) + .child( + Button::new("select_branch", "Select") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), ) - .map(|kb| kb.size(rems_from_px(12.))), + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(false, window, cx); + })), + ); + + 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); + })), + ) + }, + ) + } else { + this.justify_between() + .child({ + let focus_handle = focus_handle.clone(); + Button::new("filter-remotes", "Filter Remotes") + .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) + } + }) + .into_any_element(), + ) + } + PickerState::NewBranch => { + let branch_from_default_button = + self.default_branch.as_ref().map(|default_branch| { + let button_label = format!("Create New From: {default_branch}"); + + Button::new("branch-from-default", button_label) + .key_binding( + KeyBinding::for_action_in( + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(true, window, cx); + })) + }); + + Some( + footer_container() + .gap_1() + .justify_end() + .when_some(branch_from_default_button, |this, button| { + this.child(button) + }) + .child( + Button::new("branch-from-default", "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); + })), ) - .on_click(|_, window, cx| { - window.dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); - }), + .into_any_element(), ) - .into_any(), + } + PickerState::CreateRemote(_) => Some( + footer_container() + .justify_end() + .child( + Button::new("branch-from-default", "Confirm") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(false, window, cx); + })) + .disabled(self.last_query.is_empty()), + ) + .into_any_element(), + ), + PickerState::NewRemote => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + use git::repository::{CommitSummary, Remote}; + use gpui::{AppContext, TestAppContext, VisualTestContext}; + use project::{FakeFs, Project}; + use rand::{Rng, rngs::StdRng}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + + fn create_test_branch( + name: &str, + is_head: bool, + remote_name: Option<&str>, + timestamp: Option, + ) -> Branch { + let ref_name = match remote_name { + Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"), + None => format!("refs/heads/{name}"), + }; + + Branch { + is_head, + ref_name: ref_name.into(), + upstream: None, + most_recent_commit: timestamp.map(|ts| CommitSummary { + sha: "abc123".into(), + commit_timestamp: ts, + author_name: "Test Author".into(), + subject: "Test commit".into(), + has_parent: true, + }), + } + } + + fn create_test_branches() -> Vec { + vec![ + create_test_branch("main", true, None, Some(1000)), + create_test_branch("feature-auth", false, None, Some(900)), + create_test_branch("feature-ui", false, None, Some(800)), + create_test_branch("develop", false, None, Some(700)), + ] + } + + async fn init_branch_list_test( + repository: Option>, + branches: Vec, + cx: &mut TestAppContext, + ) -> (Entity, VisualTestContext) { + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + + let branch_list = workspace + .update(cx, |workspace, window, cx| { + cx.new(|cx| { + let mut delegate = BranchListDelegate::new( + workspace.weak_handle(), + repository, + BranchListStyle::Modal, + cx, + ); + delegate.all_branches = Some(branches); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + }); + + let _subscription = cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + + BranchList { + picker, + picker_focus_handle, + width: rems(34.), + _subscription, + } + }) + }) + .unwrap(); + + let cx = VisualTestContext::from_window(*workspace, cx); + + (branch_list, cx) + } + + async fn init_fake_repository(cx: &mut TestAppContext) -> Entity { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + ".git": {}, + "file.txt": "buffer_text".to_string() + }), ) + .await; + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", "test".to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", "index_text".to_string())], + ); + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let repository = cx.read(|cx| project.read(cx).active_repository(cx)); + + repository.unwrap() } - fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - None + #[gpui::test] + async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) { + init_test(cx); + + let branches = create_test_branches(); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + let query = "feature".to_string(); + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 2 existing branches + 1 "create new branch" entry = 3 total + assert_eq!(picker.delegate.matches.len(), 3); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "feature-auth") + ); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "feature-ui") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + async fn update_branch_list_matches_with_empty_query( + branch_list: &Entity, + cx: &mut VisualTestContext, + ) { + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.update_matches(String::new(), window, cx) + }) + }) + .await; + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_delete_branch(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + + let branches = create_test_branches(); + + let branch_names = branches + .iter() + .map(|branch| branch.name().to_string()) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in branch_names { + repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) + .unwrap() + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 4); + let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); + picker.delegate.delete_at(1, window, cx); + branch_to_delete + }) + }); + cx.run_until_parked(); + + branch_list.update(cx, move |branch_list, cx| { + branch_list.picker.update(cx, move |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 3); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["main", "feature-auth", "feature-ui", "develop"] + .into_iter() + .filter(|name| name != &branch_to_delete) + .collect::>() + ); + }) + }); + } + + #[gpui::test] + async fn test_delete_remote(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + let branches = vec![ + create_test_branch("main", true, Some("origin"), Some(1000)), + create_test_branch("feature-auth", false, Some("origin"), Some(900)), + create_test_branch("feature-ui", false, Some("fork"), Some(800)), + create_test_branch("develop", false, Some("private"), Some(700)), + ]; + + let remote_names = branches + .iter() + .filter_map(|branch| branch.remote_name().map(|r| r.to_string())) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in remote_names { + repo.update(&mut cx, |repo, _| { + repo.create_remote(branch, String::from("test")) + }) + .unwrap() + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; + let cx = &mut ctx; + // Enable remote filter + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + picker.delegate.branch_filter = BranchFilter::Remote; + }); + }); + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + // Check matches, it should match all existing branches and no option to create new branch + let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 4); + let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); + picker.delegate.delete_at(1, window, cx); + branch_to_delete + }) + }); + cx.run_until_parked(); + + // Check matches, it should match one less branch than before + branch_list.update(cx, move |branch_list, cx| { + branch_list.picker.update(cx, move |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 3); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + [ + "origin/main", + "origin/feature-auth", + "fork/feature-ui", + "private/develop" + ] + .into_iter() + .filter(|name| name != &branch_to_delete) + .collect::>() + ); + }) + }); + } + + #[gpui::test] + async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) { + init_test(cx); + + let branches = vec![ + create_test_branch("main", true, Some("origin"), Some(1000)), + create_test_branch("feature-auth", false, Some("fork"), Some(900)), + create_test_branch("feature-ui", false, None, Some(800)), + create_test_branch("develop", false, None, Some(700)), + ]; + + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 4); + + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["origin/main", "fork/feature-auth", "feature-ui", "develop"] + .into_iter() + .collect::>() + ); + + // Locals should be listed before remotes. + let ordered = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + ordered, + vec!["feature-ui", "develop", "origin/main", "fork/feature-auth"] + ); + + // Verify the last entry is NOT the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(!last_match.is_new_branch()); + assert!(!last_match.is_new_url()); + }) + }); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + picker.delegate.branch_filter = BranchFilter::Remote; + }) + }); + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + branch_list + .update_in(cx, |branch_list, window, cx| { + 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()); + picker.delegate.branch_filter = BranchFilter::Remote; + picker + .delegate + .update_matches(String::from("fork"), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 1 existing branch + 1 "create new branch" entry = 2 total + assert_eq!(picker.delegate.matches.len(), 2); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "fork/feature-auth") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + #[gpui::test] + async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) { + const MAIN_BRANCH: &str = "main"; + const FEATURE_BRANCH: &str = "feature"; + const NEW_BRANCH: &str = "new-feature-branch"; + + init_test(test_cx); + let repository = init_fake_repository(test_cx).await; + + let branches = vec![ + create_test_branch(MAIN_BRANCH, true, None, Some(1000)), + create_test_branch(FEATURE_BRANCH, false, None, Some(900)), + ]; + + let (branch_list, mut ctx) = + init_branch_list_test(repository.into(), branches, test_cx).await; + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .update_matches(NEW_BRANCH.to_string(), window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + assert_eq!(last_match.name(), NEW_BRANCH); + // State is NewBranch because no existing branches fuzzy-match the query + assert!(matches!(picker.delegate.state, PickerState::NewBranch)); + picker.delegate.confirm(false, window, cx); + }) + }); + cx.run_until_parked(); + + let branches = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.branches()) + }) + }) + .await + .unwrap() + .unwrap(); + + let new_branch = branches + .into_iter() + .find(|branch| branch.name() == NEW_BRANCH) + .expect("new-feature-branch should exist"); + assert_eq!( + new_branch.ref_name.as_ref(), + &format!("refs/heads/{NEW_BRANCH}"), + "branch ref_name should not have duplicate refs/heads/ prefix" + ); + } + + #[gpui::test] + async fn test_remote_url_detection_https(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + let branches = vec![create_test_branch("main", true, None, Some(1000))]; + + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await; + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "https://github.com/user/repo.git".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_url()); + assert!(matches!(picker.delegate.state, PickerState::NewRemote)); + picker.delegate.confirm(false, window, cx); + assert_eq!(picker.delegate.matches.len(), 0); + if let PickerState::CreateRemote(remote_url) = &picker.delegate.state + && remote_url.as_ref() == "https://github.com/user/repo.git" + { + } else { + panic!("wrong picker state"); + } + picker + .delegate + .update_matches("my_new_remote".to_string(), window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 1); + assert!(matches!( + picker.delegate.matches.first(), + Some(Entry::NewRemoteName { name, url }) + if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git" + )); + picker.delegate.confirm(false, window, cx); + }) + }); + cx.run_until_parked(); + + // List remotes + let remotes = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.get_remotes(None, false)) + }) + }) + .await + .unwrap() + .unwrap(); + assert_eq!( + remotes, + vec![Remote { + name: SharedString::from("my_new_remote".to_string()) + }] + ); + } + + #[gpui::test] + async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) { + init_test(cx); + + let branches = vec![create_test_branch("main_branch", true, None, Some(1000))]; + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "https://github.com/user/repo.git".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + // Try to create a new remote but cancel in the middle of the process + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = picker.delegate.matches.len() - 1; + picker.delegate.confirm(false, window, cx); + + assert!(matches!( + picker.delegate.state, + PickerState::CreateRemote(_) + )); + if let PickerState::CreateRemote(ref url) = picker.delegate.state { + assert_eq!(url.as_ref(), "https://github.com/user/repo.git"); + } + assert_eq!(picker.delegate.matches.len(), 0); + picker.delegate.dismissed(window, cx); + assert!(matches!(picker.delegate.state, PickerState::List)); + let query = "main".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + // Try to search a branch again to see if the state is restored properly + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 1 existing branch + 1 "create new branch" entry = 2 total + assert_eq!(picker.delegate.matches.len(), 2); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "main_branch") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + #[gpui::test] + async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) { + const REMOTE_URL: &str = "https://github.com/user/repo.git"; + + init_test(cx); + let branches = vec![create_test_branch("main", true, None, Some(1000))]; + + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; + let cx = &mut ctx; + + let subscription = cx.update(|_, cx| { + cx.subscribe(&branch_list, |_, _: &DismissEvent, _| { + panic!("DismissEvent should not be emitted when confirming a remote URL"); + }) + }); + + branch_list + .update_in(cx, |branch_list, window, cx| { + window.focus(&branch_list.picker_focus_handle, cx); + assert!( + branch_list.picker_focus_handle.is_focused(window), + "Branch picker should be focused when selecting an entry" + ); + + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .update_matches(REMOTE_URL.to_string(), window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + // Re-focus the picker since workspace initialization during run_until_parked + window.focus(&branch_list.picker_focus_handle, cx); + + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_url()); + assert!(matches!(picker.delegate.state, PickerState::NewRemote)); + + picker.delegate.confirm(false, window, cx); + + assert!( + matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL), + "State should transition to CreateRemote with the URL" + ); + }); + + assert!( + branch_list.picker_focus_handle.is_focused(window), + "Branch list picker should still be focused after confirming remote URL" + ); + }); + + cx.run_until_parked(); + + drop(subscription); + } + + #[gpui::test(iterations = 10)] + async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) { + init_test(cx); + let branch_count = rng.random_range(13..540); + + let branches: Vec = (0..branch_count) + .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100))) + .collect(); + + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx).await; + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), branch_count as usize); + }) + }); } } diff --git a/crates/git_ui/src/clone.rs b/crates/git_ui/src/clone.rs new file mode 100644 index 0000000000000000000000000000000000000000..a6767d33304d3f20b7a5e78340f62c89ebe3ae58 --- /dev/null +++ b/crates/git_ui/src/clone.rs @@ -0,0 +1,155 @@ +use gpui::{App, Context, WeakEntity, Window}; +use notifications::status_toast::{StatusToast, ToastIcon}; +use std::sync::Arc; +use ui::{Color, IconName, SharedString}; +use util::ResultExt; +use workspace::{self, Workspace}; + +pub fn clone_and_open( + repo_url: SharedString, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + on_success: Arc< + dyn Fn(&mut Workspace, &mut Window, &mut Context) + Send + Sync + 'static, + >, +) { + let destination_prompt = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Select as Repository Destination".into()), + }); + + window + .spawn(cx, async move |cx| { + let mut paths = destination_prompt.await.ok()?.ok()??; + let mut destination_dir = paths.pop()?; + + let repo_name = repo_url + .split('/') + .next_back() + .map(|name| name.strip_suffix(".git").unwrap_or(name)) + .unwrap_or("repository") + .to_owned(); + + let clone_task = workspace + .update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + let destination_dir = destination_dir.clone(); + let repo_url = repo_url.clone(); + cx.spawn(async move |_workspace, _cx| { + fs.git_clone(&repo_url, destination_dir.as_path()).await + }) + }) + .ok()?; + + if let Err(error) = clone_task.await { + workspace + .update(cx, |workspace, cx| { + let toast = StatusToast::new(error.to_string(), cx, |this, _| { + this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) + .dismiss_button(true) + }); + workspace.toggle_status_toast(toast, cx); + }) + .log_err(); + return None; + } + + let has_worktrees = workspace + .read_with(cx, |workspace, cx| { + workspace.project().read(cx).worktrees(cx).next().is_some() + }) + .ok()?; + + let prompt_answer = if has_worktrees { + cx.update(|window, cx| { + window.prompt( + gpui::PromptLevel::Info, + &format!("Git Clone: {}", repo_name), + None, + &["Add repo to project", "Open repo in new project"], + cx, + ) + }) + .ok()? + .await + .ok()? + } else { + // Don't ask if project is empty + 0 + }; + + destination_dir.push(&repo_name); + + match prompt_answer { + 0 => { + workspace + .update_in(cx, |workspace, window, cx| { + let create_task = workspace.project().update(cx, |project, cx| { + project.create_worktree(destination_dir.as_path(), true, cx) + }); + + let workspace_weak = cx.weak_entity(); + let on_success = on_success.clone(); + cx.spawn_in(window, async move |_window, cx| { + if create_task.await.log_err().is_some() { + workspace_weak + .update_in(cx, |workspace, window, cx| { + (on_success)(workspace, window, cx); + }) + .ok(); + } + }) + .detach(); + }) + .ok()?; + } + 1 => { + workspace + .update(cx, move |workspace, cx| { + let app_state = workspace.app_state().clone(); + let destination_path = destination_dir.clone(); + let on_success = on_success.clone(); + + workspace::open_new( + Default::default(), + app_state, + cx, + move |workspace, window, cx| { + cx.activate(true); + + let create_task = + workspace.project().update(cx, |project, cx| { + project.create_worktree( + destination_path.as_path(), + true, + cx, + ) + }); + + let workspace_weak = cx.weak_entity(); + cx.spawn_in(window, async move |_window, cx| { + if create_task.await.log_err().is_some() { + workspace_weak + .update_in(cx, |workspace, window, cx| { + (on_success)(workspace, window, cx); + }) + .ok(); + } + }) + .detach(); + }, + ) + .detach(); + }) + .ok(); + } + _ => {} + } + + Some(()) + }) + .detach(); +} diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 45b1563dca0ceed5ed2ac488026fe94084050780..e154933adc794221159c7f1b28b3d1e33cf1854d 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -139,7 +139,7 @@ impl CommitModal { && !git_panel.amend_pending() { git_panel.set_amend_pending(true, cx); - git_panel.load_last_commit_message_if_empty(cx); + git_panel.load_last_commit_message(cx); } } ForceMode::Commit => { @@ -337,6 +337,7 @@ impl CommitModal { active_repo, is_amend_pending, is_signoff_enabled, + workspace, ) = self.git_panel.update(cx, |git_panel, cx| { let (can_commit, tooltip) = git_panel.configure_commit_button(cx); let title = git_panel.commit_button_title(); @@ -354,6 +355,7 @@ impl CommitModal { active_repo, is_amend_pending, is_signoff_enabled, + git_panel.workspace.clone(), ) }); @@ -375,7 +377,14 @@ impl CommitModal { .style(ButtonStyle::Transparent); let branch_picker = PopoverMenu::new("popover-button") - .menu(move |window, cx| Some(branch_picker::popover(active_repo.clone(), window, cx))) + .menu(move |window, cx| { + Some(branch_picker::popover( + workspace.clone(), + active_repo.clone(), + window, + cx, + )) + }) .with_handle(self.branch_list_handle.clone()) .trigger_with_tooltip( branch_picker_button, @@ -492,60 +501,27 @@ impl CommitModal { } } - fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { - if self.git_panel.read(cx).amend_pending() { - return; + fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.git_panel.update(cx, |git_panel, cx| { + git_panel.commit(&self.commit_editor.focus_handle(cx), window, cx) + }) { + telemetry::event!("Git Committed", source = "Git Modal"); + cx.emit(DismissEvent); } - telemetry::event!("Git Committed", source = "Git Modal"); - self.git_panel.update(cx, |git_panel, cx| { - git_panel.commit_changes( - CommitOptions { - amend: false, - signoff: git_panel.signoff_enabled(), - }, - window, - cx, - ) - }); - cx.emit(DismissEvent); } - fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { - if self - .git_panel - .read(cx) - .active_repository - .as_ref() - .and_then(|repo| repo.read(cx).head_commit.as_ref()) - .is_none() - { - return; - } - if !self.git_panel.read(cx).amend_pending() { - self.git_panel.update(cx, |git_panel, cx| { - git_panel.set_amend_pending(true, cx); - git_panel.load_last_commit_message_if_empty(cx); - }); - } else { + fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + if self.git_panel.update(cx, |git_panel, cx| { + git_panel.amend(&self.commit_editor.focus_handle(cx), window, cx) + }) { telemetry::event!("Git Amended", source = "Git Modal"); - self.git_panel.update(cx, |git_panel, cx| { - git_panel.set_amend_pending(false, cx); - git_panel.commit_changes( - CommitOptions { - amend: true, - signoff: git_panel.signoff_enabled(), - }, - window, - cx, - ); - }); cx.emit(DismissEvent); } } fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context) { if self.branch_list_handle.is_focused(window, cx) { - self.focus_handle(cx).focus(window) + self.focus_handle(cx).focus(window, cx) } else { self.branch_list_handle.toggle(window, cx); } @@ -564,8 +540,8 @@ impl Render for CommitModal { .id("commit-modal") .key_context("GitCommit") .on_action(cx.listener(Self::dismiss)) - .on_action(cx.listener(Self::commit)) - .on_action(cx.listener(Self::amend)) + .on_action(cx.listener(Self::on_commit)) + .on_action(cx.listener(Self::on_amend)) .when(!DisableAiSettings::get_global(cx).disable_ai, |this| { this.on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| { this.git_panel.update(cx, |panel, cx| { @@ -611,8 +587,8 @@ impl Render for CommitModal { .bg(cx.theme().colors().editor_background) .border_1() .border_color(cx.theme().colors().border_variant) - .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| { - window.focus(&editor_focus_handle); + .on_click(cx.listener(move |_, _: &ClickEvent, window, cx| { + window.focus(&editor_focus_handle, cx); })) .child( div() diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 26bd42c6549457df0f530580bbfc838797134860..d18770a704ff31d6dffd705baf44defaaf6d8d4a 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -3,7 +3,7 @@ use editor::hover_markdown_style; use futures::Future; use git::blame::BlameEntry; use git::repository::CommitSummary; -use git::{GitRemote, blame::ParsedCommitMessage}; +use git::{GitRemote, commit::ParsedCommitMessage}; use gpui::{ App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle, StatefulInteractiveElement, WeakEntity, prelude::*, @@ -29,11 +29,16 @@ pub struct CommitDetails { pub struct CommitAvatar<'a> { sha: &'a SharedString, remote: Option<&'a GitRemote>, + size: Option, } impl<'a> CommitAvatar<'a> { pub fn new(sha: &'a SharedString, remote: Option<&'a GitRemote>) -> Self { - Self { sha, remote } + Self { + sha, + remote, + size: None, + } } pub fn from_commit_details(details: &'a CommitDetails) -> Self { @@ -43,28 +48,37 @@ impl<'a> CommitAvatar<'a> { .message .as_ref() .and_then(|details| details.remote.as_ref()), + size: None, } } -} -impl<'a> CommitAvatar<'a> { - pub fn render(&'a self, window: &mut Window, cx: &mut App) -> Option> { + pub fn size(mut self, size: IconSize) -> Self { + self.size = Some(size); + self + } + + pub fn render(&'a self, window: &mut Window, cx: &mut App) -> AnyElement { + match self.avatar(window, cx) { + // Loading or no avatar found + None => Icon::new(IconName::Person) + .color(Color::Muted) + .when_some(self.size, |this, size| this.size(size)) + .into_any_element(), + // Found + Some(avatar) => avatar + .when_some(self.size, |this, size| this.size(size.rems())) + .into_any_element(), + } + } + + pub fn avatar(&'a self, window: &mut Window, cx: &mut App) -> Option { let remote = self .remote .filter(|remote| remote.host_supports_avatars())?; - let avatar_url = CommitAvatarAsset::new(remote.clone(), self.sha.clone()); - let element = match window.use_asset::(&avatar_url, cx) { - // Loading or no avatar found - None | Some(None) => Icon::new(IconName::Person) - .color(Color::Muted) - .into_element() - .into_any(), - // Found - Some(Some(url)) => Avatar::new(url.to_string()).into_element().into_any(), - }; - Some(element) + let url = window.use_asset::(&avatar_url, cx)??; + Some(Avatar::new(url.to_string())) } } @@ -197,10 +211,7 @@ impl Render for CommitTooltip { time_format::TimestampFormat::MediumAbsolute, ); let markdown_style = { - let mut style = hover_markdown_style(window, cx); - if let Some(code_block) = &style.code_block.text { - style.base_text_style.refine(code_block); - } + let style = hover_markdown_style(window, cx); style }; @@ -256,7 +267,7 @@ impl Render for CommitTooltip { .gap_x_2() .overflow_x_hidden() .flex_wrap() - .children(avatar) + .child(avatar) .child(author) .when(!author_email.is_empty(), |this| { this.child( diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index cdfcc8ed0f7d686d12d3afe9c1e7a0d66d08721d..1f242e7caf26d9a670a580dcba4074258564e5d5 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -1,20 +1,21 @@ use anyhow::{Context as _, Result}; use buffer_diff::BufferDiff; -use editor::{Addon, Editor, EditorEvent, MultiBuffer}; +use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; +use editor::{Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines}; use git::repository::{CommitDetails, CommitDiff, RepoPath}; -use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; +use git::{ + BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, ParsedGitRemote, + parse_git_remote_url, +}; use gpui::{ - AnyElement, App, AppContext as _, Asset, AsyncApp, AsyncWindowContext, Context, Element, - Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, - PromptLevel, Render, Styled, Task, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, - actions, px, + AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, ClipboardItem, Context, + Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, + ParentElement, PromptLevel, Render, Styled, Task, WeakEntity, Window, actions, }; use language::{ - Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, ReplicaId, Rope, TextBuffer, - ToPoint, + Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _, + Point, ReplicaId, Rope, TextBuffer, }; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use multi_buffer::ExcerptInfo; use multi_buffer::PathKey; use project::{Project, WorktreeId, git_store::Repository}; use std::{ @@ -23,11 +24,9 @@ use std::{ sync::Arc, }; use theme::ActiveTheme; -use ui::{ - Avatar, Button, ButtonCommon, Clickable, Color, Icon, IconName, IconSize, Label, - LabelCommon as _, LabelSize, SharedString, div, h_flex, v_flex, -}; +use ui::{ButtonLike, DiffStat, Tooltip, prelude::*}; use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff}; +use workspace::item::TabTooltipContent; use workspace::{ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -37,6 +36,7 @@ use workspace::{ searchable::SearchableItemHandle, }; +use crate::commit_tooltip::CommitAvatar; use crate::git_panel::GitPanel; actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]); @@ -63,15 +63,16 @@ pub struct CommitView { multibuffer: Entity, repository: Entity, remote: Option, - markdown: Entity, } struct GitBlob { path: RepoPath, worktree_id: WorktreeId, is_deleted: bool, + display_name: String, } +const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0; const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; impl CommitView { @@ -149,17 +150,73 @@ impl CommitView { ) -> Self { let language_registry = project.read(cx).languages().clone(); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly)); + + let message_buffer = cx.new(|cx| { + let mut buffer = Buffer::local(commit.message.clone(), cx); + buffer.set_capability(Capability::ReadOnly, cx); + buffer + }); + + multibuffer.update(cx, |multibuffer, cx| { + let snapshot = message_buffer.read(cx).snapshot(); + let full_range = Point::zero()..snapshot.max_point(); + let range = ExcerptRange { + context: full_range.clone(), + primary: full_range, + }; + multibuffer.set_excerpt_ranges_for_path( + PathKey::with_sort_prefix( + COMMIT_MESSAGE_SORT_PREFIX, + RelPath::unix("commit message").unwrap().into(), + ), + message_buffer.clone(), + &snapshot, + vec![range], + cx, + ) + }); + let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); + editor.disable_inline_diagnostics(); + editor.set_show_breakpoints(false, cx); editor.set_expand_all_diff_hunks(cx); - editor.register_addon(CommitViewAddon { - multibuffer: multibuffer.downgrade(), - }); + editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx); + editor.disable_indent_guides_for_buffer(message_buffer.read(cx).remote_id(), cx); + + editor.insert_blocks( + [BlockProperties { + placement: BlockPlacement::Above(editor::Anchor::min()), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }] + .into_iter() + .chain( + editor + .buffer() + .read(cx) + .buffer_anchor_to_anchor(&message_buffer, Anchor::MAX, cx) + .map(|anchor| BlockProperties { + placement: BlockPlacement::Below(anchor), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }), + ), + None, + cx, + ); + editor }); + let commit_sha = Arc::::from(commit.sha.as_ref()); + let first_worktree_id = project .read(cx) .worktrees(cx) @@ -167,6 +224,7 @@ impl CommitView { .map(|worktree| worktree.read(cx).id()); let repository_clone = repository.clone(); + cx.spawn(async move |this, cx| { for file in commit_diff.files { let is_deleted = file.new_text.is_none(); @@ -180,10 +238,19 @@ impl CommitView { .or(first_worktree_id) })? .context("project has no worktrees")?; + let short_sha = commit_sha.get(0..7).unwrap_or(&commit_sha); + let file_name = file + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_else(|| file.path.display(PathStyle::local()).to_string()); + let display_name = format!("{short_sha} - {file_name}"); + let file = Arc::new(GitBlob { path: file.path.clone(), is_deleted, worktree_id, + display_name, }) as Arc; let buffer = build_buffer(new_text, file, &language_registry, cx).await?; @@ -194,40 +261,30 @@ impl CommitView { this.multibuffer.update(cx, |multibuffer, cx| { let snapshot = buffer.read(cx).snapshot(); let path = snapshot.file().unwrap().path().clone(); - - let hunks: Vec<_> = - buffer_diff.read(cx).snapshot(cx).hunks(&snapshot).collect(); - - let excerpt_ranges = if hunks.is_empty() { - vec![language::Point::zero()..snapshot.max_point()] - } else { - hunks - .into_iter() - .map(|hunk| { - let start = hunk.range.start.max(language::Point::new( - hunk.range.start.row.saturating_sub(3), - 0, - )); - let end_row = - (hunk.range.end.row + 3).min(snapshot.max_point().row); - let end = - language::Point::new(end_row, snapshot.line_len(end_row)); - start..end - }) - .collect() + let excerpt_ranges = { + let diff_snapshot = buffer_diff.read(cx).snapshot(cx); + let mut hunks = diff_snapshot.hunks(&snapshot).peekable(); + if hunks.peek().is_none() { + vec![language::Point::zero()..snapshot.max_point()] + } else { + hunks + .map(|hunk| hunk.buffer_range.to_point(&snapshot)) + .collect::>() + } }; let _is_newly_added = multibuffer.set_excerpts_for_path( PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path), buffer, excerpt_ranges, - 0, + multibuffer_context_lines(cx), cx, ); multibuffer.add_diff(buffer_diff, cx); }); })?; } + anyhow::Ok(()) }) .detach(); @@ -247,14 +304,6 @@ impl CommitView { }) }); - let processed_message = if let Some(ref remote) = remote { - Self::process_github_issues(&commit.message, remote) - } else { - commit.message.to_string() - }; - - let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx)); - Self { commit, editor, @@ -262,18 +311,9 @@ impl CommitView { stash, repository, remote, - markdown, } } - fn fallback_commit_avatar() -> AnyElement { - Icon::new(IconName::Person) - .color(Color::Muted) - .size(IconSize::Medium) - .into_element() - .into_any() - } - fn render_commit_avatar( &self, sha: &SharedString, @@ -281,26 +321,70 @@ impl CommitView { window: &mut Window, cx: &mut App, ) -> AnyElement { - let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars()); - - if let Some(remote) = remote { - let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone()); - if let Some(Some(url)) = window.use_asset::(&avatar_asset, cx) { - Avatar::new(url.to_string()) - .size(size) - .into_element() - .into_any() - } else { - Self::fallback_commit_avatar() + let size = size.into(); + let avatar = CommitAvatar::new(sha, self.remote.as_ref()); + + v_flex() + .w(size) + .h(size) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_full() + .justify_center() + .items_center() + .child( + avatar + .avatar(window, cx) + .map(|a| a.size(size).into_any_element()) + .unwrap_or_else(|| { + Icon::new(IconName::Person) + .color(Color::Muted) + .size(IconSize::Medium) + .into_any_element() + }), + ) + .into_any() + } + + fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) { + let snapshot = self.multibuffer.read(cx).snapshot(cx); + let mut total_additions = 0u32; + let mut total_deletions = 0u32; + + let mut seen_buffers = std::collections::HashSet::new(); + for (_, buffer, _) in snapshot.excerpts() { + let buffer_id = buffer.remote_id(); + if !seen_buffers.insert(buffer_id) { + continue; + } + + let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else { + continue; + }; + + let base_text = diff.base_text(); + + for hunk in diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer) { + let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); + total_additions += added_rows; + + let base_start = base_text + .offset_to_point(hunk.diff_base_byte_range.start) + .row; + let base_end = base_text.offset_to_point(hunk.diff_base_byte_range.end).row; + let deleted_rows = base_end.saturating_sub(base_start); + + total_deletions += deleted_rows; } - } else { - Self::fallback_commit_avatar() } + + (total_additions, total_deletions) } fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let commit = &self.commit; let author_name = commit.author_name.clone(); + let commit_sha = commit.sha.clone(); let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); @@ -311,109 +395,152 @@ impl CommitView { time_format::TimestampFormat::MediumAbsolute, ); - let github_url = self.remote.as_ref().map(|remote| { - format!( - "{}/{}/{}/commit/{}", - remote.host.base_url(), - remote.owner, - remote.repo, - commit.sha - ) + let remote_info = self.remote.as_ref().map(|remote| { + let provider = remote.host.name(); + let parsed_remote = ParsedGitRemote { + owner: remote.owner.as_ref().into(), + repo: remote.repo.as_ref().into(), + }; + let params = BuildCommitPermalinkParams { sha: &commit.sha }; + let url = remote + .host + .build_commit_permalink(&parsed_remote, params) + .to_string(); + (provider, url) }); - v_flex() - .p_4() - .gap_4() + let (additions, deletions) = self.calculate_changed_lines(cx); + + let commit_diff_stat = if additions > 0 || deletions > 0 { + Some(DiffStat::new( + "commit-diff-stat", + additions as usize, + deletions as usize, + )) + } else { + None + }; + + let gutter_width = self.editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + let style = editor.style(cx); + let font_id = window.text_system().resolve_font(&style.text.font()); + let font_size = style.text.font_size.to_pixels(window.rem_size()); + snapshot + .gutter_dimensions(font_id, font_size, style, window, cx) + .full_width() + }); + + let clipboard_has_link = cx + .read_from_clipboard() + .and_then(|entry| entry.text()) + .map_or(false, |clipboard_text| { + clipboard_text.trim() == commit_sha.as_ref() + }); + + let (copy_icon, copy_icon_color) = if clipboard_has_link { + (IconName::Check, Color::Success) + } else { + (IconName::Copy, Color::Muted) + }; + + h_flex() .border_b_1() - .border_color(cx.theme().colors().border) + .border_color(cx.theme().colors().border_variant) + .w_full() .child( h_flex() + .w(gutter_width) + .justify_center() + .child(self.render_commit_avatar(&commit.sha, rems_from_px(48.), window, cx)), + ) + .child( + h_flex() + .py_4() + .pl_1() + .pr_4() + .w_full() .items_start() - .gap_3() - .child(self.render_commit_avatar(&commit.sha, gpui::rems(3.0), window, cx)) + .justify_between() + .flex_wrap() .child( v_flex() - .gap_1() .child( h_flex() - .gap_3() - .items_baseline() + .gap_1() .child(Label::new(author_name).color(Color::Default)) - .child( - Label::new(format!("commit {}", commit.sha)) - .color(Color::Muted), - ), + .child({ + ButtonLike::new("sha") + .child( + h_flex() + .group("sha_btn") + .size_full() + .max_w_32() + .gap_0p5() + .child( + Label::new(commit_sha.clone()) + .color(Color::Muted) + .size(LabelSize::Small) + .truncate() + .buffer_font(cx), + ) + .child( + div().visible_on_hover("sha_btn").child( + Icon::new(copy_icon) + .color(copy_icon_color) + .size(IconSize::Small), + ), + ), + ) + .tooltip({ + let commit_sha = commit_sha.clone(); + move |_, cx| { + Tooltip::with_meta( + "Copy Commit SHA", + None, + commit_sha.clone(), + cx, + ) + } + }) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string( + commit_sha.to_string(), + )); + }) + }), ) - .child(Label::new(date_string).color(Color::Muted)), + .child( + h_flex() + .gap_1p5() + .child( + Label::new(date_string) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + Label::new("•") + .color(Color::Ignored) + .size(LabelSize::Small), + ) + .children(commit_diff_stat), + ), ) - .child(div().flex_grow()) - .children(github_url.map(|url| { - Button::new("view_on_github", "View on GitHub") - .icon(IconName::Github) - .style(ui::ButtonStyle::Subtle) + .children(remote_info.map(|(provider_name, url)| { + let icon = match provider_name.as_str() { + "GitHub" => IconName::Github, + _ => IconName::Link, + }; + + Button::new("view_on_provider", format!("View on {}", provider_name)) + .icon(icon) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) .on_click(move |_, _, cx| cx.open_url(&url)) })), ) - .child(self.render_commit_message(window, cx)) - } - - fn process_github_issues(message: &str, remote: &GitRemote) -> String { - let mut result = String::new(); - let chars: Vec = message.chars().collect(); - let mut i = 0; - - while i < chars.len() { - if chars[i] == '#' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() { - let mut j = i + 1; - while j < chars.len() && chars[j].is_ascii_digit() { - j += 1; - } - let issue_number = &message[i + 1..i + (j - i)]; - let url = format!( - "{}/{}/{}/issues/{}", - remote.host.base_url().as_str().trim_end_matches('/'), - remote.owner, - remote.repo, - issue_number - ); - result.push_str(&format!("[#{}]({})", issue_number, url)); - i = j; - } else if i + 3 < chars.len() - && chars[i] == 'G' - && chars[i + 1] == 'H' - && chars[i + 2] == '-' - && chars[i + 3].is_ascii_digit() - { - let mut j = i + 3; - while j < chars.len() && chars[j].is_ascii_digit() { - j += 1; - } - let issue_number = &message[i + 3..i + (j - i)]; - let url = format!( - "{}/{}/{}/issues/{}", - remote.host.base_url().as_str().trim_end_matches('/'), - remote.owner, - remote.repo, - issue_number - ); - result.push_str(&format!("[GH-{}]({})", issue_number, url)); - i = j; - } else { - result.push(chars[i]); - i += 1; - } - } - - result - } - - fn render_commit_message( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let style = hover_markdown_style(window, cx); - MarkdownElement::new(self.markdown.clone(), style) } fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { @@ -576,69 +703,19 @@ impl CommitView { } } -#[derive(Clone, Debug)] -struct CommitAvatarAsset { - sha: SharedString, - remote: GitRemote, -} - -impl std::hash::Hash for CommitAvatarAsset { - fn hash(&self, state: &mut H) { - self.sha.hash(state); - self.remote.host.name().hash(state); - } -} - -impl CommitAvatarAsset { - fn new(remote: GitRemote, sha: SharedString) -> Self { - Self { remote, sha } - } -} - -impl Asset for CommitAvatarAsset { - type Source = Self; - type Output = Option; - - fn load( - source: Self::Source, - cx: &mut App, - ) -> impl Future + Send + 'static { - let client = cx.http_client(); - async move { - match source - .remote - .host - .commit_author_avatar_url( - &source.remote.owner, - &source.remote.repo, - source.sha.clone(), - client, - ) - .await - { - Ok(Some(url)) => Some(SharedString::from(url.to_string())), - Ok(None) => None, - Err(_) => None, - } - } - } -} - impl language::File for GitBlob { fn as_local(&self) -> Option<&dyn language::LocalFile> { None } fn disk_state(&self) -> DiskState { - if self.is_deleted { - DiskState::Deleted - } else { - DiskState::New + DiskState::Historic { + was_deleted: self.is_deleted, } } fn path_style(&self, _: &App) -> PathStyle { - PathStyle::Posix + PathStyle::local() } fn path(&self) -> &Arc { @@ -650,7 +727,7 @@ impl language::File for GitBlob { } fn file_name<'a>(&'a self, _: &'a App) -> &'a str { - self.path.file_name().unwrap() + self.display_name.as_ref() } fn worktree_id(&self, _: &App) -> WorktreeId { @@ -666,94 +743,6 @@ impl language::File for GitBlob { } } -// No longer needed since metadata buffer is not created -// impl language::File for CommitMetadataFile { -// fn as_local(&self) -> Option<&dyn language::LocalFile> { -// None -// } -// -// fn disk_state(&self) -> DiskState { -// DiskState::New -// } -// -// fn path_style(&self, _: &App) -> PathStyle { -// PathStyle::Posix -// } -// -// fn path(&self) -> &Arc { -// &self.title -// } -// -// fn full_path(&self, _: &App) -> PathBuf { -// self.title.as_std_path().to_path_buf() -// } -// -// fn file_name<'a>(&'a self, _: &'a App) -> &'a str { -// self.title.file_name().unwrap_or("commit") -// } -// -// fn worktree_id(&self, _: &App) -> WorktreeId { -// self.worktree_id -// } -// -// fn to_proto(&self, _cx: &App) -> language::proto::File { -// unimplemented!() -// } -// -// fn is_private(&self) -> bool { -// false -// } -// } - -struct CommitViewAddon { - multibuffer: WeakEntity, -} - -impl Addon for CommitViewAddon { - fn render_buffer_header_controls( - &self, - excerpt: &ExcerptInfo, - _window: &Window, - cx: &App, - ) -> Option { - let multibuffer = self.multibuffer.upgrade()?; - let snapshot = multibuffer.read(cx).snapshot(cx); - let excerpts = snapshot.excerpts().collect::>(); - let current_idx = excerpts.iter().position(|(id, _, _)| *id == excerpt.id)?; - let (_, _, current_range) = &excerpts[current_idx]; - - let start_row = current_range.context.start.to_point(&excerpt.buffer).row; - - let prev_end_row = if current_idx > 0 { - let (_, prev_buffer, prev_range) = &excerpts[current_idx - 1]; - if prev_buffer.remote_id() == excerpt.buffer_id { - prev_range.context.end.to_point(&excerpt.buffer).row - } else { - 0 - } - } else { - 0 - }; - - let skipped_lines = start_row.saturating_sub(prev_end_row); - - if skipped_lines > 0 { - Some( - Label::new(format!("{} unchanged lines", skipped_lines)) - .color(Color::Muted) - .size(LabelSize::Small) - .into_any_element(), - ) - } else { - None - } - } - - fn to_any(&self) -> &dyn Any { - self - } -} - async fn build_buffer( mut text: String, blob: Arc, @@ -851,13 +840,28 @@ impl Item for CommitView { fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha); let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20); - format!("{short_sha} - {subject}").into() + format!("{short_sha} — {subject}").into() } - fn tab_tooltip_text(&self, _: &App) -> Option { + fn tab_tooltip_content(&self, _: &App) -> Option { let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha); let subject = self.commit.message.split('\n').next().unwrap(); - Some(format!("{short_sha} - {subject}").into()) + + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + let subject = subject.to_string(); + let short_sha = short_sha.to_string(); + + move |_, _| { + v_flex() + .child(Label::new(subject.clone())) + .child( + Label::new(short_sha.clone()) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + } + })))) } fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { @@ -959,12 +963,6 @@ impl Item for CommitView { .update(cx, |editor, cx| editor.clone(window, cx)) }); let multibuffer = editor.read(cx).buffer().clone(); - let processed_message = if let Some(ref remote) = self.remote { - Self::process_github_issues(&self.commit.message, remote) - } else { - self.commit.message.to_string() - }; - let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx)); Self { editor, multibuffer, @@ -972,7 +970,6 @@ impl Item for CommitView { stash: self.stash, repository: self.repository.clone(), remote: self.remote.clone(), - markdown, } }))) } @@ -981,14 +978,15 @@ impl Item for CommitView { impl Render for CommitView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_stash = self.stash.is_some(); - div() + + v_flex() .key_context(if is_stash { "StashDiff" } else { "CommitDiff" }) - .bg(cx.theme().colors().editor_background) - .flex() - .flex_col() .size_full() + .bg(cx.theme().colors().editor_background) .child(self.render_header(window, cx)) - .child(div().flex_grow().child(self.editor.clone())) + .when(!self.editor.read(cx).is_empty(cx), |this| { + this.child(div().flex_grow().child(self.editor.clone())) + }) } } @@ -1006,7 +1004,7 @@ impl EventEmitter for CommitViewToolbar {} impl Render for CommitViewToolbar { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() + div().hidden() } } @@ -1042,117 +1040,3 @@ fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool .map(|entry| entry.oid.to_string() == sha) .unwrap_or(false) } - -fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let colors = cx.theme().colors(); - let mut style = MarkdownStyle::default(); - style.base_text_style = window.text_style(); - style.syntax = cx.theme().syntax().clone(); - style.selection_background_color = colors.element_selection_background; - style.link = TextStyleRefinement { - color: Some(colors.text_accent), - underline: Some(UnderlineStyle { - thickness: px(1.0), - color: Some(colors.text_accent), - wavy: false, - }), - ..Default::default() - }; - style -} - -#[cfg(test)] -mod tests { - use super::*; - use git_hosting_providers::Github; - - fn create_test_remote() -> GitRemote { - GitRemote { - host: Arc::new(Github::public_instance()), - owner: "zed-industries".into(), - repo: "zed".into(), - } - } - - #[test] - fn test_process_github_issues_simple_issue_number() { - let remote = create_test_remote(); - let message = "Fix bug #123"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix bug [#123](https://github.com/zed-industries/zed/issues/123)" - ); - } - - #[test] - fn test_process_github_issues_multiple_issue_numbers() { - let remote = create_test_remote(); - let message = "Fix #123 and #456"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [#456](https://github.com/zed-industries/zed/issues/456)" - ); - } - - #[test] - fn test_process_github_issues_gh_format() { - let remote = create_test_remote(); - let message = "Fix GH-789"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix [GH-789](https://github.com/zed-industries/zed/issues/789)" - ); - } - - #[test] - fn test_process_github_issues_mixed_formats() { - let remote = create_test_remote(); - let message = "Fix #123 and GH-456"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [GH-456](https://github.com/zed-industries/zed/issues/456)" - ); - } - - #[test] - fn test_process_github_issues_no_issues() { - let remote = create_test_remote(); - let message = "This is a commit message without any issues"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!(result, message); - } - - #[test] - fn test_process_github_issues_hash_without_number() { - let remote = create_test_remote(); - let message = "Use # for comments"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!(result, message); - } - - #[test] - fn test_process_github_issues_consecutive_issues() { - let remote = create_test_remote(); - let message = "#123#456"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "[#123](https://github.com/zed-industries/zed/issues/123)[#456](https://github.com/zed-industries/zed/issues/456)" - ); - } - - #[test] - fn test_process_github_issues_multiline() { - let remote = create_test_remote(); - let message = "Fix #123\n\nThis also fixes #456"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix [#123](https://github.com/zed-industries/zed/issues/123)\n\nThis also fixes [#456](https://github.com/zed-industries/zed/issues/456)" - ); - } -} diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 91cc3ce76b3f10aa310185b566b6c6086580b69c..813e63ab8c96e736cf0cc126526a683b418c2137 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -111,6 +111,7 @@ fn excerpt_for_buffer_updated( ); } +#[ztracing::instrument(skip_all)] fn buffer_added(editor: &mut Editor, buffer: Entity, cx: &mut Context) { let Some(project) = editor.project() else { return; @@ -166,6 +167,7 @@ fn buffers_removed(editor: &mut Editor, removed_buffer_ids: &[BufferId], cx: &mu editor.remove_blocks(removed_block_ids, None, cx); } +#[ztracing::instrument(skip_all)] fn conflicts_updated( editor: &mut Editor, conflict_set: Entity, @@ -311,6 +313,7 @@ fn conflicts_updated( } } +#[ztracing::instrument(skip_all)] fn update_conflict_highlighting( editor: &mut Editor, conflict: &ConflictRegion, @@ -372,7 +375,7 @@ fn render_conflict_buttons( .gap_1() .bg(cx.theme().colors().editor_background) .child( - Button::new("head", "Use HEAD") + Button::new("head", format!("Use {}", conflict.ours_branch_name)) .label_size(LabelSize::Small) .on_click({ let editor = editor.clone(); @@ -392,7 +395,7 @@ fn render_conflict_buttons( }), ) .child( - Button::new("origin", "Use Origin") + Button::new("origin", format!("Use {}", conflict.theirs_branch_name)) .label_size(LabelSize::Small) .on_click({ let editor = editor.clone(); diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index 311fe1ff247feb4207eaf8ac46471e933fe3fd3b..28ade64dd4f3a04e615b7ee063362576edb69b91 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -109,7 +109,7 @@ impl FileDiffView { for buffer in [&old_buffer, &new_buffer] { cx.subscribe(buffer, move |this, _, event, _| match event { language::BufferEvent::Edited - | language::BufferEvent::LanguageChanged + | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); } diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs index e34806aacae48122caf3a12246b04862898f2bed..f48160719ba5d9b00b8961b75e9ea402c80dd06a 100644 --- a/crates/git_ui/src/file_history_view.rs +++ b/crates/git_ui/src/file_history_view.rs @@ -4,7 +4,8 @@ use git::repository::{FileHistory, FileHistoryEntry, RepoPath}; use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; use gpui::{ AnyElement, AnyEntity, App, Asset, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, Render, Task, UniformListScrollHandle, WeakEntity, Window, actions, uniform_list, + IntoElement, Render, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window, + actions, uniform_list, }; use project::{ Project, ProjectPath, @@ -191,6 +192,93 @@ impl FileHistoryView { task.detach(); } + fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = match self.selected_entry { + _ if entry_count == 0 => None, + None => Some(0), + Some(ix) => { + if ix == entry_count - 1 { + Some(0) + } else { + Some(ix + 1) + } + } + }; + self.select_ix(ix, cx); + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _: &mut Window, + cx: &mut Context, + ) { + let entry_count = self.history.entries.len(); + let ix = match self.selected_entry { + _ if entry_count == 0 => None, + None => Some(entry_count - 1), + Some(ix) => { + if ix == 0 { + Some(entry_count - 1) + } else { + Some(ix - 1) + } + } + }; + self.select_ix(ix, cx); + } + + fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = if entry_count != 0 { Some(0) } else { None }; + self.select_ix(ix, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = if entry_count != 0 { + Some(entry_count - 1) + } else { + None + }; + self.select_ix(ix, cx); + } + + fn select_ix(&mut self, ix: Option, cx: &mut Context) { + self.selected_entry = ix; + if let Some(ix) = ix { + self.scroll_handle.scroll_to_item(ix, ScrollStrategy::Top); + } + cx.notify(); + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + self.open_commit_view(window, cx); + } + + fn open_commit_view(&mut self, window: &mut Window, cx: &mut Context) { + let Some(entry) = self + .selected_entry + .and_then(|ix| self.history.entries.get(ix)) + else { + return; + }; + + if let Some(repo) = self.repository.upgrade() { + let sha_str = entry.sha.to_string(); + CommitView::open( + sha_str, + repo.downgrade(), + self.workspace.clone(), + None, + Some(self.history.path.clone()), + window, + cx, + ); + } + } + fn render_commit_avatar( &self, sha: &SharedString, @@ -245,12 +333,8 @@ impl FileHistoryView { time_format::TimestampFormat::Relative, ); - let sha = entry.sha.clone(); - let repo = self.repository.clone(); - let workspace = self.workspace.clone(); - let file_path = self.history.path.clone(); - ListItem::new(("commit", ix)) + .toggle_state(Some(ix) == self.selected_entry) .child( h_flex() .h_8() @@ -267,15 +351,19 @@ impl FileHistoryView { .child(self.render_commit_avatar(&entry.sha, window, cx)) .child( h_flex() + .min_w_0() .w_full() .justify_between() .child( h_flex() + .min_w_0() + .w_full() .gap_1() .child( Label::new(entry.author_name.clone()) .size(LabelSize::Small) - .color(Color::Default), + .color(Color::Default) + .truncate(), ) .child( Label::new(&entry.subject) @@ -285,9 +373,11 @@ impl FileHistoryView { ), ) .child( - Label::new(relative_timestamp) - .size(LabelSize::Small) - .color(Color::Muted), + h_flex().flex_none().child( + Label::new(relative_timestamp) + .size(LabelSize::Small) + .color(Color::Muted), + ), ), ), ) @@ -295,18 +385,7 @@ impl FileHistoryView { this.selected_entry = Some(ix); cx.notify(); - if let Some(repo) = repo.upgrade() { - let sha_str = sha.to_string(); - CommitView::open( - sha_str, - repo.downgrade(), - workspace.clone(), - None, - Some(file_path.clone()), - window, - cx, - ); - } + this.open_commit_view(window, cx); })) .into_any_element() } @@ -374,6 +453,14 @@ impl Render for FileHistoryView { let entry_count = self.history.entries.len(); v_flex() + .id("file_history_view") + .key_context("FileHistoryView") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) .size_full() .bg(cx.theme().colors().editor_background) .child( @@ -546,9 +633,9 @@ impl Item for FileHistoryView { &mut self, _workspace: &mut Workspace, window: &mut Window, - _cx: &mut Context, + cx: &mut Context, ) { - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); } fn show_toolbar(&self) -> bool { diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 8579cafa58a22ea6d182f23b753d3b1a365f37fa..0540bb55971879c7feb7ebe36276da03684a2b4d 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -6,19 +6,22 @@ use crate::project_diff::{self, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; use crate::{branch_picker, picker_prompt, render_remote_button}; use crate::{ - git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, + file_history_view::FileHistoryView, git_panel_settings::GitPanelSettings, git_status_icon, + repository_selector::RepositorySelector, }; use agent_settings::AgentSettings; use anyhow::Context as _; use askpass::AskPassDelegate; use cloud_llm_client::CompletionIntent; +use collections::{BTreeMap, HashMap, HashSet}; use db::kvp::KEY_VALUE_STORE; +use editor::RewrapOptions; use editor::{ Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, actions::ExpandAllDiffHunks, }; use futures::StreamExt as _; -use git::blame::ParsedCommitMessage; +use git::commit::ParsedCommitMessage; use git::repository::{ Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter, PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, @@ -28,21 +31,22 @@ use git::stash::GitStash; use git::status::StageStatus; use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus}; use git::{ - ExpandCommitEditor, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop, - TrashUntrackedFiles, UnstageAll, + ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll, + StashApply, StashPop, TrashUntrackedFiles, UnstageAll, }; use gpui::{ - Action, AsyncApp, AsyncWindowContext, ClickEvent, Corner, DismissEvent, Entity, EventEmitter, - FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, - MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task, - UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list, + Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity, + EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point, + PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions, + anchored, deferred, point, size, uniform_list, }; use itertools::Itertools; use language::{Buffer, File}; use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role, + ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + Role, ZED_CLOUD_PROVIDER_ID, }; -use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use menu; use multi_buffer::ExcerptInfo; use notifications::status_toast::{StatusToast, ToastIcon}; use panel::{ @@ -54,20 +58,22 @@ use project::{ git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op}, project_settings::{GitPathStyle, ProjectSettings}, }; +use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore, StatusStyle}; use std::future::Future; use std::ops::Range; use std::path::Path; -use std::{collections::HashSet, sync::Arc, time::Duration, usize}; +use std::{sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; use time::OffsetDateTime; use ui::{ - ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, PopoverMenu, ScrollAxes, - Scrollbars, SplitButton, Tooltip, WithScrollbar, prelude::*, + ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors, + PopoverMenu, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar, + prelude::*, }; use util::paths::PathStyle; -use util::{ResultExt, TryFutureExt, maybe}; +use util::{ResultExt, TryFutureExt, maybe, rel_path::RelPath}; use workspace::SERIALIZATION_THROTTLE_TIME; use workspace::{ Workspace, @@ -87,10 +93,24 @@ actions!( FocusEditor, /// Focuses on the changes list. FocusChanges, + /// Select next git panel menu item, and show it in the diff view + NextEntry, + /// Select previous git panel menu item, and show it in the diff view + PreviousEntry, + /// Select first git panel menu item, and show it in the diff view + FirstEntry, + /// Select last git panel menu item, and show it in the diff view + LastEntry, /// Toggles automatic co-author suggestions. ToggleFillCoAuthors, /// Toggles sorting entries by path vs status. ToggleSortByPath, + /// Toggles showing entries in tree vs flat view. + ToggleTreeView, + /// Expands the selected entry to show its children. + ExpandSelectedEntry, + /// Collapses the selected entry to hide its children. + CollapseSelectedEntry, ] ); @@ -121,6 +141,7 @@ struct GitMenuState { has_new_changes: bool, sort_by_path: bool, has_stash_items: bool, + tree_view: bool, } fn git_panel_context_menu( @@ -165,20 +186,33 @@ fn git_panel_context_menu( ) .separator() .entry( - if state.sort_by_path { - "Sort by Status" + if state.tree_view { + "Flat View" } else { - "Sort by Path" + "Tree View" }, - Some(Box::new(ToggleSortByPath)), - move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), + Some(Box::new(ToggleTreeView)), + move |window, cx| window.dispatch_action(Box::new(ToggleTreeView), cx), ) + .when(!state.tree_view, |this| { + this.entry( + if state.sort_by_path { + "Sort by Status" + } else { + "Sort by Path" + }, + Some(Box::new(ToggleSortByPath)), + move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx), + ) + }) }) } const GIT_PANEL_KEY: &str = "GitPanel"; const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); +// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel +const TREE_INDENT: f32 = 16.0; pub fn register(workspace: &mut Workspace) { workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { @@ -203,7 +237,7 @@ struct SerializedGitPanel { signoff_enabled: bool, } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] enum Section { Conflict, Tracked, @@ -239,6 +273,8 @@ impl GitHeaderEntry { #[derive(Debug, PartialEq, Eq, Clone)] enum GitListEntry { Status(GitStatusEntry), + TreeStatus(GitTreeStatusEntry), + Directory(GitTreeDirEntry), Header(GitHeaderEntry), } @@ -246,9 +282,213 @@ impl GitListEntry { fn status_entry(&self) -> Option<&GitStatusEntry> { match self { GitListEntry::Status(entry) => Some(entry), + GitListEntry::TreeStatus(entry) => Some(&entry.entry), _ => None, } } + + fn directory_entry(&self) -> Option<&GitTreeDirEntry> { + match self { + GitListEntry::Directory(entry) => Some(entry), + _ => None, + } + } +} + +enum GitPanelViewMode { + Flat, + Tree(TreeViewState), +} + +impl GitPanelViewMode { + fn from_settings(cx: &App) -> Self { + if GitPanelSettings::get_global(cx).tree_view { + GitPanelViewMode::Tree(TreeViewState::default()) + } else { + GitPanelViewMode::Flat + } + } + + fn tree_state(&self) -> Option<&TreeViewState> { + match self { + GitPanelViewMode::Tree(state) => Some(state), + GitPanelViewMode::Flat => None, + } + } + + fn tree_state_mut(&mut self) -> Option<&mut TreeViewState> { + match self { + GitPanelViewMode::Tree(state) => Some(state), + GitPanelViewMode::Flat => None, + } + } +} + +#[derive(Default)] +struct TreeViewState { + // Maps visible index to actual entry index. + // Length equals the number of visible entries. + // This is needed because some entries (like collapsed directories) may be hidden. + logical_indices: Vec, + expanded_dirs: HashMap, + directory_descendants: HashMap>, +} + +impl TreeViewState { + fn build_tree_entries( + &mut self, + section: Section, + mut entries: Vec, + seen_directories: &mut HashSet, + ) -> Vec<(GitListEntry, bool)> { + if entries.is_empty() { + return Vec::new(); + } + + entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); + + let mut root = TreeNode::default(); + for entry in entries { + let components: Vec<&str> = entry.repo_path.components().collect(); + if components.is_empty() { + root.files.push(entry); + continue; + } + + let mut current = &mut root; + let mut current_path = String::new(); + + for (ix, component) in components.iter().enumerate() { + if ix == components.len() - 1 { + current.files.push(entry.clone()); + } else { + if !current_path.is_empty() { + current_path.push('/'); + } + current_path.push_str(component); + let dir_path = RepoPath::new(¤t_path) + .expect("repo path from status entry component"); + + let component = SharedString::from(component.to_string()); + + current = current + .children + .entry(component.clone()) + .or_insert_with(|| TreeNode { + name: component, + path: Some(dir_path), + ..Default::default() + }); + } + } + } + + let (flattened, _) = self.flatten_tree(&root, section, 0, seen_directories); + flattened + } + + fn flatten_tree( + &mut self, + node: &TreeNode, + section: Section, + depth: usize, + seen_directories: &mut HashSet, + ) -> (Vec<(GitListEntry, bool)>, Vec) { + let mut all_statuses = Vec::new(); + let mut flattened = Vec::new(); + + for child in node.children.values() { + let (terminal, name) = Self::compact_directory_chain(child); + let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else { + continue; + }; + let (child_flattened, mut child_statuses) = + self.flatten_tree(terminal, section, depth + 1, seen_directories); + let key = TreeKey { section, path }; + let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true); + self.expanded_dirs.entry(key.clone()).or_insert(true); + seen_directories.insert(key.clone()); + + self.directory_descendants + .insert(key.clone(), child_statuses.clone()); + + flattened.push(( + GitListEntry::Directory(GitTreeDirEntry { + key, + name, + depth, + expanded, + }), + true, + )); + + if expanded { + flattened.extend(child_flattened); + } else { + flattened.extend(child_flattened.into_iter().map(|(child, _)| (child, false))); + } + + all_statuses.append(&mut child_statuses); + } + + for file in &node.files { + all_statuses.push(file.clone()); + flattened.push(( + GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: file.clone(), + depth, + }), + true, + )); + } + + (flattened, all_statuses) + } + + fn compact_directory_chain(mut node: &TreeNode) -> (&TreeNode, SharedString) { + let mut parts = vec![node.name.clone()]; + while node.files.is_empty() && node.children.len() == 1 { + let Some(child) = node.children.values().next() else { + continue; + }; + if child.path.is_none() { + break; + } + parts.push(child.name.clone()); + node = child; + } + let name = parts.join("/"); + (node, SharedString::from(name)) + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct GitTreeStatusEntry { + entry: GitStatusEntry, + depth: usize, +} + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +struct TreeKey { + section: Section, + path: RepoPath, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +struct GitTreeDirEntry { + key: TreeKey, + name: SharedString, + depth: usize, + // staged_state: ToggleState, + expanded: bool, +} + +#[derive(Default)] +struct TreeNode { + name: SharedString, + path: Option, + children: BTreeMap, + files: Vec, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -344,12 +584,15 @@ pub struct GitPanel { add_coauthors: bool, generate_commit_message_task: Option>>, entries: Vec, + view_mode: GitPanelViewMode, + entries_indices: HashMap, single_staged_entry: Option, single_tracked_entry: Option, focus_handle: FocusHandle, fs: Arc, new_count: usize, entry_count: usize, + changes_count: usize, new_staged_count: usize, pending_commit: Option>, amend_pending: bool, @@ -365,7 +608,7 @@ pub struct GitPanel { tracked_staged_count: usize, update_visible_entries_task: Task<()>, width: Option, - workspace: WeakEntity, + pub(crate) workspace: WeakEntity, context_menu: Option<(Entity, Point, Subscription)>, modal_open: bool, show_placeholders: bool, @@ -432,14 +675,19 @@ impl GitPanel { cx.on_focus(&focus_handle, window, Self::focus_in).detach(); let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; cx.observe_global_in::(window, move |this, window, cx| { - let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; - if is_sort_by_path != was_sort_by_path { - this.entries.clear(); + let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let tree_view = GitPanelSettings::get_global(cx).tree_view; + if tree_view != was_tree_view { + this.view_mode = GitPanelViewMode::from_settings(cx); + } + if sort_by_path != was_sort_by_path || tree_view != was_tree_view { this.bulk_staging.take(); this.update_visible_entries(window, cx); } - was_sort_by_path = is_sort_by_path + was_sort_by_path = sort_by_path; + was_tree_view = tree_view; }) .detach(); @@ -505,10 +753,13 @@ impl GitPanel { add_coauthors: true, generate_commit_message_task: None, entries: Vec::new(), + view_mode: GitPanelViewMode::from_settings(cx), + entries_indices: HashMap::default(), focus_handle: cx.focus_handle(), fs, new_count: 0, new_staged_count: 0, + changes_count: 0, pending_commit: None, amend_pending: false, original_commit_message: None, @@ -542,70 +793,70 @@ impl GitPanel { }) } - pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option { - if GitPanelSettings::get_global(cx).sort_by_path { - return self - .entries - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - .ok(); - } - - if self.conflicted_count > 0 { - let conflicted_start = 1; - if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(conflicted_start + ix); - } - } - if self.tracked_count > 0 { - let tracked_start = if self.conflicted_count > 0 { - 1 + self.conflicted_count - } else { - 0 - } + 1; - if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(tracked_start + ix); - } - } - if self.new_count > 0 { - let untracked_start = if self.conflicted_count > 0 { - 1 + self.conflicted_count - } else { - 0 - } + if self.tracked_count > 0 { - 1 + self.tracked_count - } else { - 0 - } + 1; - if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count] - .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path)) - { - return Some(untracked_start + ix); - } - } - None + pub fn entry_by_path(&self, path: &RepoPath) -> Option { + self.entries_indices.get(path).copied() } pub fn select_entry_by_path( &mut self, path: ProjectPath, - _: &mut Window, + window: &mut Window, cx: &mut Context, ) { let Some(git_repo) = self.active_repository.as_ref() else { return; }; - let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else { - return; + + let (repo_path, section) = { + let repo = git_repo.read(cx); + let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else { + return; + }; + + let section = repo + .status_for_path(&repo_path) + .map(|status| status.status) + .map(|status| { + if repo.had_conflict_on_last_merge_head_change(&repo_path) { + Section::Conflict + } else if status.is_created() { + Section::New + } else { + Section::Tracked + } + }); + + (repo_path, section) }; - let Some(ix) = self.entry_by_path(&repo_path, cx) else { + + let mut needs_rebuild = false; + if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) { + let mut current_dir = repo_path.parent(); + while let Some(dir) = current_dir { + let key = TreeKey { + section, + path: RepoPath::from_rel_path(dir), + }; + + if tree_state.expanded_dirs.get(&key) == Some(&false) { + tree_state.expanded_dirs.insert(key, true); + needs_rebuild = true; + } + + current_dir = dir.parent(); + } + } + + if needs_rebuild { + self.update_visible_entries(window, cx); + } + + let Some(ix) = self.entry_by_path(&repo_path) else { return; }; + self.selected_entry = Some(ix); - cx.notify(); + self.scroll_to_selected_entry(cx); } fn serialization_key(workspace: &Workspace) -> Option { @@ -693,24 +944,98 @@ impl GitPanel { } fn scroll_to_selected_entry(&mut self, cx: &mut Context) { - if let Some(selected_entry) = self.selected_entry { + let Some(selected_entry) = self.selected_entry else { + cx.notify(); + return; + }; + + let visible_index = match &self.view_mode { + GitPanelViewMode::Flat => Some(selected_entry), + GitPanelViewMode::Tree(state) => state + .logical_indices + .iter() + .position(|&ix| ix == selected_entry), + }; + + if let Some(visible_index) = visible_index { self.scroll_handle - .scroll_to_item(selected_entry, ScrollStrategy::Center); + .scroll_to_item(visible_index, ScrollStrategy::Center); } cx.notify(); } - fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - if !self.entries.is_empty() { - self.selected_entry = Some(1); + fn expand_selected_entry( + &mut self, + _: &ExpandSelectedEntry, + window: &mut Window, + cx: &mut Context, + ) { + let Some(entry) = self.get_selected_entry().cloned() else { + return; + }; + + if let GitListEntry::Directory(dir_entry) = entry { + if dir_entry.expanded { + self.select_next(&menu::SelectNext, window, cx); + } else { + self.toggle_directory(&dir_entry.key, window, cx); + } + } else { + self.select_next(&menu::SelectNext, window, cx); + } + } + + fn collapse_selected_entry( + &mut self, + _: &CollapseSelectedEntry, + window: &mut Window, + cx: &mut Context, + ) { + let Some(entry) = self.get_selected_entry().cloned() else { + return; + }; + + if let GitListEntry::Directory(dir_entry) = entry { + if dir_entry.expanded { + self.toggle_directory(&dir_entry.key, window, cx); + } else { + self.select_previous(&menu::SelectPrevious, window, cx); + } + } else { + self.select_previous(&menu::SelectPrevious, window, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + let first_entry = match &self.view_mode { + GitPanelViewMode::Flat => self + .entries + .iter() + .position(|entry| entry.status_entry().is_some()), + GitPanelViewMode::Tree(state) => { + let index = self.entries.iter().position(|entry| { + entry.status_entry().is_some() || entry.directory_entry().is_some() + }); + + index.map(|index| state.logical_indices[index]) + } + }; + + if let Some(first_entry) = first_entry { + self.selected_entry = Some(first_entry); self.scroll_to_selected_entry(cx); } } fn select_previous( &mut self, - _: &SelectPrevious, + _: &menu::SelectPrevious, _window: &mut Window, cx: &mut Context, ) { @@ -719,80 +1044,142 @@ impl GitPanel { return; } - if let Some(selected_entry) = self.selected_entry { - let new_selected_entry = if selected_entry > 0 { - selected_entry - 1 - } else { - selected_entry - }; + let Some(selected_entry) = self.selected_entry else { + return; + }; - if matches!( - self.entries.get(new_selected_entry), - Some(GitListEntry::Header(..)) - ) { - if new_selected_entry > 0 { - self.selected_entry = Some(new_selected_entry - 1) - } - } else { - self.selected_entry = Some(new_selected_entry); + let new_index = match &self.view_mode { + GitPanelViewMode::Flat => selected_entry.saturating_sub(1), + GitPanelViewMode::Tree(state) => { + let Some(current_logical_index) = state + .logical_indices + .iter() + .position(|&i| i == selected_entry) + else { + return; + }; + + state.logical_indices[current_logical_index.saturating_sub(1)] } + }; - self.scroll_to_selected_entry(cx); + if selected_entry == 0 && new_index == 0 { + return; } - cx.notify(); + if matches!( + self.entries.get(new_index.saturating_sub(1)), + Some(GitListEntry::Header(..)) + ) && new_index == 0 + { + return; + } + + if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) { + self.selected_entry = Some(new_index.saturating_sub(1)); + } else { + self.selected_entry = Some(new_index); + } + + self.scroll_to_selected_entry(cx); } - fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { + fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { let item_count = self.entries.len(); if item_count == 0 { return; } - if let Some(selected_entry) = self.selected_entry { - let new_selected_entry = if selected_entry < item_count - 1 { - selected_entry + 1 - } else { - selected_entry - }; - if matches!( - self.entries.get(new_selected_entry), - Some(GitListEntry::Header(..)) - ) { - self.selected_entry = Some(new_selected_entry + 1); - } else { - self.selected_entry = Some(new_selected_entry); + let Some(selected_entry) = self.selected_entry else { + return; + }; + + if selected_entry == item_count - 1 { + return; + } + + let new_index = match &self.view_mode { + GitPanelViewMode::Flat => selected_entry.saturating_add(1), + GitPanelViewMode::Tree(state) => { + let Some(current_logical_index) = state + .logical_indices + .iter() + .position(|&i| i == selected_entry) + else { + return; + }; + + state.logical_indices[current_logical_index.saturating_add(1)] } + }; - self.scroll_to_selected_entry(cx); + if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) { + self.selected_entry = Some(new_index.saturating_add(1)); + } else { + self.selected_entry = Some(new_index); } - cx.notify(); + self.scroll_to_selected_entry(cx); } - fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { if self.entries.last().is_some() { self.selected_entry = Some(self.entries.len() - 1); self.scroll_to_selected_entry(cx); } } + /// Show diff view at selected entry, only if the diff view is open + fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context) { + maybe!({ + let workspace = self.workspace.upgrade()?; + + if let Some(project_diff) = workspace.read(cx).item_of_type::(cx) { + let entry = self.entries.get(self.selected_entry?)?.status_entry()?; + + project_diff.update(cx, |project_diff, cx| { + project_diff.move_to_entry(entry.clone(), window, cx); + }); + } + + Some(()) + }); + } + + fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context) { + self.select_first(&menu::SelectFirst, window, cx); + self.move_diff_to_entry(window, cx); + } + + fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context) { + self.select_last(&menu::SelectLast, window, cx); + self.move_diff_to_entry(window, cx); + } + + fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context) { + self.select_next(&menu::SelectNext, window, cx); + self.move_diff_to_entry(window, cx); + } + + fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context) { + self.select_previous(&menu::SelectPrevious, window, cx); + self.move_diff_to_entry(window, cx); + } + fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context) { self.commit_editor.update(cx, |editor, cx| { - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); }); cx.notify(); } - fn select_first_entry_if_none(&mut self, cx: &mut Context) { + fn select_first_entry_if_none(&mut self, window: &mut Window, cx: &mut Context) { let have_entries = self .active_repository .as_ref() .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0); if have_entries && self.selected_entry.is_none() { - self.selected_entry = Some(1); - self.scroll_to_selected_entry(cx); - cx.notify(); + self.select_first(&menu::SelectFirst, window, cx); } } @@ -802,10 +1189,8 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) { - self.select_first_entry_if_none(cx); - - self.focus_handle.focus(window); - cx.notify(); + self.focus_handle.focus(window, cx); + self.select_first_entry_if_none(window, cx); } fn get_selected_entry(&self) -> Option<&GitListEntry> { @@ -826,7 +1211,7 @@ impl GitPanel { .project_path_to_repo_path(&project_path, cx) .as_ref() { - project_diff.focus_handle(cx).focus(window); + project_diff.focus_handle(cx).focus(window, cx); project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx)); return None; }; @@ -836,7 +1221,27 @@ impl GitPanel { ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx); }) .ok(); - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); + + Some(()) + }); + } + + fn file_history(&mut self, _: &git::FileHistory, window: &mut Window, cx: &mut Context) { + maybe!({ + let entry = self.entries.get(self.selected_entry?)?.status_entry()?; + let active_repo = self.active_repository.as_ref()?; + let repo_path = entry.repo_path.clone(); + let git_store = self.project.read(cx).git_store(); + + FileHistoryView::open( + repo_path, + git_store.downgrade(), + active_repo.downgrade(), + self.workspace.clone(), + window, + cx, + ); Some(()) }); @@ -919,14 +1324,14 @@ impl GitPanel { let prompt = window.prompt( PromptLevel::Warning, &format!( - "Are you sure you want to restore {}?", + "Are you sure you want to discard changes to {}?", entry .repo_path .file_name() .unwrap_or(entry.repo_path.display(path_style).as_ref()), ), None, - &["Restore", "Cancel"], + &["Discard Changes", "Cancel"], cx, ); cx.background_spawn(prompt) @@ -1297,6 +1702,71 @@ impl GitPanel { .detach(); } + fn stage_status_for_entry(entry: &GitStatusEntry, repo: &Repository) -> StageStatus { + // Checking for current staged/unstaged file status is a chained operation: + // 1. first, we check for any pending operation recorded in repository + // 2. if there are no pending ops either running or finished, we then ask the repository + // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry` + // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on + // the checkbox's state (or flickering) which is undesirable. + // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded + // in `entry` arg. + repo.pending_ops_for_path(&entry.repo_path) + .map(|ops| { + if ops.staging() || ops.staged() { + StageStatus::Staged + } else { + StageStatus::Unstaged + } + }) + .or_else(|| { + repo.status_for_path(&entry.repo_path) + .map(|status| status.status.staging()) + }) + .unwrap_or(entry.staging) + } + + fn stage_status_for_directory( + &self, + entry: &GitTreeDirEntry, + repo: &Repository, + ) -> StageStatus { + let GitPanelViewMode::Tree(tree_state) = &self.view_mode else { + util::debug_panic!("We should never render a directory entry while in flat view mode"); + return StageStatus::Unstaged; + }; + + let Some(descendants) = tree_state.directory_descendants.get(&entry.key) else { + return StageStatus::Unstaged; + }; + + let mut fully_staged_count = 0usize; + let mut any_staged_or_partially_staged = false; + + for descendant in descendants { + match GitPanel::stage_status_for_entry(descendant, repo) { + StageStatus::Staged => { + fully_staged_count += 1; + any_staged_or_partially_staged = true; + } + StageStatus::PartiallyStaged => { + any_staged_or_partially_staged = true; + } + StageStatus::Unstaged => {} + } + } + + if descendants.is_empty() { + StageStatus::Unstaged + } else if fully_staged_count == descendants.len() { + StageStatus::Staged + } else if any_staged_or_partially_staged { + StageStatus::PartiallyStaged + } else { + StageStatus::Unstaged + } + } + pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context) { self.change_all_files_stage(true, cx); } @@ -1311,50 +1781,101 @@ impl GitPanel { _window: &mut Window, cx: &mut Context, ) { - let Some(active_repository) = self.active_repository.as_ref() else { + let Some(active_repository) = self.active_repository.clone() else { return; }; - let repo = active_repository.read(cx); - let (stage, repo_paths) = match entry { - GitListEntry::Status(status_entry) => { - let repo_paths = vec![status_entry.clone()]; - let stage = if repo - .pending_ops_for_path(&status_entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .or_else(|| { - repo.status_for_path(&status_entry.repo_path) - .map(|status| status.status.staging().has_staged()) - }) - .unwrap_or(status_entry.staging.has_staged()) - { - if let Some(op) = self.bulk_staging.clone() - && op.anchor == status_entry.repo_path - { - self.bulk_staging = None; - } - false - } else { - self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx); - true - }; - (stage, repo_paths) - } - GitListEntry::Header(section) => { - let goal_staged_state = !self.header_state(section.header).selected(); - let entries = self - .entries - .iter() - .filter_map(|entry| entry.status_entry()) - .filter(|status_entry| { - section.contains(status_entry, repo) - && status_entry.staging.as_bool() != Some(goal_staged_state) - }) - .cloned() - .collect::>(); + let mut set_anchor: Option = None; + let mut clear_anchor = None; + + let (stage, repo_paths) = { + let repo = active_repository.read(cx); + match entry { + GitListEntry::Status(status_entry) => { + let repo_paths = vec![status_entry.clone()]; + let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) { + StageStatus::Staged => { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } + StageStatus::Unstaged | StageStatus::PartiallyStaged => { + set_anchor = Some(status_entry.repo_path.clone()); + true + } + }; + (stage, repo_paths) + } + GitListEntry::TreeStatus(status_entry) => { + let repo_paths = vec![status_entry.entry.clone()]; + let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) { + StageStatus::Staged => { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == status_entry.entry.repo_path + { + clear_anchor = Some(op.anchor); + } + false + } + StageStatus::Unstaged | StageStatus::PartiallyStaged => { + set_anchor = Some(status_entry.entry.repo_path.clone()); + true + } + }; + (stage, repo_paths) + } + GitListEntry::Header(section) => { + let goal_staged_state = !self.header_state(section.header).selected(); + let entries = self + .entries + .iter() + .filter_map(|entry| entry.status_entry()) + .filter(|status_entry| { + section.contains(status_entry, &repo) + && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool() + != Some(goal_staged_state) + }) + .cloned() + .collect::>(); - (goal_staged_state, entries) + (goal_staged_state, entries) + } + GitListEntry::Directory(entry) => { + let goal_staged_state = match self.stage_status_for_directory(entry, repo) { + StageStatus::Staged => StageStatus::Unstaged, + StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged, + }; + let goal_stage = goal_staged_state == StageStatus::Staged; + + let entries = self + .view_mode + .tree_state() + .and_then(|state| state.directory_descendants.get(&entry.key)) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|status_entry| { + GitPanel::stage_status_for_entry(status_entry, &repo) + != goal_staged_state + }) + .collect::>(); + (goal_stage, entries) + } } }; + if let Some(anchor) = clear_anchor { + if let Some(op) = self.bulk_staging.clone() + && op.anchor == anchor + { + self.bulk_staging = None; + } + } + if let Some(anchor) = set_anchor { + self.set_bulk_staging_anchor(anchor, cx); + } + self.change_file_stage(stage, repo_paths, cx); } @@ -1528,16 +2049,26 @@ impl GitPanel { } } - fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context) { + if self.commit(&self.commit_editor.focus_handle(cx), window, cx) { + telemetry::event!("Git Committed", source = "Git Panel"); + } + } + + /// Commits staged changes with the current commit message. + /// + /// Returns `true` if the commit was executed, `false` otherwise. + pub(crate) fn commit( + &mut self, + commit_editor_focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> bool { if self.amend_pending { - return; + return false; } - if self - .commit_editor - .focus_handle(cx) - .contains_focused(window, cx) - { - telemetry::event!("Git Committed", source = "Git Panel"); + + if commit_editor_focus_handle.contains_focused(window, cx) { self.commit_changes( CommitOptions { amend: false, @@ -1545,24 +2076,39 @@ impl GitPanel { }, window, cx, - ) + ); + true } else { cx.propagate(); + false } } - fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { - if self - .commit_editor - .focus_handle(cx) - .contains_focused(window, cx) - { + fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context) { + if self.amend(&self.commit_editor.focus_handle(cx), window, cx) { + telemetry::event!("Git Amended", source = "Git Panel"); + } + } + + /// Amends the most recent commit with staged changes and/or an updated commit message. + /// + /// Uses a two-stage workflow where the first invocation loads the commit + /// message for editing, second invocation performs the amend. Returns + /// `true` if the amend was executed, `false` otherwise. + pub(crate) fn amend( + &mut self, + commit_editor_focus_handle: &FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if commit_editor_focus_handle.contains_focused(window, cx) { if self.head_commit(cx).is_some() { if !self.amend_pending { self.set_amend_pending(true, cx); - self.load_last_commit_message_if_empty(cx); + self.load_last_commit_message(cx); + + return false; } else { - telemetry::event!("Git Amended", source = "Git Panel"); self.commit_changes( CommitOptions { amend: true, @@ -1571,13 +2117,16 @@ impl GitPanel { window, cx, ); + + return true; } } + return false; } else { cx.propagate(); + return false; } } - pub fn head_commit(&self, cx: &App) -> Option { self.active_repository .as_ref() @@ -1585,13 +2134,11 @@ impl GitPanel { .cloned() } - pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context) { - if !self.commit_editor.read(cx).is_empty(cx) { - return; - } + pub fn load_last_commit_message(&mut self, cx: &mut Context) { let Some(head_commit) = self.head_commit(cx) else { return; }; + let recent_sha = head_commit.sha.to_string(); let detail_task = self.load_commit_details(recent_sha, cx); cx.spawn(async move |this, cx| { @@ -1634,7 +2181,13 @@ impl GitPanel { let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx)); let wrapped_message = editor.update(cx, |editor, cx| { editor.select_all(&Default::default(), window, cx); - editor.rewrap(&Default::default(), window, cx); + editor.rewrap_impl( + RewrapOptions { + override_language_settings: false, + preserve_existing_whitespace: true, + }, + cx, + ); editor.text(cx) }); if wrapped_message.trim().is_empty() { @@ -1685,7 +2238,10 @@ impl GitPanel { let commit_message = self.custom_or_suggested_commit_message(window, cx); let Some(mut message) = commit_message else { - self.commit_editor.read(cx).focus_handle(cx).focus(window); + self.commit_editor + .read(cx) + .focus_handle(cx) + .focus(window, cx); return; }; @@ -1727,11 +2283,16 @@ impl GitPanel { let result = task.await; this.update_in(cx, |this, window, cx| { this.pending_commit.take(); + match result { Ok(()) => { - this.commit_editor - .update(cx, |editor, cx| editor.clear(window, cx)); - this.original_commit_message = None; + if options.amend { + this.set_amend_pending(false, cx); + } else { + this.commit_editor + .update(cx, |editor, cx| editor.clear(window, cx)); + this.original_commit_message = None; + } } Err(e) => this.show_error_toast("commit", e, cx), } @@ -1740,9 +2301,6 @@ impl GitPanel { }); self.pending_commit = Some(task); - if options.amend { - self.set_amend_pending(false, cx); - } } pub(crate) fn uncommit(&mut self, window: &mut Window, cx: &mut Context) { @@ -1967,6 +2525,82 @@ impl GitPanel { compressed } + async fn load_project_rules( + project: &Entity, + repo_work_dir: &Arc, + cx: &mut AsyncApp, + ) -> Option { + let rules_path = cx + .update(|cx| { + for worktree in project.read(cx).worktrees(cx) { + let worktree_abs_path = worktree.read(cx).abs_path(); + if !worktree_abs_path.starts_with(&repo_work_dir) { + continue; + } + + let worktree_snapshot = worktree.read(cx).snapshot(); + for rules_name in RULES_FILE_NAMES { + if let Ok(rel_path) = RelPath::unix(rules_name) { + if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) { + if entry.is_file() { + return Some(ProjectPath { + worktree_id: worktree.read(cx).id(), + path: entry.path.clone(), + }); + } + } + } + } + } + None + }) + .ok()??; + + let buffer = project + .update(cx, |project, cx| project.open_buffer(rules_path, cx)) + .ok()? + .await + .ok()?; + + let content = buffer + .read_with(cx, |buffer, _| buffer.text()) + .ok()? + .trim() + .to_string(); + + if content.is_empty() { + None + } else { + Some(content) + } + } + + async fn load_commit_message_prompt( + is_using_legacy_zed_pro: bool, + cx: &mut AsyncApp, + ) -> String { + // Remove this once we stop supporting legacy Zed Pro + // In legacy Zed Pro, Git commit summary generation did not count as a + // prompt. If the user changes the prompt, our classification will fail, + // meaning that users will be charged for generating commit messages. + if is_using_legacy_zed_pro { + return BuiltInPrompt::CommitMessage.default_content().to_string(); + } + + let load = async { + let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?; + store + .update(cx, |s, cx| { + s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx) + }) + .ok()? + .await + .ok() + }; + load.await + .unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string()) + } + /// Generates a commit message using an LLM. pub fn generate_commit_message(&mut self, cx: &mut Context) { if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) { @@ -1994,8 +2628,17 @@ impl GitPanel { }); let temperature = AgentSettings::temperature_for_model(&model, cx); + let project = self.project.clone(); + let repo_work_dir = repo.read(cx).work_directory_abs_path.clone(); + + // Remove this once we stop supporting legacy Zed Pro + let is_using_legacy_zed_pro = provider.id() == ZED_CLOUD_PROVIDER_ID + && self.workspace.upgrade().map_or(false, |workspace| { + workspace.read(cx).user_store().read(cx).plan() + == Some(cloud_llm_client::Plan::V1(cloud_llm_client::PlanV1::ZedPro)) + }); - self.generate_commit_message_task = Some(cx.spawn(async move |this, cx| { + self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| { async move { let _defer = cx.on_drop(&this, |this, _cx| { this.generate_commit_message_task.take(); @@ -2028,19 +2671,33 @@ impl GitPanel { const MAX_DIFF_BYTES: usize = 20_000; diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES); + let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await; + + let prompt = Self::load_commit_message_prompt(is_using_legacy_zed_pro, &mut cx).await; + let subject = this.update(cx, |this, cx| { this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default() })?; let text_empty = subject.trim().is_empty(); - let content = if text_empty { - format!("{PROMPT}\nHere are the changes in this commit:\n{diff_text}") + let rules_section = match &rules_content { + Some(rules) => format!( + "\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\ + \n{rules}\n\n" + ), + None => String::new(), + }; + + let subject_section = if text_empty { + String::new() } else { - format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n") + format!("\nHere is the user's subject line:\n{subject}") }; - const PROMPT: &str = include_str!("commit_message_prompt.txt"); + let content = format!( + "{prompt}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}" + ); let request = LanguageModelRequest { thread_id: None, @@ -2192,93 +2849,15 @@ impl GitPanel { } pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context) { - let path = cx.prompt_for_paths(gpui::PathPromptOptions { - files: false, - directories: true, - multiple: false, - prompt: Some("Select as Repository Destination".into()), - }); - let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |this, cx| { - let mut paths = path.await.ok()?.ok()??; - let mut path = paths.pop()?; - let repo_name = repo.split("/").last()?.strip_suffix(".git")?.to_owned(); - - let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?; - - let prompt_answer = match fs.git_clone(&repo, path.as_path()).await { - Ok(_) => cx.update(|window, cx| { - window.prompt( - PromptLevel::Info, - &format!("Git Clone: {}", repo_name), - None, - &["Add repo to project", "Open repo in new project"], - cx, - ) - }), - Err(e) => { - this.update(cx, |this: &mut GitPanel, cx| { - let toast = StatusToast::new(e.to_string(), cx, |this, _| { - this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) - .dismiss_button(true) - }); - - this.workspace - .update(cx, |workspace, cx| { - workspace.toggle_status_toast(toast, cx); - }) - .ok(); - }) - .ok()?; - - return None; - } - } - .ok()?; - - path.push(repo_name); - match prompt_answer.await.ok()? { - 0 => { - workspace - .update(cx, |workspace, cx| { - workspace - .project() - .update(cx, |project, cx| { - project.create_worktree(path.as_path(), true, cx) - }) - .detach(); - }) - .ok(); - } - 1 => { - workspace - .update(cx, move |workspace, cx| { - workspace::open_new( - Default::default(), - workspace.app_state().clone(), - cx, - move |workspace, _, cx| { - cx.activate(true); - workspace - .project() - .update(cx, |project, cx| { - project.create_worktree(&path, true, cx) - }) - .detach(); - }, - ) - .detach(); - }) - .ok(); - } - _ => {} - } - - Some(()) - }) - .detach(); + crate::clone::clone_and_open( + repo.into(), + workspace, + window, + cx, + Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}), + ); } pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context) { @@ -2669,6 +3248,29 @@ impl GitPanel { } } + fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context) { + let current_setting = GitPanelSettings::get_global(cx).tree_view; + if let Some(workspace) = self.workspace.upgrade() { + let workspace = workspace.read(cx); + let fs = workspace.app_state().fs.clone(); + cx.update_global::(|store, _cx| { + store.update_settings_file(fs, move |settings, _cx| { + settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting); + }); + }) + } + } + + fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context) { + if let Some(state) = self.view_mode.tree_state_mut() { + let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true); + *expanded = !*expanded; + self.update_visible_entries(window, cx); + } else { + util::debug_panic!("Attempted to toggle directory in flat Git Panel state"); + } + } + fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context) { const CO_AUTHOR_PREFIX: &str = "Co-authored-by: "; @@ -2778,27 +3380,34 @@ impl GitPanel { let bulk_staging = self.bulk_staging.take(); let last_staged_path_prev_index = bulk_staging .as_ref() - .and_then(|op| self.entry_by_path(&op.anchor, cx)); + .and_then(|op| self.entry_by_path(&op.anchor)); self.entries.clear(); + self.entries_indices.clear(); self.single_staged_entry.take(); self.single_tracked_entry.take(); self.conflicted_count = 0; self.conflicted_staged_count = 0; + self.changes_count = 0; self.new_count = 0; self.tracked_count = 0; self.new_staged_count = 0; self.tracked_staged_count = 0; self.entry_count = 0; + self.max_width_item_index = None; let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; + let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_)); + let group_by_status = is_tree_view || !sort_by_path; let mut changed_entries = Vec::new(); let mut new_entries = Vec::new(); let mut conflict_entries = Vec::new(); let mut single_staged_entry = None; let mut staged_count = 0; - let mut max_width_item: Option<(RepoPath, usize)> = None; + let mut seen_directories = HashSet::default(); + let mut max_width_estimate = 0usize; + let mut max_width_item_index = None; let Some(repo) = self.active_repository.as_ref() else { // Just clear entries if no repository is active. @@ -2811,6 +3420,7 @@ impl GitPanel { self.stash_entries = repo.cached_stash(); for entry in repo.cached_status() { + self.changes_count += 1; let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path); let is_new = entry.status.is_created(); let staging = entry.status.staging(); @@ -2835,26 +3445,9 @@ impl GitPanel { single_staged_entry = Some(entry.clone()); } - let width_estimate = Self::item_width_estimate( - entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0), - entry.display_name(path_style).len(), - ); - - match max_width_item.as_mut() { - Some((repo_path, estimate)) => { - if width_estimate > *estimate { - *repo_path = entry.repo_path.clone(); - *estimate = width_estimate; - } - } - None => max_width_item = Some((entry.repo_path.clone(), width_estimate)), - } - - if sort_by_path { - changed_entries.push(entry); - } else if is_conflict { + if group_by_status && is_conflict { conflict_entries.push(entry); - } else if is_new { + } else if group_by_status && is_new { new_entries.push(entry); } else { changed_entries.push(entry); @@ -2889,57 +3482,122 @@ impl GitPanel { self.single_tracked_entry = changed_entries.first().cloned(); } - if !conflict_entries.is_empty() { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::Conflict, - })); - self.entries - .extend(conflict_entries.into_iter().map(GitListEntry::Status)); + let mut push_entry = + |this: &mut Self, + entry: GitListEntry, + is_visible: bool, + logical_indices: Option<&mut Vec>| { + if let Some(estimate) = + this.width_estimate_for_list_entry(is_tree_view, &entry, path_style) + { + if estimate > max_width_estimate { + max_width_estimate = estimate; + max_width_item_index = Some(this.entries.len()); + } + } + + if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone()) + { + this.entries_indices.insert(repo_path, this.entries.len()); + } + + if let (Some(indices), true) = (logical_indices, is_visible) { + indices.push(this.entries.len()); + } + + this.entries.push(entry); + }; + + macro_rules! take_section_entries { + () => { + [ + (Section::Conflict, std::mem::take(&mut conflict_entries)), + (Section::Tracked, std::mem::take(&mut changed_entries)), + (Section::New, std::mem::take(&mut new_entries)), + ] + }; } - if !changed_entries.is_empty() { - if !sort_by_path { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::Tracked, - })); + match &mut self.view_mode { + GitPanelViewMode::Tree(tree_state) => { + tree_state.logical_indices.clear(); + tree_state.directory_descendants.clear(); + + // This is just to get around the borrow checker + // because push_entry mutably borrows self + let mut tree_state = std::mem::take(tree_state); + + for (section, entries) in take_section_entries!() { + if entries.is_empty() { + continue; + } + + push_entry( + self, + GitListEntry::Header(GitHeaderEntry { header: section }), + true, + Some(&mut tree_state.logical_indices), + ); + + for (entry, is_visible) in + tree_state.build_tree_entries(section, entries, &mut seen_directories) + { + push_entry( + self, + entry, + is_visible, + Some(&mut tree_state.logical_indices), + ); + } + } + + tree_state + .expanded_dirs + .retain(|key, _| seen_directories.contains(key)); + self.view_mode = GitPanelViewMode::Tree(tree_state); } - self.entries - .extend(changed_entries.into_iter().map(GitListEntry::Status)); - } - if !new_entries.is_empty() { - self.entries.push(GitListEntry::Header(GitHeaderEntry { - header: Section::New, - })); - self.entries - .extend(new_entries.into_iter().map(GitListEntry::Status)); - } + GitPanelViewMode::Flat => { + for (section, entries) in take_section_entries!() { + if entries.is_empty() { + continue; + } - if let Some((repo_path, _)) = max_width_item { - self.max_width_item_index = self.entries.iter().position(|entry| match entry { - GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path, - GitListEntry::Header(_) => false, - }); + if section != Section::Tracked || !sort_by_path { + push_entry( + self, + GitListEntry::Header(GitHeaderEntry { header: section }), + true, + None, + ); + } + + for entry in entries { + push_entry(self, GitListEntry::Status(entry), true, None); + } + } + } } + self.max_width_item_index = max_width_item_index; + self.update_counts(repo); let bulk_staging_anchor_new_index = bulk_staging .as_ref() .filter(|op| op.repo_id == repo.id) - .and_then(|op| self.entry_by_path(&op.anchor, cx)); + .and_then(|op| self.entry_by_path(&op.anchor)); if bulk_staging_anchor_new_index == last_staged_path_prev_index && let Some(index) = bulk_staging_anchor_new_index && let Some(entry) = self.entries.get(index) && let Some(entry) = entry.status_entry() - && repo - .pending_ops_for_path(&entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .unwrap_or(entry.staging.has_staged()) + && GitPanel::stage_status_for_entry(entry, &repo) + .as_bool() + .unwrap_or(false) { self.bulk_staging = bulk_staging; } - self.select_first_entry_if_none(cx); + self.select_first_entry_if_none(window, cx); let suggested_commit_message = self.suggest_commit_message(cx); let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into()); @@ -2975,15 +3633,13 @@ impl GitPanel { self.new_staged_count = 0; self.tracked_staged_count = 0; self.entry_count = 0; - for entry in &self.entries { - let Some(status_entry) = entry.status_entry() else { - continue; - }; + + for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) { self.entry_count += 1; - let is_staging_or_staged = repo - .pending_ops_for_path(&status_entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .unwrap_or(status_entry.staging.has_staged()); + let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo) + .as_bool() + .unwrap_or(true); + if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) { self.conflicted_count += 1; if is_staging_or_staged { @@ -3080,6 +3736,7 @@ impl GitPanel { .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) .action(text, move |_, cx| cx.open_url(&link)), } + .dismiss_button(true) }); workspace.toggle_status_toast(status_toast, cx) }); @@ -3097,10 +3754,48 @@ impl GitPanel { self.has_staged_changes() } - // eventually we'll need to take depth into account here - // if we add a tree view - fn item_width_estimate(path: usize, file_name: usize) -> usize { - path + file_name + fn status_width_estimate( + tree_view: bool, + entry: &GitStatusEntry, + path_style: PathStyle, + depth: usize, + ) -> usize { + if tree_view { + Self::item_width_estimate(0, entry.display_name(path_style).len(), depth) + } else { + Self::item_width_estimate( + entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0), + entry.display_name(path_style).len(), + 0, + ) + } + } + + fn width_estimate_for_list_entry( + &self, + tree_view: bool, + entry: &GitListEntry, + path_style: PathStyle, + ) -> Option { + match entry { + GitListEntry::Status(status) => Some(Self::status_width_estimate( + tree_view, status, path_style, 0, + )), + GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate( + tree_view, + &status.entry, + path_style, + status.depth, + )), + GitListEntry::Directory(dir) => { + Some(Self::item_width_estimate(0, dir.name.len(), dir.depth)) + } + GitListEntry::Header(_) => None, + } + } + + fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize { + path + file_name + depth * 2 } fn render_overflow_menu(&self, id: impl Into) -> impl IntoElement { @@ -3127,6 +3822,7 @@ impl GitPanel { has_new_changes, sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, has_stash_items, + tree_view: GitPanelSettings::get_global(cx).tree_view, }, window, cx, @@ -3361,10 +4057,10 @@ impl GitPanel { ("Stage All", StageAll.boxed_clone(), true, "git add --all") }; - let change_string = match self.entry_count { + let change_string = match self.changes_count { 0 => "No Changes".to_string(), 1 => "1 Change".to_string(), - _ => format!("{} Changes", self.entry_count), + count => format!("{} Changes", count), }; Some( @@ -3442,7 +4138,6 @@ impl GitPanel { ) -> Option { let active_repository = self.active_repository.clone()?; let panel_editor_style = panel_editor_style(true, window, cx); - let enable_coauthors = self.render_co_authors(cx); let editor_focus_handle = self.commit_editor.focus_handle(cx); @@ -3487,7 +4182,7 @@ impl GitPanel { .border_color(cx.theme().colors().border) .cursor_text() .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { - window.focus(&this.commit_editor.focus_handle(cx)); + window.focus(&this.commit_editor.focus_handle(cx), cx); })) .child( h_flex() @@ -3787,7 +4482,7 @@ impl GitPanel { let repo = self.active_repository.as_ref()?.read(cx); let project_path = (file.worktree_id(cx), file.path().clone()).into(); let repo_path = repo.project_path_to_repo_path(&project_path, cx)?; - let ix = self.entry_by_path(&repo_path, cx)?; + let ix = self.entry_by_path(&repo_path)?; let entry = self.entries.get(ix)?; let is_staging_or_staged = repo @@ -3838,7 +4533,10 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let entry_count = self.entries.len(); + let (is_tree_view, entry_count) = match &self.view_mode { + GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()), + GitPanelViewMode::Flat => (false, self.entries.len()), + }; v_flex() .flex_1() @@ -3858,10 +4556,33 @@ impl GitPanel { cx.processor(move |this, range: Range, window, cx| { let mut items = Vec::with_capacity(range.end - range.start); - for ix in range { + for ix in range.into_iter().map(|ix| match &this.view_mode { + GitPanelViewMode::Tree(state) => state.logical_indices[ix], + GitPanelViewMode::Flat => ix, + }) { match &this.entries.get(ix) { Some(GitListEntry::Status(entry)) => { - items.push(this.render_entry( + items.push(this.render_status_entry( + ix, + entry, + 0, + has_write_access, + window, + cx, + )); + } + Some(GitListEntry::TreeStatus(entry)) => { + items.push(this.render_status_entry( + ix, + &entry.entry, + entry.depth, + has_write_access, + window, + cx, + )); + } + Some(GitListEntry::Directory(entry)) => { + items.push(this.render_directory_entry( ix, entry, has_write_access, @@ -3885,12 +4606,56 @@ impl GitPanel { items }), ) + .when(is_tree_view, |list| { + let indent_size = px(TREE_INDENT); + list.with_decoration( + ui::indent_guides(indent_size, IndentGuideColors::panel(cx)) + .with_compute_indents_fn( + cx.entity(), + |this, range, _window, _cx| { + range + .map(|ix| match this.entries.get(ix) { + Some(GitListEntry::Directory(dir)) => dir.depth, + Some(GitListEntry::TreeStatus(status)) => { + status.depth + } + _ => 0, + }) + .collect() + }, + ) + .with_render_fn(cx.entity(), |_, params, _, _| { + // Magic number to align the tree item is 3 here + // because we're using 12px as the left-side padding + // and 3 makes the alignment work with the bounding box of the icon + let left_offset = px(TREE_INDENT + 3_f32); + let indent_size = params.indent_size; + let item_height = params.item_height; + + params + .indent_guides + .into_iter() + .map(|layout| { + let bounds = Bounds::new( + point( + layout.offset.x * indent_size + left_offset, + layout.offset.y * item_height, + ), + size(px(1.), layout.length * item_height), + ); + RenderedIndentGuide { + bounds, + layout, + is_active: false, + hitbox: None, + } + }) + .collect() + }), + ) + }) .size_full() .flex_grow() - .with_sizing_behavior(ListSizingBehavior::Auto) - .with_horizontal_sizing_behavior( - ListHorizontalSizingBehavior::Unconstrained, - ) .with_width_from_item(self.max_width_item_index) .track_scroll(&self.scroll_handle), ) @@ -3914,7 +4679,7 @@ impl GitPanel { } fn entry_label(&self, label: impl Into, color: Color) -> Label { - Label::new(label.into()).color(color).single_line() + Label::new(label.into()).color(color) } fn list_item_height(&self) -> Rems { @@ -3936,8 +4701,8 @@ impl GitPanel { .h(self.list_item_height()) .w_full() .items_end() - .px(rems(0.75)) // ~12px - .pb(rems(0.3125)) // ~ 5px + .px_3() + .pb_1() .child( Label::new(header.title()) .color(Color::Muted) @@ -3980,23 +4745,24 @@ impl GitPanel { let restore_title = if entry.status.is_created() { "Trash File" } else { - "Restore File" + "Discard Changes" }; let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| { - let mut context_menu = context_menu + let is_created = entry.status.is_created(); + context_menu .context(self.focus_handle.clone()) .action(stage_title, ToggleStaged.boxed_clone()) - .action(restore_title, git::RestoreFile::default().boxed_clone()); - - if entry.status.is_created() { - context_menu = - context_menu.action("Add to .gitignore", git::AddToGitignore.boxed_clone()); - } - - context_menu + .action(restore_title, git::RestoreFile::default().boxed_clone()) + .action_disabled_when( + !is_created, + "Add to .gitignore", + git::AddToGitignore.boxed_clone(), + ) .separator() - .action("Open Diff", Confirm.boxed_clone()) - .action("Open File", SecondaryConfirm.boxed_clone()) + .action("Open Diff", menu::Confirm.boxed_clone()) + .action("Open File", menu::SecondaryConfirm.boxed_clone()) + .separator() + .action_disabled_when(is_created, "View File History", Box::new(git::FileHistory)) }); self.selected_entry = Some(ix); self.set_context_menu(context_menu, position, window, cx); @@ -4017,6 +4783,7 @@ impl GitPanel { has_new_changes: self.new_count > 0, sort_by_path: GitPanelSettings::get_global(cx).sort_by_path, has_stash_items: self.stash_entries.entries.len() > 0, + tree_view: GitPanelSettings::get_global(cx).tree_view, }, window, cx, @@ -4048,14 +4815,16 @@ impl GitPanel { cx.notify(); } - fn render_entry( + fn render_status_entry( &self, ix: usize, entry: &GitStatusEntry, + depth: usize, has_write_access: bool, window: &Window, cx: &Context, ) -> AnyElement { + let tree_view = GitPanelSettings::get_global(cx).tree_view; let path_style = self.project.read(cx).path_style(cx); let git_path_style = ProjectSettings::get_global(cx).git.path_style; let display_name = entry.display_name(path_style); @@ -4068,10 +4837,13 @@ impl GitPanel { let has_conflict = status.is_conflicted(); let is_modified = status.is_modified(); let is_deleted = status.is_deleted(); + let is_created = status.is_created(); let label_color = if status_style == StatusStyle::LabelColor { if has_conflict { Color::VersionControlConflict + } else if is_created { + Color::VersionControlAdded } else if is_modified { Color::VersionControlModified } else if is_deleted { @@ -4102,23 +4874,12 @@ impl GitPanel { .active_repository(cx) .expect("active repository must be set"); let repo = active_repo.read(cx); - // Checking for current staged/unstaged file status is a chained operation: - // 1. first, we check for any pending operation recorded in repository - // 2. if there are no pending ops either running or finished, we then ask the repository - // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry` - // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on - // the checkbox's state (or flickering) which is undesirable. - // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded - // in `entry` arg. - let is_staging_or_staged = repo - .pending_ops_for_path(&entry.repo_path) - .map(|ops| ops.staging() || ops.staged()) - .or_else(|| { - repo.status_for_path(&entry.repo_path) - .and_then(|status| status.status.staging().as_bool()) - }) - .or_else(|| entry.staging.as_bool()); - let mut is_staged: ToggleState = is_staging_or_staged.into(); + let stage_status = GitPanel::stage_status_for_entry(entry, &repo); + let mut is_staged: ToggleState = match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + }; if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() { is_staged = ToggleState::Selected; } @@ -4129,50 +4890,117 @@ impl GitPanel { let marked_bg_alpha = 0.12; let state_opacity_step = 0.04; + let info_color = cx.theme().status().info; + let base_bg = match (selected, marked) { - (true, true) => cx - .theme() - .status() - .info - .alpha(selected_bg_alpha + marked_bg_alpha), - (true, false) => cx.theme().status().info.alpha(selected_bg_alpha), - (false, true) => cx.theme().status().info.alpha(marked_bg_alpha), + (true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha), + (true, false) => info_color.alpha(selected_bg_alpha), + (false, true) => info_color.alpha(marked_bg_alpha), _ => cx.theme().colors().ghost_element_background, }; - let hover_bg = if selected { - cx.theme() - .status() - .info - .alpha(selected_bg_alpha + state_opacity_step) + let (hover_bg, active_bg) = if selected { + ( + info_color.alpha(selected_bg_alpha + state_opacity_step), + info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0), + ) } else { - cx.theme().colors().ghost_element_hover + ( + cx.theme().colors().ghost_element_hover, + cx.theme().colors().ghost_element_active, + ) }; - let active_bg = if selected { - cx.theme() - .status() - .info - .alpha(selected_bg_alpha + state_opacity_step * 2.0) - } else { - cx.theme().colors().ghost_element_active - }; - h_flex() - .id(id) - .h(self.list_item_height()) - .w_full() - .items_center() - .border_1() - .when(selected && self.focus_handle.is_focused(window), |el| { - el.border_color(cx.theme().colors().border_focused) - }) - .px(rems(0.75)) // ~12px - .overflow_hidden() - .flex_none() - .gap_1p5() - .bg(base_bg) - .hover(|this| this.bg(hover_bg)) - .active(|this| this.bg(active_bg)) + let name_row = h_flex() + .min_w_0() + .flex_1() + .gap_1() + .child(git_status_icon(status)) + .map(|this| { + if tree_view { + this.pl(px(depth as f32 * TREE_INDENT)).child( + self.entry_label(display_name, label_color) + .when(status.is_deleted(), Label::strikethrough) + .truncate(), + ) + } else { + this.child(self.path_formatted( + entry.parent_dir(path_style), + path_color, + display_name, + label_color, + path_style, + git_path_style, + status.is_deleted(), + )) + } + }); + + h_flex() + .id(id) + .h(self.list_item_height()) + .w_full() + .pl_3() + .pr_1() + .gap_1p5() + .border_1() + .border_r_2() + .when(selected && self.focus_handle.is_focused(window), |el| { + el.border_color(cx.theme().colors().panel_focused_border) + }) + .bg(base_bg) + .hover(|s| s.bg(hover_bg)) + .active(|s| s.bg(active_bg)) + .child(name_row) + .child( + div() + .id(checkbox_wrapper_id) + .flex_none() + .occlude() + .cursor_pointer() + .child( + Checkbox::new(checkbox_id, is_staged) + .disabled(!has_write_access) + .fill() + .elevation(ElevationIndex::Surface) + .on_click_ext({ + let entry = entry.clone(); + let this = cx.weak_entity(); + move |_, click, window, cx| { + this.update(cx, |this, cx| { + if !has_write_access { + return; + } + if click.modifiers().shift { + this.stage_bulk(ix, cx); + } else { + let list_entry = + if GitPanelSettings::get_global(cx).tree_view { + GitListEntry::TreeStatus(GitTreeStatusEntry { + entry: entry.clone(), + depth, + }) + } else { + GitListEntry::Status(entry.clone()) + }; + this.toggle_staged_for_entry(&list_entry, window, cx); + } + cx.stop_propagation(); + }) + .ok(); + } + }) + .tooltip(move |_window, cx| { + let action = match stage_status { + StageStatus::Staged => "Unstage", + StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", + }; + let tooltip_name = action.to_string(); + + Tooltip::for_action(tooltip_name, &ToggleStaged, cx) + }), + ), + ) .on_click({ cx.listener(move |this, event: &ClickEvent, window, cx| { this.selected_entry = Some(ix); @@ -4181,7 +5009,7 @@ impl GitPanel { this.open_file(&Default::default(), window, cx) } else { this.open_diff(&Default::default(), window, cx); - this.focus_handle.focus(window); + this.focus_handle.focus(window, cx); } }) }) @@ -4202,6 +5030,97 @@ impl GitPanel { cx.stop_propagation(); }, ) + .into_any_element() + } + + fn render_directory_entry( + &self, + ix: usize, + entry: &GitTreeDirEntry, + has_write_access: bool, + window: &Window, + cx: &Context, + ) -> AnyElement { + // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that + let selected = self.selected_entry == Some(ix); + let label_color = Color::Muted; + + let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into()); + let checkbox_id: ElementId = + ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into()); + let checkbox_wrapper_id: ElementId = + ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into()); + + let selected_bg_alpha = 0.08; + let state_opacity_step = 0.04; + + let info_color = cx.theme().status().info; + let colors = cx.theme().colors(); + + let (base_bg, hover_bg, active_bg) = if selected { + ( + info_color.alpha(selected_bg_alpha), + info_color.alpha(selected_bg_alpha + state_opacity_step), + info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0), + ) + } else { + ( + colors.ghost_element_background, + colors.ghost_element_hover, + colors.ghost_element_active, + ) + }; + + let folder_icon = if entry.expanded { + IconName::FolderOpen + } else { + IconName::Folder + }; + + let stage_status = if let Some(repo) = &self.active_repository { + self.stage_status_for_directory(entry, repo.read(cx)) + } else { + util::debug_panic!( + "Won't have entries to render without an active repository in Git Panel" + ); + StageStatus::PartiallyStaged + }; + + let toggle_state: ToggleState = match stage_status { + StageStatus::Staged => ToggleState::Selected, + StageStatus::Unstaged => ToggleState::Unselected, + StageStatus::PartiallyStaged => ToggleState::Indeterminate, + }; + + let name_row = h_flex() + .min_w_0() + .gap_1() + .pl(px(entry.depth as f32 * TREE_INDENT)) + .child( + Icon::new(folder_icon) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(self.entry_label(entry.name.clone(), label_color).truncate()); + + h_flex() + .id(id) + .h(self.list_item_height()) + .min_w_0() + .w_full() + .pl_3() + .pr_1() + .gap_1p5() + .justify_between() + .border_1() + .border_r_2() + .when(selected && self.focus_handle.is_focused(window), |el| { + el.border_color(cx.theme().colors().panel_focused_border) + }) + .bg(base_bg) + .hover(|s| s.bg(hover_bg)) + .active(|s| s.bg(active_bg)) + .child(name_row) .child( div() .id(checkbox_wrapper_id) @@ -4209,70 +5128,49 @@ impl GitPanel { .occlude() .cursor_pointer() .child( - Checkbox::new(checkbox_id, is_staged) + Checkbox::new(checkbox_id, toggle_state) .disabled(!has_write_access) .fill() .elevation(ElevationIndex::Surface) - .on_click_ext({ + .on_click({ let entry = entry.clone(); let this = cx.weak_entity(); - move |_, click, window, cx| { + move |_, window, cx| { this.update(cx, |this, cx| { if !has_write_access { return; } - if click.modifiers().shift { - this.stage_bulk(ix, cx); - } else { - this.toggle_staged_for_entry( - &GitListEntry::Status(entry.clone()), - window, - cx, - ); - } + this.toggle_staged_for_entry( + &GitListEntry::Directory(entry.clone()), + window, + cx, + ); cx.stop_propagation(); }) .ok(); } }) .tooltip(move |_window, cx| { - // If is_staging_or_staged is None, this implies the file was partially staged, and so - // we allow the user to stage it in full by displaying `Stage` in the tooltip. - let action = if is_staging_or_staged.unwrap_or(false) { - "Unstage" - } else { - "Stage" + let action = match stage_status { + StageStatus::Staged => "Unstage", + StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage", }; - let tooltip_name = action.to_string(); - - Tooltip::for_action(tooltip_name, &ToggleStaged, cx) + Tooltip::simple(format!("{action} folder"), cx) }), ), ) - .child(git_status_icon(status)) - .child( - h_flex() - .items_center() - .flex_1() - .child(h_flex().items_center().flex_1().map(|this| { - self.path_formatted( - this, - entry.parent_dir(path_style), - path_color, - display_name, - label_color, - path_style, - git_path_style, - status.is_deleted(), - ) - })), - ) + .on_click({ + let key = entry.key.clone(); + cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.selected_entry = Some(ix); + this.toggle_directory(&key, window, cx); + }) + }) .into_any_element() } fn path_formatted( &self, - parent: Div, directory: Option, path_color: Color, file_name: String, @@ -4281,41 +5179,31 @@ impl GitPanel { git_path_style: GitPathStyle, strikethrough: bool, ) -> Div { - parent - .when(git_path_style == GitPathStyle::FileNameFirst, |this| { - this.child( - self.entry_label( - match directory.as_ref().is_none_or(|d| d.is_empty()) { - true => file_name.clone(), - false => format!("{file_name} "), - }, - label_color, - ) - .when(strikethrough, Label::strikethrough), - ) - }) - .when_some(directory, |this, dir| { - match ( - !dir.is_empty(), - git_path_style == GitPathStyle::FileNameFirst, - ) { - (true, true) => this.child( - self.entry_label(dir, path_color) - .when(strikethrough, Label::strikethrough), - ), - (true, false) => this.child( - self.entry_label( - format!("{dir}{}", path_style.primary_separator()), - path_color, - ) + let file_name_first = git_path_style == GitPathStyle::FileNameFirst; + let file_path_first = git_path_style == GitPathStyle::FilePathFirst; + + let file_name = format!("{} ", file_name); + + h_flex() + .min_w_0() + .overflow_hidden() + .when(file_path_first, |this| this.flex_row_reverse()) + .child( + div().flex_none().child( + self.entry_label(file_name, label_color) .when(strikethrough, Label::strikethrough), - ), - _ => this, - } - }) - .when(git_path_style == GitPathStyle::FilePathFirst, |this| { + ), + ) + .when_some(directory, |this, dir| { + let path_name = if file_name_first { + dir + } else { + format!("{dir}{}", path_style.primary_separator()) + }; + this.child( - self.entry_label(file_name, label_color) + self.entry_label(path_name, path_color) + .truncate_start() .when(strikethrough, Label::strikethrough), ) }) @@ -4329,6 +5217,9 @@ impl GitPanel { self.amend_pending } + /// Sets the pending amend state, ensuring that the original commit message + /// is either saved, when `value` is `true` and there's no pending amend, or + /// restored, when `value` is `false` and there's a pending amend. pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context) { if value && !self.amend_pending { let current_message = self.commit_message_buffer(cx).read(cx).text(); @@ -4412,7 +5303,7 @@ impl GitPanel { let Some(op) = self.bulk_staging.as_ref() else { return; }; - let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else { + let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else { return; }; if let Some(entry) = self.entries.get(index) @@ -4446,7 +5337,7 @@ impl GitPanel { pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context) { self.set_amend_pending(!self.amend_pending, cx); if self.amend_pending { - self.load_last_commit_message_if_empty(cx); + self.load_last_commit_message(cx); } } } @@ -4477,8 +5368,8 @@ impl Render for GitPanel { .when(has_write_access && !project.is_read_only(cx), |this| { this.on_action(cx.listener(Self::toggle_staged_for_selected)) .on_action(cx.listener(Self::stage_range)) - .on_action(cx.listener(GitPanel::commit)) - .on_action(cx.listener(GitPanel::amend)) + .on_action(cx.listener(GitPanel::on_commit)) + .on_action(cx.listener(GitPanel::on_amend)) .on_action(cx.listener(GitPanel::toggle_signoff_enabled)) .on_action(cx.listener(Self::stage_all)) .on_action(cx.listener(Self::unstage_all)) @@ -4492,13 +5383,20 @@ impl Render for GitPanel { .on_action(cx.listener(Self::stash_all)) .on_action(cx.listener(Self::stash_pop)) }) + .on_action(cx.listener(Self::collapse_selected_entry)) + .on_action(cx.listener(Self::expand_selected_entry)) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::first_entry)) + .on_action(cx.listener(Self::next_entry)) + .on_action(cx.listener(Self::previous_entry)) + .on_action(cx.listener(Self::last_entry)) .on_action(cx.listener(Self::close_panel)) .on_action(cx.listener(Self::open_diff)) .on_action(cx.listener(Self::open_file)) + .on_action(cx.listener(Self::file_history)) .on_action(cx.listener(Self::focus_changes_list)) .on_action(cx.listener(Self::focus_editor)) .on_action(cx.listener(Self::expand_commit_editor)) @@ -4506,6 +5404,7 @@ impl Render for GitPanel { git_panel.on_action(cx.listener(Self::toggle_fill_co_authors)) }) .on_action(cx.listener(Self::toggle_sort_by_path)) + .on_action(cx.listener(Self::toggle_tree_view)) .size_full() .overflow_hidden() .bg(cx.theme().colors().panel_background) @@ -4644,6 +5543,7 @@ impl GitPanelMessageTooltip { window: &mut Window, cx: &mut App, ) -> Entity { + let remote_url = repository.read(cx).default_remote_url(); cx.new(|cx| { cx.spawn_in(window, async move |this, cx| { let (details, workspace) = git_panel.update(cx, |git_panel, cx| { @@ -4653,16 +5553,21 @@ impl GitPanelMessageTooltip { ) })?; let details = details.await?; + let provider_registry = cx + .update(|_, app| GitHostingProviderRegistry::default_global(app)) + .ok(); let commit_details = crate::commit_tooltip::CommitDetails { sha: details.sha.clone(), author_name: details.author_name.clone(), author_email: details.author_email.clone(), commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?, - message: Some(ParsedCommitMessage { - message: details.message, - ..Default::default() - }), + message: Some(ParsedCommitMessage::parse( + details.sha.to_string(), + details.message.to_string(), + remote_url.as_deref(), + provider_registry, + )), }; this.update(cx, |this: &mut GitPanelMessageTooltip, cx| { @@ -4735,10 +5640,14 @@ impl RenderOnce for PanelRepoFooter { .as_ref() .map(|panel| panel.read(cx).project.clone()); - let repo = self + let (workspace, repo) = self .git_panel .as_ref() - .and_then(|panel| panel.read(cx).active_repository.clone()); + .map(|panel| { + let panel = panel.read(cx); + (panel.workspace.clone(), panel.active_repository.clone()) + }) + .unzip(); let single_repo = project .as_ref() @@ -4749,7 +5658,6 @@ impl RenderOnce for PanelRepoFooter { const MAX_REPO_LEN: usize = 16; const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN; const MAX_SHORT_SHA_LEN: usize = 8; - let branch_name = self .branch .as_ref() @@ -4827,7 +5735,11 @@ impl RenderOnce for PanelRepoFooter { }); let branch_selector = PopoverMenu::new("popover-button") - .menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx))) + .menu(move |window, cx| { + let workspace = workspace.clone()?; + let repo = repo.clone().flatten(); + Some(branch_picker::popover(workspace, repo, window, cx)) + }) .trigger_with_tooltip( branch_selector_button, Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch), @@ -5818,6 +6730,94 @@ mod tests { }); } + #[gpui::test] + async fn test_amend(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + } + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/project/.git")), + &[("src/main.rs", StatusCode::Modified.worktree())], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + // Wait for the project scanning to finish so that `head_commit(cx)` is + // actually set, otherwise no head commit would be available from which + // to fetch the latest commit message from. + cx.executor().run_until_parked(); + + let panel = workspace.update(cx, GitPanel::new).unwrap(); + panel.read_with(cx, |panel, cx| { + assert!(panel.active_repository.is_some()); + assert!(panel.head_commit(cx).is_some()); + }); + + panel.update_in(cx, |panel, window, cx| { + // Update the commit editor's message to ensure that its contents + // are later restored, after amending is finished. + panel.commit_message_buffer(cx).update(cx, |buffer, cx| { + buffer.set_text("refactor: update main.rs", cx); + }); + + // Start amending the previous commit. + panel.focus_editor(&Default::default(), window, cx); + panel.on_amend(&Amend, window, cx); + }); + + // Since `GitPanel.amend` attempts to fetch the latest commit message in + // a background task, we need to wait for it to complete before being + // able to assert that the commit message editor's state has been + // updated. + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + assert_eq!( + panel.commit_message_buffer(cx).read(cx).text(), + "initial commit" + ); + assert_eq!( + panel.original_commit_message, + Some("refactor: update main.rs".to_string()) + ); + + // Finish amending the previous commit. + panel.focus_editor(&Default::default(), window, cx); + panel.on_amend(&Amend, window, cx); + }); + + // Since the actual commit logic is run in a background task, we need to + // await its completion to actually ensure that the commit message + // editor's contents are set to the original message and haven't been + // cleared. + cx.run_until_parked(); + + panel.update_in(cx, |panel, _window, cx| { + // After amending, the commit editor's message should be restored to + // the original message. + assert_eq!( + panel.commit_message_buffer(cx).read(cx).text(), + "refactor: update main.rs" + ); + assert!(panel.original_commit_message.is_none()); + }); + } + #[gpui::test] async fn test_open_diff(cx: &mut TestAppContext) { init_test(cx); @@ -5864,7 +6864,7 @@ mod tests { // the Project Diff's active path. panel.update_in(cx, |panel, window, cx| { panel.selected_entry = Some(1); - panel.open_diff(&Confirm, window, cx); + panel.open_diff(&menu::Confirm, window, cx); }); cx.run_until_parked(); @@ -5880,6 +6880,128 @@ mod tests { }); } + #[gpui::test] + async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "src": { + "a": { + "foo.rs": "fn foo() {}", + }, + "b": { + "bar.rs": "fn bar() {}", + }, + }, + }), + ) + .await; + + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &[ + ("src/a/foo.rs", StatusCode::Modified.worktree()), + ("src/b/bar.rs", StatusCode::Modified.worktree()), + ], + ); + + let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().tree_view = Some(true); + }) + }); + }); + + let panel = workspace.update(cx, GitPanel::new).unwrap(); + + let handle = cx.update_window_entity(&panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + + let src_key = panel.read_with(cx, |panel, _| { + panel + .entries + .iter() + .find_map(|entry| match entry { + GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => { + Some(dir.key.clone()) + } + _ => None, + }) + .expect("src directory should exist in tree view") + }); + + panel.update_in(cx, |panel, window, cx| { + panel.toggle_directory(&src_key, window, cx); + }); + + panel.read_with(cx, |panel, _| { + let state = panel + .view_mode + .tree_state() + .expect("tree view state should exist"); + assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false)); + }); + + let worktree_id = + cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); + let project_path = ProjectPath { + worktree_id, + path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(), + }; + + panel.update_in(cx, |panel, window, cx| { + panel.select_entry_by_path(project_path, window, cx); + }); + + panel.read_with(cx, |panel, _| { + let state = panel + .view_mode + .tree_state() + .expect("tree view state should exist"); + assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true)); + + let selected_ix = panel.selected_entry.expect("selection should be set"); + assert!(state.logical_indices.contains(&selected_ix)); + + let selected_entry = panel + .entries + .get(selected_ix) + .and_then(|entry| entry.status_entry()) + .expect("selected entry should be a status entry"); + assert_eq!(selected_entry.repo_path, repo_path("src/a/foo.rs")); + }); + } + fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) { assert_eq!(entries.len(), expected_paths.len()); for (entry, expected_path) in entries.iter().zip(expected_paths) { diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index 2a6c1e8882b3f9cce02060dbf8efb6a4826b6995..6b5334e55544b465864fe3afb780c4673bb5961e 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -24,6 +24,7 @@ pub struct GitPanelSettings { pub fallback_branch_name: String, pub sort_by_path: bool, pub collapse_untracked_diff: bool, + pub tree_view: bool, } impl ScrollbarVisibility for GitPanelSettings { @@ -56,6 +57,7 @@ impl Settings for GitPanelSettings { fallback_branch_name: git_panel.fallback_branch_name.unwrap(), sort_by_path: git_panel.sort_by_path.unwrap(), collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), + tree_view: git_panel.tree_view.unwrap(), } } } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 54adc8130d78e80af5c561541efb8128f1b2a017..053c41bf10c5d97f9f5326fd17d6b5bf91297a03 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -10,6 +10,7 @@ use ui::{ }; mod blame_ui; +pub mod clone; use git::{ repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, @@ -817,7 +818,7 @@ impl GitCloneModal { }); let focus_handle = repo_input.focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Self { panel, diff --git a/crates/git_ui/src/onboarding.rs b/crates/git_ui/src/onboarding.rs index d1709e043b92216e974c1a4f451db5c28b98f773..eccb18a5400647ff86e44f4426d271d6c9361164 100644 --- a/crates/git_ui/src/onboarding.rs +++ b/crates/git_ui/src/onboarding.rs @@ -85,8 +85,8 @@ impl Render for GitOnboardingModal { git_onboarding_event!("Cancelled", trigger = "Action"); cx.emit(DismissEvent); })) - .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { - this.focus_handle.focus(window); + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); })) .child( div().p_1p5().absolute().inset_0().h(px(160.)).child( diff --git a/crates/git_ui/src/picker_prompt.rs b/crates/git_ui/src/picker_prompt.rs index 6161c62af571f3a90c3110d63cc26ea3a7e032ae..14daedda61ecc71cebe8f7778fee2f8193e65a73 100644 --- a/crates/git_ui/src/picker_prompt.rs +++ b/crates/git_ui/src/picker_prompt.rs @@ -220,7 +220,7 @@ impl PickerDelegate for PickerPromptDelegate { let shortened_option = util::truncate_and_trailoff(&hit.string, self.max_match_length); Some( - ListItem::new(SharedString::from(format!("picker-prompt-menu-{ix}"))) + ListItem::new(format!("picker-prompt-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 9f9afe0d887b9d419efafd582904fcca764e793a..c86137cb428e07f1490c6c6228acc8c9409185e3 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -34,7 +34,6 @@ use project::{ use settings::{Settings, SettingsStore}; use smol::future::yield_now; use std::any::{Any, TypeId}; -use std::ops::Range; use std::sync::Arc; use theme::ActiveTheme; use ui::{KeyBinding, Tooltip, prelude::*, vertical_divider}; @@ -46,6 +45,7 @@ use workspace::{ notifications::NotifyTaskExt, searchable::SearchableItemHandle, }; +use ztracing::instrument; actions!( git, @@ -74,6 +74,13 @@ pub struct ProjectDiff { _subscription: Subscription, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RefreshReason { + DiffChanged, + StatusesChanged, + EditorSaved, +} + const CONFLICT_SORT_PREFIX: u64 = 1; const TRACKED_SORT_PREFIX: u64 = 2; const NEW_SORT_PREFIX: u64 = 3; @@ -149,6 +156,10 @@ impl ProjectDiff { .items_of_type::(cx) .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); let project_diff = if let Some(existing) = existing { + existing.update(cx, |project_diff, cx| { + project_diff.move_to_beginning(window, cx); + }); + workspace.activate_item(&existing, true, true, window, cx); existing } else { @@ -278,7 +289,7 @@ impl ProjectDiff { BranchDiffEvent::FileListChanged => { this._task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }) } }, @@ -297,7 +308,7 @@ impl ProjectDiff { this._task = { window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }) } } @@ -308,7 +319,7 @@ impl ProjectDiff { let task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }); Self { @@ -358,6 +369,14 @@ impl ProjectDiff { }) } + fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.primary_editor().update(cx, |editor, cx| { + editor.move_to_beginning(&Default::default(), window, cx); + }); + }); + } + fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context) { if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { self.editor.update(cx, |editor, cx| { @@ -448,27 +467,36 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - if let EditorEvent::SelectionsChanged { local: true } = event { - let Some(project_path) = self.active_path(cx) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - if let Some(git_panel) = workspace.panel::(cx) { - git_panel.update(cx, |git_panel, cx| { - git_panel.select_entry_by_path(project_path, window, cx) - }) - } - }) - .ok(); + match event { + EditorEvent::SelectionsChanged { local: true } => { + let Some(project_path) = self.active_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + git_panel.update(cx, |git_panel, cx| { + git_panel.select_entry_by_path(project_path, window, cx) + }) + } + }) + .ok(); + } + EditorEvent::Saved => { + self._task = cx.spawn_in(window, async move |this, cx| { + Self::refresh(this, RefreshReason::EditorSaved, cx).await + }); + } + _ => {} } if editor.focus_handle(cx).contains_focused(window, cx) && self.multibuffer.read(cx).is_empty() { - self.focus_handle.focus(window) + self.focus_handle.focus(window, cx) } } + #[instrument(skip_all)] fn register_buffer( &mut self, path_key: PathKey, @@ -481,7 +509,7 @@ impl ProjectDiff { let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| { this._task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await }) }); self.buffer_diff_subscriptions @@ -497,24 +525,30 @@ impl ProjectDiff { .expect("project diff editor should have a conflict addon"); let snapshot = buffer.read(cx).snapshot(); - let diff_read = diff.read(cx); - let diff_snapshot = diff_read.snapshot(cx); - let diff_hunk_ranges = diff_snapshot - .hunks_intersecting_range( - Anchor::min_max_range_for_buffer(diff_read.buffer_id), - &snapshot, - ) - .map(|diff_hunk| diff_hunk.buffer_range); - let conflicts = conflict_addon - .conflict_set(snapshot.remote_id()) - .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts) - .unwrap_or_default(); - let conflicts = conflicts.iter().map(|conflict| conflict.range.clone()); - - let excerpt_ranges = - merge_anchor_ranges(diff_hunk_ranges.into_iter(), conflicts, &snapshot) - .map(|range| range.to_point(&snapshot)) - .collect::>(); + 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 conflicts = conflict_addon + .conflict_set(snapshot.remote_id()) + .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts) + .unwrap_or_default(); + let mut conflicts = conflicts + .iter() + .map(|conflict| conflict.range.to_point(&snapshot)) + .peekable(); + + if conflicts.peek().is_some() { + conflicts.collect::>() + } else { + diff_hunk_ranges.collect() + } + }; let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| { let was_empty = editor @@ -565,10 +599,10 @@ impl ProjectDiff { .focus_handle(cx) .contains_focused(window, cx) { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() { self.editor.update(cx, |editor, cx| { - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); }); } if self.pending_scroll.as_ref() == Some(&path_key) { @@ -576,14 +610,23 @@ impl ProjectDiff { } } - pub async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { + pub async fn refresh( + this: WeakEntity, + reason: RefreshReason, + cx: &mut AsyncWindowContext, + ) -> Result<()> { let mut path_keys = Vec::new(); let buffers_to_load = this.update(cx, |this, cx| { let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { let load_buffers = branch_diff.load_buffers(cx); (branch_diff.repo().cloned(), load_buffers) }); - let mut previous_paths = this.multibuffer.read(cx).paths().collect::>(); + let mut previous_paths = this + .multibuffer + .read(cx) + .paths() + .cloned() + .collect::>(); if let Some(repo) = repo { let repo = repo.read(cx); @@ -600,6 +643,18 @@ impl ProjectDiff { this.editor.update(cx, |editor, cx| { for path in previous_paths { + if let Some(buffer) = this.multibuffer.read(cx).buffer_for_path(&path, cx) { + let skip = match reason { + RefreshReason::DiffChanged | RefreshReason::EditorSaved => { + buffer.read(cx).is_dirty() + } + RefreshReason::StatusesChanged => false, + }; + if skip { + continue; + } + } + this.buffer_diff_subscriptions.remove(&path.path); editor.remove_excerpts_for_path(path, cx); } @@ -614,7 +669,27 @@ impl ProjectDiff { yield_now().await; cx.update(|window, cx| { this.update(cx, |this, cx| { - this.register_buffer(path_key, entry.file_status, buffer, diff, window, cx) + let multibuffer = this.multibuffer.read(cx); + let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some() + && multibuffer + .diff_for(buffer.read(cx).remote_id()) + .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id()) + && match reason { + RefreshReason::DiffChanged | RefreshReason::EditorSaved => { + buffer.read(cx).is_dirty() + } + RefreshReason::StatusesChanged => false, + }; + if !skip { + this.register_buffer( + path_key, + entry.file_status, + buffer, + diff, + window, + cx, + ) + } }) .ok(); })?; @@ -632,14 +707,17 @@ impl ProjectDiff { pub fn excerpt_paths(&self, cx: &App) -> Vec> { self.multibuffer .read(cx) - .excerpt_paths() + .paths() .map(|key| key.path.clone()) .collect() } } fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 { - if GitPanelSettings::get_global(cx).sort_by_path { + let settings = GitPanelSettings::get_global(cx); + + // Tree view can only sort by path + if settings.sort_by_path || settings.tree_view { TRACKED_SORT_PREFIX } else if repo.had_conflict_on_last_merge_head_change(repo_path) { CONFLICT_SORT_PREFIX @@ -907,7 +985,7 @@ impl Render for ProjectDiff { cx, )) .on_click(move |_, window, cx| { - window.focus(&keybinding_focus_handle); + window.focus(&keybinding_focus_handle, cx); window.dispatch_action( Box::new(CloseActiveItem::default()), cx, @@ -1077,7 +1155,7 @@ impl ProjectDiffToolbar { fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context) { if let Some(project_diff) = self.project_diff(cx) { - project_diff.focus_handle(cx).focus(window); + project_diff.focus_handle(cx).focus(window, cx); } let action = action.boxed_clone(); cx.defer(move |cx| { @@ -1545,53 +1623,6 @@ mod preview { } } -fn merge_anchor_ranges<'a>( - left: impl 'a + Iterator>, - right: impl 'a + Iterator>, - snapshot: &'a language::BufferSnapshot, -) -> impl 'a + Iterator> { - let mut left = left.fuse().peekable(); - let mut right = right.fuse().peekable(); - - std::iter::from_fn(move || { - let Some(left_range) = left.peek() else { - return right.next(); - }; - let Some(right_range) = right.peek() else { - return left.next(); - }; - - let mut next_range = if left_range.start.cmp(&right_range.start, snapshot).is_lt() { - left.next().unwrap() - } else { - right.next().unwrap() - }; - - // Extend the basic range while there's overlap with a range from either stream. - loop { - if let Some(left_range) = left - .peek() - .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le()) - .cloned() - { - left.next(); - next_range.end = left_range.end; - } else if let Some(right_range) = right - .peek() - .filter(|range| range.start.cmp(&next_range.end, snapshot).is_le()) - .cloned() - { - right.next(); - next_range.end = right_range.end; - } else { - break; - } - } - - Some(next_range) - }) -} - struct BranchDiffAddon { branch_diff: Entity, } @@ -1689,9 +1720,13 @@ mod tests { .unindent(), ); - editor.update_in(cx, |editor, window, cx| { - editor.git_restore(&Default::default(), window, cx); - }); + editor + .update_in(cx, |editor, window, cx| { + editor.git_restore(&Default::default(), window, cx); + editor.save(SaveOptions::default(), project.clone(), window, cx) + }) + .await + .unwrap(); cx.run_until_parked(); assert_state_with_diff(&editor, cx, &"ˇ".unindent()); @@ -1880,8 +1915,8 @@ mod tests { cx, &" - original - + ˇdifferent - " + + different + ˇ" .unindent(), ); } diff --git a/crates/git_ui/src/remote_output.rs b/crates/git_ui/src/remote_output.rs index 8437bf0d0d37c2b2767624110fed056bbae25d05..7fe863ee29df20ca0f61cef5bf64cdae4b198c7a 100644 --- a/crates/git_ui/src/remote_output.rs +++ b/crates/git_ui/src/remote_output.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; + use git::repository::{Remote, RemoteCommandOutput}; use linkify::{LinkFinder, LinkKind}; use ui::SharedString; diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index fd81176a127e6032ebb84f1c8afdb6f61a5aa9b8..6d0a9d291e4a8c7096c525b9b401e54e599b0b53 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -464,7 +464,7 @@ impl PickerDelegate for StashListDelegate { ); Some( - ListItem::new(SharedString::from(format!("stash-{ix}"))) + ListItem::new(format!("stash-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 74dd4e1590c933f7c760ca92036604e3248d81a2..25f4603e4cf70bcf23e1dc64dd171b70a1d231c5 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -170,7 +170,7 @@ impl TextDiffView { cx.subscribe(&source_buffer, move |this, _, event, _| match event { language::BufferEvent::Edited - | language::BufferEvent::LanguageChanged + | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); } diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index d1231b51e3a37db2b3ee2316e866fcbdbe70d459..fef5e16c80ddd26ae6dd0b2a5c0ad1d8e5b21b2c 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; +use collections::HashSet; use fuzzy::StringMatchCandidate; use git::repository::Worktree as GitWorktree; @@ -9,7 +10,11 @@ use gpui::{ actions, rems, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; -use project::{DirectoryLister, git_store::Repository}; +use project::{ + DirectoryLister, + git_store::Repository, + trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees}, +}; use recent_projects::{RemoteConnectionModal, connect}; use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier}; use std::{path::PathBuf, sync::Arc}; @@ -219,7 +224,6 @@ impl WorktreeListDelegate { window: &mut Window, cx: &mut Context>, ) { - let workspace = self.workspace.clone(); let Some(repo) = self.repo.clone() else { return; }; @@ -247,6 +251,7 @@ impl WorktreeListDelegate { let branch = worktree_branch.to_string(); let window_handle = window.window_handle(); + let workspace = self.workspace.clone(); cx.spawn_in(window, async move |_, cx| { let Some(paths) = worktree_path.await? else { return anyhow::Ok(()); @@ -257,8 +262,32 @@ impl WorktreeListDelegate { repo.create_worktree(branch.clone(), path.clone(), commit) })? .await??; - - let final_path = path.join(branch); + let new_worktree_path = path.join(branch); + + workspace.update(cx, |workspace, cx| { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + let repo_path = &repo.read(cx).snapshot().work_directory_abs_path; + let project = workspace.project(); + if let Some((parent_worktree, _)) = + project.read(cx).find_worktree(repo_path, cx) + { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + if trusted_worktrees.can_trust(parent_worktree.read(cx).id(), cx) { + trusted_worktrees.trust( + HashSet::from_iter([PathTrust::AbsPath( + new_worktree_path.clone(), + )]), + project + .read(cx) + .remote_connection_options(cx) + .map(RemoteHostLocation::from), + cx, + ); + } + }); + } + } + })?; let (connection_options, app_state, is_local) = workspace.update(cx, |workspace, cx| { @@ -274,7 +303,7 @@ impl WorktreeListDelegate { .update_in(cx, |workspace, window, cx| { workspace.open_workspace_for_paths( replace_current_window, - vec![final_path], + vec![new_worktree_path], window, cx, ) @@ -283,7 +312,7 @@ impl WorktreeListDelegate { } else if let Some(connection_options) = connection_options { open_remote_worktree( connection_options, - vec![final_path], + vec![new_worktree_path], app_state, window_handle, replace_current_window, @@ -421,6 +450,7 @@ async fn open_remote_worktree( app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), + true, cx, ) })?; @@ -665,7 +695,7 @@ impl PickerDelegate for WorktreeListDelegate { }; Some( - ListItem::new(SharedString::from(format!("worktree-menu-{ix}"))) + ListItem::new(format!("worktree-menu-{ix}")) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 461b0be659fc3ffb7b7bc984485dc68ece988500..7c42972a75420ae87bf3c5b9caaf041852efc009 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -268,7 +268,7 @@ impl GoToLine { cx, |s| s.select_anchor_ranges([start..start]), ); - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); cx.notify() }); self.prev_scroll_position.take(); diff --git a/crates/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index 3eff860e16f15fae76d8f9cb2523d2b91b611125..a7d82c584b208cec33075d65a53a74c963ec05b5 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -512,6 +512,8 @@ pub enum Model { Gemini25Pro, #[serde(rename = "gemini-3-pro-preview")] Gemini3Pro, + #[serde(rename = "gemini-3-flash-preview")] + Gemini3Flash, #[serde(rename = "custom")] Custom { name: String, @@ -534,6 +536,7 @@ impl Model { Self::Gemini25Flash => "gemini-2.5-flash", Self::Gemini25Pro => "gemini-2.5-pro", Self::Gemini3Pro => "gemini-3-pro-preview", + Self::Gemini3Flash => "gemini-3-flash-preview", Self::Custom { name, .. } => name, } } @@ -543,6 +546,7 @@ impl Model { Self::Gemini25Flash => "gemini-2.5-flash", Self::Gemini25Pro => "gemini-2.5-pro", Self::Gemini3Pro => "gemini-3-pro-preview", + Self::Gemini3Flash => "gemini-3-flash-preview", Self::Custom { name, .. } => name, } } @@ -553,6 +557,7 @@ impl Model { Self::Gemini25Flash => "Gemini 2.5 Flash", Self::Gemini25Pro => "Gemini 2.5 Pro", Self::Gemini3Pro => "Gemini 3 Pro", + Self::Gemini3Flash => "Gemini 3 Flash", Self::Custom { name, display_name, .. } => display_name.as_ref().unwrap_or(name), @@ -561,20 +566,22 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { - Self::Gemini25FlashLite => 1_048_576, - Self::Gemini25Flash => 1_048_576, - Self::Gemini25Pro => 1_048_576, - Self::Gemini3Pro => 1_048_576, + Self::Gemini25FlashLite + | Self::Gemini25Flash + | Self::Gemini25Pro + | Self::Gemini3Pro + | Self::Gemini3Flash => 1_048_576, Self::Custom { max_tokens, .. } => *max_tokens, } } pub fn max_output_tokens(&self) -> Option { match self { - Model::Gemini25FlashLite => Some(65_536), - Model::Gemini25Flash => Some(65_536), - Model::Gemini25Pro => Some(65_536), - Model::Gemini3Pro => Some(65_536), + Model::Gemini25FlashLite + | Model::Gemini25Flash + | Model::Gemini25Pro + | Model::Gemini3Pro + | Model::Gemini3Flash => Some(65_536), Model::Custom { .. } => None, } } @@ -599,6 +606,7 @@ impl Model { budget_tokens: None, } } + Self::Gemini3Flash => GoogleModelMode::Default, Self::Custom { mode, .. } => *mode, } } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 4985cc07383aac56d6975fa09a410a0cee6c549d..40376f476b6d80f6b5170840f295a71acdfebb7d 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -21,7 +21,6 @@ default = ["font-kit", "wayland", "x11", "windows-manifest"] test-support = [ "leak-detection", "collections/test-support", - "rand", "util/test-support", "http_client/test-support", "wayland", @@ -109,7 +108,7 @@ parking = "2.0.0" parking_lot.workspace = true postage.workspace = true profiling.workspace = true -rand = { optional = true, workspace = true } +rand.workspace = true raw-window-handle = "0.6" refineable.workspace = true resvg = { version = "0.45.0", default-features = false, features = [ @@ -158,8 +157,10 @@ media.workspace = true objc.workspace = true objc2 = { version = "0.6", optional = true } objc2-metal = { version = "0.3", optional = true } +mach2.workspace = true #TODO: replace with "objc2" metal.workspace = true +flume = "0.11" [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))'.dependencies] pathfinder_geometry = "0.5" @@ -197,14 +198,14 @@ wayland-backend = { version = "0.3.3", features = [ "client_system", "dlopen", ], optional = true } -wayland-client = { version = "0.31.2", optional = true } -wayland-cursor = { version = "0.31.1", optional = true } -wayland-protocols = { version = "0.31.2", features = [ +wayland-client = { version = "0.31.11", optional = true } +wayland-cursor = { version = "0.31.11", optional = true } +wayland-protocols = { version = "0.32.9", features = [ "client", "staging", "unstable", ], optional = true } -wayland-protocols-plasma = { version = "0.2.0", features = [ +wayland-protocols-plasma = { version = "0.3.9", features = [ "client", ], optional = true } wayland-protocols-wlr = { version = "0.3.9", features = [ @@ -329,3 +330,7 @@ path = "examples/window_shadow.rs" [[example]] name = "grid_layout" path = "examples/grid_layout.rs" + +[[example]] +name = "mouse_pressure" +path = "examples/mouse_pressure.rs" diff --git a/crates/gpui/README.md b/crates/gpui/README.md index 30847c8b8cfb0ac5c662601acbe2008b41e42ee1..ad3fd37fc55857699f5fd23cbe4f4f088ee687c8 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -11,7 +11,7 @@ GPUI is still in active development as we work on the Zed code editor, and is st gpui = { version = "*" } ``` - - [Ownership and data flow](src/_ownership_and_data_flow.rs) + - [Ownership and data flow](_ownership_and_data_flow) Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index ec35ec0bc63113582a945c71198cd7bc14301dcc..c7ae7ac9f239f2f6ce3880f9329f2ba92b2174f3 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -84,6 +84,8 @@ mod macos { .allowlist_var("_dispatch_main_q") .allowlist_var("_dispatch_source_type_data_add") .allowlist_var("DISPATCH_QUEUE_PRIORITY_HIGH") + .allowlist_var("DISPATCH_QUEUE_PRIORITY_DEFAULT") + .allowlist_var("DISPATCH_QUEUE_PRIORITY_LOW") .allowlist_var("DISPATCH_TIME_NOW") .allowlist_function("dispatch_get_global_queue") .allowlist_function("dispatch_async_f") diff --git a/crates/gpui/examples/focus_visible.rs b/crates/gpui/examples/focus_visible.rs index 737317cabadb7d3358c9c0497b52d4c2ff2e1028..d7c15396f0381ef29b3d6600347fd90a602256f5 100644 --- a/crates/gpui/examples/focus_visible.rs +++ b/crates/gpui/examples/focus_visible.rs @@ -29,7 +29,7 @@ impl Example { ]; let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Self { focus_handle, @@ -40,13 +40,13 @@ impl Example { } } - fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); self.message = SharedString::from("Pressed Tab - focus-visible border should appear!"); } - fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { - window.focus_prev(); + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); self.message = SharedString::from("Pressed Shift-Tab - focus-visible border should appear!"); } diff --git a/crates/gpui/examples/input.rs b/crates/gpui/examples/input.rs index 37115feaa551a787562e7299c9d44bcc97b5fca3..aac56bdf1d0b739f28f395e537b171264c78a1e8 100644 --- a/crates/gpui/examples/input.rs +++ b/crates/gpui/examples/input.rs @@ -546,8 +546,15 @@ impl Element for TextElement { window.paint_quad(selection) } let line = prepaint.line.take().unwrap(); - line.paint(bounds.origin, window.line_height(), window, cx) - .unwrap(); + line.paint( + bounds.origin, + window.line_height(), + gpui::TextAlign::Left, + None, + window, + cx, + ) + .unwrap(); if focus_handle.is_focused(window) && let Some(cursor) = prepaint.cursor.take() @@ -736,7 +743,7 @@ fn main() { window .update(cx, |view, window, cx| { - window.focus(&view.text_input.focus_handle(cx)); + window.focus(&view.text_input.focus_handle(cx), cx); cx.activate(true); }) .unwrap(); diff --git a/crates/gpui/examples/mouse_pressure.rs b/crates/gpui/examples/mouse_pressure.rs new file mode 100644 index 0000000000000000000000000000000000000000..12790f988eedac3009ae619cadbc6f40c4af2e4b --- /dev/null +++ b/crates/gpui/examples/mouse_pressure.rs @@ -0,0 +1,66 @@ +use gpui::{ + App, Application, Bounds, Context, MousePressureEvent, PressureStage, Window, WindowBounds, + WindowOptions, div, prelude::*, px, rgb, size, +}; + +struct MousePressureExample { + pressure_stage: PressureStage, + pressure_amount: f32, +} + +impl Render for MousePressureExample { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_3() + .bg(rgb(0x505050)) + .size(px(500.0)) + .justify_center() + .items_center() + .shadow_lg() + .border_1() + .border_color(rgb(0x0000ff)) + .text_xl() + .text_color(rgb(0xffffff)) + .child(format!("Pressure stage: {:?}", &self.pressure_stage)) + .child(format!("Pressure amount: {:.2}", &self.pressure_amount)) + .on_mouse_pressure(cx.listener(Self::on_mouse_pressure)) + } +} + +impl MousePressureExample { + fn on_mouse_pressure( + &mut self, + pressure_event: &MousePressureEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.pressure_amount = pressure_event.pressure; + self.pressure_stage = pressure_event.stage; + + cx.notify(); + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| { + cx.new(|_| MousePressureExample { + pressure_stage: PressureStage::Zero, + pressure_amount: 0.0, + }) + }, + ) + .unwrap(); + + cx.activate(true); + }); +} diff --git a/crates/gpui/examples/on_window_close_quit.rs b/crates/gpui/examples/on_window_close_quit.rs index 8fe24001445d94b1629bf766294d850d0918a5e8..9a2b2f2fee43f753aece55d076be647ad8060965 100644 --- a/crates/gpui/examples/on_window_close_quit.rs +++ b/crates/gpui/examples/on_window_close_quit.rs @@ -55,7 +55,7 @@ fn main() { cx.activate(false); cx.new(|cx| { let focus_handle = cx.focus_handle(); - focus_handle.focus(window); + focus_handle.focus(window, cx); ExampleWindow { focus_handle } }) }, @@ -72,7 +72,7 @@ fn main() { |window, cx| { cx.new(|cx| { let focus_handle = cx.focus_handle(); - focus_handle.focus(window); + focus_handle.focus(window, cx); ExampleWindow { focus_handle } }) }, diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index e7055cbdbbd781523edbc851d143bf56a551728f..9f15d12f469fa6ec5c7be52d30a63b30163ff254 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -1,7 +1,7 @@ use gpui::{ Application, Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder, - PathStyle, Pixels, Point, Render, SharedString, StrokeOptions, Window, WindowOptions, canvas, - div, linear_color_stop, linear_gradient, point, prelude::*, px, quad, rgb, size, + PathStyle, Pixels, Point, Render, StrokeOptions, Window, WindowOptions, canvas, div, + linear_color_stop, linear_gradient, point, prelude::*, px, quad, rgb, size, }; struct PaintingViewer { @@ -309,7 +309,7 @@ fn button( on_click: impl Fn(&mut PaintingViewer, &mut Context) + 'static, ) -> impl IntoElement { div() - .id(SharedString::from(text.to_string())) + .id(text.to_string()) .child(text.to_string()) .bg(gpui::black()) .text_color(gpui::white()) diff --git a/crates/gpui/examples/popover.rs b/crates/gpui/examples/popover.rs new file mode 100644 index 0000000000000000000000000000000000000000..9a3d47d06dd0f92d176dd4e81d739403a4870062 --- /dev/null +++ b/crates/gpui/examples/popover.rs @@ -0,0 +1,174 @@ +use gpui::{ + App, Application, Context, Corner, Div, Hsla, Stateful, Window, WindowOptions, anchored, + deferred, div, prelude::*, px, +}; + +/// An example show use deferred to create a floating layers. +struct HelloWorld { + open: bool, + secondary_open: bool, +} + +fn button(id: &'static str) -> Stateful

{ + div() + .id(id) + .bg(gpui::black()) + .text_color(gpui::white()) + .px_3() + .py_1() +} + +fn popover() -> Div { + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .shadow_lg() + .p_3() + .rounded_md() + .bg(gpui::white()) + .text_color(gpui::black()) + .border_1() + .text_sm() + .border_color(gpui::black().opacity(0.1)) +} + +fn line(color: Hsla) -> Div { + div().w(px(480.)).h_2().bg(color.opacity(0.25)) +} + +impl HelloWorld { + fn render_secondary_popover( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + button("secondary-btn") + .mt_2() + .child("Child Popover") + .on_click(cx.listener(|this, _, _, cx| { + this.secondary_open = true; + cx.notify(); + })) + .when(self.secondary_open, |this| { + this.child( + // GPUI can't support deferred here yet, + // it was inside another deferred element. + anchored() + .anchor(Corner::TopLeft) + .snap_to_window_with_margin(px(8.)) + .child( + popover() + .child("This is second level Popover") + .bg(gpui::white()) + .border_color(gpui::blue()) + .on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.secondary_open = false; + cx.notify(); + })), + ), + ) + }) + } +} + +impl Render for HelloWorld { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_3() + .size_full() + .bg(gpui::white()) + .text_color(gpui::black()) + .justify_center() + .items_center() + .child( + div() + .flex() + .flex_row() + .gap_4() + .child( + button("popover0").child("Opened Popover").child( + deferred( + anchored() + .anchor(Corner::TopLeft) + .snap_to_window_with_margin(px(8.)) + .child(popover().w_96().gap_3().child( + "This is a default opened Popover, \ + we can use deferred to render it \ + in a floating layer.", + )), + ) + .priority(0), + ), + ) + .child( + button("popover1") + .child("Open Popover") + .on_click(cx.listener(|this, _, _, cx| { + this.open = true; + cx.notify(); + })) + .when(self.open, |this| { + this.child( + deferred( + anchored() + .anchor(Corner::TopLeft) + .snap_to_window_with_margin(px(8.)) + .child( + popover() + .w_96() + .gap_3() + .child( + "This is first level Popover, \ + we can use deferred to render it \ + in a floating layer.\n\ + Click outside to close.", + ) + .when(!self.secondary_open, |this| { + this.on_mouse_down_out(cx.listener( + |this, _, _, cx| { + this.open = false; + cx.notify(); + }, + )) + }) + // Here we need render popover after the content + // to ensure it will be on top layer. + .child( + self.render_secondary_popover(window, cx), + ), + ), + ) + .priority(1), + ) + }), + ), + ) + .child( + "Here is an example text rendered, \ + to ensure the Popover will float above this contents.", + ) + .children([ + line(gpui::red()), + line(gpui::yellow()), + line(gpui::blue()), + line(gpui::green()), + ]) + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + cx.open_window(WindowOptions::default(), |_, cx| { + cx.new(|_| HelloWorld { + open: false, + secondary_open: false, + }) + }) + .unwrap(); + cx.activate(true); + }); +} diff --git a/crates/gpui/examples/tab_stop.rs b/crates/gpui/examples/tab_stop.rs index 8dbcbeccb7351fda18e8d36fe38d8f26c4a70cc9..efad97236cfc778870db1ee723c92691578860dd 100644 --- a/crates/gpui/examples/tab_stop.rs +++ b/crates/gpui/examples/tab_stop.rs @@ -22,7 +22,7 @@ impl Example { ]; let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); Self { focus_handle, @@ -31,13 +31,13 @@ impl Example { } } - fn on_tab(&mut self, _: &Tab, window: &mut Window, _: &mut Context) { - window.focus_next(); + fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); self.message = SharedString::from("You have pressed `Tab`."); } - fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, _: &mut Context) { - window.focus_prev(); + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); self.message = SharedString::from("You have pressed `Shift-Tab`."); } } @@ -130,6 +130,50 @@ impl Render for Example { })), ), ) + .child( + div() + .id("group-1") + .tab_index(6) + .tab_group() + .tab_stop(false) + .child( + button("group-1-button-1") + .tab_index(1) + .child("Tab index [6, 1]"), + ) + .child( + button("group-1-button-2") + .tab_index(2) + .child("Tab index [6, 2]"), + ) + .child( + button("group-1-button-3") + .tab_index(3) + .child("Tab index [6, 3]"), + ), + ) + .child( + div() + .id("group-2") + .tab_index(7) + .tab_group() + .tab_stop(false) + .child( + button("group-2-button-1") + .tab_index(1) + .child("Tab index [7, 1]"), + ) + .child( + button("group-2-button-2") + .tab_index(2) + .child("Tab index [7, 2]"), + ) + .child( + button("group-2-button-3") + .tab_index(3) + .child("Tab index [7, 3]"), + ), + ) } } diff --git a/crates/gpui/examples/window.rs b/crates/gpui/examples/window.rs index 4445f24e4ec0f2809109964fd34610cad1299e90..3f41f3d55f240e688965ac8248ac3d5b4ef40401 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -1,15 +1,16 @@ use gpui::{ - App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, SharedString, Timer, - Window, WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, + App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, Timer, Window, + WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, }; struct SubWindow { custom_titlebar: bool, + is_dialog: bool, } fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> impl IntoElement { div() - .id(SharedString::from(text.to_string())) + .id(text.to_string()) .flex_none() .px_2() .bg(rgb(0xf7f7f7)) @@ -23,7 +24,10 @@ fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> imp } impl Render for SubWindow { - fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let window_bounds = + WindowBounds::Windowed(Bounds::centered(None, size(px(250.0), px(200.0)), cx)); + div() .flex() .flex_col() @@ -52,8 +56,28 @@ impl Render for SubWindow { .child( div() .p_8() + .flex() + .flex_col() .gap_2() .child("SubWindow") + .when(self.is_dialog, |div| { + div.child(button("Open Nested Dialog", move |_, cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(window_bounds), + kind: WindowKind::Dialog, + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + is_dialog: true, + }) + }, + ) + .unwrap(); + })) + }) .child(button("Close", |window, _| { window.remove_window(); })), @@ -86,6 +110,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) @@ -101,6 +126,39 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, + }) + }, + ) + .unwrap(); + })) + .child(button("Floating", move |_, cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(window_bounds), + kind: WindowKind::Floating, + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + is_dialog: false, + }) + }, + ) + .unwrap(); + })) + .child(button("Dialog", move |_, cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(window_bounds), + kind: WindowKind::Dialog, + ..Default::default() + }, + |_, cx| { + cx.new(|_| SubWindow { + custom_titlebar: false, + is_dialog: true, }) }, ) @@ -116,6 +174,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: true, + is_dialog: false, }) }, ) @@ -131,6 +190,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) @@ -147,6 +207,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) @@ -162,6 +223,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) @@ -177,6 +239,7 @@ impl Render for WindowDemo { |_, cx| { cx.new(|_| SubWindow { custom_titlebar: false, + is_dialog: false, }) }, ) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index c042d85a1239dc6723b6501b27690a9f593a021b..96f815ac0b592600f22b3c9b9686571487ff77a2 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -38,10 +38,11 @@ use crate::{ AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, PromptBuilder, - PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, - Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, - TextSystem, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, + PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, + RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, + Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, WindowHandle, WindowId, + WindowInvalidator, colors::{Colors, GlobalColors}, current_platform, hash, init_app_menus, }; @@ -315,6 +316,7 @@ impl SystemWindowTabController { .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); let current_group = current_group?; + // TODO: `.keys()` returns arbitrary order, what does "next" mean? let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); let idx = group_ids.iter().position(|g| *g == current_group)?; let next_idx = (idx + 1) % group_ids.len(); @@ -339,6 +341,7 @@ impl SystemWindowTabController { .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group)); let current_group = current_group?; + // TODO: `.keys()` returns arbitrary order, what does "previous" mean? let mut group_ids: Vec<_> = controller.tab_groups.keys().collect(); let idx = group_ids.iter().position(|g| *g == current_group)?; let prev_idx = if idx == 0 { @@ -360,12 +363,9 @@ impl SystemWindowTabController { /// Get all tabs in the same window. pub fn tabs(&self, id: WindowId) -> Option<&Vec> { - let tab_group = self - .tab_groups - .iter() - .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group))?; - - self.tab_groups.get(&tab_group) + self.tab_groups + .values() + .find(|tabs| tabs.iter().any(|tab| tab.id == id)) } /// Initialize the visibility of the system window tab controller. @@ -440,7 +440,7 @@ impl SystemWindowTabController { /// Insert a tab into a tab group. pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec) { let mut controller = cx.global_mut::(); - let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else { + let Some(tab) = tabs.iter().find(|tab| tab.id == id).cloned() else { return; }; @@ -503,16 +503,14 @@ impl SystemWindowTabController { return; }; + let initial_tabs_len = initial_tabs.len(); let mut all_tabs = initial_tabs.clone(); - for tabs in controller.tab_groups.values() { - all_tabs.extend( - tabs.iter() - .filter(|tab| !initial_tabs.contains(tab)) - .cloned(), - ); + + for (_, mut tabs) in controller.tab_groups.drain() { + tabs.retain(|tab| !all_tabs[..initial_tabs_len].contains(tab)); + all_tabs.extend(tabs); } - controller.tab_groups.clear(); controller.tab_groups.insert(0, all_tabs); } @@ -551,12 +549,39 @@ impl SystemWindowTabController { } } +pub(crate) enum GpuiMode { + #[cfg(any(test, feature = "test-support"))] + Test { + skip_drawing: bool, + }, + Production, +} + +impl GpuiMode { + #[cfg(any(test, feature = "test-support"))] + pub fn test() -> Self { + GpuiMode::Test { + skip_drawing: false, + } + } + + #[inline] + pub(crate) fn skip_drawing(&self) -> bool { + match self { + #[cfg(any(test, feature = "test-support"))] + GpuiMode::Test { skip_drawing } => *skip_drawing, + GpuiMode::Production => false, + } + } +} + /// Contains the state of the full application, and passed as a reference to a variety of callbacks. /// Other [Context] derefs to this type. /// You need a reference to an `App` to access the state of a [Entity]. pub struct App { pub(crate) this: Weak, pub(crate) platform: Rc, + pub(crate) mode: GpuiMode, text_system: Arc, flushing_effects: bool, pending_updates: usize, @@ -635,6 +660,7 @@ impl App { this: this.clone(), platform: platform.clone(), text_system, + mode: GpuiMode::Production, actions: Rc::new(ActionRegistry::default()), flushing_effects: false, pending_updates: 0, @@ -1051,11 +1077,9 @@ impl App { self.platform.window_appearance() } - /// Writes data to the primary selection buffer. - /// Only available on Linux. - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - pub fn write_to_primary(&self, item: ClipboardItem) { - self.platform.write_to_primary(item) + /// Reads data from the platform clipboard. + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() } /// Writes data to the platform clipboard. @@ -1070,9 +1094,31 @@ impl App { self.platform.read_from_primary() } - /// Reads data from the platform clipboard. - pub fn read_from_clipboard(&self) -> Option { - self.platform.read_from_clipboard() + /// Writes data to the primary selection buffer. + /// Only available on Linux. + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + pub fn write_to_primary(&self, item: ClipboardItem) { + self.platform.write_to_primary(item) + } + + /// Reads data from macOS's "Find" pasteboard. + /// + /// Used to share the current search string between apps. + /// + /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find + #[cfg(target_os = "macos")] + pub fn read_from_find_pasteboard(&self) -> Option { + self.platform.read_from_find_pasteboard() + } + + /// Writes data to macOS's "Find" pasteboard. + /// + /// Used to share the current search string between apps. + /// + /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find + #[cfg(target_os = "macos")] + pub fn write_to_find_pasteboard(&self, item: ClipboardItem) { + self.platform.write_to_find_pasteboard(item) } /// Writes credentials to the platform keychain. @@ -1466,6 +1512,24 @@ impl App { .spawn(async move { f(&mut cx).await }) } + /// Spawns the future returned by the given function on the main thread with + /// the given priority. The closure will be invoked with [AsyncApp], which + /// allows the application state to be accessed across await points. + pub fn spawn_with_priority(&self, priority: Priority, f: AsyncFn) -> Task + where + AsyncFn: AsyncFnOnce(&mut AsyncApp) -> R + 'static, + R: 'static, + { + if self.quitting { + debug_panic!("Can't spawn on main thread after on_app_quit") + }; + + let mut cx = self.to_async(); + + self.foreground_executor + .spawn_with_priority(priority, async move { f(&mut cx).await }) + } + /// Schedules the given function to be run at the end of the current effect cycle, allowing entities /// that are currently on the stack to be returned to the app. pub fn defer(&mut self, f: impl FnOnce(&mut App) + 'static) { @@ -1730,7 +1794,10 @@ impl App { /// Register a global handler for actions invoked via the keyboard. These handlers are run at /// the end of the bubble phase for actions, and so will only be invoked if there are no other /// handlers or if they called `cx.propagate()`. - pub fn on_action(&mut self, listener: impl Fn(&A, &mut Self) + 'static) { + pub fn on_action( + &mut self, + listener: impl Fn(&A, &mut Self) + 'static, + ) -> &mut Self { self.global_action_listeners .entry(TypeId::of::()) .or_default() @@ -1740,6 +1807,7 @@ impl App { listener(action, cx) } })); + self } /// Event handlers propagate events by default. Call this method to stop dispatching to @@ -1849,8 +1917,11 @@ impl App { pub(crate) fn clear_pending_keystrokes(&mut self) { for window in self.windows() { window - .update(self, |_, window, _| { - window.clear_pending_keystrokes(); + .update(self, |_, window, cx| { + if window.pending_input_keystrokes().is_some() { + window.clear_pending_keystrokes(); + window.pending_input_changed(cx); + } }) .ok(); } diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index f5dcd30ae943954cbc042e1ce02edad39370a04a..805dfced162cd27f0cc785a8282ae3b802c2873a 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -487,7 +487,7 @@ impl VisualContext for AsyncWindowContext { V: Focusable, { self.app.update_window(self.window, |_, window, cx| { - view.read(cx).focus_handle(cx).focus(window); + view.read(cx).focus_handle(cx).focus(window, cx); }) } } diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index 65bb5521e32bb6fcfac2bcd95009949499589df1..b780ca426c15c99030f24ee48bde978ad38526e7 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -1,7 +1,7 @@ use crate::{ AnyView, AnyWindowHandle, AppContext, AsyncApp, DispatchPhase, Effect, EntityId, EventEmitter, - FocusHandle, FocusOutEvent, Focusable, Global, KeystrokeObserver, Reservation, SubscriberSet, - Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, + FocusHandle, FocusOutEvent, Focusable, Global, KeystrokeObserver, Priority, Reservation, + SubscriberSet, Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, }; use anyhow::Result; use futures::FutureExt; @@ -285,7 +285,7 @@ impl<'a, T: 'static> Context<'a, T> { /// Focus the given view in the given window. View type is required to implement Focusable. pub fn focus_view(&mut self, view: &Entity, window: &mut Window) { - window.focus(&view.focus_handle(self)); + window.focus(&view.focus_handle(self), self); } /// Sets a given callback to be run on the next frame. @@ -667,6 +667,25 @@ impl<'a, T: 'static> Context<'a, T> { window.spawn(self, async move |cx| f(view, cx).await) } + /// Schedule a future to be run asynchronously with the given priority. + /// The given callback is invoked with a [`WeakEntity`] to avoid leaking the entity for a long-running process. + /// It's also given an [`AsyncWindowContext`], which can be used to access the state of the entity across await points. + /// The returned future will be polled on the main thread. + #[track_caller] + pub fn spawn_in_with_priority( + &self, + priority: Priority, + window: &Window, + f: AsyncFn, + ) -> Task + where + R: 'static, + AsyncFn: AsyncFnOnce(WeakEntity, &mut AsyncWindowContext) -> R + 'static, + { + let view = self.weak_entity(); + window.spawn_with_priority(priority, self, async move |cx| f(view, cx).await) + } + /// Register a callback to be invoked when the given global state changes. pub fn observe_global_in( &mut self, @@ -713,7 +732,7 @@ impl<'a, T: 'static> Context<'a, T> { { let view = self.entity(); window.defer(self, move |window, cx| { - view.read(cx).focus_handle(cx).focus(window) + view.read(cx).focus_handle(cx).focus(window, cx) }) } } diff --git a/crates/gpui/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs index 81dbfdbf5733eed92a77fc2dc18fb971bd9bd4a7..8c1bdfa1cee509dcbc061200cb651ce5d3bf4fcd 100644 --- a/crates/gpui/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -584,7 +584,33 @@ impl AnyWeakEntity { }) } - /// Assert that entity referenced by this weak handle has been released. + /// Asserts that the entity referenced by this weak handle has been fully released. + /// + /// # Example + /// + /// ```ignore + /// let entity = cx.new(|_| MyEntity::new()); + /// let weak = entity.downgrade(); + /// drop(entity); + /// + /// // Verify the entity was released + /// weak.assert_released(); + /// ``` + /// + /// # Debugging Leaks + /// + /// If this method panics due to leaked handles, set the `LEAK_BACKTRACE` environment + /// variable to see where the leaked handles were allocated: + /// + /// ```bash + /// LEAK_BACKTRACE=1 cargo test my_test + /// ``` + /// + /// # Panics + /// + /// - Panics if any strong handles to the entity are still alive. + /// - Panics if the entity was recently dropped but cleanup hasn't completed yet + /// (resources are retained until the end of the effect cycle). #[cfg(any(test, feature = "leak-detection"))] pub fn assert_released(&self) { self.entity_ref_counts @@ -814,16 +840,70 @@ impl PartialOrd for WeakEntity { } } +/// Controls whether backtraces are captured when entity handles are created. +/// +/// Set the `LEAK_BACKTRACE` environment variable to any non-empty value to enable +/// backtrace capture. This helps identify where leaked handles were allocated. #[cfg(any(test, feature = "leak-detection"))] static LEAK_BACKTRACE: std::sync::LazyLock = std::sync::LazyLock::new(|| std::env::var("LEAK_BACKTRACE").is_ok_and(|b| !b.is_empty())); +/// Unique identifier for a specific entity handle instance. +/// +/// This is distinct from `EntityId` - while multiple handles can point to the same +/// entity (same `EntityId`), each handle has its own unique `HandleId`. #[cfg(any(test, feature = "leak-detection"))] #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)] pub(crate) struct HandleId { - id: u64, // id of the handle itself, not the pointed at object + id: u64, } +/// Tracks entity handle allocations to detect leaks. +/// +/// The leak detector is enabled in tests and when the `leak-detection` feature is active. +/// It tracks every `Entity` and `AnyEntity` handle that is created and released, +/// allowing you to verify that all handles to an entity have been properly dropped. +/// +/// # How do leaks happen? +/// +/// Entities are reference-counted structures that can own other entities +/// allowing to form cycles. If such a strong-reference counted cycle is +/// created, all participating strong entities in this cycle will effectively +/// leak as they cannot be released anymore. +/// +/// # Usage +/// +/// You can use `WeakEntity::assert_released` or `AnyWeakEntity::assert_released` +/// to verify that an entity has been fully released: +/// +/// ```ignore +/// let entity = cx.new(|_| MyEntity::new()); +/// let weak = entity.downgrade(); +/// drop(entity); +/// +/// // This will panic if any handles to the entity are still alive +/// weak.assert_released(); +/// ``` +/// +/// # Debugging Leaks +/// +/// When a leak is detected, the detector will panic with information about the leaked +/// handles. To see where the leaked handles were allocated, set the `LEAK_BACKTRACE` +/// environment variable: +/// +/// ```bash +/// LEAK_BACKTRACE=1 cargo test my_test +/// ``` +/// +/// This will capture and display backtraces for each leaked handle, helping you +/// identify where handles were created but not released. +/// +/// # How It Works +/// +/// - When an entity handle is created (via `Entity::new`, `Entity::clone`, or +/// `WeakEntity::upgrade`), `handle_created` is called to register the handle. +/// - When a handle is dropped, `handle_released` removes it from tracking. +/// - `assert_released` verifies that no handles remain for a given entity. #[cfg(any(test, feature = "leak-detection"))] pub(crate) struct LeakDetector { next_handle_id: u64, @@ -832,6 +912,11 @@ pub(crate) struct LeakDetector { #[cfg(any(test, feature = "leak-detection"))] impl LeakDetector { + /// Records that a new handle has been created for the given entity. + /// + /// Returns a unique `HandleId` that must be passed to `handle_released` when + /// the handle is dropped. If `LEAK_BACKTRACE` is set, captures a backtrace + /// at the allocation site. #[track_caller] pub fn handle_created(&mut self, entity_id: EntityId) -> HandleId { let id = util::post_inc(&mut self.next_handle_id); @@ -844,23 +929,40 @@ impl LeakDetector { handle_id } + /// Records that a handle has been released (dropped). + /// + /// This removes the handle from tracking. The `handle_id` should be the same + /// one returned by `handle_created` when the handle was allocated. pub fn handle_released(&mut self, entity_id: EntityId, handle_id: HandleId) { let handles = self.entity_handles.entry(entity_id).or_default(); handles.remove(&handle_id); } + /// Asserts that all handles to the given entity have been released. + /// + /// # Panics + /// + /// Panics if any handles to the entity are still alive. The panic message + /// includes backtraces for each leaked handle if `LEAK_BACKTRACE` is set, + /// otherwise it suggests setting the environment variable to get more info. pub fn assert_released(&mut self, entity_id: EntityId) { + use std::fmt::Write as _; let handles = self.entity_handles.entry(entity_id).or_default(); if !handles.is_empty() { + let mut out = String::new(); for backtrace in handles.values_mut() { if let Some(mut backtrace) = backtrace.take() { backtrace.resolve(); - eprintln!("Leaked handle: {:#?}", backtrace); + writeln!(out, "Leaked handle:\n{:?}", backtrace).unwrap(); } else { - eprintln!("Leaked handle: export LEAK_BACKTRACE to find allocation site"); + writeln!( + out, + "Leaked handle: (export LEAK_BACKTRACE to find allocation site)" + ) + .unwrap(); } } - panic!(); + panic!("{out}"); } } } diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 4a7b73c359ed3dd55b136b22e9487dee1735e42e..9b982f9a1ca3c14b99dfc93e938aafe4e2f75cff 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -5,7 +5,7 @@ use crate::{ ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, - WindowHandle, WindowOptions, + WindowHandle, WindowOptions, app::GpuiMode, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; @@ -132,8 +132,11 @@ impl TestAppContext { let http_client = http_client::FakeHttpClient::with_404_response(); let text_system = Arc::new(TextSystem::new(platform.text_system())); + let mut app = App::new_app(platform.clone(), asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + Self { - app: App::new_app(platform.clone(), asset_source, http_client), + app, background_executor, foreground_executor, dispatcher, @@ -144,6 +147,11 @@ impl TestAppContext { } } + /// Skip all drawing operations for the duration of this test. + pub fn skip_drawing(&mut self) { + self.app.borrow_mut().mode = GpuiMode::Test { skip_drawing: true }; + } + /// Create a single TestAppContext, for non-multi-client tests pub fn single() -> Self { let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); @@ -1037,7 +1045,7 @@ impl VisualContext for VisualTestContext { fn focus(&mut self, view: &Entity) -> Self::Result<()> { self.window .update(&mut self.cx, |_, window, cx| { - view.read(cx).focus_handle(cx).focus(window) + view.read(cx).focus_handle(cx).focus(window, cx) }) .unwrap() } diff --git a/crates/gpui/src/bounds_tree.rs b/crates/gpui/src/bounds_tree.rs index d621609bf7334801059513e03dfd11b4036ea816..9cf86a2cc9b6def8fbf5ca7e94f7cd19236468cc 100644 --- a/crates/gpui/src/bounds_tree.rs +++ b/crates/gpui/src/bounds_tree.rs @@ -5,14 +5,91 @@ use std::{ ops::{Add, Sub}, }; +/// Maximum children per internal node (R-tree style branching factor). +/// Higher values = shorter tree = fewer cache misses, but more work per node. +const MAX_CHILDREN: usize = 12; + +/// A spatial tree optimized for finding maximum ordering among intersecting bounds. +/// +/// This is an R-tree variant specifically designed for the use case of assigning +/// z-order to overlapping UI elements. Key optimizations: +/// - Tracks the leaf with global max ordering for O(1) fast-path queries +/// - Uses higher branching factor (4) for lower tree height +/// - Aggressive pruning during search based on max_order metadata #[derive(Debug)] pub(crate) struct BoundsTree where U: Clone + Debug + Default + PartialEq, { - root: Option, + /// All nodes stored contiguously for cache efficiency. nodes: Vec>, - stack: Vec, + /// Index of the root node, if any. + root: Option, + /// Index of the leaf with the highest ordering (for fast-path lookups). + max_leaf: Option, + /// Reusable stack for tree traversal during insertion. + insert_path: Vec, + /// Reusable stack for search operations. + search_stack: Vec, +} + +/// A node in the bounds tree. +#[derive(Debug, Clone)] +struct Node +where + U: Clone + Debug + Default + PartialEq, +{ + /// Bounding box containing this node and all descendants. + bounds: Bounds, + /// Maximum ordering value in this subtree. + max_order: u32, + /// Node-specific data. + kind: NodeKind, +} + +#[derive(Debug, Clone)] +enum NodeKind { + /// Leaf node containing actual bounds data. + Leaf { + /// The ordering assigned to this bounds. + order: u32, + }, + /// Internal node with children. + Internal { + /// Indices of child nodes (2 to MAX_CHILDREN). + children: NodeChildren, + }, +} + +/// Fixed-size array for child indices, avoiding heap allocation. +#[derive(Debug, Clone)] +struct NodeChildren { + // Keeps an invariant where the max order child is always at the end + indices: [usize; MAX_CHILDREN], + len: u8, +} + +impl NodeChildren { + fn new() -> Self { + Self { + indices: [0; MAX_CHILDREN], + len: 0, + } + } + + fn push(&mut self, index: usize) { + debug_assert!((self.len as usize) < MAX_CHILDREN); + self.indices[self.len as usize] = index; + self.len += 1; + } + + fn len(&self) -> usize { + self.len as usize + } + + fn as_slice(&self) -> &[usize] { + &self.indices[..self.len as usize] + } } impl BoundsTree @@ -26,158 +103,250 @@ where + Half + Default, { + /// Clears all nodes from the tree. pub fn clear(&mut self) { - self.root = None; self.nodes.clear(); - self.stack.clear(); + self.root = None; + self.max_leaf = None; + self.insert_path.clear(); + self.search_stack.clear(); } + /// Inserts bounds into the tree and returns its assigned ordering. + /// + /// The ordering is one greater than the maximum ordering of any + /// existing bounds that intersect with the new bounds. pub fn insert(&mut self, new_bounds: Bounds) -> u32 { - // If the tree is empty, make the root the new leaf. - let Some(mut index) = self.root else { - let new_node = self.push_leaf(new_bounds, 1); - self.root = Some(new_node); - return 1; + // Find maximum ordering among intersecting bounds + let max_intersecting = self.find_max_ordering(&new_bounds); + let ordering = max_intersecting + 1; + + // Insert the new leaf + let new_leaf_idx = self.insert_leaf(new_bounds, ordering); + + // Update max_leaf tracking + self.max_leaf = match self.max_leaf { + None => Some(new_leaf_idx), + Some(old_idx) if self.nodes[old_idx].max_order < ordering => Some(new_leaf_idx), + some => some, }; - // Search for the best place to add the new leaf based on heuristics. - let mut max_intersecting_ordering = 0; - while let Node::Internal { - left, - right, - bounds: node_bounds, - .. - } = &mut self.nodes[index] - { - let left = *left; - let right = *right; - *node_bounds = node_bounds.union(&new_bounds); - self.stack.push(index); - - // Descend to the best-fit child, based on which one would increase - // the surface area the least. This attempts to keep the tree balanced - // in terms of surface area. If there is an intersection with the other child, - // add its keys to the intersections vector. - let left_cost = new_bounds.union(self.nodes[left].bounds()).half_perimeter(); - let right_cost = new_bounds - .union(self.nodes[right].bounds()) - .half_perimeter(); - if left_cost < right_cost { - max_intersecting_ordering = - self.find_max_ordering(right, &new_bounds, max_intersecting_ordering); - index = left; - } else { - max_intersecting_ordering = - self.find_max_ordering(left, &new_bounds, max_intersecting_ordering); - index = right; + ordering + } + + /// Finds the maximum ordering among all bounds that intersect with the query. + fn find_max_ordering(&mut self, query: &Bounds) -> u32 { + let Some(root_idx) = self.root else { + return 0; + }; + + // Fast path: check if the max-ordering leaf intersects + if let Some(max_idx) = self.max_leaf { + let max_node = &self.nodes[max_idx]; + if query.intersects(&max_node.bounds) { + return max_node.max_order; } } - // We've found a leaf ('index' now refers to a leaf node). - // We'll insert a new parent node above the leaf and attach our new leaf to it. - let sibling = index; - - // Check for collision with the located leaf node - let Node::Leaf { - bounds: sibling_bounds, - order: sibling_ordering, - .. - } = &self.nodes[index] - else { - unreachable!(); - }; - if sibling_bounds.intersects(&new_bounds) { - max_intersecting_ordering = cmp::max(max_intersecting_ordering, *sibling_ordering); + // Slow path: search the tree + self.search_stack.clear(); + self.search_stack.push(root_idx); + + let mut max_found = 0u32; + + while let Some(node_idx) = self.search_stack.pop() { + let node = &self.nodes[node_idx]; + + // Pruning: skip if this subtree can't improve our result + if node.max_order <= max_found { + continue; + } + + // Spatial pruning: skip if bounds don't intersect + if !query.intersects(&node.bounds) { + continue; + } + + match &node.kind { + NodeKind::Leaf { order } => { + max_found = cmp::max(max_found, *order); + } + NodeKind::Internal { children } => { + // Children are maintained with highest max_order at the end. + // Push in forward order to highest (last) is popped first. + for &child_idx in children.as_slice() { + if self.nodes[child_idx].max_order > max_found { + self.search_stack.push(child_idx); + } + } + } + } } - let ordering = max_intersecting_ordering + 1; - let new_node = self.push_leaf(new_bounds, ordering); - let new_parent = self.push_internal(sibling, new_node); + max_found + } - // If there was an old parent, we need to update its children indices. - if let Some(old_parent) = self.stack.last().copied() { - let Node::Internal { left, right, .. } = &mut self.nodes[old_parent] else { - unreachable!(); - }; + /// Inserts a leaf node with the given bounds and ordering. + /// Returns the index of the new leaf. + fn insert_leaf(&mut self, bounds: Bounds, order: u32) -> usize { + let new_leaf_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: bounds.clone(), + max_order: order, + kind: NodeKind::Leaf { order }, + }); - if *left == sibling { - *left = new_parent; + let Some(root_idx) = self.root else { + // Tree is empty, new leaf becomes root + self.root = Some(new_leaf_idx); + return new_leaf_idx; + }; + + // If root is a leaf, create internal node with both + if matches!(self.nodes[root_idx].kind, NodeKind::Leaf { .. }) { + let root_bounds = self.nodes[root_idx].bounds.clone(); + let root_order = self.nodes[root_idx].max_order; + + let mut children = NodeChildren::new(); + // Max end invariant + if order > root_order { + children.push(root_idx); + children.push(new_leaf_idx); } else { - *right = new_parent; + children.push(new_leaf_idx); + children.push(root_idx); } - } else { - // If the old parent was the root, the new parent is the new root. - self.root = Some(new_parent); + + let new_root_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: root_bounds.union(&bounds), + max_order: cmp::max(root_order, order), + kind: NodeKind::Internal { children }, + }); + self.root = Some(new_root_idx); + return new_leaf_idx; } - for node_index in self.stack.drain(..).rev() { - let Node::Internal { - max_order: max_ordering, - .. - } = &mut self.nodes[node_index] - else { - unreachable!() + // Descend to find the best internal node to insert into + self.insert_path.clear(); + let mut current_idx = root_idx; + + loop { + let current = &self.nodes[current_idx]; + let NodeKind::Internal { children } = ¤t.kind else { + unreachable!("Should only traverse internal nodes"); }; - if *max_ordering >= ordering { - break; - } - *max_ordering = ordering; - } - ordering - } + self.insert_path.push(current_idx); + + // Find the best child to descend into + let mut best_child_idx = children.as_slice()[0]; + let mut best_child_pos = 0; + let mut best_cost = bounds + .union(&self.nodes[best_child_idx].bounds) + .half_perimeter(); - fn find_max_ordering(&self, index: usize, bounds: &Bounds, mut max_ordering: u32) -> u32 { - match &self.nodes[index] { - Node::Leaf { - bounds: node_bounds, - order: ordering, - .. - } => { - if bounds.intersects(node_bounds) { - max_ordering = cmp::max(*ordering, max_ordering); + for (pos, &child_idx) in children.as_slice().iter().enumerate().skip(1) { + let cost = bounds.union(&self.nodes[child_idx].bounds).half_perimeter(); + if cost < best_cost { + best_cost = cost; + best_child_idx = child_idx; + best_child_pos = pos; } } - Node::Internal { - left, - right, - bounds: node_bounds, - max_order: node_max_ordering, - .. - } => { - if bounds.intersects(node_bounds) && max_ordering < *node_max_ordering { - let left_max_ordering = self.nodes[*left].max_ordering(); - let right_max_ordering = self.nodes[*right].max_ordering(); - if left_max_ordering > right_max_ordering { - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); + + // Check if best child is a leaf or internal + if matches!(self.nodes[best_child_idx].kind, NodeKind::Leaf { .. }) { + // Best child is a leaf. Check if current node has room for another child. + if children.len() < MAX_CHILDREN { + // Add new leaf directly to this node + let node = &mut self.nodes[current_idx]; + + if let NodeKind::Internal { children } = &mut node.kind { + children.push(new_leaf_idx); + // Swap new leaf only if it has the highest max_order + if order <= node.max_order { + let last = children.len() - 1; + children.indices.swap(last - 1, last); + } + } + + node.bounds = node.bounds.union(&bounds); + node.max_order = cmp::max(node.max_order, order); + break; + } else { + // Node is full, create new internal with [best_leaf, new_leaf] + let sibling_bounds = self.nodes[best_child_idx].bounds.clone(); + let sibling_order = self.nodes[best_child_idx].max_order; + + let mut new_children = NodeChildren::new(); + // Max end invariant + if order > sibling_order { + new_children.push(best_child_idx); + new_children.push(new_leaf_idx); } else { - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); + new_children.push(new_leaf_idx); + new_children.push(best_child_idx); + } + + let new_internal_idx = self.nodes.len(); + let new_internal_max = cmp::max(sibling_order, order); + self.nodes.push(Node { + bounds: sibling_bounds.union(&bounds), + max_order: new_internal_max, + kind: NodeKind::Internal { + children: new_children, + }, + }); + + // Replace the leaf with the new internal in parent + let parent = &mut self.nodes[current_idx]; + if let NodeKind::Internal { children } = &mut parent.kind { + let children_len = children.len(); + + children.indices[best_child_pos] = new_internal_idx; + + // If new internal has highest max_order, swap it to the end + // to maintain sorting invariant + if new_internal_max > parent.max_order { + children.indices.swap(best_child_pos, children_len - 1); + } } + break; } + } else { + // Best child is internal, continue descent + current_idx = best_child_idx; } } - max_ordering - } - fn push_leaf(&mut self, bounds: Bounds, order: u32) -> usize { - self.nodes.push(Node::Leaf { bounds, order }); - self.nodes.len() - 1 - } + // Propagate bounds and max_order updates up the tree + let mut updated_child_idx = None; + for &node_idx in self.insert_path.iter().rev() { + let node = &mut self.nodes[node_idx]; + node.bounds = node.bounds.union(&bounds); - fn push_internal(&mut self, left: usize, right: usize) -> usize { - let left_node = &self.nodes[left]; - let right_node = &self.nodes[right]; - let new_bounds = left_node.bounds().union(right_node.bounds()); - let max_ordering = cmp::max(left_node.max_ordering(), right_node.max_ordering()); - self.nodes.push(Node::Internal { - bounds: new_bounds, - left, - right, - max_order: max_ordering, - }); - self.nodes.len() - 1 + if node.max_order < order { + node.max_order = order; + + // Swap updated child to end (skip first iteration since the invariant is already handled by previous cases) + if let Some(child_idx) = updated_child_idx { + if let NodeKind::Internal { children } = &mut node.kind { + if let Some(pos) = children.as_slice().iter().position(|&c| c == child_idx) + { + let last = children.len() - 1; + if pos != last { + children.indices.swap(pos, last); + } + } + } + } + } + + updated_child_idx = Some(node_idx); + } + + new_leaf_idx } } @@ -187,50 +356,11 @@ where { fn default() -> Self { BoundsTree { - root: None, nodes: Vec::new(), - stack: Vec::new(), - } - } -} - -#[derive(Debug, Clone)] -enum Node -where - U: Clone + Debug + Default + PartialEq, -{ - Leaf { - bounds: Bounds, - order: u32, - }, - Internal { - left: usize, - right: usize, - bounds: Bounds, - max_order: u32, - }, -} - -impl Node -where - U: Clone + Debug + Default + PartialEq, -{ - fn bounds(&self) -> &Bounds { - match self { - Node::Leaf { bounds, .. } => bounds, - Node::Internal { bounds, .. } => bounds, - } - } - - fn max_ordering(&self) -> u32 { - match self { - Node::Leaf { - order: ordering, .. - } => *ordering, - Node::Internal { - max_order: max_ordering, - .. - } => *max_ordering, + root: None, + max_leaf: None, + insert_path: Vec::new(), + search_stack: Vec::new(), } } } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 821f155f96d168e5319d9a8981ca4be75df7b854..952cef7f58e6628c71b517ff74735b092edc37f7 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -20,8 +20,8 @@ use crate::{ DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, - MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow, - ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, + MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, size, }; @@ -166,6 +166,38 @@ impl Interactivity { })); } + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn capture_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Capture && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + /// Bind the given callback to the mouse up event for the given button, during the bubble phase. /// The imperative API equivalent to [`InteractiveElement::on_mouse_up`]. /// @@ -622,7 +654,7 @@ pub trait InteractiveElement: Sized { /// Set whether this element is a tab stop. /// /// When false, the element remains in tab-index order but cannot be reached via keyboard navigation. - /// Useful for container elements: focus the container, then call `window.focus_next()` to focus + /// Useful for container elements: focus the container, then call `window.focus_next(cx)` to focus /// the first tab stop inside it while having the container element itself be unreachable via the keyboard. /// Should only be used with `tab_index`. fn tab_stop(mut self, tab_stop: bool) -> Self { @@ -769,6 +801,30 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_mouse_pressure(listener); + self + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn capture_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_mouse_pressure(listener); + self + } + /// Bind the given callback to the mouse down event, on any button, during the capture phase, /// when the mouse is outside of the bounds of this element. /// The fluent API equivalent to [`Interactivity::on_mouse_down_out`]. @@ -1197,7 +1253,8 @@ pub(crate) type MouseDownListener = Box; pub(crate) type MouseUpListener = Box; - +pub(crate) type MousePressureListener = + Box; pub(crate) type MouseMoveListener = Box; @@ -1521,6 +1578,7 @@ pub struct Interactivity { pub(crate) group_drag_over_styles: Vec<(TypeId, GroupStyle)>, pub(crate) mouse_down_listeners: Vec, pub(crate) mouse_up_listeners: Vec, + pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, pub(crate) key_down_listeners: Vec, @@ -1672,6 +1730,11 @@ impl Interactivity { let clicked_state = clicked_state.borrow(); self.active = Some(clicked_state.element); } + if self.hover_style.is_some() || self.group_hover_style.is_some() { + element_state + .hover_state + .get_or_insert_with(Default::default); + } if let Some(active_tooltip) = element_state.active_tooltip.as_ref() { if self.tooltip_builder.is_some() { self.tooltip_id = set_tooltip_on_window(active_tooltip, window); @@ -1714,6 +1777,7 @@ impl Interactivity { || self.group_hover_style.is_some() || self.hover_listener.is_some() || !self.mouse_up_listeners.is_empty() + || !self.mouse_pressure_listeners.is_empty() || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() || !self.click_listeners.is_empty() @@ -2037,12 +2101,12 @@ impl Interactivity { // This behavior can be suppressed by using `cx.prevent_default()`. if let Some(focus_handle) = self.tracked_focus_handle.clone() { let hitbox = hitbox.clone(); - window.on_mouse_event(move |_: &MouseDownEvent, phase, window, _| { + window.on_mouse_event(move |_: &MouseDownEvent, phase, window, cx| { if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) && !window.default_prevented() { - window.focus(&focus_handle); + window.focus(&focus_handle, cx); // If there is a parent that is also focusable, prevent it // from transferring focus because we already did so. window.prevent_default(); @@ -2064,6 +2128,13 @@ impl Interactivity { }) } + for listener in self.mouse_pressure_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &MousePressureEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.mouse_move_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| { @@ -2083,15 +2154,51 @@ impl Interactivity { || cx.active_drag.is_some() && !self.drag_over_styles.is_empty() { let hitbox = hitbox.clone(); - let was_hovered = hitbox.is_hovered(window); + let hover_state = self.hover_style.as_ref().and_then(|_| { + element_state + .as_ref() + .and_then(|state| state.hover_state.as_ref()) + .cloned() + }); let current_view = window.current_view(); + window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| { let hovered = hitbox.is_hovered(window); + let was_hovered = hover_state + .as_ref() + .is_some_and(|state| state.borrow().element); if phase == DispatchPhase::Capture && hovered != was_hovered { + if let Some(hover_state) = &hover_state { + hover_state.borrow_mut().element = hovered; + } cx.notify(current_view); } }); } + + if let Some(group_hover) = self.group_hover_style.as_ref() { + if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) { + let hover_state = element_state + .as_ref() + .and_then(|element| element.hover_state.as_ref()) + .cloned(); + let current_view = window.current_view(); + + window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| { + let group_hovered = group_hitbox_id.is_hovered(window); + let was_group_hovered = hover_state + .as_ref() + .is_some_and(|state| state.borrow().group); + 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); + } + }); + } + } + let drag_cursor_style = self.base_style.as_ref().mouse_cursor; let mut drag_listener = mem::take(&mut self.drag_listener); @@ -2280,8 +2387,8 @@ impl Interactivity { && hitbox.is_hovered(window); let mut was_hovered = was_hovered.borrow_mut(); - if is_hovered != *was_hovered { - *was_hovered = is_hovered; + if is_hovered != was_hovered.element { + was_hovered.element = is_hovered; drop(was_hovered); hover_listener(&is_hovered, window, cx); @@ -2514,22 +2621,46 @@ impl Interactivity { } } - if let Some(hitbox) = hitbox { - if !cx.has_active_drag() { - if let Some(group_hover) = self.group_hover_style.as_ref() - && let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) - && group_hitbox_id.is_hovered(window) - { + if !cx.has_active_drag() { + if let Some(group_hover) = self.group_hover_style.as_ref() { + let is_group_hovered = + if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) { + group_hitbox_id.is_hovered(window) + } else if let Some(element_state) = element_state.as_ref() { + element_state + .hover_state + .as_ref() + .map(|state| state.borrow().group) + .unwrap_or(false) + } else { + false + }; + + if is_group_hovered { style.refine(&group_hover.style); } + } - if let Some(hover_style) = self.hover_style.as_ref() - && hitbox.is_hovered(window) - { + if let Some(hover_style) = self.hover_style.as_ref() { + let is_hovered = if let Some(hitbox) = hitbox { + hitbox.is_hovered(window) + } else if let Some(element_state) = element_state.as_ref() { + element_state + .hover_state + .as_ref() + .map(|state| state.borrow().element) + .unwrap_or(false) + } else { + false + }; + + if is_hovered { style.refine(hover_style); } } + } + if let Some(hitbox) = hitbox { if let Some(drag) = cx.active_drag.take() { let mut can_drop = true; if let Some(can_drop_predicate) = &self.can_drop_predicate { @@ -2588,7 +2719,7 @@ impl Interactivity { pub struct InteractiveElementState { pub(crate) focus_handle: Option, pub(crate) clicked_state: Option>>, - pub(crate) hover_state: Option>>, + pub(crate) hover_state: Option>>, pub(crate) pending_mouse_down: Option>>>, pub(crate) scroll_offset: Option>>>, pub(crate) active_tooltip: Option>>>, @@ -2610,6 +2741,16 @@ impl ElementClickedState { } } +/// Whether or not the element or a group that contains it is hovered. +#[derive(Copy, Clone, Default, Eq, PartialEq)] +pub struct ElementHoverState { + /// True if this element's group is hovered, false otherwise + pub group: bool, + + /// True if this element is hovered, false otherwise + pub element: bool, +} + pub(crate) enum ActiveTooltip { /// Currently delaying before showing the tooltip. WaitingForShow { _task: Task<()> }, @@ -3193,7 +3334,11 @@ impl ScrollHandle { match active_item.strategy { ScrollStrategy::FirstVisible => { if state.overflow.y == Overflow::Scroll { - if bounds.top() + scroll_offset.y < state.bounds.top() { + let child_height = bounds.size.height; + let viewport_height = state.bounds.size.height; + if child_height > viewport_height { + scroll_offset.y = state.bounds.top() - bounds.top(); + } else if bounds.top() + scroll_offset.y < state.bounds.top() { scroll_offset.y = state.bounds.top() - bounds.top(); } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() { scroll_offset.y = state.bounds.bottom() - bounds.bottom(); @@ -3206,7 +3351,11 @@ impl ScrollHandle { } if state.overflow.x == Overflow::Scroll { - if bounds.left() + scroll_offset.x < state.bounds.left() { + let child_width = bounds.size.width; + let viewport_width = state.bounds.size.width; + if child_width > viewport_width { + scroll_offset.x = state.bounds.left() - bounds.left(); + } else if bounds.left() + scroll_offset.x < state.bounds.left() { scroll_offset.x = state.bounds.left() - bounds.left(); } else if bounds.right() + scroll_offset.x > state.bounds.right() { scroll_offset.x = state.bounds.right() - bounds.right(); @@ -3268,3 +3417,46 @@ impl ScrollHandle { self.0.borrow().child_bounds.len() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_handle_aligns_wide_children_to_left_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(80.), px(20.))); + state.child_bounds = vec![Bounds::new(point(px(25.), px(0.)), size(px(200.), px(20.)))]; + state.overflow.x = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().x, px(-25.)); + } + + #[test] + fn scroll_handle_aligns_tall_children_to_top_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(20.), px(80.))); + state.child_bounds = vec![Bounds::new(point(px(0.), px(25.)), size(px(20.), px(200.)))]; + state.overflow.y = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().y, px(-25.)); + } +} diff --git a/crates/gpui/src/elements/surface.rs b/crates/gpui/src/elements/surface.rs index b4fced1001b3f9881b66f2f93e81588c750aa64c..ac1c247b47ec81bca06e458827786f549ca2d747 100644 --- a/crates/gpui/src/elements/surface.rs +++ b/crates/gpui/src/elements/surface.rs @@ -29,6 +29,7 @@ pub struct Surface { } /// Create a new surface element. +#[cfg(target_os = "macos")] pub fn surface(source: impl Into) -> Surface { Surface { source: source.into(), diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 914e8a286510a2ffd833db4c4d3ef85c84db073f..770c1f871432afbecc9ffd4e903dfeddcfcba6ee 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -2,10 +2,11 @@ use crate::{ ActiveTooltip, AnyView, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId, HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextOverflow, - TextRun, TextStyle, TooltipId, WhiteSpace, Window, WrappedLine, WrappedLineLayout, - register_tooltip_mouse_handlers, set_tooltip_on_window, + TextRun, TextStyle, TooltipId, TruncateFrom, WhiteSpace, Window, WrappedLine, + WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window, }; use anyhow::Context as _; +use itertools::Itertools; use smallvec::SmallVec; use std::{ borrow::Cow, @@ -353,7 +354,7 @@ impl TextLayout { None }; - let (truncate_width, truncation_suffix) = + let (truncate_width, truncation_affix, truncate_from) = if let Some(text_overflow) = text_style.text_overflow.clone() { let width = known_dimensions.width.or(match available_space.width { crate::AvailableSpace::Definite(x) => match text_style.line_clamp { @@ -364,17 +365,24 @@ impl TextLayout { }); match text_overflow { - TextOverflow::Truncate(s) => (width, s), + TextOverflow::Truncate(s) => (width, s, TruncateFrom::End), + TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start), } } else { - (None, "".into()) + (None, "".into(), TruncateFrom::End) }; + // Only use cached layout if: + // 1. We have a cached size + // 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) if let Some(text_layout) = element_state.0.borrow().as_ref() - && text_layout.size.is_some() + && let Some(size) = text_layout.size && (wrap_width.is_none() || wrap_width == text_layout.wrap_width) + && truncate_width.is_none() { - return text_layout.size.unwrap(); + return size; } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); @@ -382,8 +390,9 @@ impl TextLayout { line_wrapper.truncate_line( text.clone(), truncate_width, - &truncation_suffix, + &truncation_affix, &runs, + truncate_from, ) } else { (text.clone(), Cow::Borrowed(&*runs)) @@ -597,14 +606,14 @@ impl TextLayout { .unwrap() .lines .iter() - .map(|s| s.text.to_string()) - .collect::>() + .map(|s| &s.text) .join("\n") } /// The text for this layout (with soft-wraps as newlines) pub fn wrapped_text(&self) -> String { - let mut lines = Vec::new(); + let mut accumulator = String::new(); + for wrapped in self.0.borrow().as_ref().unwrap().lines.iter() { let mut seen = 0; for boundary in wrapped.layout.wrap_boundaries.iter() { @@ -612,13 +621,16 @@ impl TextLayout { [boundary.glyph_ix] .index; - lines.push(wrapped.text[seen..index].to_string()); + accumulator.push_str(&wrapped.text[seen..index]); + accumulator.push('\n'); seen = index; } - lines.push(wrapped.text[seen..].to_string()); + accumulator.push_str(&wrapped.text[seen..]); + accumulator.push('\n'); } - - lines.join("\n") + // Remove trailing newline + accumulator.pop(); + accumulator } } diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 1e38b0e7ac9abcf891201b7db61b819abe00ef1e..a7486f0c00ac4e11ef807af90f6fb75b74b5d142 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -712,8 +712,8 @@ mod test { #[gpui::test] fn test_scroll_strategy_nearest(cx: &mut TestAppContext) { use crate::{ - Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, actions, div, - prelude::*, px, uniform_list, + Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, div, prelude::*, + px, uniform_list, }; use std::ops::Range; @@ -788,7 +788,7 @@ mod test { let (view, cx) = cx.add_window_view(|window, cx| { let focus_handle = cx.focus_handle(); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); TestView { scroll_handle: UniformListScrollHandle::new(), index: 0, diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index c0aa978c8eb0b217aa1cf7cd734664dc0736c355..6c2ecb341ff2fe446efd7823c107fd32a557feb5 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,6 +1,7 @@ -use crate::{App, PlatformDispatcher, RunnableMeta, RunnableVariant}; +use crate::{App, PlatformDispatcher, RunnableMeta, RunnableVariant, TaskTiming, profiler}; use async_task::Runnable; use futures::channel::mpsc; +use parking_lot::{Condvar, Mutex}; use smol::prelude::*; use std::{ fmt::Debug, @@ -46,6 +47,52 @@ pub struct ForegroundExecutor { not_send: PhantomData>, } +/// Realtime task priority +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum RealtimePriority { + /// Audio task + Audio, + /// Other realtime task + #[default] + Other, +} + +/// Task priority +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum Priority { + /// Realtime priority + /// + /// Spawning a task with this priority will spin it off on a separate thread dedicated just to that task. + Realtime(RealtimePriority), + /// High priority + /// + /// Only use for tasks that are critical to the user experience / responsiveness of the editor. + High, + /// Medium priority, probably suits most of your use cases. + #[default] + Medium, + /// Low priority + /// + /// Prioritize this for background work that can come in large quantities + /// to not starve the executor of resources for high priority tasks + Low, +} + +impl Priority { + #[allow(dead_code)] + pub(crate) const fn probability(&self) -> u32 { + match self { + // realtime priorities are not considered for probability scheduling + Priority::Realtime(_) => 0, + Priority::High => 60, + Priority::Medium => 30, + Priority::Low => 10, + } + } +} + /// Task is a primitive that allows work to happen in the background. /// /// It implements [`Future`] so you can `.await` on it. @@ -151,7 +198,77 @@ impl BackgroundExecutor { where R: Send + 'static, { - self.spawn_internal::(Box::pin(future), None) + self.spawn_with_priority(Priority::default(), future) + } + + /// Enqueues the given future to be run to completion on a background thread. + #[track_caller] + pub fn spawn_with_priority( + &self, + priority: Priority, + future: impl Future + Send + 'static, + ) -> Task + where + R: Send + 'static, + { + self.spawn_internal::(Box::pin(future), None, priority) + } + + /// Enqueues the given future to be run to completion on a background thread and blocking the current task on it. + /// + /// This allows to spawn background work that borrows from its scope. Note that the supplied future will run to + /// completion before the current task is resumed, even if the current task is slated for cancellation. + pub async fn await_on_background(&self, future: impl Future + Send) -> R + where + R: Send, + { + // We need to ensure that cancellation of the parent task does not drop the environment + // before the our own task has completed or got cancelled. + struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); + + impl Drop for NotifyOnDrop<'_> { + fn drop(&mut self) { + *self.0.1.lock() = true; + self.0.0.notify_all(); + } + } + + struct WaitOnDrop<'a>(&'a (Condvar, Mutex)); + + impl Drop for WaitOnDrop<'_> { + fn drop(&mut self) { + let mut done = self.0.1.lock(); + if !*done { + self.0.0.wait(&mut done); + } + } + } + + let dispatcher = self.dispatcher.clone(); + let location = core::panic::Location::caller(); + + let pair = &(Condvar::new(), Mutex::new(false)); + let _wait_guard = WaitOnDrop(pair); + + let (runnable, task) = unsafe { + async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn_unchecked( + move |_| async { + let _notify_guard = NotifyOnDrop(pair); + future.await + }, + move |runnable| { + dispatcher.dispatch( + RunnableVariant::Meta(runnable), + None, + Priority::default(), + ) + }, + ) + }; + runnable.schedule(); + task.await } /// Enqueues the given future to be run to completion on a background thread. @@ -165,7 +282,7 @@ impl BackgroundExecutor { where R: Send + 'static, { - self.spawn_internal::(Box::pin(future), Some(label)) + self.spawn_internal::(Box::pin(future), Some(label), Priority::default()) } #[track_caller] @@ -173,15 +290,65 @@ impl BackgroundExecutor { &self, future: AnyFuture, label: Option, + #[cfg_attr( + target_os = "windows", + expect( + unused_variables, + reason = "Multi priority scheduler is broken on windows" + ) + )] + priority: Priority, ) -> Task { let dispatcher = self.dispatcher.clone(); - let location = core::panic::Location::caller(); - let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn( - move |_| future, - move |runnable| dispatcher.dispatch(RunnableVariant::Meta(runnable), label), + #[cfg(target_os = "windows")] + let priority = Priority::Medium; // multi-prio scheduler is broken on windows + + let (runnable, task) = if let Priority::Realtime(realtime) = priority { + let location = core::panic::Location::caller(); + let (mut tx, rx) = flume::bounded::>(1); + + dispatcher.spawn_realtime( + realtime, + Box::new(move || { + while let Ok(runnable) = rx.recv() { + let start = Instant::now(); + let location = runnable.metadata().location; + let mut timing = TaskTiming { + location, + start, + end: None, + }; + profiler::add_task_timing(timing); + + runnable.run(); + + let end = Instant::now(); + timing.end = Some(end); + profiler::add_task_timing(timing); + } + }), ); + + async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn( + move |_| future, + move |runnable| { + let _ = tx.send(runnable); + }, + ) + } else { + let location = core::panic::Location::caller(); + async_task::Builder::new() + .metadata(RunnableMeta { location }) + .spawn( + move |_| future, + move |runnable| { + dispatcher.dispatch(RunnableVariant::Meta(runnable), label, priority) + }, + ) + }; + runnable.schedule(); Task(TaskState::Spawned(task)) } @@ -354,11 +521,28 @@ impl BackgroundExecutor { where F: FnOnce(&mut Scope<'scope>), { - let mut scope = Scope::new(self.clone()); + let mut scope = Scope::new(self.clone(), Priority::default()); (scheduler)(&mut scope); let spawned = mem::take(&mut scope.futures) .into_iter() - .map(|f| self.spawn(f)) + .map(|f| self.spawn_with_priority(scope.priority, f)) + .collect::>(); + for task in spawned { + task.await; + } + } + + /// Scoped lets you start a number of tasks and waits + /// for all of them to complete before returning. + pub async fn scoped_priority<'scope, F>(&self, priority: Priority, scheduler: F) + where + F: FnOnce(&mut Scope<'scope>), + { + let mut scope = Scope::new(self.clone(), priority); + (scheduler)(&mut scope); + let spawned = mem::take(&mut scope.futures) + .into_iter() + .map(|f| self.spawn_with_priority(scope.priority, f)) .collect::>(); for task in spawned { task.await; @@ -494,6 +678,19 @@ impl ForegroundExecutor { /// Enqueues the given Task to run on the main thread at some point in the future. #[track_caller] pub fn spawn(&self, future: impl Future + 'static) -> Task + where + R: 'static, + { + self.spawn_with_priority(Priority::default(), future) + } + + /// Enqueues the given Task to run on the main thread at some point in the future. + #[track_caller] + pub fn spawn_with_priority( + &self, + priority: Priority, + future: impl Future + 'static, + ) -> Task where R: 'static, { @@ -505,16 +702,19 @@ impl ForegroundExecutor { dispatcher: Arc, future: AnyLocalFuture, location: &'static core::panic::Location<'static>, + priority: Priority, ) -> Task { let (runnable, task) = spawn_local_with_source_location( future, - move |runnable| dispatcher.dispatch_on_main_thread(RunnableVariant::Meta(runnable)), + move |runnable| { + dispatcher.dispatch_on_main_thread(RunnableVariant::Meta(runnable), priority) + }, RunnableMeta { location }, ); runnable.schedule(); Task(TaskState::Spawned(task)) } - inner::(dispatcher, Box::pin(future), location) + inner::(dispatcher, Box::pin(future), location, priority) } } @@ -590,6 +790,7 @@ where /// Scope manages a set of tasks that are enqueued and waited on together. See [`BackgroundExecutor::scoped`]. pub struct Scope<'a> { executor: BackgroundExecutor, + priority: Priority, futures: Vec + Send + 'static>>>, tx: Option>, rx: mpsc::Receiver<()>, @@ -597,10 +798,11 @@ pub struct Scope<'a> { } impl<'a> Scope<'a> { - fn new(executor: BackgroundExecutor) -> Self { + fn new(executor: BackgroundExecutor, priority: Priority) -> Self { let (tx, rx) = mpsc::channel(1); Self { executor, + priority, tx: Some(tx), rx, futures: Default::default(), diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 859ecb3d0e6c7b5c33f5765ce4c6295cef7fd566..fc735ba5e0e7e719ed12b6b1b168ec3ee49e22bb 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -1416,9 +1416,9 @@ where /// ``` pub fn contains(&self, point: &Point) -> bool { point.x >= self.origin.x - && point.x <= self.origin.x.clone() + self.size.width.clone() + && point.x < self.origin.x.clone() + self.size.width.clone() && point.y >= self.origin.y - && point.y <= self.origin.y.clone() + self.size.height.clone() + && point.y < self.origin.y.clone() + self.size.height.clone() } /// Checks if this bounds is completely contained within another bounds. @@ -2648,6 +2648,18 @@ impl Debug for Pixels { } } +impl std::iter::Sum for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + b) + } +} + +impl<'a> std::iter::Sum<&'a Pixels> for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + *b) + } +} + impl TryFrom<&'_ str> for Pixels { type Error = anyhow::Error; @@ -3567,7 +3579,7 @@ pub const fn relative(fraction: f32) -> DefiniteLength { } /// Returns the Golden Ratio, i.e. `~(1.0 + sqrt(5.0)) / 2.0`. -pub fn phi() -> DefiniteLength { +pub const fn phi() -> DefiniteLength { relative(1.618_034) } @@ -3580,7 +3592,7 @@ pub fn phi() -> DefiniteLength { /// # Returns /// /// A `Rems` representing the specified number of rems. -pub fn rems(rems: f32) -> Rems { +pub const fn rems(rems: f32) -> Rems { Rems(rems) } @@ -3608,7 +3620,7 @@ pub const fn px(pixels: f32) -> Pixels { /// # Returns /// /// A `Length` variant set to `Auto`. -pub fn auto() -> Length { +pub const fn auto() -> Length { Length::Auto } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index bc70362047d7826519f6f7c734b7c5a84281b31f..76a61e286d3fe6c1acae8e4e628d4c9130f1305f 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -31,6 +31,8 @@ mod path_builder; mod platform; pub mod prelude; mod profiler; +#[cfg(target_os = "linux")] +mod queue; mod scene; mod shared_string; mod shared_uri; @@ -89,16 +91,20 @@ pub use keymap::*; pub use path_builder::*; pub use platform::*; pub use profiler::*; +#[cfg(target_os = "linux")] +pub(crate) use queue::{PriorityQueueReceiver, PriorityQueueSender}; pub use refineable::*; pub use scene::*; pub use shared_string::*; pub use shared_uri::*; pub use smol::Timer; +use std::{any::Any, future::Future}; pub use style::*; pub use styled::*; pub use subscription::*; pub use svg_renderer::*; pub(crate) use tab_stop::*; +use taffy::TaffyLayoutEngine; pub use taffy::{AvailableSpace, LayoutId}; #[cfg(any(test, feature = "test-support"))] pub use test::*; @@ -109,9 +115,6 @@ pub use util::{FutureExt, Timeout, arc_cow::ArcCow}; pub use view::*; pub use window::*; -use std::{any::Any, future::Future}; -use taffy::TaffyLayoutEngine; - /// The context trait, allows the different contexts in GPUI to be used /// interchangeably for certain operations. pub trait AppContext { diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 03acf81addaad1ae9800ef476a2dc7d13e690cf7..a500ac46f0bbf96fc2b9d326a3a61da42c40b7ec 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -174,6 +174,40 @@ pub struct MouseClickEvent { pub up: MouseUpEvent, } +/// The stage of a pressure click event. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum PressureStage { + /// No pressure. + #[default] + Zero, + /// Normal click pressure. + Normal, + /// High pressure, enough to trigger a force click. + Force, +} + +/// A mouse pressure event from the platform. Generated when a force-sensitive trackpad is pressed hard. +/// Currently only implemented for macOS trackpads. +#[derive(Debug, Clone, Default)] +pub struct MousePressureEvent { + /// Pressure of the current stage as a float between 0 and 1 + pub pressure: f32, + /// The pressure stage of the event. + pub stage: PressureStage, + /// The position of the mouse on the window. + pub position: Point, + /// The modifiers that were held down when the mouse pressure changed. + pub modifiers: Modifiers, +} + +impl Sealed for MousePressureEvent {} +impl InputEvent for MousePressureEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::MousePressure(self) + } +} +impl MouseEvent for MousePressureEvent {} + /// A click event that was generated by a keyboard button being pressed and released. #[derive(Clone, Debug, Default)] pub struct KeyboardClickEvent { @@ -571,6 +605,8 @@ pub enum PlatformInput { MouseDown(MouseDownEvent), /// The mouse was released. MouseUp(MouseUpEvent), + /// Mouse pressure. + MousePressure(MousePressureEvent), /// The mouse was moved. MouseMove(MouseMoveEvent), /// The mouse exited the window. @@ -590,6 +626,7 @@ impl PlatformInput { PlatformInput::MouseDown(event) => Some(event), PlatformInput::MouseUp(event) => Some(event), PlatformInput::MouseMove(event) => Some(event), + PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), @@ -604,6 +641,7 @@ impl PlatformInput { PlatformInput::MouseDown(_) => None, PlatformInput::MouseUp(_) => None, PlatformInput::MouseMove(_) => None, + PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, PlatformInput::FileDrop(_) => None, @@ -667,8 +705,8 @@ mod test { }); window - .update(cx, |test_view, window, _cx| { - window.focus(&test_view.focus_handle) + .update(cx, |test_view, window, cx| { + window.focus(&test_view.focus_handle, cx) }) .unwrap(); diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index ae4553408fa8d0dc7ed640319ae0b0a178465b74..1b92b9fe3ffabdbeec4bc7450adc1439e8e223eb 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -462,6 +462,17 @@ impl DispatchTree { (bindings, partial, context_stack) } + /// Find the bindings that can follow the current input sequence. + pub fn possible_next_bindings_for_input( + &self, + input: &[Keystroke], + context_stack: &[KeyContext], + ) -> Vec { + self.keymap + .borrow() + .possible_next_bindings_for_input(input, context_stack) + } + /// dispatch_key processes the keystroke /// input should be set to the value of `pending` from the previous call to dispatch_key. /// This returns three instructions to the input handler: @@ -610,8 +621,8 @@ impl DispatchTree { #[cfg(test)] mod tests { use crate::{ - self as gpui, DispatchResult, Element, ElementId, GlobalElementId, InspectorElementId, - Keystroke, LayoutId, Style, + self as gpui, AppContext, DispatchResult, Element, ElementId, GlobalElementId, + InspectorElementId, Keystroke, LayoutId, Style, }; use core::panic; use smallvec::SmallVec; @@ -619,8 +630,8 @@ mod tests { use crate::{ Action, ActionRegistry, App, Bounds, Context, DispatchTree, FocusHandle, InputHandler, - IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, TestAppContext, - UTF16Selection, Window, + IntoElement, KeyBinding, KeyContext, Keymap, Pixels, Point, Render, Subscription, + TestAppContext, UTF16Selection, Window, }; #[derive(PartialEq, Eq)] @@ -723,6 +734,213 @@ mod tests { assert!(!result.pending_has_binding); } + #[crate::test] + fn test_pending_input_observers_notified_on_focus_change(cx: &mut TestAppContext) { + #[derive(Clone)] + struct CustomElement { + focus_handle: FocusHandle, + text: Rc>, + } + + impl CustomElement { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + text: Rc::default(), + } + } + } + + impl Element for CustomElement { + type RequestLayoutState = (); + + type PrepaintState = (); + + fn id(&self) -> Option { + Some("custom".into()) + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + window.set_focus_handle(&self.focus_handle, cx); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let mut key_context = KeyContext::default(); + key_context.add("Terminal"); + window.set_key_context(key_context); + window.handle_input(&self.focus_handle, self.clone(), cx); + window.on_action(std::any::TypeId::of::(), |_, _, _, _| {}); + } + } + + impl IntoElement for CustomElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl InputHandler for CustomElement { + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { + None + } + + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(text) + } + + fn replace_and_mark_text_in_range( + &mut self, + replacement_range: Option>, + new_text: &str, + _: Option>, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(new_text) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + + fn bounds_for_range( + &mut self, + _: Range, + _: &mut Window, + _: &mut App, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + } + + impl Render for CustomElement { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.clone() + } + } + + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); + }); + + let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); + + let pending_input_changed_count = Rc::new(RefCell::new(0usize)); + let pending_input_changed_count_for_observer = pending_input_changed_count.clone(); + + struct PendingInputObserver { + _subscription: Subscription, + } + + let _observer = cx.update(|window, cx| { + cx.new(|cx| PendingInputObserver { + _subscription: cx.observe_pending_input(window, move |_, _, _| { + *pending_input_changed_count_for_observer.borrow_mut() += 1; + }), + }) + }); + + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + cx.simulate_keystrokes("ctrl-b"); + + let count_after_pending = Rc::new(RefCell::new(0usize)); + let count_after_pending_for_assertion = count_after_pending.clone(); + + cx.update(|window, cx| { + assert!(window.has_pending_keystrokes()); + *count_after_pending.borrow_mut() = *pending_input_changed_count.borrow(); + assert!(*count_after_pending.borrow() > 0); + + window.focus(&cx.focus_handle(), cx); + + assert!(!window.has_pending_keystrokes()); + }); + + // Focus-triggered pending-input notifications are deferred to the end of the current + // effect cycle, so the observer callback should run after the focus update completes. + cx.update(|_, _| { + let count_after_focus_change = *pending_input_changed_count.borrow(); + assert!(count_after_focus_change > *count_after_pending_for_assertion.borrow()); + }); + } + #[crate::test] fn test_input_handler_pending(cx: &mut TestAppContext) { #[derive(Clone)] @@ -876,8 +1094,9 @@ mod tests { cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); }); let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); cx.update(|window, cx| { - window.focus(&test.read(cx).focus_handle); + window.focus(&focus_handle, cx); window.activate_window(); }); cx.simulate_keystrokes("ctrl-b ["); diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index 33d956917055942cce365e9069cbb007e202eaf2..d5398ff0447849ca5bfcdbbb5a838af0cbc22836 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -215,6 +215,41 @@ impl Keymap { Some(contexts.len()) } } + + /// Find the bindings that can follow the current input sequence. + pub fn possible_next_bindings_for_input( + &self, + input: &[Keystroke], + context_stack: &[KeyContext], + ) -> Vec { + let mut bindings = self + .bindings() + .enumerate() + .rev() + .filter_map(|(ix, binding)| { + let depth = self.binding_enabled(binding, context_stack)?; + let pending = binding.match_keystrokes(input); + match pending { + None => None, + Some(is_pending) => { + if !is_pending || is_no_action(&*binding.action) { + return None; + } + Some((depth, BindingIndex(ix), binding)) + } + } + }) + .collect::>(); + + bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| { + depth_b.cmp(depth_a).then(ix_b.cmp(ix_a)) + }); + + bindings + .into_iter() + .map(|(_, _, binding)| binding.clone()) + .collect::>() + } } #[cfg(test)] diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 370043fb6b8ec7f5df251931d1363f577327caaa..112775890ef6e478f0b2d347bc9c9ae56dac3c73 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -39,9 +39,10 @@ use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput, - Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, - ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, TaskLabel, TaskTiming, - ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size, + Point, Priority, RealtimePriority, RenderGlyphParams, RenderImage, RenderImageParams, + RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, + SystemWindowTab, Task, TaskLabel, TaskTiming, ThreadTaskTimings, Window, WindowControlArea, + hash, point, px, size, }; use anyhow::Result; use async_task::Runnable; @@ -261,12 +262,18 @@ pub(crate) trait Platform: 'static { fn set_cursor_style(&self, style: CursorStyle); fn should_auto_hide_scrollbars(&self) -> bool; - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - fn write_to_primary(&self, item: ClipboardItem); + fn read_from_clipboard(&self) -> Option; fn write_to_clipboard(&self, item: ClipboardItem); + #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn read_from_primary(&self) -> Option; - fn read_from_clipboard(&self) -> Option; + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, item: ClipboardItem); + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option; + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem); fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task>; fn read_credentials(&self, url: &str) -> Task)>>>; @@ -289,6 +296,13 @@ pub trait PlatformDisplay: Send + Sync + Debug { /// Get the bounds for this display fn bounds(&self) -> Bounds; + /// Get the visible bounds for this display, excluding taskbar/dock areas. + /// This is the usable area where windows can be placed without being obscured. + /// Defaults to the full display bounds if not overridden. + fn visible_bounds(&self) -> Bounds { + self.bounds() + } + /// Get the default bounds for this display to place a window fn default_bounds(&self) -> Bounds { let bounds = self.bounds(); @@ -580,9 +594,10 @@ pub trait PlatformDispatcher: Send + Sync { fn get_all_timings(&self) -> Vec; fn get_current_thread_timings(&self) -> Vec; fn is_main_thread(&self) -> bool; - fn dispatch(&self, runnable: RunnableVariant, label: Option); - fn dispatch_on_main_thread(&self, runnable: RunnableVariant); + fn dispatch(&self, runnable: RunnableVariant, label: Option, priority: Priority); + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant); + fn spawn_realtime(&self, priority: RealtimePriority, f: Box); fn now(&self) -> Instant { Instant::now() @@ -1339,6 +1354,10 @@ pub enum WindowKind { /// docks, notifications or wallpapers. #[cfg(all(target_os = "linux", feature = "wayland"))] LayerShell(layer_shell::LayerShellOptions), + + /// A window that appears on top of its parent window and blocks interaction with it + /// until the modal window is closed + Dialog, } /// The appearance of the window, as defined by the operating system. diff --git a/crates/gpui/src/platform/linux/dispatcher.rs b/crates/gpui/src/platform/linux/dispatcher.rs index c300109ffe32b3537acbbca47b4c39674cad2fd1..c8ae7269edd495669baa6ab0e22e745917f143b2 100644 --- a/crates/gpui/src/platform/linux/dispatcher.rs +++ b/crates/gpui/src/platform/linux/dispatcher.rs @@ -1,17 +1,21 @@ -use crate::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, RunnableVariant, THREAD_TIMINGS, TaskLabel, - TaskTiming, ThreadTaskTimings, -}; use calloop::{ - EventLoop, + EventLoop, PostAction, channel::{self, Sender}, timer::TimeoutAction, }; +use util::ResultExt; + use std::{ + mem::MaybeUninit, thread, time::{Duration, Instant}, }; -use util::ResultExt; + +use crate::{ + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, + PriorityQueueSender, RealtimePriority, RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, + ThreadTaskTimings, profiler, +}; struct TimerAfter { duration: Duration, @@ -19,27 +23,30 @@ struct TimerAfter { } pub(crate) struct LinuxDispatcher { - main_sender: Sender, + main_sender: PriorityQueueCalloopSender, timer_sender: Sender, - background_sender: flume::Sender, + background_sender: PriorityQueueSender, _background_threads: Vec>, main_thread_id: thread::ThreadId, } +const MIN_THREADS: usize = 2; + impl LinuxDispatcher { - pub fn new(main_sender: Sender) -> Self { - let (background_sender, background_receiver) = flume::unbounded::(); - let thread_count = std::thread::available_parallelism() - .map(|i| i.get()) - .unwrap_or(1); + pub fn new(main_sender: PriorityQueueCalloopSender) -> Self { + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + let thread_count = + std::thread::available_parallelism().map_or(MIN_THREADS, |i| i.get().max(MIN_THREADS)); + // These thread should really be lower prio then the foreground + // executor let mut background_threads = (0..thread_count) .map(|i| { - let receiver = background_receiver.clone(); + let mut receiver = background_receiver.clone(); std::thread::Builder::new() .name(format!("Worker-{i}")) .spawn(move || { - for runnable in receiver { + for runnable in receiver.iter() { let start = Instant::now(); let mut location = match runnable { @@ -50,7 +57,7 @@ impl LinuxDispatcher { start, end: None, }; - Self::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -62,7 +69,7 @@ impl LinuxDispatcher { start, end: None, }; - Self::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -71,7 +78,7 @@ impl LinuxDispatcher { let end = Instant::now(); location.end = Some(end); - Self::add_task_timing(location); + profiler::add_task_timing(location); log::trace!( "background thread {}: ran runnable. took: {:?}", @@ -112,7 +119,7 @@ impl LinuxDispatcher { start, end: None, }; - Self::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -123,7 +130,7 @@ impl LinuxDispatcher { start, end: None, }; - Self::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -132,7 +139,7 @@ impl LinuxDispatcher { let end = Instant::now(); timing.end = Some(end); - Self::add_task_timing(timing); + profiler::add_task_timing(timing); } TimeoutAction::Drop }, @@ -156,22 +163,6 @@ impl LinuxDispatcher { main_thread_id: thread::current().id(), } } - - pub(crate) fn add_task_timing(timing: TaskTiming) { - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - - if let Some(last_timing) = timings.iter_mut().rev().next() { - if last_timing.location == timing.location { - last_timing.end = timing.end; - return; - } - } - - timings.push_back(timing); - }); - } } impl PlatformDispatcher for LinuxDispatcher { @@ -198,22 +189,26 @@ impl PlatformDispatcher for LinuxDispatcher { thread::current().id() == self.main_thread_id } - fn dispatch(&self, runnable: RunnableVariant, _: Option) { - self.background_sender.send(runnable).unwrap(); + fn dispatch(&self, runnable: RunnableVariant, _: Option, priority: Priority) { + self.background_sender + .send(priority, runnable) + .unwrap_or_else(|_| panic!("blocking sender returned without value")); } - fn dispatch_on_main_thread(&self, runnable: RunnableVariant) { - self.main_sender.send(runnable).unwrap_or_else(|runnable| { - // NOTE: Runnable may wrap a Future that is !Send. - // - // This is usually safe because we only poll it on the main thread. - // However if the send fails, we know that: - // 1. main_receiver has been dropped (which implies the app is shutting down) - // 2. we are on a background thread. - // It is not safe to drop something !Send on the wrong thread, and - // the app will exit soon anyway, so we must forget the runnable. - std::mem::forget(runnable); - }); + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + self.main_sender + .send(priority, runnable) + .unwrap_or_else(|runnable| { + // NOTE: Runnable may wrap a Future that is !Send. + // + // This is usually safe because we only poll it on the main thread. + // However if the send fails, we know that: + // 1. main_receiver has been dropped (which implies the app is shutting down) + // 2. we are on a background thread. + // It is not safe to drop something !Send on the wrong thread, and + // the app will exit soon anyway, so we must forget the runnable. + std::mem::forget(runnable); + }); } fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { @@ -221,4 +216,255 @@ impl PlatformDispatcher for LinuxDispatcher { .send(TimerAfter { duration, runnable }) .ok(); } + + fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + std::thread::spawn(move || { + // SAFETY: always safe to call + let thread_id = unsafe { libc::pthread_self() }; + + let policy = match priority { + RealtimePriority::Audio => libc::SCHED_FIFO, + RealtimePriority::Other => libc::SCHED_RR, + }; + let sched_priority = match priority { + RealtimePriority::Audio => 65, + RealtimePriority::Other => 45, + }; + + // SAFETY: all sched_param members are valid when initialized to zero. + let mut sched_param = + unsafe { MaybeUninit::::zeroed().assume_init() }; + sched_param.sched_priority = sched_priority; + // SAFETY: sched_param is a valid initialized structure + let result = unsafe { libc::pthread_setschedparam(thread_id, policy, &sched_param) }; + if result != 0 { + log::warn!("failed to set realtime thread priority to {:?}", priority); + } + + f(); + }); + } +} + +pub struct PriorityQueueCalloopSender { + sender: PriorityQueueSender, + ping: calloop::ping::Ping, +} + +impl PriorityQueueCalloopSender { + fn new(tx: PriorityQueueSender, ping: calloop::ping::Ping) -> Self { + Self { sender: tx, ping } + } + + fn send(&self, priority: Priority, item: T) -> Result<(), crate::queue::SendError> { + let res = self.sender.send(priority, item); + if res.is_ok() { + self.ping.ping(); + } + res + } +} + +impl Drop for PriorityQueueCalloopSender { + fn drop(&mut self) { + self.ping.ping(); + } +} + +pub struct PriorityQueueCalloopReceiver { + receiver: PriorityQueueReceiver, + source: calloop::ping::PingSource, + ping: calloop::ping::Ping, +} + +impl PriorityQueueCalloopReceiver { + pub fn new() -> (PriorityQueueCalloopSender, Self) { + let (ping, source) = calloop::ping::make_ping().expect("Failed to create a Ping."); + + let (tx, rx) = PriorityQueueReceiver::new(); + + ( + PriorityQueueCalloopSender::new(tx, ping.clone()), + Self { + receiver: rx, + source, + ping, + }, + ) + } +} + +use calloop::channel::Event; + +#[derive(Debug)] +pub struct ChannelError(calloop::ping::PingError); + +impl std::fmt::Display for ChannelError { + #[cfg_attr(feature = "nightly_coverage", coverage(off))] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl std::error::Error for ChannelError { + #[cfg_attr(feature = "nightly_coverage", coverage(off))] + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl calloop::EventSource for PriorityQueueCalloopReceiver { + type Event = Event; + type Metadata = (); + type Ret = (); + type Error = ChannelError; + + fn process_events( + &mut self, + readiness: calloop::Readiness, + token: calloop::Token, + mut callback: F, + ) -> Result + where + F: FnMut(Self::Event, &mut Self::Metadata) -> Self::Ret, + { + let mut clear_readiness = false; + let mut disconnected = false; + + let action = self + .source + .process_events(readiness, token, |(), &mut ()| { + let mut is_empty = true; + + let mut receiver = self.receiver.clone(); + for runnable in receiver.try_iter() { + match runnable { + Ok(r) => { + callback(Event::Msg(r), &mut ()); + is_empty = false; + } + Err(_) => { + disconnected = true; + } + } + } + + if disconnected { + callback(Event::Closed, &mut ()); + } + + if is_empty { + clear_readiness = true; + } + }) + .map_err(ChannelError)?; + + if disconnected { + Ok(PostAction::Remove) + } else if clear_readiness { + Ok(action) + } else { + // Re-notify the ping source so we can try again. + self.ping.ping(); + Ok(PostAction::Continue) + } + } + + fn register( + &mut self, + poll: &mut calloop::Poll, + token_factory: &mut calloop::TokenFactory, + ) -> calloop::Result<()> { + self.source.register(poll, token_factory) + } + + fn reregister( + &mut self, + poll: &mut calloop::Poll, + token_factory: &mut calloop::TokenFactory, + ) -> calloop::Result<()> { + self.source.reregister(poll, token_factory) + } + + fn unregister(&mut self, poll: &mut calloop::Poll) -> calloop::Result<()> { + self.source.unregister(poll) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calloop_works() { + let mut event_loop = calloop::EventLoop::try_new().unwrap(); + let handle = event_loop.handle(); + + let (tx, rx) = PriorityQueueCalloopReceiver::new(); + + struct Data { + got_msg: bool, + got_closed: bool, + } + + let mut data = Data { + got_msg: false, + got_closed: false, + }; + + let _channel_token = handle + .insert_source(rx, move |evt, &mut (), data: &mut Data| match evt { + Event::Msg(()) => { + data.got_msg = true; + } + + Event::Closed => { + data.got_closed = true; + } + }) + .unwrap(); + + // nothing is sent, nothing is received + event_loop + .dispatch(Some(::std::time::Duration::ZERO), &mut data) + .unwrap(); + + assert!(!data.got_msg); + assert!(!data.got_closed); + // a message is send + + tx.send(Priority::Medium, ()).unwrap(); + event_loop + .dispatch(Some(::std::time::Duration::ZERO), &mut data) + .unwrap(); + + assert!(data.got_msg); + assert!(!data.got_closed); + + // the sender is dropped + drop(tx); + event_loop + .dispatch(Some(::std::time::Duration::ZERO), &mut data) + .unwrap(); + + assert!(data.got_msg); + assert!(data.got_closed); + } } + +// running 1 test +// test platform::linux::dispatcher::tests::tomato ... FAILED + +// failures: + +// ---- platform::linux::dispatcher::tests::tomato stdout ---- +// [crates/gpui/src/platform/linux/dispatcher.rs:262:9] +// returning 1 tasks to process +// [crates/gpui/src/platform/linux/dispatcher.rs:480:75] evt = Msg( +// (), +// ) +// returning 0 tasks to process + +// thread 'platform::linux::dispatcher::tests::tomato' (478301) panicked at crates/gpui/src/platform/linux/dispatcher.rs:515:9: +// assertion failed: data.got_closed +// note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 21468370c499058af207a7e7b02ca1bbd7563ec1..06a81ec342e9d528a081456583f3ba0f3fb77b6f 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -14,7 +14,7 @@ use std::{ }; use anyhow::{Context as _, anyhow}; -use calloop::{LoopSignal, channel::Channel}; +use calloop::LoopSignal; use futures::channel::oneshot; use util::ResultExt as _; use util::command::{new_smol_command, new_std_command}; @@ -25,8 +25,8 @@ use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, - PlatformTextSystem, PlatformWindow, Point, Result, RunnableVariant, Task, WindowAppearance, - WindowParams, px, + PlatformTextSystem, PlatformWindow, Point, PriorityQueueCalloopReceiver, Result, + RunnableVariant, Task, WindowAppearance, WindowParams, px, }; #[cfg(any(feature = "wayland", feature = "x11"))] @@ -43,6 +43,50 @@ pub(crate) const KEYRING_LABEL: &str = "zed-github-account"; const FILE_PICKER_PORTAL_MISSING: &str = "Couldn't open file picker due to missing xdg-desktop-portal implementation."; +#[cfg(any(feature = "x11", feature = "wayland"))] +pub trait ResultExt { + type Ok; + + fn notify_err(self, msg: &'static str) -> Self::Ok; +} + +#[cfg(any(feature = "x11", feature = "wayland"))] +impl ResultExt for anyhow::Result { + type Ok = T; + + fn notify_err(self, msg: &'static str) -> T { + match self { + Ok(v) => v, + Err(e) => { + use ashpd::desktop::notification::{Notification, NotificationProxy, Priority}; + use futures::executor::block_on; + + let proxy = block_on(NotificationProxy::new()).expect(msg); + + let notification_id = "dev.zed.Oops"; + block_on( + proxy.add_notification( + notification_id, + Notification::new("Zed failed to launch") + .body(Some( + format!( + "{e:?}. See https://zed.dev/docs/linux for troubleshooting steps." + ) + .as_str(), + )) + .priority(Priority::High) + .icon(ashpd::desktop::Icon::with_names(&[ + "dialog-question-symbolic", + ])), + ) + ).expect(msg); + + panic!("{msg}"); + } + } + } +} + pub trait LinuxClient { fn compositor_name(&self) -> &'static str; fn with_common(&self, f: impl FnOnce(&mut LinuxCommon) -> R) -> R; @@ -105,8 +149,8 @@ pub(crate) struct LinuxCommon { } impl LinuxCommon { - pub fn new(signal: LoopSignal) -> (Self, Channel) { - let (main_sender, main_receiver) = calloop::channel::channel::(); + pub fn new(signal: LoopSignal) -> (Self, PriorityQueueCalloopReceiver) { + let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); #[cfg(any(feature = "wayland", feature = "x11"))] let text_system = Arc::new(crate::CosmicTextSystem::new()); @@ -605,8 +649,9 @@ pub(super) fn open_uri_internal( .activation_token(activation_token.clone().map(ashpd::ActivationToken::from)) .send_uri(&uri) .await + .and_then(|e| e.response()) { - Ok(_) => return, + Ok(()) => return, Err(e) => log::error!("Failed to open with dbus: {}", e), } diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index a2324648fbb332e75af7df74923806797d93a05a..b6bfbec0679f9413fceef2bb37e7bd304371707e 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -17,7 +17,7 @@ use collections::HashMap; use filedescriptor::Pipe; use http_client::Url; use smallvec::SmallVec; -use util::ResultExt; +use util::ResultExt as _; use wayland_backend::client::ObjectId; use wayland_backend::protocol::WEnum; use wayland_client::event_created_child; @@ -36,12 +36,6 @@ use wayland_client::{ wl_shm_pool, wl_surface, }, }; -use wayland_protocols::wp::cursor_shape::v1::client::{ - wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1, -}; -use wayland_protocols::wp::fractional_scale::v1::client::{ - wp_fractional_scale_manager_v1, wp_fractional_scale_v1, -}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{ self, ZwpPrimarySelectionOfferV1, }; @@ -61,6 +55,14 @@ use wayland_protocols::xdg::decoration::zv1::client::{ zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, }; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; +use wayland_protocols::{ + wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1}, + xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1}, +}; +use wayland_protocols::{ + wp::fractional_scale::v1::client::{wp_fractional_scale_manager_v1, wp_fractional_scale_v1}, + xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1, +}; use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager}; use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1; @@ -76,11 +78,11 @@ use crate::{ FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, LinuxCommon, LinuxKeyboardLayout, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, - PlatformInput, PlatformKeyboardLayout, Point, SCROLL_LINES, ScrollDelta, ScrollWheelEvent, - Size, TouchPhase, WindowParams, point, px, size, + PlatformInput, PlatformKeyboardLayout, Point, ResultExt as _, SCROLL_LINES, ScrollDelta, + ScrollWheelEvent, Size, TouchPhase, WindowParams, point, profiler, px, size, }; use crate::{ - LinuxDispatcher, RunnableVariant, TaskTiming, + RunnableVariant, TaskTiming, platform::{PlatformWindow, blade::BladeContext}, }; use crate::{ @@ -122,6 +124,7 @@ pub struct Globals { pub layer_shell: Option, pub blur_manager: Option, pub text_input_manager: Option, + pub dialog: Option, pub executor: ForegroundExecutor, } @@ -132,6 +135,7 @@ impl Globals { qh: QueueHandle, seat: wl_seat::WlSeat, ) -> Self { + let dialog_v = XdgWmDialogV1::interface().version; Globals { activation: globals.bind(&qh, 1..=1, ()).ok(), compositor: globals @@ -160,6 +164,7 @@ impl Globals { layer_shell: globals.bind(&qh, 1..=5, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), + dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), executor, qh, } @@ -503,7 +508,7 @@ impl WaylandClient { start, end: None, }; - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -515,7 +520,7 @@ impl WaylandClient { start, end: None, }; - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -524,14 +529,15 @@ impl WaylandClient { let end = Instant::now(); timing.end = Some(end); - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); }); } } }) .unwrap(); - let gpu_context = BladeContext::new().expect("Unable to init GPU context"); + // This could be unified with the notification handling in zed/main:fail_to_open_window. + let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); let seat = seat.unwrap(); let globals = Globals::new( @@ -728,10 +734,7 @@ impl LinuxClient for WaylandClient { ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); - let parent = state - .keyboard_focused_window - .as_ref() - .and_then(|w| w.toplevel()); + let parent = state.keyboard_focused_window.clone(); let (window, surface_id) = WaylandWindow::new( handle, @@ -750,7 +753,12 @@ impl LinuxClient for WaylandClient { fn set_cursor_style(&self, style: CursorStyle) { let mut state = self.0.borrow_mut(); - let need_update = state.cursor_style != Some(style); + let need_update = state.cursor_style != Some(style) + && (state.mouse_focused_window.is_none() + || state + .mouse_focused_window + .as_ref() + .is_some_and(|w| !w.is_blocked())); if need_update { let serial = state.serial_tracker.get(SerialKind::MouseEnter); @@ -1010,7 +1018,7 @@ impl Dispatch for WaylandClientStatePtr { } } -fn get_window( +pub(crate) fn get_window( mut state: &mut RefMut, surface_id: &ObjectId, ) -> Option { @@ -1419,7 +1427,7 @@ impl Dispatch for WaylandClientStatePtr { state.repeat.current_keycode = Some(keycode); let rate = state.repeat.characters_per_second; - let repeat_interval = Duration::from_secs(1) / rate; + let repeat_interval = Duration::from_secs(1) / rate.max(1); let id = state.repeat.current_id; state .loop_handle @@ -1653,6 +1661,30 @@ impl Dispatch for WaylandClientStatePtr { state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); if let Some(window) = state.mouse_focused_window.clone() { + if window.is_blocked() { + let default_style = CursorStyle::Arrow; + if state.cursor_style != Some(default_style) { + let serial = state.serial_tracker.get(SerialKind::MouseEnter); + state.cursor_style = Some(default_style); + + if let Some(cursor_shape_device) = &state.cursor_shape_device { + cursor_shape_device.set_shape(serial, default_style.to_shape()); + } else { + // cursor-shape-v1 isn't supported, set the cursor using a surface. + let wl_pointer = state + .wl_pointer + .clone() + .expect("window is focused by pointer"); + let scale = window.primary_output_scale(); + state.cursor.set_icon( + &wl_pointer, + serial, + default_style.to_icon_names(), + scale, + ); + } + } + } if state .keyboard_focused_window .as_ref() @@ -2224,3 +2256,27 @@ impl Dispatch } } } + +impl Dispatch for WaylandClientStatePtr { + fn event( + _: &mut Self, + _: &XdgWmDialogV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for WaylandClientStatePtr { + fn event( + _state: &mut Self, + _proxy: &XdgDialogV1, + _event: ::Event, + _data: &(), + _conn: &Connection, + _qhandle: &QueueHandle, + ) { + } +} diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 3334ae28a31927b2150e79fc513855fa699c55ba..6b4dad3b3917d025a80594b5ece63c26bbadde69 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -7,7 +7,7 @@ use std::{ }; use blade_graphics as gpu; -use collections::HashMap; +use collections::{FxHashSet, HashMap}; use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; @@ -20,7 +20,7 @@ use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; use wayland_protocols::{ wp::fractional_scale::v1::client::wp_fractional_scale_v1, - xdg::shell::client::xdg_toplevel::XdgToplevel, + xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1, }; use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; @@ -29,7 +29,7 @@ use crate::{ AnyWindowHandle, Bounds, Decorations, Globals, GpuSpecs, Modifiers, Output, Pixels, PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, + WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, get_window, layer_shell::LayerShellNotSupportedError, px, size, }; use crate::{ @@ -87,6 +87,8 @@ struct InProgressConfigure { pub struct WaylandWindowState { surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, + parent: Option, + children: FxHashSet, pub surface: wl_surface::WlSurface, app_id: Option, appearance: WindowAppearance, @@ -126,7 +128,7 @@ impl WaylandSurfaceState { surface: &wl_surface::WlSurface, globals: &Globals, params: &WindowParams, - parent: Option, + parent: Option, ) -> anyhow::Result { // For layer_shell windows, create a layer surface instead of an xdg surface if let WindowKind::LayerShell(options) = ¶ms.kind { @@ -178,10 +180,28 @@ impl WaylandSurfaceState { .get_xdg_surface(&surface, &globals.qh, surface.id()); let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id()); - if params.kind == WindowKind::Floating { - toplevel.set_parent(parent.as_ref()); + let xdg_parent = parent.as_ref().and_then(|w| w.toplevel()); + + if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog { + toplevel.set_parent(xdg_parent.as_ref()); } + let dialog = if params.kind == WindowKind::Dialog { + let dialog = globals.dialog.as_ref().map(|dialog| { + let xdg_dialog = dialog.get_xdg_dialog(&toplevel, &globals.qh, ()); + xdg_dialog.set_modal(); + xdg_dialog + }); + + if let Some(parent) = parent.as_ref() { + parent.add_child(surface.id()); + } + + dialog + } else { + None + }; + if let Some(size) = params.window_min_size { toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32); } @@ -198,6 +218,7 @@ impl WaylandSurfaceState { xdg_surface, toplevel, decoration, + dialog, })) } } @@ -206,6 +227,7 @@ pub struct WaylandXdgSurfaceState { xdg_surface: xdg_surface::XdgSurface, toplevel: xdg_toplevel::XdgToplevel, decoration: Option, + dialog: Option, } pub struct WaylandLayerSurfaceState { @@ -258,7 +280,13 @@ impl WaylandSurfaceState { xdg_surface, toplevel, decoration: _decoration, + dialog, }) => { + // drop the dialog before toplevel so compositor can explicitly unapply it's effects + if let Some(dialog) = dialog { + dialog.destroy(); + } + // The role object (toplevel) must always be destroyed before the xdg_surface. // See https://wayland.app/protocols/xdg-shell#xdg_surface:request:destroy toplevel.destroy(); @@ -288,6 +316,7 @@ impl WaylandWindowState { globals: Globals, gpu_context: &BladeContext, options: WindowParams, + parent: Option, ) -> anyhow::Result { let renderer = { let raw_window = RawWindow { @@ -319,6 +348,8 @@ impl WaylandWindowState { Ok(Self { surface_state, acknowledged_first_configure: false, + parent, + children: FxHashSet::default(), surface, app_id: None, blur: None, @@ -391,6 +422,10 @@ impl Drop for WaylandWindow { fn drop(&mut self) { let mut state = self.0.state.borrow_mut(); let surface_id = state.surface.id(); + if let Some(parent) = state.parent.as_ref() { + parent.state.borrow_mut().children.remove(&surface_id); + } + let client = state.client.clone(); state.renderer.destroy(); @@ -448,10 +483,10 @@ impl WaylandWindow { client: WaylandClientStatePtr, params: WindowParams, appearance: WindowAppearance, - parent: Option, + parent: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent)?; + let surface_state = WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone())?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); @@ -473,6 +508,7 @@ impl WaylandWindow { globals, gpu_context, params, + parent, )?)), callbacks: Rc::new(RefCell::new(Callbacks::default())), }); @@ -501,6 +537,16 @@ impl WaylandWindowStatePtr { Rc::ptr_eq(&self.state, &other.state) } + pub fn add_child(&self, child: ObjectId) { + let mut state = self.state.borrow_mut(); + state.children.insert(child); + } + + pub fn is_blocked(&self) -> bool { + let state = self.state.borrow(); + !state.children.is_empty() + } + pub fn frame(&self) { let mut state = self.state.borrow_mut(); state.surface.frame(&state.globals.qh, state.surface.id()); @@ -818,6 +864,9 @@ impl WaylandWindowStatePtr { } pub fn handle_ime(&self, ime: ImeInput) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -894,6 +943,21 @@ impl WaylandWindowStatePtr { } pub fn close(&self) { + let state = self.state.borrow(); + let client = state.client.get_client(); + #[allow(clippy::mutable_key_type)] + let children = state.children.clone(); + drop(state); + + for child in children { + let mut client_state = client.borrow_mut(); + let window = get_window(&mut client_state, &child); + drop(client_state); + + if let Some(child) = window { + child.close(); + } + } let mut callbacks = self.callbacks.borrow_mut(); if let Some(fun) = callbacks.close.take() { fun() @@ -901,6 +965,9 @@ impl WaylandWindowStatePtr { } pub fn handle_input(&self, input: PlatformInput) { + if self.is_blocked() { + return; + } if let Some(ref mut fun) = self.callbacks.borrow_mut().input && !fun(input.clone()).propagate { @@ -1025,13 +1092,26 @@ impl PlatformWindow for WaylandWindow { fn resize(&mut self, size: Size) { let state = self.borrow(); let state_ptr = self.0.clone(); - let dp_size = size.to_device_pixels(self.scale_factor()); + + // Keep window geometry consistent with configure handling. On Wayland, window geometry is + // surface-local: resizing should not attempt to translate the window; the compositor + // controls placement. We also account for client-side decoration insets and tiling. + let window_geometry = inset_by_tiling( + Bounds { + origin: Point::default(), + size, + }, + state.inset(), + state.tiling, + ) + .map(|v| v.0 as i32) + .map_size(|v| if v <= 0 { 1 } else { v }); state.surface_state.set_geometry( - state.bounds.origin.x.0 as i32, - state.bounds.origin.y.0 as i32, - dp_size.width.0, - dp_size.height.0, + window_geometry.origin.x, + window_geometry.origin.y, + window_geometry.size.width, + window_geometry.size.height, ); state diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index 32f50cdf5d9d9439909c7ecaf35df0d75a9c9eae..0de5ff02f7d0895da05dfa480bff2e19abff40db 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1,4 +1,4 @@ -use crate::{Capslock, LinuxDispatcher, RunnableVariant, TaskTiming, xcb_flush}; +use crate::{Capslock, ResultExt as _, RunnableVariant, TaskTiming, profiler, xcb_flush}; use anyhow::{Context as _, anyhow}; use ashpd::WindowIdentifier; use calloop::{ @@ -18,7 +18,7 @@ use std::{ rc::{Rc, Weak}, time::{Duration, Instant}, }; -use util::ResultExt; +use util::ResultExt as _; use x11rb::{ connection::{Connection, RequestConnection}, @@ -29,7 +29,7 @@ use x11rb::{ protocol::xkb::ConnectionExt as _, protocol::xproto::{ AtomEnum, ChangeWindowAttributesAux, ClientMessageData, ClientMessageEvent, - ConnectionExt as _, EventMask, Visibility, + ConnectionExt as _, EventMask, ModMask, Visibility, }, protocol::{Event, randr, render, xinput, xkb, xproto}, resource_manager::Database, @@ -222,7 +222,7 @@ pub struct X11ClientState { pub struct X11ClientStatePtr(pub Weak>); impl X11ClientStatePtr { - fn get_client(&self) -> Option { + pub fn get_client(&self) -> Option { self.0.upgrade().map(X11Client) } @@ -322,7 +322,7 @@ impl X11Client { start, end: None, }; - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -334,7 +334,7 @@ impl X11Client { start, end: None, }; - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); runnable.run(); timing @@ -343,7 +343,7 @@ impl X11Client { let end = Instant::now(); timing.end = Some(end); - LinuxDispatcher::add_task_timing(timing); + profiler::add_task_timing(timing); }); } } @@ -437,7 +437,7 @@ impl X11Client { .to_string(); let keyboard_layout = LinuxKeyboardLayout::new(layout_name.into()); - let gpu_context = BladeContext::new().context("Unable to init GPU context")?; + let gpu_context = BladeContext::new().notify_err("Unable to init GPU context"); let resource_database = x11rb::resource_manager::new_from_default(&xcb_connection) .context("Failed to create resource database")?; @@ -752,7 +752,7 @@ impl X11Client { } } - fn get_window(&self, win: xproto::Window) -> Option { + pub(crate) fn get_window(&self, win: xproto::Window) -> Option { let state = self.0.borrow(); state .windows @@ -789,12 +789,12 @@ impl X11Client { let [atom, arg1, arg2, arg3, arg4] = event.data.as_data32(); let mut state = self.0.borrow_mut(); - if atom == state.atoms.WM_DELETE_WINDOW { + if atom == state.atoms.WM_DELETE_WINDOW && window.should_close() { // window "x" button clicked by user - if window.should_close() { - // Rest of the close logic is handled in drop_window() - window.close(); - } + // Rest of the close logic is handled in drop_window() + drop(state); + window.close(); + state = self.0.borrow_mut(); } else if atom == state.atoms._NET_WM_SYNC_REQUEST { window.state.borrow_mut().last_sync_counter = Some(x11rb::protocol::sync::Int64 { @@ -944,6 +944,8 @@ impl X11Client { let window = self.get_window(event.event)?; window.set_active(false); let mut state = self.0.borrow_mut(); + // Set last scroll values to `None` so that a large delta isn't created if scrolling is done outside the window (the valuator is global) + reset_all_pointer_device_scroll_positions(&mut state.pointer_device_states); state.keyboard_focused_window = None; if let Some(compose_state) = state.compose_state.as_mut() { compose_state.reset(); @@ -1018,6 +1020,12 @@ impl X11Client { let modifiers = modifiers_from_state(event.state); state.modifiers = modifiers; state.pre_key_char_down.take(); + + // Macros containing modifiers might result in + // the modifiers missing from the event. + // We therefore update the mask from the global state. + update_xkb_mask_from_event_state(&mut state.xkb, event.state); + let keystroke = { let code = event.detail.into(); let mut keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); @@ -1083,6 +1091,11 @@ impl X11Client { let modifiers = modifiers_from_state(event.state); state.modifiers = modifiers; + // Macros containing modifiers might result in + // the modifiers missing from the event. + // We therefore update the mask from the global state. + update_xkb_mask_from_event_state(&mut state.xkb, event.state); + let keystroke = { let code = event.detail.into(); let keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); @@ -1205,6 +1218,33 @@ impl X11Client { Event::XinputMotion(event) => { let window = self.get_window(event.event)?; let mut state = self.0.borrow_mut(); + if window.is_blocked() { + // We want to set the cursor to the default arrow + // when the window is blocked + let style = CursorStyle::Arrow; + + let current_style = state + .cursor_styles + .get(&window.x_window) + .unwrap_or(&CursorStyle::Arrow); + if *current_style != style + && let Some(cursor) = state.get_cursor_icon(style) + { + state.cursor_styles.insert(window.x_window, style); + check_reply( + || "Failed to set cursor style", + state.xcb_connection.change_window_attributes( + window.x_window, + &ChangeWindowAttributesAux { + cursor: Some(cursor), + ..Default::default() + }, + ), + ) + .log_err(); + state.xcb_connection.flush().log_err(); + }; + } let pressed_button = pressed_button_from_mask(event.button_mask[0]); let position = point( px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), @@ -1478,7 +1518,7 @@ impl LinuxClient for X11Client { let parent_window = state .keyboard_focused_window .and_then(|focused_window| state.windows.get(&focused_window)) - .map(|window| window.window.x_window); + .map(|w| w.window.clone()); let x_window = state .xcb_connection .generate_id() @@ -1533,7 +1573,15 @@ impl LinuxClient for X11Client { .cursor_styles .get(&focused_window) .unwrap_or(&CursorStyle::Arrow); - if *current_style == style { + + let window = state + .mouse_focused_window + .and_then(|w| state.windows.get(&w)); + + let should_change = *current_style != style + && (window.is_none() || window.is_some_and(|w| !w.is_blocked())); + + if !should_change { return; } @@ -2516,3 +2564,19 @@ fn get_dpi_factor((width_px, height_px): (u32, u32), (width_mm, height_mm): (u64 fn valid_scale_factor(scale_factor: f32) -> bool { scale_factor.is_sign_positive() && scale_factor.is_normal() } + +#[inline] +fn update_xkb_mask_from_event_state(xkb: &mut xkbc::State, event_state: xproto::KeyButMask) { + let depressed_mods = event_state.remove((ModMask::LOCK | ModMask::M2).bits()); + let latched_mods = xkb.serialize_mods(xkbc::STATE_MODS_LATCHED); + let locked_mods = xkb.serialize_mods(xkbc::STATE_MODS_LOCKED); + let locked_layout = xkb.serialize_layout(xkbc::STATE_LAYOUT_LOCKED); + xkb.update_mask( + depressed_mods.into(), + latched_mods, + locked_mods, + 0, + 0, + locked_layout, + ); +} diff --git a/crates/gpui/src/platform/linux/x11/window.rs b/crates/gpui/src/platform/linux/x11/window.rs index fe197a670177689ce776b6b55d439483c43921e0..46d1cbd3253618cae5a7df9c27195ea8921954eb 100644 --- a/crates/gpui/src/platform/linux/x11/window.rs +++ b/crates/gpui/src/platform/linux/x11/window.rs @@ -11,6 +11,7 @@ use crate::{ }; use blade_graphics as gpu; +use collections::FxHashSet; use raw_window_handle as rwh; use util::{ResultExt, maybe}; use x11rb::{ @@ -74,6 +75,7 @@ x11rb::atom_manager! { _NET_WM_WINDOW_TYPE, _NET_WM_WINDOW_TYPE_NOTIFICATION, _NET_WM_WINDOW_TYPE_DIALOG, + _NET_WM_STATE_MODAL, _NET_WM_SYNC, _NET_SUPPORTED, _MOTIF_WM_HINTS, @@ -249,6 +251,8 @@ pub struct Callbacks { pub struct X11WindowState { pub destroyed: bool, + parent: Option, + children: FxHashSet, client: X11ClientStatePtr, executor: ForegroundExecutor, atoms: XcbAtoms, @@ -394,7 +398,7 @@ impl X11WindowState { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, - parent_window: Option, + parent_window: Option, ) -> anyhow::Result { let x_screen_index = params .display_id @@ -427,6 +431,7 @@ impl X11WindowState { // https://stackoverflow.com/questions/43218127/x11-xlib-xcb-creating-a-window-requires-border-pixel-if-specifying-colormap-wh .border_pixel(visual_set.black_pixel) .colormap(colormap) + .override_redirect((params.kind == WindowKind::PopUp) as u32) .event_mask( xproto::EventMask::EXPOSURE | xproto::EventMask::STRUCTURE_NOTIFY @@ -546,8 +551,8 @@ impl X11WindowState { )?; } - if params.kind == WindowKind::Floating { - if let Some(parent_window) = parent_window { + if params.kind == WindowKind::Floating || params.kind == WindowKind::Dialog { + if let Some(parent_window) = parent_window.as_ref().map(|w| w.x_window) { // WM_TRANSIENT_FOR hint indicating the main application window. For floating windows, we set // a parent window (WM_TRANSIENT_FOR) such that the window manager knows where to // place the floating window in relation to the main window. @@ -563,11 +568,23 @@ impl X11WindowState { ), )?; } + } + + let parent = if params.kind == WindowKind::Dialog + && let Some(parent) = parent_window + { + parent.add_child(x_window); + + Some(parent) + } else { + None + }; + if params.kind == WindowKind::Dialog { // _NET_WM_WINDOW_TYPE_DIALOG indicates that this is a dialog (floating) window // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html check_reply( - || "X11 ChangeProperty32 setting window type for floating window failed.", + || "X11 ChangeProperty32 setting window type for dialog window failed.", xcb.change_property32( xproto::PropMode::REPLACE, x_window, @@ -576,6 +593,20 @@ impl X11WindowState { &[atoms._NET_WM_WINDOW_TYPE_DIALOG], ), )?; + + // We set the modal state for dialog windows, so that the window manager + // can handle it appropriately (e.g., prevent interaction with the parent window + // while the dialog is open). + check_reply( + || "X11 ChangeProperty32 setting modal state for dialog window failed.", + xcb.change_property32( + xproto::PropMode::REPLACE, + x_window, + atoms._NET_WM_STATE, + xproto::AtomEnum::ATOM, + &[atoms._NET_WM_STATE_MODAL], + ), + )?; } check_reply( @@ -667,6 +698,8 @@ impl X11WindowState { let display = Rc::new(X11Display::new(xcb, scale_factor, x_screen_index)?); Ok(Self { + parent, + children: FxHashSet::default(), client, executor, display, @@ -720,6 +753,11 @@ pub(crate) struct X11Window(pub X11WindowStatePtr); impl Drop for X11Window { fn drop(&mut self) { let mut state = self.0.state.borrow_mut(); + + if let Some(parent) = state.parent.as_ref() { + parent.state.borrow_mut().children.remove(&self.0.x_window); + } + state.renderer.destroy(); let destroy_x_window = maybe!({ @@ -734,8 +772,6 @@ impl Drop for X11Window { .log_err(); if destroy_x_window.is_some() { - // Mark window as destroyed so that we can filter out when X11 events - // for it still come in. state.destroyed = true; let this_ptr = self.0.clone(); @@ -773,7 +809,7 @@ impl X11Window { atoms: &XcbAtoms, scale_factor: f32, appearance: WindowAppearance, - parent_window: Option, + parent_window: Option, ) -> anyhow::Result { let ptr = X11WindowStatePtr { state: Rc::new(RefCell::new(X11WindowState::new( @@ -979,7 +1015,31 @@ impl X11WindowStatePtr { Ok(()) } + pub fn add_child(&self, child: xproto::Window) { + let mut state = self.state.borrow_mut(); + state.children.insert(child); + } + + pub fn is_blocked(&self) -> bool { + let state = self.state.borrow(); + !state.children.is_empty() + } + pub fn close(&self) { + let state = self.state.borrow(); + let client = state.client.clone(); + #[allow(clippy::mutable_key_type)] + let children = state.children.clone(); + drop(state); + + if let Some(client) = client.get_client() { + for child in children { + if let Some(child_window) = client.get_window(child) { + child_window.close(); + } + } + } + let mut callbacks = self.callbacks.borrow_mut(); if let Some(fun) = callbacks.close.take() { fun() @@ -994,6 +1054,9 @@ impl X11WindowStatePtr { } pub fn handle_input(&self, input: PlatformInput) { + if self.is_blocked() { + return; + } if let Some(ref mut fun) = self.callbacks.borrow_mut().input && !fun(input.clone()).propagate { @@ -1016,6 +1079,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_commit(&self, text: String) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1026,6 +1092,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_preedit(&self, text: String) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1036,6 +1105,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_unmark(&self) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); @@ -1046,6 +1118,9 @@ impl X11WindowStatePtr { } pub fn handle_ime_delete(&self) { + if self.is_blocked() { + return; + } let mut state = self.state.borrow_mut(); if let Some(mut input_handler) = state.input_handler.take() { drop(state); diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index 76d636b457517da64cf66988325652ddea56c5d3..a229ec7dce928597ec73b1f4be50edd1ea3e5114 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -5,6 +5,7 @@ mod display; mod display_link; mod events; mod keyboard; +mod pasteboard; #[cfg(feature = "screen-capture")] mod screen_capture; @@ -21,8 +22,6 @@ use metal_renderer as renderer; #[cfg(feature = "macos-blade")] use crate::platform::blade as renderer; -mod attributed_string; - #[cfg(feature = "font-kit")] mod open_type; @@ -135,6 +134,8 @@ unsafe impl objc::Encode for NSRange { } } +/// Allow NSString::alloc use here because it sets autorelease +#[allow(clippy::disallowed_methods)] unsafe fn ns_string(string: &str) -> id { unsafe { NSString::alloc(nil).init_str(string).autorelease() } } diff --git a/crates/gpui/src/platform/mac/attributed_string.rs b/crates/gpui/src/platform/mac/attributed_string.rs deleted file mode 100644 index 5f313ac699d6e1a096c4bcf807fd6c080d0064da..0000000000000000000000000000000000000000 --- a/crates/gpui/src/platform/mac/attributed_string.rs +++ /dev/null @@ -1,119 +0,0 @@ -use cocoa::base::id; -use cocoa::foundation::NSRange; -use objc::{class, msg_send, sel, sel_impl}; - -/// The `cocoa` crate does not define NSAttributedString (and related Cocoa classes), -/// which are needed for copying rich text (that is, text intermingled with images) -/// to the clipboard. This adds access to those APIs. -#[allow(non_snake_case)] -pub trait NSAttributedString: Sized { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSAttributedString), alloc] - } - - unsafe fn init_attributed_string(self, string: id) -> id; - unsafe fn appendAttributedString_(self, attr_string: id); - unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id; - unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id; - unsafe fn string(self) -> id; -} - -impl NSAttributedString for id { - unsafe fn init_attributed_string(self, string: id) -> id { - msg_send![self, initWithString: string] - } - - unsafe fn appendAttributedString_(self, attr_string: id) { - let _: () = msg_send![self, appendAttributedString: attr_string]; - } - - unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id { - msg_send![self, RTFDFromRange: range documentAttributes: attrs] - } - - unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id { - msg_send![self, RTFFromRange: range documentAttributes: attrs] - } - - unsafe fn string(self) -> id { - msg_send![self, string] - } -} - -pub trait NSMutableAttributedString: NSAttributedString { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSMutableAttributedString), alloc] - } -} - -impl NSMutableAttributedString for id {} - -#[cfg(test)] -mod tests { - use super::*; - use cocoa::appkit::NSImage; - use cocoa::base::nil; - use cocoa::foundation::NSString; - #[test] - #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348 - fn test_nsattributed_string() { - // TODO move these to parent module once it's actually ready to be used - #[allow(non_snake_case)] - pub trait NSTextAttachment: Sized { - unsafe fn alloc(_: Self) -> id { - msg_send![class!(NSTextAttachment), alloc] - } - } - - impl NSTextAttachment for id {} - - unsafe { - let image: id = msg_send![class!(NSImage), alloc]; - image.initWithContentsOfFile_(NSString::alloc(nil).init_str("test.jpeg")); - let _size = image.size(); - - let string = NSString::alloc(nil).init_str("Test String"); - let attr_string = NSMutableAttributedString::alloc(nil).init_attributed_string(string); - let hello_string = NSString::alloc(nil).init_str("Hello World"); - let hello_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(hello_string); - attr_string.appendAttributedString_(hello_attr_string); - - let attachment = NSTextAttachment::alloc(nil); - let _: () = msg_send![attachment, setImage: image]; - let image_attr_string = - msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment]; - attr_string.appendAttributedString_(image_attr_string); - - let another_string = NSString::alloc(nil).init_str("Another String"); - let another_attr_string = - NSAttributedString::alloc(nil).init_attributed_string(another_string); - attr_string.appendAttributedString_(another_attr_string); - - let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length]; - - /////////////////////////////////////////////////// - // pasteboard.clearContents(); - - let rtfd_data = attr_string.RTFDFromRange_documentAttributes_( - NSRange::new(0, msg_send![attr_string, length]), - nil, - ); - assert_ne!(rtfd_data, nil); - // if rtfd_data != nil { - // pasteboard.setData_forType(rtfd_data, NSPasteboardTypeRTFD); - // } - - // let rtf_data = attributed_string.RTFFromRange_documentAttributes_( - // NSRange::new(0, attributed_string.length()), - // nil, - // ); - // if rtf_data != nil { - // pasteboard.setData_forType(rtf_data, NSPasteboardTypeRTF); - // } - - // let plain_text = attributed_string.string(); - // pasteboard.setString_forType(plain_text, NSPasteboardTypeString); - } - } -} diff --git a/crates/gpui/src/platform/mac/dispatcher.rs b/crates/gpui/src/platform/mac/dispatcher.rs index 8a2f42234eea960669cb212853c437ec680a7fd7..1dfea82d58cbf2387571cabdcd7fbcfcf785c735 100644 --- a/crates/gpui/src/platform/mac/dispatcher.rs +++ b/crates/gpui/src/platform/mac/dispatcher.rs @@ -3,11 +3,22 @@ #![allow(non_snake_case)] use crate::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, RunnableMeta, RunnableVariant, THREAD_TIMINGS, - TaskLabel, TaskTiming, ThreadTaskTimings, + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RealtimePriority, RunnableMeta, + RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, ThreadTaskTimings, }; +use anyhow::Context; use async_task::Runnable; +use mach2::{ + kern_return::KERN_SUCCESS, + mach_time::mach_timebase_info_data_t, + thread_policy::{ + THREAD_EXTENDED_POLICY, THREAD_EXTENDED_POLICY_COUNT, THREAD_PRECEDENCE_POLICY, + THREAD_PRECEDENCE_POLICY_COUNT, THREAD_TIME_CONSTRAINT_POLICY, + THREAD_TIME_CONSTRAINT_POLICY_COUNT, thread_extended_policy_data_t, + thread_precedence_policy_data_t, thread_time_constraint_policy_data_t, + }, +}; use objc::{ class, msg_send, runtime::{BOOL, YES}, @@ -15,9 +26,11 @@ use objc::{ }; use std::{ ffi::c_void, + mem::MaybeUninit, ptr::{NonNull, addr_of}, time::{Duration, Instant}, }; +use util::ResultExt; /// All items in the generated file are marked as pub, so we're gonna wrap it in a separate mod to prevent /// these pub items from leaking into public API. @@ -56,7 +69,7 @@ impl PlatformDispatcher for MacDispatcher { is_main_thread == YES } - fn dispatch(&self, runnable: RunnableVariant, _: Option) { + fn dispatch(&self, runnable: RunnableVariant, _: Option, priority: Priority) { let (context, trampoline) = match runnable { RunnableVariant::Meta(runnable) => ( runnable.into_raw().as_ptr() as *mut c_void, @@ -67,16 +80,24 @@ impl PlatformDispatcher for MacDispatcher { Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)), ), }; + + let queue_priority = match priority { + Priority::Realtime(_) => unreachable!(), + Priority::High => DISPATCH_QUEUE_PRIORITY_HIGH as isize, + Priority::Medium => DISPATCH_QUEUE_PRIORITY_DEFAULT as isize, + Priority::Low => DISPATCH_QUEUE_PRIORITY_LOW as isize, + }; + unsafe { dispatch_async_f( - dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH.try_into().unwrap(), 0), + dispatch_get_global_queue(queue_priority, 0), context, trampoline, ); } } - fn dispatch_on_main_thread(&self, runnable: RunnableVariant) { + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { let (context, trampoline) = match runnable { RunnableVariant::Meta(runnable) => ( runnable.into_raw().as_ptr() as *mut c_void, @@ -110,6 +131,120 @@ impl PlatformDispatcher for MacDispatcher { dispatch_after_f(when, queue, context, trampoline); } } + + fn spawn_realtime(&self, priority: RealtimePriority, f: Box) { + std::thread::spawn(move || { + match priority { + RealtimePriority::Audio => set_audio_thread_priority(), + RealtimePriority::Other => set_high_thread_priority(), + } + .context(format!("for priority {:?}", priority)) + .log_err(); + + f(); + }); + } +} + +fn set_high_thread_priority() -> anyhow::Result<()> { + // SAFETY: always safe to call + let thread_id = unsafe { libc::pthread_self() }; + + // SAFETY: all sched_param members are valid when initialized to zero. + let mut sched_param = unsafe { MaybeUninit::::zeroed().assume_init() }; + sched_param.sched_priority = 45; + + let result = unsafe { libc::pthread_setschedparam(thread_id, libc::SCHED_FIFO, &sched_param) }; + if result != 0 { + anyhow::bail!("failed to set realtime thread priority") + } + + Ok(()) +} + +fn set_audio_thread_priority() -> anyhow::Result<()> { + // https://chromium.googlesource.com/chromium/chromium/+/master/base/threading/platform_thread_mac.mm#93 + + // SAFETY: always safe to call + let thread_id = unsafe { libc::pthread_self() }; + + // SAFETY: thread_id is a valid thread id + let thread_id = unsafe { libc::pthread_mach_thread_np(thread_id) }; + + // Fixed priority thread + let mut policy = thread_extended_policy_data_t { timeshare: 0 }; + + // SAFETY: thread_id is a valid thread id + // SAFETY: thread_extended_policy_data_t is passed as THREAD_EXTENDED_POLICY + let result = unsafe { + mach2::thread_policy::thread_policy_set( + thread_id, + THREAD_EXTENDED_POLICY, + &mut policy as *mut _ as *mut _, + THREAD_EXTENDED_POLICY_COUNT, + ) + }; + + if result != KERN_SUCCESS { + anyhow::bail!("failed to set thread extended policy"); + } + + // relatively high priority + let mut precedence = thread_precedence_policy_data_t { importance: 63 }; + + // SAFETY: thread_id is a valid thread id + // SAFETY: thread_precedence_policy_data_t is passed as THREAD_PRECEDENCE_POLICY + let result = unsafe { + mach2::thread_policy::thread_policy_set( + thread_id, + THREAD_PRECEDENCE_POLICY, + &mut precedence as *mut _ as *mut _, + THREAD_PRECEDENCE_POLICY_COUNT, + ) + }; + + if result != KERN_SUCCESS { + anyhow::bail!("failed to set thread precedence policy"); + } + + const GUARANTEED_AUDIO_DUTY_CYCLE: f32 = 0.75; + const MAX_AUDIO_DUTY_CYCLE: f32 = 0.85; + + // ~128 frames @ 44.1KHz + const TIME_QUANTUM: f32 = 2.9; + + const AUDIO_TIME_NEEDED: f32 = GUARANTEED_AUDIO_DUTY_CYCLE * TIME_QUANTUM; + const MAX_TIME_ALLOWED: f32 = MAX_AUDIO_DUTY_CYCLE * TIME_QUANTUM; + + let mut timebase_info = mach_timebase_info_data_t { numer: 0, denom: 0 }; + // SAFETY: timebase_info is a valid pointer to a mach_timebase_info_data_t struct + unsafe { mach2::mach_time::mach_timebase_info(&mut timebase_info) }; + + let ms_to_abs_time = ((timebase_info.denom as f32) / (timebase_info.numer as f32)) * 1000000f32; + + let mut time_constraints = thread_time_constraint_policy_data_t { + period: (TIME_QUANTUM * ms_to_abs_time) as u32, + computation: (AUDIO_TIME_NEEDED * ms_to_abs_time) as u32, + constraint: (MAX_TIME_ALLOWED * ms_to_abs_time) as u32, + preemptible: 0, + }; + + // SAFETY: thread_id is a valid thread id + // SAFETY: thread_precedence_pthread_time_constraint_policy_data_t is passed as THREAD_TIME_CONSTRAINT_POLICY + let result = unsafe { + mach2::thread_policy::thread_policy_set( + thread_id, + THREAD_TIME_CONSTRAINT_POLICY, + &mut time_constraints as *mut _ as *mut _, + THREAD_TIME_CONSTRAINT_POLICY_COUNT, + ) + }; + + if result != KERN_SUCCESS { + anyhow::bail!("failed to set thread time constraint policy"); + } + + Ok(()) } extern "C" fn trampoline(runnable: *mut c_void) { diff --git a/crates/gpui/src/platform/mac/display.rs b/crates/gpui/src/platform/mac/display.rs index 4ee27027d5fbff973b9ef2c27b5d55739c8a711a..94791620e8a394f67a38c257c95c575398cee0b7 100644 --- a/crates/gpui/src/platform/mac/display.rs +++ b/crates/gpui/src/platform/mac/display.rs @@ -1,9 +1,10 @@ -use crate::{Bounds, DisplayId, Pixels, PlatformDisplay, px, size}; +use super::ns_string; +use crate::{Bounds, DisplayId, Pixels, PlatformDisplay, point, px, size}; use anyhow::Result; use cocoa::{ appkit::NSScreen, base::{id, nil}, - foundation::{NSDictionary, NSString}, + foundation::{NSArray, NSDictionary}, }; use core_foundation::uuid::{CFUUIDGetUUIDBytes, CFUUIDRef}; use core_graphics::display::{CGDirectDisplayID, CGDisplayBounds, CGGetActiveDisplayList}; @@ -35,7 +36,7 @@ impl MacDisplay { let screens = NSScreen::screens(nil); let screen = cocoa::foundation::NSArray::objectAtIndex(screens, 0); let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: CGDirectDisplayID = msg_send![screen_number, unsignedIntegerValue]; Self(screen_number) @@ -114,4 +115,53 @@ impl PlatformDisplay for MacDisplay { } } } + + fn visible_bounds(&self) -> Bounds { + unsafe { + let dominated_screen = self.get_nsscreen(); + + if dominated_screen == nil { + return self.bounds(); + } + + let screen_frame = NSScreen::frame(dominated_screen); + let visible_frame = NSScreen::visibleFrame(dominated_screen); + + // Convert from bottom-left origin (AppKit) to top-left origin + let origin_y = + screen_frame.size.height - visible_frame.origin.y - visible_frame.size.height + + screen_frame.origin.y; + + Bounds { + origin: point( + px(visible_frame.origin.x as f32 - screen_frame.origin.x as f32), + px(origin_y as f32), + ), + size: size( + px(visible_frame.size.width as f32), + px(visible_frame.size.height as f32), + ), + } + } + } +} + +impl MacDisplay { + /// Find the NSScreen corresponding to this display + unsafe fn get_nsscreen(&self) -> id { + let screens = unsafe { NSScreen::screens(nil) }; + let count = unsafe { NSArray::count(screens) }; + let screen_number_key: id = unsafe { ns_string("NSScreenNumber") }; + + for i in 0..count { + let screen = unsafe { NSArray::objectAtIndex(screens, i) }; + let device_description = unsafe { NSScreen::deviceDescription(screen) }; + let screen_number = unsafe { device_description.objectForKey_(screen_number_key) }; + let screen_id: CGDirectDisplayID = msg_send![screen_number, unsignedIntegerValue]; + if screen_id == self.0 { + return screen; + } + } + nil + } } diff --git a/crates/gpui/src/platform/mac/events.rs b/crates/gpui/src/platform/mac/events.rs index acc392a5f3429f20931455ea06733376ea0f587a..7a12e8d3d7ccb2e8a2f7b32b81c24a29f650e6e2 100644 --- a/crates/gpui/src/platform/mac/events.rs +++ b/crates/gpui/src/platform/mac/events.rs @@ -1,7 +1,8 @@ use crate::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, - MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, - PlatformInput, ScrollDelta, ScrollWheelEvent, TouchPhase, + MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent, + TouchPhase, platform::mac::{ LMGetKbdType, NSStringExt, TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, UCKeyTranslate, kTISPropertyUnicodeKeyLayoutData, @@ -187,6 +188,26 @@ impl PlatformInput { }) }) } + NSEventType::NSEventTypePressure => { + let stage = native_event.stage(); + let pressure = native_event.pressure(); + + window_height.map(|window_height| { + Self::MousePressure(MousePressureEvent { + stage: match stage { + 1 => PressureStage::Normal, + 2 => PressureStage::Force, + _ => PressureStage::Zero, + }, + pressure, + modifiers: read_modifiers(native_event), + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + }) + }) + } // Some mice (like Logitech MX Master) send navigation buttons as swipe events NSEventType::NSEventTypeSwipe => { let navigation_direction = match native_event.phase() { diff --git a/crates/gpui/src/platform/mac/metal_renderer.rs b/crates/gpui/src/platform/mac/metal_renderer.rs index 550041a0ccb4cd39bc7a86317d9540e806af2a28..62a4385cf7d4ce63b78a610f97e5d65a6206ed37 100644 --- a/crates/gpui/src/platform/mac/metal_renderer.rs +++ b/crates/gpui/src/platform/mac/metal_renderer.rs @@ -46,9 +46,9 @@ pub unsafe fn new_renderer( _native_window: *mut c_void, _native_view: *mut c_void, _bounds: crate::Size, - _transparent: bool, + transparent: bool, ) -> Renderer { - MetalRenderer::new(context) + MetalRenderer::new(context, transparent) } pub(crate) struct InstanceBufferPool { @@ -128,7 +128,7 @@ pub struct PathRasterizationVertex { } impl MetalRenderer { - pub fn new(instance_buffer_pool: Arc>) -> Self { + pub fn new(instance_buffer_pool: Arc>, transparent: bool) -> Self { // Prefer low‐power integrated GPUs on Intel Mac. On Apple // Silicon, there is only ever one GPU, so this is equivalent to // `metal::Device::system_default()`. @@ -152,7 +152,9 @@ impl MetalRenderer { let layer = metal::MetalLayer::new(); layer.set_device(&device); layer.set_pixel_format(MTLPixelFormat::BGRA8Unorm); - layer.set_opaque(false); + // Support direct-to-display rendering if the window is not transparent + // https://developer.apple.com/documentation/metal/managing-your-game-window-for-metal-in-macos + layer.set_opaque(!transparent); layer.set_maximum_drawable_count(3); unsafe { let _: () = msg_send![&*layer, setAllowsNextDrawableTimeout: NO]; @@ -352,8 +354,8 @@ impl MetalRenderer { } } - pub fn update_transparency(&self, _transparent: bool) { - // todo(mac)? + pub fn update_transparency(&self, transparent: bool) { + self.layer.set_opaque(!transparent); } pub fn destroy(&self) { diff --git a/crates/gpui/src/platform/mac/open_type.rs b/crates/gpui/src/platform/mac/open_type.rs index 37a29559fdfbc284ffd1021cc6c2c6ed717ca228..ff501df15f671318548a3959bd6b966f97e051b1 100644 --- a/crates/gpui/src/platform/mac/open_type.rs +++ b/crates/gpui/src/platform/mac/open_type.rs @@ -52,6 +52,11 @@ pub fn apply_features_and_fallbacks( &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks, ); + + for value in &values { + CFRelease(*value as _); + } + let new_descriptor = CTFontDescriptorCreateWithAttributes(attrs); CFRelease(attrs as _); let new_descriptor = CTFontDescriptor::wrap_under_create_rule(new_descriptor); diff --git a/crates/gpui/src/platform/mac/pasteboard.rs b/crates/gpui/src/platform/mac/pasteboard.rs new file mode 100644 index 0000000000000000000000000000000000000000..38710951f15b25515d906afc738c5b971b1bb135 --- /dev/null +++ b/crates/gpui/src/platform/mac/pasteboard.rs @@ -0,0 +1,344 @@ +use core::slice; +use std::ffi::c_void; + +use cocoa::{ + appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF}, + base::{id, nil}, + foundation::NSData, +}; +use objc::{msg_send, runtime::Object, sel, sel_impl}; +use strum::IntoEnumIterator as _; + +use crate::{ + ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash, + platform::mac::ns_string, +}; + +pub struct Pasteboard { + inner: id, + text_hash_type: id, + metadata_type: id, +} + +impl Pasteboard { + pub fn general() -> Self { + unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) } + } + + pub fn find() -> Self { + unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) } + } + + #[cfg(test)] + pub fn unique() -> Self { + unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) } + } + + unsafe fn new(inner: id) -> Self { + Self { + inner, + text_hash_type: unsafe { ns_string("zed-text-hash") }, + metadata_type: unsafe { ns_string("zed-metadata") }, + } + } + + pub fn read(&self) -> Option { + // First, see if it's a string. + unsafe { + let pasteboard_types: id = self.inner.types(); + let string_type: id = ns_string("public.utf8-plain-text"); + + if msg_send![pasteboard_types, containsObject: string_type] { + let data = self.inner.dataForType(string_type); + if data == nil { + return None; + } else if data.bytes().is_null() { + // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc + // "If the length of the NSData object is 0, this property returns nil." + return Some(self.read_string(&[])); + } else { + let bytes = + slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize); + + return Some(self.read_string(bytes)); + } + } + + // If it wasn't a string, try the various supported image types. + for format in ImageFormat::iter() { + if let Some(item) = self.read_image(format) { + return Some(item); + } + } + } + + // If it wasn't a string or a supported image type, give up. + None + } + + fn read_image(&self, format: ImageFormat) -> Option { + let mut ut_type: UTType = format.into(); + + unsafe { + let types: id = self.inner.types(); + if msg_send![types, containsObject: ut_type.inner()] { + self.data_for_type(ut_type.inner_mut()).map(|bytes| { + let bytes = bytes.to_vec(); + let id = hash(&bytes); + + ClipboardItem { + entries: vec![ClipboardEntry::Image(Image { format, bytes, id })], + } + }) + } else { + None + } + } + } + + fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem { + unsafe { + let text = String::from_utf8_lossy(text_bytes).to_string(); + let metadata = self + .data_for_type(self.text_hash_type) + .and_then(|hash_bytes| { + let hash_bytes = hash_bytes.try_into().ok()?; + let hash = u64::from_be_bytes(hash_bytes); + let metadata = self.data_for_type(self.metadata_type)?; + + if hash == ClipboardString::text_hash(&text) { + String::from_utf8(metadata.to_vec()).ok() + } else { + None + } + }); + + ClipboardItem { + entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })], + } + } + } + + unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> { + unsafe { + let data = self.inner.dataForType(kind); + if data == nil { + None + } else { + Some(slice::from_raw_parts( + data.bytes() as *mut u8, + data.length() as usize, + )) + } + } + } + + pub fn write(&self, item: ClipboardItem) { + unsafe { + match item.entries.as_slice() { + [] => { + // Writing an empty list of entries just clears the clipboard. + self.inner.clearContents(); + } + [ClipboardEntry::String(string)] => { + self.write_plaintext(string); + } + [ClipboardEntry::Image(image)] => { + self.write_image(image); + } + [ClipboardEntry::ExternalPaths(_)] => {} + _ => { + // Agus NB: We're currently only writing string entries to the clipboard when we have more than one. + // + // This was the existing behavior before I refactored the outer clipboard code: + // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110 + // + // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor. + + let mut combined = ClipboardString { + text: String::new(), + metadata: None, + }; + + for entry in item.entries { + match entry { + ClipboardEntry::String(text) => { + combined.text.push_str(&text.text()); + if combined.metadata.is_none() { + combined.metadata = text.metadata; + } + } + _ => {} + } + } + + self.write_plaintext(&combined); + } + } + } + } + + fn write_plaintext(&self, string: &ClipboardString) { + unsafe { + self.inner.clearContents(); + + let text_bytes = NSData::dataWithBytes_length_( + nil, + string.text.as_ptr() as *const c_void, + string.text.len() as u64, + ); + self.inner + .setData_forType(text_bytes, NSPasteboardTypeString); + + if let Some(metadata) = string.metadata.as_ref() { + let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes(); + let hash_bytes = NSData::dataWithBytes_length_( + nil, + hash_bytes.as_ptr() as *const c_void, + hash_bytes.len() as u64, + ); + self.inner.setData_forType(hash_bytes, self.text_hash_type); + + let metadata_bytes = NSData::dataWithBytes_length_( + nil, + metadata.as_ptr() as *const c_void, + metadata.len() as u64, + ); + self.inner + .setData_forType(metadata_bytes, self.metadata_type); + } + } + } + + unsafe fn write_image(&self, image: &Image) { + unsafe { + self.inner.clearContents(); + + let bytes = NSData::dataWithBytes_length_( + nil, + image.bytes.as_ptr() as *const c_void, + image.bytes.len() as u64, + ); + + self.inner + .setData_forType(bytes, Into::::into(image.format).inner_mut()); + } + } +} + +#[link(name = "AppKit", kind = "framework")] +unsafe extern "C" { + /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc) + pub static NSPasteboardNameFind: id; +} + +impl From for UTType { + fn from(value: ImageFormat) -> Self { + match value { + ImageFormat::Png => Self::png(), + ImageFormat::Jpeg => Self::jpeg(), + ImageFormat::Tiff => Self::tiff(), + ImageFormat::Webp => Self::webp(), + ImageFormat::Gif => Self::gif(), + ImageFormat::Bmp => Self::bmp(), + ImageFormat::Svg => Self::svg(), + ImageFormat::Ico => Self::ico(), + } + } +} + +// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ +pub struct UTType(id); + +impl UTType { + pub fn png() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png + Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType + } + + pub fn jpeg() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg + Self(unsafe { ns_string("public.jpeg") }) + } + + pub fn gif() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif + Self(unsafe { ns_string("com.compuserve.gif") }) + } + + pub fn webp() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp + Self(unsafe { ns_string("org.webmproject.webp") }) + } + + pub fn bmp() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp + Self(unsafe { ns_string("com.microsoft.bmp") }) + } + + pub fn svg() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg + Self(unsafe { ns_string("public.svg-image") }) + } + + pub fn ico() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico + Self(unsafe { ns_string("com.microsoft.ico") }) + } + + pub fn tiff() -> Self { + // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff + Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType + } + + fn inner(&self) -> *const Object { + self.0 + } + + pub fn inner_mut(&self) -> *mut Object { + self.0 as *mut _ + } +} + +#[cfg(test)] +mod tests { + use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData}; + + use crate::{ClipboardEntry, ClipboardItem, ClipboardString}; + + use super::*; + + #[test] + fn test_string() { + let pasteboard = Pasteboard::unique(); + assert_eq!(pasteboard.read(), None); + + let item = ClipboardItem::new_string("1".to_string()); + pasteboard.write(item.clone()); + assert_eq!(pasteboard.read(), Some(item)); + + let item = ClipboardItem { + entries: vec![ClipboardEntry::String( + ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]), + )], + }; + pasteboard.write(item.clone()); + assert_eq!(pasteboard.read(), Some(item)); + + let text_from_other_app = "text from other app"; + unsafe { + let bytes = NSData::dataWithBytes_length_( + nil, + text_from_other_app.as_ptr() as *const c_void, + text_from_other_app.len() as u64, + ); + pasteboard + .inner + .setData_forType(bytes, NSPasteboardTypeString); + } + assert_eq!( + pasteboard.read(), + Some(ClipboardItem::new_string(text_from_other_app.to_string())) + ); + } +} diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index c2363afe270f973513c8ba696bf5d3f99fb92cad..9b32c6735bf6215fecc0455defc4237fd25e8cb0 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1,29 +1,24 @@ use super::{ - BoolExt, MacKeyboardLayout, MacKeyboardMapper, - attributed_string::{NSAttributedString, NSMutableAttributedString}, - events::key_to_native, - renderer, + BoolExt, MacKeyboardLayout, MacKeyboardMapper, events::key_to_native, ns_string, renderer, }; use crate::{ - Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, - CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, - MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash, + Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, + KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, + PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, + WindowParams, platform::mac::pasteboard::Pasteboard, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; use cocoa::{ appkit::{ NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, - NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard, - NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString, - NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow, + NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel, + NSVisualEffectState, NSVisualEffectView, NSWindow, }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ - NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString, - NSUInteger, NSURL, + NSArray, NSAutoreleasePool, NSBundle, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL, }, }; use core_foundation::{ @@ -49,7 +44,6 @@ use ptr::null_mut; use semver::Version; use std::{ cell::Cell, - convert::TryInto, ffi::{CStr, OsStr, c_void}, os::{raw::c_char, unix::ffi::OsStrExt}, path::{Path, PathBuf}, @@ -58,7 +52,6 @@ use std::{ slice, str, sync::{Arc, OnceLock}, }; -use strum::IntoEnumIterator; use util::{ ResultExt, command::{new_smol_command, new_std_command}, @@ -164,9 +157,8 @@ pub(crate) struct MacPlatformState { text_system: Arc, renderer_context: renderer::Context, headless: bool, - pasteboard: id, - text_hash_pasteboard_type: id, - metadata_pasteboard_type: id, + general_pasteboard: Pasteboard, + find_pasteboard: Pasteboard, reopen: Option>, on_keyboard_layout_change: Option>, quit: Option>, @@ -206,9 +198,8 @@ impl MacPlatform { background_executor: BackgroundExecutor::new(dispatcher.clone()), foreground_executor: ForegroundExecutor::new(dispatcher), renderer_context: renderer::Context::default(), - pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) }, - text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") }, - metadata_pasteboard_type: unsafe { ns_string("zed-metadata") }, + general_pasteboard: Pasteboard::general(), + find_pasteboard: Pasteboard::find(), reopen: None, quit: None, menu_command: None, @@ -224,20 +215,6 @@ impl MacPlatform { })) } - unsafe fn read_from_pasteboard(&self, pasteboard: *mut Object, kind: id) -> Option<&[u8]> { - unsafe { - let data = pasteboard.dataForType(kind); - if data == nil { - None - } else { - Some(slice::from_raw_parts( - data.bytes() as *mut u8, - data.length() as usize, - )) - } - } - } - unsafe fn create_menu_bar( &self, menus: &Vec, @@ -1034,117 +1011,24 @@ impl Platform for MacPlatform { } } - fn write_to_clipboard(&self, item: ClipboardItem) { - use crate::ClipboardEntry; - - unsafe { - // We only want to use NSAttributedString if there are multiple entries to write. - if item.entries.len() <= 1 { - match item.entries.first() { - Some(entry) => match entry { - ClipboardEntry::String(string) => { - self.write_plaintext_to_clipboard(string); - } - ClipboardEntry::Image(image) => { - self.write_image_to_clipboard(image); - } - ClipboardEntry::ExternalPaths(_) => {} - }, - None => { - // Writing an empty list of entries just clears the clipboard. - let state = self.0.lock(); - state.pasteboard.clearContents(); - } - } - } else { - let mut any_images = false; - let attributed_string = { - let mut buf = NSMutableAttributedString::alloc(nil) - // TODO can we skip this? Or at least part of it? - .init_attributed_string(NSString::alloc(nil).init_str("")); - - for entry in item.entries { - if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry - { - let to_append = NSAttributedString::alloc(nil) - .init_attributed_string(NSString::alloc(nil).init_str(&text)); - - buf.appendAttributedString_(to_append); - } - } - - buf - }; - - let state = self.0.lock(); - state.pasteboard.clearContents(); - - // Only set rich text clipboard types if we actually have 1+ images to include. - if any_images { - let rtfd_data = attributed_string.RTFDFromRange_documentAttributes_( - NSRange::new(0, msg_send![attributed_string, length]), - nil, - ); - if rtfd_data != nil { - state - .pasteboard - .setData_forType(rtfd_data, NSPasteboardTypeRTFD); - } - - let rtf_data = attributed_string.RTFFromRange_documentAttributes_( - NSRange::new(0, attributed_string.length()), - nil, - ); - if rtf_data != nil { - state - .pasteboard - .setData_forType(rtf_data, NSPasteboardTypeRTF); - } - } - - let plain_text = attributed_string.string(); - state - .pasteboard - .setString_forType(plain_text, NSPasteboardTypeString); - } - } - } - fn read_from_clipboard(&self) -> Option { let state = self.0.lock(); - let pasteboard = state.pasteboard; - - // First, see if it's a string. - unsafe { - let types: id = pasteboard.types(); - let string_type: id = ns_string("public.utf8-plain-text"); - - if msg_send![types, containsObject: string_type] { - let data = pasteboard.dataForType(string_type); - if data == nil { - return None; - } else if data.bytes().is_null() { - // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc - // "If the length of the NSData object is 0, this property returns nil." - return Some(self.read_string_from_clipboard(&state, &[])); - } else { - let bytes = - slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize); + state.general_pasteboard.read() + } - return Some(self.read_string_from_clipboard(&state, bytes)); - } - } + fn write_to_clipboard(&self, item: ClipboardItem) { + let state = self.0.lock(); + state.general_pasteboard.write(item); + } - // If it wasn't a string, try the various supported image types. - for format in ImageFormat::iter() { - if let Some(item) = try_clipboard_image(pasteboard, format) { - return Some(item); - } - } - } + fn read_from_find_pasteboard(&self) -> Option { + let state = self.0.lock(); + state.find_pasteboard.read() + } - // If it wasn't a string or a supported image type, give up. - None + fn write_to_find_pasteboard(&self, item: ClipboardItem) { + let state = self.0.lock(); + state.find_pasteboard.write(item); } fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { @@ -1253,116 +1137,6 @@ impl Platform for MacPlatform { } } -impl MacPlatform { - unsafe fn read_string_from_clipboard( - &self, - state: &MacPlatformState, - text_bytes: &[u8], - ) -> ClipboardItem { - unsafe { - let text = String::from_utf8_lossy(text_bytes).to_string(); - let metadata = self - .read_from_pasteboard(state.pasteboard, state.text_hash_pasteboard_type) - .and_then(|hash_bytes| { - let hash_bytes = hash_bytes.try_into().ok()?; - let hash = u64::from_be_bytes(hash_bytes); - let metadata = self - .read_from_pasteboard(state.pasteboard, state.metadata_pasteboard_type)?; - - if hash == ClipboardString::text_hash(&text) { - String::from_utf8(metadata.to_vec()).ok() - } else { - None - } - }); - - ClipboardItem { - entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })], - } - } - } - - unsafe fn write_plaintext_to_clipboard(&self, string: &ClipboardString) { - unsafe { - let state = self.0.lock(); - state.pasteboard.clearContents(); - - let text_bytes = NSData::dataWithBytes_length_( - nil, - string.text.as_ptr() as *const c_void, - string.text.len() as u64, - ); - state - .pasteboard - .setData_forType(text_bytes, NSPasteboardTypeString); - - if let Some(metadata) = string.metadata.as_ref() { - let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes(); - let hash_bytes = NSData::dataWithBytes_length_( - nil, - hash_bytes.as_ptr() as *const c_void, - hash_bytes.len() as u64, - ); - state - .pasteboard - .setData_forType(hash_bytes, state.text_hash_pasteboard_type); - - let metadata_bytes = NSData::dataWithBytes_length_( - nil, - metadata.as_ptr() as *const c_void, - metadata.len() as u64, - ); - state - .pasteboard - .setData_forType(metadata_bytes, state.metadata_pasteboard_type); - } - } - } - - unsafe fn write_image_to_clipboard(&self, image: &Image) { - unsafe { - let state = self.0.lock(); - state.pasteboard.clearContents(); - - let bytes = NSData::dataWithBytes_length_( - nil, - image.bytes.as_ptr() as *const c_void, - image.bytes.len() as u64, - ); - - state - .pasteboard - .setData_forType(bytes, Into::::into(image.format).inner_mut()); - } - } -} - -fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option { - let mut ut_type: UTType = format.into(); - - unsafe { - let types: id = pasteboard.types(); - if msg_send![types, containsObject: ut_type.inner()] { - let data = pasteboard.dataForType(ut_type.inner_mut()); - if data == nil { - None - } else { - let bytes = Vec::from(slice::from_raw_parts( - data.bytes() as *mut u8, - data.length() as usize, - )); - let id = hash(&bytes); - - Some(ClipboardItem { - entries: vec![ClipboardEntry::Image(Image { format, bytes, id })], - }) - } - } else { - None - } - } -} - unsafe fn path_from_objc(path: id) -> PathBuf { let len = msg_send![path, lengthOfBytesUsingEncoding: NSUTF8StringEncoding]; let bytes = unsafe { path.UTF8String() as *const u8 }; @@ -1543,10 +1317,6 @@ extern "C" fn handle_dock_menu(this: &mut Object, _: Sel, _: id) -> id { } } -unsafe fn ns_string(string: &str) -> id { - unsafe { NSString::alloc(nil).init_str(string).autorelease() } -} - unsafe fn ns_url_to_path(url: id) -> Result { let path: *mut c_char = msg_send![url, fileSystemRepresentation]; anyhow::ensure!(!path.is_null(), "url is not a file path: {}", unsafe { @@ -1607,120 +1377,3 @@ mod security { pub const errSecUserCanceled: OSStatus = -128; pub const errSecItemNotFound: OSStatus = -25300; } - -impl From for UTType { - fn from(value: ImageFormat) -> Self { - match value { - ImageFormat::Png => Self::png(), - ImageFormat::Jpeg => Self::jpeg(), - ImageFormat::Tiff => Self::tiff(), - ImageFormat::Webp => Self::webp(), - ImageFormat::Gif => Self::gif(), - ImageFormat::Bmp => Self::bmp(), - ImageFormat::Svg => Self::svg(), - ImageFormat::Ico => Self::ico(), - } - } -} - -// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ -struct UTType(id); - -impl UTType { - pub fn png() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png - Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType - } - - pub fn jpeg() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg - Self(unsafe { ns_string("public.jpeg") }) - } - - pub fn gif() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif - Self(unsafe { ns_string("com.compuserve.gif") }) - } - - pub fn webp() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp - Self(unsafe { ns_string("org.webmproject.webp") }) - } - - pub fn bmp() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp - Self(unsafe { ns_string("com.microsoft.bmp") }) - } - - pub fn svg() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg - Self(unsafe { ns_string("public.svg-image") }) - } - - pub fn ico() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico - Self(unsafe { ns_string("com.microsoft.ico") }) - } - - pub fn tiff() -> Self { - // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff - Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType - } - - fn inner(&self) -> *const Object { - self.0 - } - - fn inner_mut(&self) -> *mut Object { - self.0 as *mut _ - } -} - -#[cfg(test)] -mod tests { - use crate::ClipboardItem; - - use super::*; - - #[test] - fn test_clipboard() { - let platform = build_platform(); - assert_eq!(platform.read_from_clipboard(), None); - - let item = ClipboardItem::new_string("1".to_string()); - platform.write_to_clipboard(item.clone()); - assert_eq!(platform.read_from_clipboard(), Some(item)); - - let item = ClipboardItem { - entries: vec![ClipboardEntry::String( - ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]), - )], - }; - platform.write_to_clipboard(item.clone()); - assert_eq!(platform.read_from_clipboard(), Some(item)); - - let text_from_other_app = "text from other app"; - unsafe { - let bytes = NSData::dataWithBytes_length_( - nil, - text_from_other_app.as_ptr() as *const c_void, - text_from_other_app.len() as u64, - ); - platform - .0 - .lock() - .pasteboard - .setData_forType(bytes, NSPasteboardTypeString); - } - assert_eq!( - platform.read_from_clipboard(), - Some(ClipboardItem::new_string(text_from_other_app.to_string())) - ); - } - - fn build_platform() -> MacPlatform { - let platform = MacPlatform::new(false); - platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) }; - platform - } -} diff --git a/crates/gpui/src/platform/mac/screen_capture.rs b/crates/gpui/src/platform/mac/screen_capture.rs index 4d4ffa6896520e465dfeb7b1ccc06e1149f9e25d..4b80a87d32f45540c76790065514f29cc7f93b3f 100644 --- a/crates/gpui/src/platform/mac/screen_capture.rs +++ b/crates/gpui/src/platform/mac/screen_capture.rs @@ -1,3 +1,4 @@ +use super::ns_string; use crate::{ DevicePixels, ForegroundExecutor, SharedString, SourceMetadata, platform::{ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream}, @@ -7,7 +8,7 @@ use anyhow::{Result, anyhow}; use block::ConcreteBlock; use cocoa::{ base::{YES, id, nil}, - foundation::{NSArray, NSString}, + foundation::NSArray, }; use collections::HashMap; use core_foundation::base::TCFType; @@ -109,13 +110,21 @@ impl ScreenCaptureSource for MacScreenCaptureSource { let _: id = msg_send![configuration, setHeight: meta.resolution.height.0 as i64]; let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate]; + // Stream contains filter, configuration, and delegate internally so we release them here + // to prevent a memory leak when steam is dropped + let _: () = msg_send![filter, release]; + let _: () = msg_send![configuration, release]; + let _: () = msg_send![delegate, release]; + let (mut tx, rx) = oneshot::channel(); let mut error: id = nil; let _: () = msg_send![stream, addStreamOutput:output type:SCStreamOutputTypeScreen sampleHandlerQueue:0 error:&mut error as *mut id]; if error != nil { let message: id = msg_send![error, localizedDescription]; - tx.send(Err(anyhow!("failed to add stream output {message:?}"))) + let _: () = msg_send![stream, release]; + let _: () = msg_send![output, release]; + tx.send(Err(anyhow!("failed to add stream output {message:?}"))) .ok(); return rx; } @@ -131,8 +140,10 @@ impl ScreenCaptureSource for MacScreenCaptureSource { }; Ok(Box::new(stream) as Box) } else { + let _: () = msg_send![stream, release]; + let _: () = msg_send![output, release]; let message: id = msg_send![error, localizedDescription]; - Err(anyhow!("failed to stop screen capture stream {message:?}")) + Err(anyhow!("failed to start screen capture stream {message:?}")) }; if let Some(tx) = tx.borrow_mut().take() { tx.send(result).ok(); @@ -195,7 +206,7 @@ unsafe fn screen_id_to_human_label() -> HashMap { let screens: id = msg_send![class!(NSScreen), screens]; let count: usize = msg_send![screens, count]; let mut map = HashMap::default(); - let screen_number_key = unsafe { NSString::alloc(nil).init_str("NSScreenNumber") }; + let screen_number_key = unsafe { ns_string("NSScreenNumber") }; for i in 0..count { let screen: id = msg_send![screens, objectAtIndex: i]; let device_desc: id = msg_send![screen, deviceDescription]; diff --git a/crates/gpui/src/platform/mac/text_system.rs b/crates/gpui/src/platform/mac/text_system.rs index 3faf4e6491e6588bdb1341d5a8845171562fa8a0..8595582f4ad7e078f7cfb0140e249feb0a9740dc 100644 --- a/crates/gpui/src/platform/mac/text_system.rs +++ b/crates/gpui/src/platform/mac/text_system.rs @@ -8,6 +8,7 @@ use anyhow::anyhow; use cocoa::appkit::CGFloat; use collections::HashMap; use core_foundation::{ + array::{CFArray, CFArrayRef}, attributed_string::CFMutableAttributedString, base::{CFRange, TCFType}, number::CFNumber, @@ -21,8 +22,10 @@ use core_graphics::{ }; use core_text::{ font::CTFont, + font_collection::CTFontCollectionRef, font_descriptor::{ - kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait, kCTFontWidthTrait, + CTFontDescriptor, kCTFontSlantTrait, kCTFontSymbolicTrait, kCTFontWeightTrait, + kCTFontWidthTrait, }, line::CTLine, string_attributes::kCTFontAttributeName, @@ -97,7 +100,26 @@ impl PlatformTextSystem for MacTextSystem { fn all_font_names(&self) -> Vec { let mut names = Vec::new(); let collection = core_text::font_collection::create_for_all_families(); - let Some(descriptors) = collection.get_descriptors() else { + // NOTE: We intentionally avoid using `collection.get_descriptors()` here because + // it has a memory leak bug in core-text v21.0.0. The upstream code uses + // `wrap_under_get_rule` but `CTFontCollectionCreateMatchingFontDescriptors` + // follows the Create Rule (caller owns the result), so it should use + // `wrap_under_create_rule`. We call the function directly with correct memory management. + unsafe extern "C" { + fn CTFontCollectionCreateMatchingFontDescriptors( + collection: CTFontCollectionRef, + ) -> CFArrayRef; + } + let descriptors: Option> = unsafe { + let array_ref = + CTFontCollectionCreateMatchingFontDescriptors(collection.as_concrete_TypeRef()); + if array_ref.is_null() { + None + } else { + Some(CFArray::wrap_under_create_rule(array_ref)) + } + }; + let Some(descriptors) = descriptors else { return names; }; for descriptor in descriptors.into_iter() { diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 23752fc53edbc1062db19caf13c5c65fc282ca87..f843fcd943523dc9a1c228cea1c4dcdf63c76097 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -62,9 +62,12 @@ static mut BLURRED_VIEW_CLASS: *const Class = ptr::null(); #[allow(non_upper_case_globals)] const NSWindowStyleMaskNonactivatingPanel: NSWindowStyleMask = NSWindowStyleMask::from_bits_retain(1 << 7); +// WindowLevel const value ref: https://docs.rs/core-graphics2/0.4.1/src/core_graphics2/window_level.rs.html #[allow(non_upper_case_globals)] const NSNormalWindowLevel: NSInteger = 0; #[allow(non_upper_case_globals)] +const NSFloatingWindowLevel: NSInteger = 3; +#[allow(non_upper_case_globals)] const NSPopUpWindowLevel: NSInteger = 101; #[allow(non_upper_case_globals)] const NSTrackingMouseEnteredAndExited: NSUInteger = 0x01; @@ -153,6 +156,10 @@ unsafe fn build_classes() { sel!(mouseMoved:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(pressureChangeWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(mouseExited:), handle_view_event as extern "C" fn(&Object, Sel, id), @@ -419,6 +426,8 @@ struct MacWindowState { select_previous_tab_callback: Option>, toggle_tab_bar_callback: Option>, activated_least_once: bool, + // The parent window if this window is a sheet (Dialog kind) + sheet_parent: Option, } impl MacWindowState { @@ -618,11 +627,16 @@ impl MacWindow { } let native_window: id = match kind { - WindowKind::Normal | WindowKind::Floating => msg_send![WINDOW_CLASS, alloc], + WindowKind::Normal => { + msg_send![WINDOW_CLASS, alloc] + } WindowKind::PopUp => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] } + WindowKind::Floating | WindowKind::Dialog => { + msg_send![PANEL_CLASS, alloc] + } }; let display = display_id @@ -725,6 +739,7 @@ impl MacWindow { select_previous_tab_callback: None, toggle_tab_bar_callback: None, activated_least_once: false, + sheet_parent: None, }))); (*native_window).set_ivar( @@ -775,13 +790,22 @@ impl MacWindow { content_view.addSubview_(native_view.autorelease()); native_window.makeFirstResponder_(native_view); + let app: id = NSApplication::sharedApplication(nil); + let main_window: id = msg_send![app, mainWindow]; + let mut sheet_parent = None; + match kind { WindowKind::Normal | WindowKind::Floating => { - native_window.setLevel_(NSNormalWindowLevel); + if kind == WindowKind::Floating { + // Let the window float keep above normal windows. + native_window.setLevel_(NSFloatingWindowLevel); + } else { + native_window.setLevel_(NSNormalWindowLevel); + } native_window.setAcceptsMouseMovedEvents_(YES); if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -812,10 +836,23 @@ impl MacWindow { NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary ); } + WindowKind::Dialog => { + if !main_window.is_null() { + let parent = { + let active_sheet: id = msg_send![main_window, attachedSheet]; + if active_sheet.is_null() { + main_window + } else { + active_sheet + } + }; + let _: () = + msg_send![parent, beginSheet: native_window completionHandler: nil]; + sheet_parent = Some(parent); + } + } } - let app = NSApplication::sharedApplication(nil); - let main_window: id = msg_send![app, mainWindow]; if allows_automatic_window_tabbing && !main_window.is_null() && main_window != native_window @@ -857,7 +894,11 @@ impl MacWindow { // the window position might be incorrect if the main screen (the screen that contains the window that has focus) // is different from the primary screen. NSWindow::setFrameTopLeftPoint_(native_window, window_rect.origin); - window.0.lock().move_traffic_light(); + { + let mut window_state = window.0.lock(); + window_state.move_traffic_light(); + window_state.sheet_parent = sheet_parent; + } pool.drain(); @@ -904,8 +945,8 @@ impl MacWindow { pub fn get_user_tabbing_preference() -> Option { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleWindowTabbingMode"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let value: id = if !dict.is_null() { @@ -934,6 +975,7 @@ impl Drop for MacWindow { let mut this = self.0.lock(); this.renderer.destroy(); let window = this.native_window; + let sheet_parent = this.sheet_parent.take(); this.display_link.take(); unsafe { this.native_window.setDelegate_(nil); @@ -942,6 +984,9 @@ impl Drop for MacWindow { this.executor .spawn(async move { unsafe { + if let Some(parent) = sheet_parent { + let _: () = msg_send![parent, endSheet: window]; + } window.close(); window.autorelease(); } @@ -1033,7 +1078,7 @@ impl PlatformWindow for MacWindow { } if let Some(tabbing_identifier) = tabbing_identifier { - let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str()); + let tabbing_id = ns_string(tabbing_identifier.as_str()); let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id]; } else { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; @@ -1059,10 +1104,8 @@ impl PlatformWindow for MacWindow { return None; } let device_description: id = msg_send![screen, deviceDescription]; - let screen_number: id = NSDictionary::valueForKey_( - device_description, - NSString::alloc(nil).init_str("NSScreenNumber"), - ); + let screen_number: id = + NSDictionary::valueForKey_(device_description, ns_string("NSScreenNumber")); let screen_number: u32 = msg_send![screen_number, unsignedIntValue]; @@ -1188,6 +1231,7 @@ impl PlatformWindow for MacWindow { let (done_tx, done_rx) = oneshot::channel(); let done_tx = Cell::new(Some(done_tx)); let block = ConcreteBlock::new(move |answer: NSInteger| { + let _: () = msg_send![alert, release]; if let Some(done_tx) = done_tx.take() { let _ = done_tx.send(answer.try_into().unwrap()); } @@ -1505,8 +1549,8 @@ impl PlatformWindow for MacWindow { .spawn(async move { unsafe { let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = NSString::alloc(nil).init_str("NSGlobalDomain"); - let key = NSString::alloc(nil).init_str("AppleActionOnDoubleClick"); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleActionOnDoubleClick"); let dict: id = msg_send![defaults, persistentDomainForName: domain]; let action: id = if !dict.is_null() { @@ -2508,7 +2552,7 @@ where unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID { unsafe { let device_description = NSScreen::deviceDescription(screen); - let screen_number_key: id = NSString::alloc(nil).init_str("NSScreenNumber"); + let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: NSUInteger = msg_send![screen_number, unsignedIntegerValue]; screen_number as CGDirectDisplayID @@ -2554,7 +2598,7 @@ unsafe fn remove_layer_background(layer: id) { // `description` reflects its name and some parameters. Currently `NSVisualEffectView` // uses a `CAFilter` named "colorSaturate". If one day they switch to `CIFilter`, the // `description` will still contain "Saturat" ("... inputSaturation = ..."). - let test_string: id = NSString::alloc(nil).init_str("Saturat").autorelease(); + let test_string: id = ns_string("Saturat"); let count = NSArray::count(filters); for i in 0..count { let description: id = msg_send![filters.objectAtIndex(i), description]; diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index 538aacda83a095449193db6aab63f3a06189ef7a..c271430586106abc93e0bb3258c9e25a06b12383 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -1,4 +1,4 @@ -use crate::{PlatformDispatcher, RunnableVariant, TaskLabel}; +use crate::{PlatformDispatcher, Priority, RunnableVariant, TaskLabel}; use backtrace::Backtrace; use collections::{HashMap, HashSet, VecDeque}; use parking::Unparker; @@ -284,7 +284,7 @@ impl PlatformDispatcher for TestDispatcher { state.start_time + state.time } - fn dispatch(&self, runnable: RunnableVariant, label: Option) { + fn dispatch(&self, runnable: RunnableVariant, label: Option, _priority: Priority) { { let mut state = self.state.lock(); if label.is_some_and(|label| state.deprioritized_task_labels.contains(&label)) { @@ -296,7 +296,7 @@ impl PlatformDispatcher for TestDispatcher { self.unpark_all(); } - fn dispatch_on_main_thread(&self, runnable: RunnableVariant) { + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { self.state .lock() .foreground @@ -318,4 +318,10 @@ impl PlatformDispatcher for TestDispatcher { fn as_test(&self) -> Option<&TestDispatcher> { Some(self) } + + fn spawn_realtime(&self, _priority: crate::RealtimePriority, f: Box) { + std::thread::spawn(move || { + f(); + }); + } } diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index dfada364667989792325e02f8530e6c91bdf4716..ca9d5e2c3b7d405e40f208f5406f879467eafc5c 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -32,6 +32,8 @@ pub(crate) struct TestPlatform { current_clipboard_item: Mutex>, #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex>, + #[cfg(target_os = "macos")] + current_find_pasteboard_item: Mutex>, pub(crate) prompts: RefCell, screen_capture_sources: RefCell>, pub opened_url: RefCell>, @@ -117,6 +119,8 @@ impl TestPlatform { current_clipboard_item: Mutex::new(None), #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex::new(None), + #[cfg(target_os = "macos")] + current_find_pasteboard_item: Mutex::new(None), weak: weak.clone(), opened_url: Default::default(), #[cfg(target_os = "windows")] @@ -398,9 +402,8 @@ impl Platform for TestPlatform { false } - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - fn write_to_primary(&self, item: ClipboardItem) { - *self.current_primary_item.lock() = Some(item); + fn read_from_clipboard(&self) -> Option { + self.current_clipboard_item.lock().clone() } fn write_to_clipboard(&self, item: ClipboardItem) { @@ -412,8 +415,19 @@ impl Platform for TestPlatform { self.current_primary_item.lock().clone() } - fn read_from_clipboard(&self) -> Option { - self.current_clipboard_item.lock().clone() + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn write_to_primary(&self, item: ClipboardItem) { + *self.current_primary_item.lock() = Some(item); + } + + #[cfg(target_os = "macos")] + fn read_from_find_pasteboard(&self) -> Option { + self.current_find_pasteboard_item.lock().clone() + } + + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem) { + *self.current_find_pasteboard_item.lock() = Some(item); } fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { diff --git a/crates/gpui/src/platform/windows/dispatcher.rs b/crates/gpui/src/platform/windows/dispatcher.rs index dd53c86f5ed687c9b22a08779f262392f44a66ce..14486ccee9843ef9c0792d62f22fa825f0db43ee 100644 --- a/crates/gpui/src/platform/windows/dispatcher.rs +++ b/crates/gpui/src/platform/windows/dispatcher.rs @@ -7,9 +7,7 @@ use std::{ use flume::Sender; use util::ResultExt; use windows::{ - System::Threading::{ - ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority, - }, + System::Threading::{ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler}, Win32::{ Foundation::{LPARAM, WPARAM}, UI::WindowsAndMessaging::PostMessageW, @@ -55,7 +53,7 @@ impl WindowsDispatcher { Ok(()) }) }; - ThreadPool::RunWithPriorityAsync(&handler, WorkItemPriority::High).log_err(); + ThreadPool::RunAsync(&handler).log_err(); } fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) { @@ -148,14 +146,19 @@ impl PlatformDispatcher for WindowsDispatcher { current().id() == self.main_thread_id } - fn dispatch(&self, runnable: RunnableVariant, label: Option) { + fn dispatch( + &self, + runnable: RunnableVariant, + label: Option, + _priority: gpui::Priority, + ) { self.dispatch_on_threadpool(runnable); if let Some(label) = label { log::debug!("TaskLabel: {label:?}"); } } - fn dispatch_on_main_thread(&self, runnable: RunnableVariant) { + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: gpui::Priority) { match self.main_sender.send(runnable) { Ok(_) => { if !self.wake_posted.swap(true, Ordering::AcqRel) { @@ -187,4 +190,9 @@ impl PlatformDispatcher for WindowsDispatcher { fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { self.dispatch_on_threadpool_after(runnable, duration); } + + fn spawn_realtime(&self, _priority: crate::RealtimePriority, _f: Box) { + // disabled on windows for now. + unimplemented!(); + } } diff --git a/crates/gpui/src/platform/windows/display.rs b/crates/gpui/src/platform/windows/display.rs index ea8960580dc7f45f0dc878247e8387b6a1032ea2..720d459c1ce3b0251d8009dc2b77864727ed5441 100644 --- a/crates/gpui/src/platform/windows/display.rs +++ b/crates/gpui/src/platform/windows/display.rs @@ -23,6 +23,7 @@ pub(crate) struct WindowsDisplay { pub display_id: DisplayId, scale_factor: f32, bounds: Bounds, + visible_bounds: Bounds, physical_bounds: Bounds, uuid: Uuid, } @@ -36,6 +37,7 @@ impl WindowsDisplay { let screen = available_monitors().into_iter().nth(display_id.0 as _)?; let info = get_monitor_info(screen).log_err()?; let monitor_size = info.monitorInfo.rcMonitor; + let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); let scale_factor = get_scale_factor_for_monitor(screen).log_err()?; let physical_size = size( @@ -55,6 +57,14 @@ impl WindowsDisplay { ), size: physical_size.to_pixels(scale_factor), }, + visible_bounds: Bounds { + origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), + size: size( + (work_area.right - work_area.left) as f32 / scale_factor, + (work_area.bottom - work_area.top) as f32 / scale_factor, + ) + .map(crate::px), + }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), size: physical_size, @@ -66,6 +76,7 @@ impl WindowsDisplay { pub fn new_with_handle(monitor: HMONITOR) -> anyhow::Result { let info = get_monitor_info(monitor)?; let monitor_size = info.monitorInfo.rcMonitor; + let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); let display_id = available_monitors() .iter() @@ -89,6 +100,14 @@ impl WindowsDisplay { ), size: physical_size.to_pixels(scale_factor), }, + visible_bounds: Bounds { + origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), + size: size( + (work_area.right - work_area.left) as f32 / scale_factor, + (work_area.bottom - work_area.top) as f32 / scale_factor, + ) + .map(crate::px), + }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), size: physical_size, @@ -100,6 +119,7 @@ impl WindowsDisplay { fn new_with_handle_and_id(handle: HMONITOR, display_id: DisplayId) -> anyhow::Result { let info = get_monitor_info(handle)?; let monitor_size = info.monitorInfo.rcMonitor; + let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); let scale_factor = get_scale_factor_for_monitor(handle)?; let physical_size = size( @@ -119,6 +139,14 @@ impl WindowsDisplay { ), size: physical_size.to_pixels(scale_factor), }, + visible_bounds: Bounds { + origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), + size: size( + (work_area.right - work_area.left) as f32 / scale_factor, + (work_area.bottom - work_area.top) as f32 / scale_factor, + ) + .map(crate::px), + }, physical_bounds: Bounds { origin: point(monitor_size.left.into(), monitor_size.top.into()), size: physical_size, @@ -193,6 +221,10 @@ impl PlatformDisplay for WindowsDisplay { fn bounds(&self) -> Bounds { self.bounds } + + fn visible_bounds(&self) -> Bounds { + self.visible_bounds + } } fn available_monitors() -> SmallVec<[HMONITOR; 4]> { diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index e6fa6006eb95ec45f1634cb72ef63e2f622455a7..8d44ba274ea7a3eec2714d83bb6dac63fbac370b 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -40,6 +40,11 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> LRESULT { let handled = match msg { + // eagerly activate the window, so calls to `active_window` will work correctly + WM_MOUSEACTIVATE => { + unsafe { SetActiveWindow(handle).ok() }; + None + } WM_ACTIVATE => self.handle_activate_msg(wparam), WM_CREATE => self.handle_create_msg(handle), WM_MOVE => self.handle_move_msg(handle, lparam), @@ -265,6 +270,14 @@ impl WindowsWindowInner { fn handle_destroy_msg(&self, handle: HWND) -> Option { let callback = { self.state.callbacks.close.take() }; + // Re-enable parent window if this was a modal dialog + if let Some(parent_hwnd) = self.parent_hwnd { + unsafe { + let _ = EnableWindow(parent_hwnd, true); + let _ = SetForegroundWindow(parent_hwnd); + } + } + if let Some(callback) = callback { callback(); } diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index af0cb89ecc94da70cc42c8d4c397aeb2a811d6fb..0e0fdd56c54d56587c09bca14f16dd8e5aef389d 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -659,7 +659,7 @@ impl Platform for WindowsPlatform { if let Err(err) = result { // ERROR_NOT_FOUND means the credential doesn't exist. // Return Ok(None) to match macOS and Linux behavior. - if err.code().0 == ERROR_NOT_FOUND.0 as i32 { + if err.code() == ERROR_NOT_FOUND.to_hresult() { return Ok(None); } return Err(err.into()); diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 7ef92b4150e69424b68e9417dda377aa7f2e9cc0..fa804e3d160a370851701ea0a7600103b66356a8 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -83,6 +83,7 @@ pub(crate) struct WindowsWindowInner { pub(crate) validation_number: usize, pub(crate) main_receiver: flume::Receiver, pub(crate) platform_window_handle: HWND, + pub(crate) parent_hwnd: Option, } impl WindowsWindowState { @@ -241,6 +242,7 @@ impl WindowsWindowInner { main_receiver: context.main_receiver.clone(), platform_window_handle: context.platform_window_handle, system_settings: WindowsSystemSettings::new(context.display), + parent_hwnd: context.parent_hwnd, })) } @@ -368,6 +370,7 @@ struct WindowCreateContext { disable_direct_composition: bool, directx_devices: DirectXDevices, invalidate_devices: Arc, + parent_hwnd: Option, } impl WindowsWindow { @@ -390,6 +393,20 @@ impl WindowsWindow { invalidate_devices, } = creation_info; register_window_class(icon); + let parent_hwnd = if params.kind == WindowKind::Dialog { + let parent_window = unsafe { GetActiveWindow() }; + if parent_window.is_invalid() { + None + } else { + // Disable the parent window to make this dialog modal + unsafe { + EnableWindow(parent_window, false).as_bool(); + }; + Some(parent_window) + } + } else { + None + }; let hide_title_bar = params .titlebar .as_ref() @@ -416,8 +433,14 @@ impl WindowsWindow { if params.is_minimizable { dwstyle |= WS_MINIMIZEBOX; } + let dwexstyle = if params.kind == WindowKind::Dialog { + dwstyle |= WS_POPUP | WS_CAPTION; + WS_EX_DLGMODALFRAME + } else { + WS_EX_APPWINDOW + }; - (WS_EX_APPWINDOW, dwstyle) + (dwexstyle, dwstyle) }; if !disable_direct_composition { dwexstyle |= WS_EX_NOREDIRECTIONBITMAP; @@ -449,6 +472,7 @@ impl WindowsWindow { disable_direct_composition, directx_devices, invalidate_devices, + parent_hwnd, }; let creation_result = unsafe { CreateWindowExW( @@ -460,7 +484,7 @@ impl WindowsWindow { CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, - None, + parent_hwnd, None, Some(hinstance.into()), Some(&context as *const _ as *const _), @@ -716,8 +740,8 @@ impl PlatformWindow for WindowsWindow { ShowWindowAsync(hwnd, SW_RESTORE).ok().log_err(); } - SetActiveWindow(hwnd).log_err(); - SetFocus(Some(hwnd)).log_err(); + SetActiveWindow(hwnd).ok(); + SetFocus(Some(hwnd)).ok(); } // premium ragebait by windows, this is needed because the window diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index 4e3f00c412cd19c8269497ff292ce9dbdd785fbe..73f435d7e798c78d6c7320a49da804ebe703c434 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -216,3 +216,19 @@ impl Drop for ThreadTimings { thread_timings.swap_remove(index); } } + +pub(crate) fn add_task_timing(timing: TaskTiming) { + THREAD_TIMINGS.with(|timings| { + let mut timings = timings.lock(); + let timings = &mut timings.timings; + + if let Some(last_timing) = timings.iter_mut().rev().next() { + if last_timing.location == timing.location { + last_timing.end = timing.end; + return; + } + } + + timings.push_back(timing); + }); +} diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs new file mode 100644 index 0000000000000000000000000000000000000000..1ecec183c6c58a86e305343f7bcd1056cda7a581 --- /dev/null +++ b/crates/gpui/src/queue.rs @@ -0,0 +1,329 @@ +use std::{ + collections::VecDeque, + fmt, + iter::FusedIterator, + sync::{Arc, atomic::AtomicUsize}, +}; + +use rand::{Rng, SeedableRng, rngs::SmallRng}; + +use crate::Priority; + +struct PriorityQueues { + high_priority: VecDeque, + medium_priority: VecDeque, + low_priority: VecDeque, +} + +impl PriorityQueues { + fn is_empty(&self) -> bool { + self.high_priority.is_empty() + && self.medium_priority.is_empty() + && self.low_priority.is_empty() + } +} + +struct PriorityQueueState { + queues: parking_lot::Mutex>, + condvar: parking_lot::Condvar, + receiver_count: AtomicUsize, + sender_count: AtomicUsize, +} + +impl PriorityQueueState { + fn send(&self, priority: Priority, item: T) -> Result<(), SendError> { + if self + .receiver_count + .load(std::sync::atomic::Ordering::Relaxed) + == 0 + { + return Err(SendError(item)); + } + + let mut queues = self.queues.lock(); + match priority { + Priority::Realtime(_) => unreachable!(), + Priority::High => queues.high_priority.push_back(item), + Priority::Medium => queues.medium_priority.push_back(item), + Priority::Low => queues.low_priority.push_back(item), + }; + self.condvar.notify_one(); + Ok(()) + } + + fn recv<'a>(&'a self) -> Result>, RecvError> { + let mut queues = self.queues.lock(); + + let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); + if queues.is_empty() && sender_count == 0 { + return Err(crate::queue::RecvError); + } + + while queues.is_empty() { + self.condvar.wait(&mut queues); + } + + Ok(queues) + } + + fn try_recv<'a>( + &'a self, + ) -> Result>>, RecvError> { + let mut queues = self.queues.lock(); + + let sender_count = self.sender_count.load(std::sync::atomic::Ordering::Relaxed); + if queues.is_empty() && sender_count == 0 { + return Err(crate::queue::RecvError); + } + + if queues.is_empty() { + Ok(None) + } else { + Ok(Some(queues)) + } + } +} + +pub(crate) struct PriorityQueueSender { + state: Arc>, +} + +impl PriorityQueueSender { + fn new(state: Arc>) -> Self { + Self { state } + } + + pub(crate) fn send(&self, priority: Priority, item: T) -> Result<(), SendError> { + self.state.send(priority, item)?; + Ok(()) + } +} + +impl Drop for PriorityQueueSender { + fn drop(&mut self) { + self.state + .sender_count + .fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } +} + +pub(crate) struct PriorityQueueReceiver { + state: Arc>, + rand: SmallRng, + disconnected: bool, +} + +impl Clone for PriorityQueueReceiver { + fn clone(&self) -> Self { + self.state + .receiver_count + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + Self { + state: Arc::clone(&self.state), + rand: SmallRng::seed_from_u64(0), + disconnected: self.disconnected, + } + } +} + +pub(crate) struct SendError(T); + +impl fmt::Debug for SendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SendError").field(&self.0).finish() + } +} + +#[derive(Debug)] +pub(crate) struct RecvError; + +#[allow(dead_code)] +impl PriorityQueueReceiver { + pub(crate) fn new() -> (PriorityQueueSender, Self) { + let state = PriorityQueueState { + queues: parking_lot::Mutex::new(PriorityQueues { + high_priority: VecDeque::new(), + medium_priority: VecDeque::new(), + low_priority: VecDeque::new(), + }), + condvar: parking_lot::Condvar::new(), + receiver_count: AtomicUsize::new(1), + sender_count: AtomicUsize::new(1), + }; + let state = Arc::new(state); + + let sender = PriorityQueueSender::new(Arc::clone(&state)); + + let receiver = PriorityQueueReceiver { + state, + rand: SmallRng::seed_from_u64(0), + disconnected: false, + }; + + (sender, receiver) + } + + /// Tries to pop one element from the priority queue without blocking. + /// + /// This will early return if there are no elements in the queue. + /// + /// This method is best suited if you only intend to pop one element, for better performance + /// on large queues see [`Self::try_iter`] + /// + /// # Errors + /// + /// If the sender was dropped + pub(crate) fn try_pop(&mut self) -> Result, RecvError> { + self.pop_inner(false) + } + + /// Pops an element from the priority queue blocking if necessary. + /// + /// This method is best suited if you only intend to pop one element, for better performance + /// on large queues see [`Self::iter``] + /// + /// # Errors + /// + /// If the sender was dropped + pub(crate) fn pop(&mut self) -> Result { + self.pop_inner(true).map(|e| e.unwrap()) + } + + /// Returns an iterator over the elements of the queue + /// this iterator will end when all elements have been consumed and will not wait for new ones. + pub(crate) fn try_iter(self) -> TryIter { + TryIter { + receiver: self, + ended: false, + } + } + + /// Returns an iterator over the elements of the queue + /// this iterator will wait for new elements if the queue is empty. + pub(crate) fn iter(self) -> Iter { + Iter(self) + } + + #[inline(always)] + // algorithm is the loaded die from biased coin from + // https://www.keithschwarz.com/darts-dice-coins/ + fn pop_inner(&mut self, block: bool) -> Result, RecvError> { + use Priority as P; + + let mut queues = if !block { + let Some(queues) = self.state.try_recv()? else { + return Ok(None); + }; + queues + } else { + self.state.recv()? + }; + + let high = P::High.probability() * !queues.high_priority.is_empty() as u32; + let medium = P::Medium.probability() * !queues.medium_priority.is_empty() as u32; + let low = P::Low.probability() * !queues.low_priority.is_empty() as u32; + let mut mass = high + medium + low; //% + + if !queues.high_priority.is_empty() { + let flip = self.rand.random_ratio(P::High.probability(), mass); + if flip { + return Ok(queues.high_priority.pop_front()); + } + mass -= P::High.probability(); + } + + if !queues.medium_priority.is_empty() { + let flip = self.rand.random_ratio(P::Medium.probability(), mass); + if flip { + return Ok(queues.medium_priority.pop_front()); + } + mass -= P::Medium.probability(); + } + + if !queues.low_priority.is_empty() { + let flip = self.rand.random_ratio(P::Low.probability(), mass); + if flip { + return Ok(queues.low_priority.pop_front()); + } + } + + Ok(None) + } +} + +impl Drop for PriorityQueueReceiver { + fn drop(&mut self) { + self.state + .receiver_count + .fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } +} + +/// If None is returned the sender disconnected +pub(crate) struct Iter(PriorityQueueReceiver); +impl Iterator for Iter { + type Item = T; + + fn next(&mut self) -> Option { + self.0.pop().ok() + } +} +impl FusedIterator for Iter {} + +/// If None is returned there are no more elements in the queue +pub(crate) struct TryIter { + receiver: PriorityQueueReceiver, + ended: bool, +} +impl Iterator for TryIter { + type Item = Result; + + fn next(&mut self) -> Option { + if self.ended { + return None; + } + + let res = self.receiver.try_pop(); + self.ended = res.is_err(); + + res.transpose() + } +} +impl FusedIterator for TryIter {} + +#[cfg(test)] +mod tests { + use collections::HashSet; + + use super::*; + + #[test] + fn all_tasks_get_yielded() { + let (tx, mut rx) = PriorityQueueReceiver::new(); + tx.send(Priority::Medium, 20).unwrap(); + tx.send(Priority::High, 30).unwrap(); + tx.send(Priority::Low, 10).unwrap(); + tx.send(Priority::Medium, 21).unwrap(); + tx.send(Priority::High, 31).unwrap(); + + drop(tx); + + assert_eq!( + rx.iter().collect::>(), + [30, 31, 20, 21, 10].into_iter().collect::>() + ) + } + + #[test] + fn new_high_prio_task_get_scheduled_quickly() { + let (tx, mut rx) = PriorityQueueReceiver::new(); + for _ in 0..100 { + tx.send(Priority::Low, 1).unwrap(); + } + + assert_eq!(rx.pop().unwrap(), 1); + tx.send(Priority::High, 3).unwrap(); + assert_eq!(rx.pop().unwrap(), 3); + assert_eq!(rx.pop().unwrap(), 1); + } +} diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 42f8f25e47620fe673720055037b7f91f44165a2..7481b8001e5752599b90625450d7adb0c66ea2ca 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -252,6 +252,7 @@ pub struct Style { pub box_shadow: Vec, /// The text style of this element + #[refineable] pub text: TextStyleRefinement, /// The mouse cursor style shown when the mouse pointer is over an element. @@ -264,6 +265,10 @@ pub struct Style { /// Equivalent to the Tailwind `grid-cols-` pub grid_cols: Option, + /// The grid columns with min-content minimum sizing. + /// Unlike grid_cols, it won't shrink to width 0 in AvailableSpace::MinContent constraints. + pub grid_cols_min_content: Option, + /// The row span of this element /// Equivalent to the Tailwind `grid-rows-` pub grid_rows: Option, @@ -329,9 +334,13 @@ pub enum WhiteSpace { /// How to truncate text that overflows the width of the element #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum TextOverflow { - /// Truncate the text when it doesn't fit, and represent this truncation by displaying the - /// provided string. + /// Truncate the text at the end when it doesn't fit, and represent this truncation by + /// displaying the provided string (e.g., "very long te…"). Truncate(SharedString), + /// Truncate the text at the start when it doesn't fit, and represent this truncation by + /// displaying the provided string at the beginning (e.g., "…ong text here"). + /// Typically more adequate for file paths where the end is more important than the beginning. + TruncateStart(SharedString), } /// How to align text within the element @@ -771,6 +780,7 @@ impl Default for Style { opacity: None, grid_rows: None, grid_cols: None, + grid_cols_min_content: None, grid_location: None, #[cfg(debug_assertions)] @@ -1469,4 +1479,21 @@ mod tests { ] ); } + + #[perf] + fn test_text_style_refinement() { + let mut style = Style::default(); + style.refine(&StyleRefinement::default().text_size(px(20.0))); + style.refine(&StyleRefinement::default().font_weight(FontWeight::SEMIBOLD)); + + assert_eq!( + Some(AbsoluteLength::from(px(20.0))), + style.text_style().unwrap().font_size + ); + + assert_eq!( + Some(FontWeight::SEMIBOLD), + style.text_style().unwrap().font_weight + ); + } } diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index b50432d332f7e26fdd4528c1644be3c9761b6ad0..c5eef0d4496edea4d30c665c82dc0a9f00bb83be 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -1,8 +1,9 @@ use crate::{ self as gpui, AbsoluteLength, AlignContent, AlignItems, BorderStyle, CursorStyle, - DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontStyle, FontWeight, - GridPlacement, Hsla, JustifyContent, Length, SharedString, StrikethroughStyle, StyleRefinement, - TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, relative, rems, + DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle, + FontWeight, GridPlacement, Hsla, JustifyContent, Length, SharedString, StrikethroughStyle, + StyleRefinement, TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, + relative, rems, }; pub use gpui_macros::{ border_style_methods, box_shadow_style_methods, cursor_style_methods, margin_style_methods, @@ -63,43 +64,41 @@ pub trait Styled: Sized { /// Sets the whitespace of the element to `normal`. /// [Docs](https://tailwindcss.com/docs/whitespace#normal) fn whitespace_normal(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Normal); + self.text_style().white_space = Some(WhiteSpace::Normal); self } /// Sets the whitespace of the element to `nowrap`. /// [Docs](https://tailwindcss.com/docs/whitespace#nowrap) fn whitespace_nowrap(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Nowrap); + self.text_style().white_space = Some(WhiteSpace::Nowrap); self } - /// Sets the truncate overflowing text with an ellipsis (…) if needed. + /// Sets the truncate overflowing text with an ellipsis (…) at the end if needed. /// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis) fn text_ellipsis(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); + self.text_style().text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); + self + } + + /// Sets the truncate overflowing text with an ellipsis (…) at the start if needed. + /// Typically more adequate for file paths where the end is more important than the beginning. + /// Note: This doesn't exist in Tailwind CSS. + fn text_ellipsis_start(mut self) -> Self { + self.text_style().text_overflow = Some(TextOverflow::TruncateStart(ELLIPSIS)); self } /// Sets the text overflow behavior of the element. fn text_overflow(mut self, overflow: TextOverflow) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(overflow); + self.text_style().text_overflow = Some(overflow); self } /// Set the text alignment of the element. fn text_align(mut self, align: TextAlign) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_align = Some(align); + self.text_style().text_align = Some(align); self } @@ -127,7 +126,7 @@ pub trait Styled: Sized { /// Sets number of lines to show before truncating the text. /// [Docs](https://tailwindcss.com/docs/line-clamp) fn line_clamp(mut self, lines: usize) -> Self { - let mut text_style = self.text_style().get_or_insert_with(Default::default); + let mut text_style = self.text_style(); text_style.line_clamp = Some(lines); self.overflow_hidden() } @@ -395,7 +394,7 @@ pub trait Styled: Sized { } /// Returns a mutable reference to the text style that has been configured on this element. - fn text_style(&mut self) -> &mut Option { + fn text_style(&mut self) -> &mut TextStyleRefinement { let style: &mut StyleRefinement = self.style(); &mut style.text } @@ -404,7 +403,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_color(mut self, color: impl Into) -> Self { - self.text_style().get_or_insert_with(Default::default).color = Some(color.into()); + self.text_style().color = Some(color.into()); self } @@ -412,9 +411,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn font_weight(mut self, weight: FontWeight) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_weight = Some(weight); + self.text_style().font_weight = Some(weight); self } @@ -422,9 +419,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_bg(mut self, bg: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .background_color = Some(bg.into()); + self.text_style().background_color = Some(bg.into()); self } @@ -432,97 +427,77 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_size(mut self, size: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(size.into()); + self.text_style().font_size = Some(size.into()); self } /// Sets the text size to 'extra small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xs(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.75).into()); + self.text_style().font_size = Some(rems(0.75).into()); self } /// Sets the text size to 'small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_sm(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.875).into()); + self.text_style().font_size = Some(rems(0.875).into()); self } /// Sets the text size to 'base'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_base(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.0).into()); + self.text_style().font_size = Some(rems(1.0).into()); self } /// Sets the text size to 'large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_lg(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.125).into()); + self.text_style().font_size = Some(rems(1.125).into()); self } /// Sets the text size to 'extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.25).into()); + self.text_style().font_size = Some(rems(1.25).into()); self } /// Sets the text size to 'extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_2xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.5).into()); + self.text_style().font_size = Some(rems(1.5).into()); self } /// Sets the text size to 'extra extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_3xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.875).into()); + self.text_style().font_size = Some(rems(1.875).into()); self } /// Sets the font style of the element to italic. /// [Docs](https://tailwindcss.com/docs/font-style#italicizing-text) fn italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Italic); + self.text_style().font_style = Some(FontStyle::Italic); self } /// Sets the font style of the element to normal (not italic). /// [Docs](https://tailwindcss.com/docs/font-style#displaying-text-normally) fn not_italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Normal); + self.text_style().font_style = Some(FontStyle::Normal); self } /// Sets the text decoration to underline. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#underling-text) fn underline(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.underline = Some(UnderlineStyle { thickness: px(1.), ..Default::default() @@ -533,7 +508,7 @@ pub trait Styled: Sized { /// Sets the decoration of the text to have a line through it. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#adding-a-line-through-text) fn line_through(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.strikethrough = Some(StrikethroughStyle { thickness: px(1.), ..Default::default() @@ -545,15 +520,13 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_decoration_none(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .underline = None; + self.text_style().underline = None; self } /// Sets the color for the underline on this element fn text_decoration_color(mut self, color: impl Into) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.color = Some(color.into()); self @@ -562,7 +535,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a solid line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_solid(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = false; self @@ -571,7 +544,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a wavy line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_wavy(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = true; self @@ -580,7 +553,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 0px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_0(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(0.); self @@ -589,7 +562,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 1px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_1(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(1.); self @@ -598,7 +571,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 2px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_2(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(2.); self @@ -607,7 +580,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 4px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_4(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(4.); self @@ -616,7 +589,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 8px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_8(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(8.); self @@ -624,9 +597,13 @@ pub trait Styled: Sized { /// Sets the font family of this element and its children. fn font_family(mut self, family_name: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_family = Some(family_name.into()); + self.text_style().font_family = Some(family_name.into()); + self + } + + /// Sets the font features of this element and its children. + fn font_features(mut self, features: FontFeatures) -> Self { + self.text_style().font_features = Some(features); self } @@ -640,7 +617,7 @@ pub trait Styled: Sized { style, } = font; - let text_style = self.text_style().get_or_insert_with(Default::default); + let text_style = self.text_style(); text_style.font_family = Some(family); text_style.font_features = Some(features); text_style.font_weight = Some(weight); @@ -652,9 +629,7 @@ pub trait Styled: Sized { /// Sets the line height of this element and its children. fn line_height(mut self, line_height: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .line_height = Some(line_height.into()); + self.text_style().line_height = Some(line_height.into()); self } @@ -670,6 +645,13 @@ pub trait Styled: Sized { self } + /// Sets the grid columns with min-content minimum sizing. + /// Unlike grid_cols, it won't shrink to width 0 in AvailableSpace::MinContent constraints. + fn grid_cols_min_content(mut self, cols: u16) -> Self { + self.style().grid_cols_min_content = Some(cols); + self + } + /// Sets the grid rows of this element. fn grid_rows(mut self, rows: u16) -> Self { self.style().grid_rows = Some(rows); diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 11cb0872861321c3c06c3f8a5bf79fdd30eb2275..99a50b87c8aa9f40a7694f1c2084b10f6d0a9315 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -8,6 +8,7 @@ use std::{fmt::Debug, ops::Range}; use taffy::{ TaffyTree, TraversePartialTree as _, geometry::{Point as TaffyPoint, Rect as TaffyRect, Size as TaffySize}, + prelude::min_content, style::AvailableSpace as TaffyAvailableSpace, tree::NodeId, }; @@ -314,6 +315,14 @@ impl ToTaffy for Style { .unwrap_or_default() } + fn to_grid_repeat_min_content( + unit: &Option, + ) -> Vec> { + // grid-template-columns: repeat(, minmax(min-content, 1fr)); + unit.map(|count| vec![repeat(count, vec![minmax(min_content(), fr(1.0))])]) + .unwrap_or_default() + } + taffy::style::Style { display: self.display.into(), overflow: self.overflow.into(), @@ -338,7 +347,11 @@ impl ToTaffy for Style { flex_grow: self.flex_grow, flex_shrink: self.flex_shrink, grid_template_rows: to_grid_repeat(&self.grid_rows), - grid_template_columns: to_grid_repeat(&self.grid_cols), + grid_template_columns: if self.grid_cols_min_content.is_some() { + to_grid_repeat_min_content(&self.grid_cols_min_content) + } else { + to_grid_repeat(&self.grid_cols) + }, grid_row: self .grid_location .as_ref() diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 5ae72d2be1688893374e16a55445558b5bc33040..2a5711a01a9c8f2874cea4803fc517089cafd0fe 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -69,7 +69,10 @@ pub fn run_test( std::mem::forget(error); } else { if is_multiple_runs { - eprintln!("failing seed: {}", seed); + eprintln!("failing seed: {seed}"); + eprintln!( + "You can rerun from this seed by setting the environmental variable SEED to {seed}" + ); } if let Some(on_fail_fn) = on_fail_fn { on_fail_fn() diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index 84618eccc43dc3f189d3d49ea22b9d98f5ad9f85..1e71a611c7cef4b22668db8840ea5dac7fa13709 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -64,6 +64,8 @@ impl ShapedLine { &self, origin: Point, line_height: Pixels, + align: TextAlign, + align_width: Option, window: &mut Window, cx: &mut App, ) -> Result<()> { @@ -71,8 +73,8 @@ impl ShapedLine { origin, &self.layout, line_height, - TextAlign::default(), - None, + align, + align_width, &self.decoration_runs, &[], window, @@ -87,6 +89,8 @@ impl ShapedLine { &self, origin: Point, line_height: Pixels, + align: TextAlign, + align_width: Option, window: &mut Window, cx: &mut App, ) -> Result<()> { @@ -94,8 +98,8 @@ impl ShapedLine { origin, &self.layout, line_height, - TextAlign::default(), - None, + align, + align_width, &self.decoration_runs, &[], window, diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 45159313b43c508029f2525234c80c6575d0f695..457316f353a48fa112de1736b2b7eaa2d4c72313 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -2,6 +2,15 @@ use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun, use collections::HashMap; use std::{borrow::Cow, iter, sync::Arc}; +/// Determines whether to truncate text from the start or end. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TruncateFrom { + /// Truncate text from the start. + Start, + /// Truncate text from the end. + End, +} + /// The GPUI line wrapper, used to wrap lines of text to a given width. pub struct LineWrapper { platform_text_system: Arc, @@ -128,40 +137,83 @@ impl LineWrapper { }) } - /// Truncate a line of text to the given width with this wrapper's font and font size. - pub fn truncate_line<'a>( + /// Determines if a line should be truncated based on its width. + /// + /// Returns the truncation index in `line`. + pub fn should_truncate_line( &mut self, - line: SharedString, + line: &str, truncate_width: Pixels, - truncation_suffix: &str, - runs: &'a [TextRun], - ) -> (SharedString, Cow<'a, [TextRun]>) { + truncation_affix: &str, + truncate_from: TruncateFrom, + ) -> Option { let mut width = px(0.); - let mut suffix_width = truncation_suffix + let suffix_width = truncation_affix .chars() .map(|c| self.width_for_char(c)) .fold(px(0.0), |a, x| a + x); - let mut char_indices = line.char_indices(); let mut truncate_ix = 0; - for (ix, c) in char_indices { - if width + suffix_width < truncate_width { - truncate_ix = ix; - } - let char_width = self.width_for_char(c); - width += char_width; + match truncate_from { + TruncateFrom::Start => { + for (ix, c) in line.char_indices().rev() { + if width + suffix_width < truncate_width { + truncate_ix = ix; + } - if width.floor() > truncate_width { - let result = - SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix)); - let mut runs = runs.to_vec(); - update_runs_after_truncation(&result, truncation_suffix, &mut runs); + let char_width = self.width_for_char(c); + width += char_width; - return (result, Cow::Owned(runs)); + if width.floor() > truncate_width { + return Some(truncate_ix); + } + } + } + TruncateFrom::End => { + for (ix, c) in line.char_indices() { + if width + suffix_width < truncate_width { + truncate_ix = ix; + } + + let char_width = self.width_for_char(c); + width += char_width; + + if width.floor() > truncate_width { + return Some(truncate_ix); + } + } } } - (line, Cow::Borrowed(runs)) + None + } + + /// Truncate a line of text to the given width with this wrapper's font and font size. + pub fn truncate_line<'a>( + &mut self, + line: SharedString, + truncate_width: Pixels, + truncation_affix: &str, + runs: &'a [TextRun], + truncate_from: TruncateFrom, + ) -> (SharedString, Cow<'a, [TextRun]>) { + if let Some(truncate_ix) = + self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from) + { + let result = match truncate_from { + TruncateFrom::Start => { + SharedString::from(format!("{truncation_affix}{}", &line[truncate_ix + 1..])) + } + TruncateFrom::End => { + SharedString::from(format!("{}{truncation_affix}", &line[..truncate_ix])) + } + }; + let mut runs = runs.to_vec(); + update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); + (result, Cow::Owned(runs)) + } else { + (line, Cow::Borrowed(runs)) + } } /// Any character in this list should be treated as a word character, @@ -182,6 +234,11 @@ impl LineWrapper { // Cyrillic for Russian, Ukrainian, etc. // https://en.wikipedia.org/wiki/Cyrillic_script_in_Unicode matches!(c, '\u{0400}'..='\u{04FF}') || + + // Vietnamese (https://vietunicode.sourceforge.net/charset/) + matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional + matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks + // Some other known special characters that should be treated as word characters, // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, // `2^3`, `a~b`, `a=1`, `Self::new`, etc. @@ -225,15 +282,35 @@ impl LineWrapper { } } -fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec) { +fn update_runs_after_truncation( + result: &str, + ellipsis: &str, + runs: &mut Vec, + truncate_from: TruncateFrom, +) { let mut truncate_at = result.len() - ellipsis.len(); - for (run_index, run) in runs.iter_mut().enumerate() { - if run.len <= truncate_at { - truncate_at -= run.len; - } else { - run.len = truncate_at + ellipsis.len(); - runs.truncate(run_index + 1); - break; + match truncate_from { + TruncateFrom::Start => { + for (run_index, run) in runs.iter_mut().enumerate().rev() { + if run.len <= truncate_at { + truncate_at -= run.len; + } else { + run.len = truncate_at + ellipsis.len(); + runs.splice(..run_index, std::iter::empty()); + break; + } + } + } + TruncateFrom::End => { + for (run_index, run) in runs.iter_mut().enumerate() { + if run.len <= truncate_at { + truncate_at -= run.len; + } else { + run.len = truncate_at + ellipsis.len(); + runs.truncate(run_index + 1); + break; + } + } } } } @@ -483,7 +560,7 @@ mod tests { } #[test] - fn test_truncate_line() { + fn test_truncate_line_end() { let mut wrapper = build_wrapper(); fn perform_test( @@ -494,8 +571,13 @@ mod tests { ) { let dummy_run_lens = vec![text.len()]; let dummy_runs = generate_test_runs(&dummy_run_lens); - let (result, dummy_runs) = - wrapper.truncate_line(text.into(), px(220.), ellipsis, &dummy_runs); + let (result, dummy_runs) = wrapper.truncate_line( + text.into(), + px(220.), + ellipsis, + &dummy_runs, + TruncateFrom::End, + ); assert_eq!(result, expected); assert_eq!(dummy_runs.first().unwrap().len, result.len()); } @@ -521,7 +603,50 @@ mod tests { } #[test] - fn test_truncate_multiple_runs() { + fn test_truncate_line_start() { + let mut wrapper = build_wrapper(); + + fn perform_test( + wrapper: &mut LineWrapper, + text: &'static str, + expected: &'static str, + ellipsis: &str, + ) { + let dummy_run_lens = vec![text.len()]; + let dummy_runs = generate_test_runs(&dummy_run_lens); + let (result, dummy_runs) = wrapper.truncate_line( + text.into(), + px(220.), + ellipsis, + &dummy_runs, + TruncateFrom::Start, + ); + assert_eq!(result, expected); + assert_eq!(dummy_runs.first().unwrap().len, result.len()); + } + + perform_test( + &mut wrapper, + "aaaa bbbb cccc ddddd eeee fff gg", + "cccc ddddd eeee fff gg", + "", + ); + perform_test( + &mut wrapper, + "aaaa bbbb cccc ddddd eeee fff gg", + "…ccc ddddd eeee fff gg", + "…", + ); + perform_test( + &mut wrapper, + "aaaa bbbb cccc ddddd eeee fff gg", + "......dddd eeee fff gg", + "......", + ); + } + + #[test] + fn test_truncate_multiple_runs_end() { let mut wrapper = build_wrapper(); fn perform_test( @@ -534,7 +659,7 @@ mod tests { ) { let dummy_runs = generate_test_runs(run_lens); let (result, dummy_runs) = - wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs); + wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End); assert_eq!(result, expected); for (run, result_len) in dummy_runs.iter().zip(result_run_len) { assert_eq!(run.len, *result_len); @@ -580,10 +705,75 @@ mod tests { } #[test] - fn test_update_run_after_truncation() { + fn test_truncate_multiple_runs_start() { + let mut wrapper = build_wrapper(); + + #[track_caller] + fn perform_test( + wrapper: &mut LineWrapper, + text: &'static str, + expected: &str, + run_lens: &[usize], + result_run_len: &[usize], + line_width: Pixels, + ) { + let dummy_runs = generate_test_runs(run_lens); + let (result, dummy_runs) = wrapper.truncate_line( + text.into(), + line_width, + "…", + &dummy_runs, + TruncateFrom::Start, + ); + assert_eq!(result, expected); + for (run, result_len) in dummy_runs.iter().zip(result_run_len) { + assert_eq!(run.len, *result_len); + } + } + // Case 0: Normal + // Text: abcdefghijkl + // Runs: Run0 { len: 12, ... } + // + // Truncate res: …ijkl (truncate_at = 9) + // Run res: Run0 { string: …ijkl, len: 7, ... } + perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.)); + // Case 1: Drop some runs + // Text: abcdefghijkl + // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... } + // + // Truncate res: …ghijkl (truncate_at = 7) + // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len: + // 4, ... } + perform_test( + &mut wrapper, + "abcdefghijkl", + "…ghijkl", + &[4, 4, 4], + &[5, 4], + px(70.), + ); + // Case 2: Truncate at start of some run + // Text: abcdefghijkl + // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... } + // + // Truncate res: abcdefgh… (truncate_at = 3) + // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len: + // 4, ... }, Run2 { string: ijkl, len: 4, ... } + perform_test( + &mut wrapper, + "abcdefghijkl", + "…efghijkl", + &[4, 4, 4], + &[3, 4, 4], + px(90.), + ); + } + + #[test] + fn test_update_run_after_truncation_end() { fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) { let mut dummy_runs = generate_test_runs(run_lens); - update_runs_after_truncation(result, "…", &mut dummy_runs); + update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End); for (run, result_len) in dummy_runs.iter().zip(result_run_lens) { assert_eq!(run.len, *result_len); } @@ -618,7 +808,12 @@ mod tests { #[track_caller] fn assert_word(word: &str) { for c in word.chars() { - assert!(LineWrapper::is_word_char(c), "assertion failed for '{}'", c); + assert!( + LineWrapper::is_word_char(c), + "assertion failed for '{}' (unicode 0x{:x})", + c, + c as u32 + ); } } @@ -661,6 +856,8 @@ mod tests { assert_word("ƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏ"); // Cyrillic assert_word("АБВГДЕЖЗИЙКЛМНОП"); + // Vietnamese (https://github.com/zed-industries/zed/issues/23245) + assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng"); // non-word characters assert_not_word("你好"); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index dabf7cf2b42cf57becb996e1f9360aaba0b6eead..8df421feb968677be0abbb642a7127871881bcf3 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -9,14 +9,15 @@ use crate::{ KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, KeystrokeEvent, LayoutId, LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, - PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad, - Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, - SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, - SharedString, Size, StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab, - SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement, - TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, - point, prelude::*, px, rems, size, transparent_black, + PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, Priority, PromptButton, + PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, + Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, + ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style, SubscriberSet, + Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, + TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, + transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -344,8 +345,8 @@ impl FocusHandle { } /// Moves the focus to the element associated with this handle. - pub fn focus(&self, window: &mut Window) { - window.focus(self) + pub fn focus(&self, window: &mut Window, cx: &mut App) { + window.focus(self, cx) } /// Obtains whether the element associated with this handle is currently focused. @@ -875,7 +876,9 @@ pub struct Window { active: Rc>, hovered: Rc>, pub(crate) needs_present: Rc>, - pub(crate) last_input_timestamp: Rc>, + /// Tracks recent input event timestamps to determine if input is arriving at a high rate. + /// Used to selectively enable VRR optimization only when input rate exceeds 60fps. + pub(crate) input_rate_tracker: Rc>, last_input_modality: InputModality, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, @@ -896,6 +899,51 @@ struct ModifierState { saw_keystroke: bool, } +/// Tracks input event timestamps to determine if input is arriving at a high rate. +/// Used for selective VRR (Variable Refresh Rate) optimization. +#[derive(Clone, Debug)] +pub(crate) struct InputRateTracker { + timestamps: Vec, + window: Duration, + inputs_per_second: u32, + sustain_until: Instant, + sustain_duration: Duration, +} + +impl Default for InputRateTracker { + fn default() -> Self { + Self { + timestamps: Vec::new(), + window: Duration::from_millis(100), + inputs_per_second: 60, + sustain_until: Instant::now(), + sustain_duration: Duration::from_secs(1), + } + } +} + +impl InputRateTracker { + pub fn record_input(&mut self) { + let now = Instant::now(); + self.timestamps.push(now); + self.prune_old_timestamps(now); + + let min_events = self.inputs_per_second as u128 * self.window.as_millis() / 1000; + if self.timestamps.len() as u128 >= min_events { + self.sustain_until = now + self.sustain_duration; + } + } + + pub fn is_high_rate(&self) -> bool { + Instant::now() < self.sustain_until + } + + fn prune_old_timestamps(&mut self, now: Instant) { + self.timestamps + .retain(|&t| now.duration_since(t) <= self.window); + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum DrawPhase { None, @@ -918,86 +966,69 @@ pub(crate) struct ElementStateBox { pub(crate) type_name: &'static str, } -fn default_bounds(display_id: Option, cx: &mut App) -> Bounds { - #[cfg(target_os = "macos")] - { - const CASCADE_OFFSET: f32 = 25.0; - - let display = display_id - .map(|id| cx.find_display(id)) - .unwrap_or_else(|| cx.primary_display()); - - let display_bounds = display - .as_ref() - .map(|d| d.bounds()) - .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)); - - // TODO, BUG: if you open a window with the currently active window - // on the stack, this will erroneously select the 'unwrap_or_else' - // code path - let (base_origin, base_size) = cx - .active_window() - .and_then(|w| { - w.update(cx, |_, window, _| { - let bounds = window.bounds(); - (bounds.origin, bounds.size) - }) - .ok() - }) - .unwrap_or_else(|| { - let default_bounds = display - .as_ref() - .map(|d| d.default_bounds()) - .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)); - (default_bounds.origin, default_bounds.size) - }); - - let cascade_offset = point(px(CASCADE_OFFSET), px(CASCADE_OFFSET)); - let proposed_origin = base_origin + cascade_offset; - let proposed_bounds = Bounds::new(proposed_origin, base_size); - - let display_right = display_bounds.origin.x + display_bounds.size.width; - let display_bottom = display_bounds.origin.y + display_bounds.size.height; - let window_right = proposed_bounds.origin.x + proposed_bounds.size.width; - let window_bottom = proposed_bounds.origin.y + proposed_bounds.size.height; - - let fits_horizontally = window_right <= display_right; - let fits_vertically = window_bottom <= display_bottom; - - let final_origin = match (fits_horizontally, fits_vertically) { - (true, true) => proposed_origin, - (false, true) => point(display_bounds.origin.x, base_origin.y), - (true, false) => point(base_origin.x, display_bounds.origin.y), - (false, false) => display_bounds.origin, - }; - - Bounds::new(final_origin, base_size) - } - - #[cfg(not(target_os = "macos"))] - { - const DEFAULT_WINDOW_OFFSET: Point = point(px(0.), px(35.)); - - // TODO, BUG: if you open a window with the currently active window - // on the stack, this will erroneously select the 'unwrap_or_else' - // code path - cx.active_window() - .and_then(|w| w.update(cx, |_, window, _| window.bounds()).ok()) - .map(|mut bounds| { - bounds.origin += DEFAULT_WINDOW_OFFSET; - bounds - }) - .unwrap_or_else(|| { - let display = display_id - .map(|id| cx.find_display(id)) - .unwrap_or_else(|| cx.primary_display()); - - display - .as_ref() - .map(|display| display.default_bounds()) - .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE)) - }) - } +fn default_bounds(display_id: Option, cx: &mut App) -> WindowBounds { + // TODO, BUG: if you open a window with the currently active window + // on the stack, this will erroneously fallback to `None` + // + // TODO these should be the initial window bounds not considering maximized/fullscreen + let active_window_bounds = cx + .active_window() + .and_then(|w| w.update(cx, |_, window, _| window.window_bounds()).ok()); + + const CASCADE_OFFSET: f32 = 25.0; + + let display = display_id + .map(|id| cx.find_display(id)) + .unwrap_or_else(|| cx.primary_display()); + + let default_placement = || Bounds::new(point(px(0.), px(0.)), DEFAULT_WINDOW_SIZE); + + // Use visible_bounds to exclude taskbar/dock areas + let display_bounds = display + .as_ref() + .map(|d| d.visible_bounds()) + .unwrap_or_else(default_placement); + + let ( + Bounds { + origin: base_origin, + size: base_size, + }, + window_bounds_ctor, + ): (_, fn(Bounds) -> WindowBounds) = match active_window_bounds { + Some(bounds) => match bounds { + WindowBounds::Windowed(bounds) => (bounds, WindowBounds::Windowed), + WindowBounds::Maximized(bounds) => (bounds, WindowBounds::Maximized), + WindowBounds::Fullscreen(bounds) => (bounds, WindowBounds::Fullscreen), + }, + None => ( + display + .as_ref() + .map(|d| d.default_bounds()) + .unwrap_or_else(default_placement), + WindowBounds::Windowed, + ), + }; + + let cascade_offset = point(px(CASCADE_OFFSET), px(CASCADE_OFFSET)); + let proposed_origin = base_origin + cascade_offset; + let proposed_bounds = Bounds::new(proposed_origin, base_size); + + let display_right = display_bounds.origin.x + display_bounds.size.width; + let display_bottom = display_bounds.origin.y + display_bounds.size.height; + let window_right = proposed_bounds.origin.x + proposed_bounds.size.width; + let window_bottom = proposed_bounds.origin.y + proposed_bounds.size.height; + + let fits_horizontally = window_right <= display_right; + let fits_vertically = window_bottom <= display_bottom; + + let final_origin = match (fits_horizontally, fits_vertically) { + (true, true) => proposed_origin, + (false, true) => point(display_bounds.origin.x, base_origin.y), + (true, false) => point(base_origin.x, display_bounds.origin.y), + (false, false) => display_bounds.origin, + }; + window_bounds_ctor(Bounds::new(final_origin, base_size)) } impl Window { @@ -1024,13 +1055,11 @@ impl Window { tabbing_identifier, } = options; - let bounds = window_bounds - .map(|bounds| bounds.get_bounds()) - .unwrap_or_else(|| default_bounds(display_id, cx)); + let window_bounds = window_bounds.unwrap_or_else(|| default_bounds(display_id, cx)); let mut platform_window = cx.platform.open_window( handle, WindowParams { - bounds, + bounds: window_bounds.get_bounds(), titlebar, kind, is_movable, @@ -1065,18 +1094,16 @@ impl Window { let hovered = Rc::new(Cell::new(platform_window.is_hovered())); let needs_present = Rc::new(Cell::new(false)); let next_frame_callbacks: Rc>> = Default::default(); - let last_input_timestamp = Rc::new(Cell::new(Instant::now())); + let input_rate_tracker = Rc::new(RefCell::new(InputRateTracker::default())); platform_window .request_decorations(window_decorations.unwrap_or(WindowDecorations::Server)); platform_window.set_background_appearance(window_background); - if let Some(ref window_open_state) = window_bounds { - match window_open_state { - WindowBounds::Fullscreen(_) => platform_window.toggle_fullscreen(), - WindowBounds::Maximized(_) => platform_window.zoom(), - WindowBounds::Windowed(_) => {} - } + match window_bounds { + WindowBounds::Fullscreen(_) => platform_window.toggle_fullscreen(), + WindowBounds::Maximized(_) => platform_window.zoom(), + WindowBounds::Windowed(_) => {} } platform_window.on_close(Box::new({ @@ -1095,7 +1122,7 @@ impl Window { let active = active.clone(); let needs_present = needs_present.clone(); let next_frame_callbacks = next_frame_callbacks.clone(); - let last_input_timestamp = last_input_timestamp.clone(); + let input_rate_tracker = input_rate_tracker.clone(); move |request_frame_options| { let next_frame_callbacks = next_frame_callbacks.take(); if !next_frame_callbacks.is_empty() { @@ -1108,12 +1135,12 @@ impl Window { .log_err(); } - // Keep presenting the current scene for 1 extra second since the - // last input to prevent the display from underclocking the refresh rate. + // Keep presenting if input was recently arriving at a high rate (>= 60fps). + // Once high-rate input is detected, we sustain presentation for 1 second + // to prevent display underclocking during active input. let needs_present = request_frame_options.require_presentation || needs_present.get() - || (active.get() - && last_input_timestamp.get().elapsed() < Duration::from_secs(1)); + || (active.get() && input_rate_tracker.borrow_mut().is_high_rate()); if invalidator.is_dirty() || request_frame_options.force_render { measure("frame duration", || { @@ -1121,7 +1148,6 @@ impl Window { .update(&mut cx, |_, window, cx| { let arena_clear_needed = window.draw(cx); window.present(); - // drop the arena elements after present to reduce latency arena_clear_needed.clear(); }) .log_err(); @@ -1319,7 +1345,7 @@ impl Window { active, hovered, needs_present, - last_input_timestamp, + input_rate_tracker, last_input_modality: InputModality::Mouse, refreshing: false, activation_observers: SubscriberSet::new(), @@ -1456,13 +1482,25 @@ impl Window { } /// Move focus to the element associated with the given [`FocusHandle`]. - pub fn focus(&mut self, handle: &FocusHandle) { + pub fn focus(&mut self, handle: &FocusHandle, cx: &mut App) { if !self.focus_enabled || self.focus == Some(handle.id) { return; } self.focus = Some(handle.id); self.clear_pending_keystrokes(); + + // Avoid re-entrant entity updates by deferring observer notifications to the end of the + // current effect cycle, and only for this window. + let window_handle = self.handle; + cx.defer(move |cx| { + window_handle + .update(cx, |_, window, cx| { + window.pending_input_changed(cx); + }) + .ok(); + }); + self.refresh(); } @@ -1483,24 +1521,24 @@ impl Window { } /// Move focus to next tab stop. - pub fn focus_next(&mut self) { + pub fn focus_next(&mut self, cx: &mut App) { if !self.focus_enabled { return; } if let Some(handle) = self.rendered_frame.tab_stops.next(self.focus.as_ref()) { - self.focus(&handle) + self.focus(&handle, cx) } } /// Move focus to previous tab stop. - pub fn focus_prev(&mut self) { + pub fn focus_prev(&mut self, cx: &mut App) { if !self.focus_enabled { return; } if let Some(handle) = self.rendered_frame.tab_stops.prev(self.focus.as_ref()) { - self.focus(&handle) + self.focus(&handle, cx) } } @@ -1518,7 +1556,8 @@ impl Window { style } - /// Check if the platform window is maximized + /// Check if the platform window is maximized. + /// /// On some platforms (namely Windows) this is different than the bounds being the size of the display pub fn is_maximized(&self) -> bool { self.platform_window.is_maximized() @@ -1745,6 +1784,27 @@ impl Window { }) } + /// Spawn the future returned by the given closure on the application thread + /// pool, with the given priority. The closure is provided a handle to the + /// current window and an `AsyncWindowContext` for use within your future. + #[track_caller] + pub fn spawn_with_priority( + &self, + priority: Priority, + cx: &App, + f: AsyncFn, + ) -> Task + where + R: 'static, + AsyncFn: AsyncFnOnce(&mut AsyncWindowContext) -> R + 'static, + { + let handle = self.handle; + cx.spawn_with_priority(priority, async move |app| { + let mut async_window_cx = AsyncWindowContext::new_context(app.clone(), handle); + f(&mut async_window_cx).await + }) + } + fn bounds_changed(&mut self, cx: &mut App) { self.scale_factor = self.platform_window.scale_factor(); self.viewport_size = self.platform_window.content_size(); @@ -1959,7 +2019,7 @@ impl Window { } /// Determine whether the given action is available along the dispatch path to the currently focused element. - pub fn is_action_available(&self, action: &dyn Action, cx: &mut App) -> bool { + pub fn is_action_available(&self, action: &dyn Action, cx: &App) -> bool { let node_id = self.focus_node_id_in_rendered_frame(self.focused(cx).map(|handle| handle.id)); self.rendered_frame @@ -1967,6 +2027,14 @@ impl Window { .is_action_available(action, node_id) } + /// Determine whether the given action is available along the dispatch path to the given focus_handle. + pub fn is_action_available_in(&self, action: &dyn Action, focus_handle: &FocusHandle) -> bool { + let node_id = self.focus_node_id_in_rendered_frame(Some(focus_handle.id)); + self.rendered_frame + .dispatch_tree + .is_action_available(action, node_id) + } + /// The position of the mouse relative to the window. pub fn mouse_position(&self) -> Point { self.mouse_position @@ -2006,7 +2074,9 @@ impl Window { if let Some(input_handler) = self.platform_window.take_input_handler() { self.rendered_frame.input_handlers.push(Some(input_handler)); } - self.draw_roots(cx); + if !cx.mode.skip_drawing() { + self.draw_roots(cx); + } self.dirty_views.clear(); self.next_frame.window_active = self.active.get(); @@ -3667,8 +3737,6 @@ impl Window { /// Dispatch a mouse or keyboard event on the window. #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { - self.last_input_timestamp.set(Instant::now()); - // Track whether this input was keyboard-based for focus-visible styling self.last_input_modality = match &event { PlatformInput::KeyDown(_) | PlatformInput::ModifiersChanged(_) => { @@ -3701,6 +3769,9 @@ impl Window { self.modifiers = mouse_up.modifiers; PlatformInput::MouseUp(mouse_up) } + PlatformInput::MousePressure(mouse_pressure) => { + PlatformInput::MousePressure(mouse_pressure) + } PlatformInput::MouseExited(mouse_exited) => { self.modifiers = mouse_exited.modifiers; PlatformInput::MouseExited(mouse_exited) @@ -3766,6 +3837,10 @@ impl Window { self.dispatch_key_event(any_key_event, cx); } + if self.invalidator.is_dirty() { + self.input_rate_tracker.borrow_mut().record_input(); + } + DispatchEventResult { propagate: cx.propagate_event, default_prevented: self.default_prevented, @@ -4005,7 +4080,7 @@ impl Window { self.dispatch_keystroke_observers(event, None, context_stack, cx); } - fn pending_input_changed(&mut self, cx: &mut App) { + pub(crate) fn pending_input_changed(&mut self, cx: &mut App) { self.pending_input_observers .clone() .retain(&(), |callback| callback(self, cx)); @@ -4423,6 +4498,13 @@ impl Window { dispatch_tree.highest_precedence_binding_for_action(action, &context_stack) } + /// Find the bindings that can follow the current input sequence for the current context stack. + pub fn possible_bindings_for_input(&self, input: &[Keystroke]) -> Vec { + self.rendered_frame + .dispatch_tree + .possible_next_bindings_for_input(input, &self.context_stack()) + } + fn context_stack_for_focus_handle( &self, focus_handle: &FocusHandle, @@ -4932,7 +5014,7 @@ impl From> for AnyWindowHandle { } /// A handle to a window with any root view type, which can be downcast to a window with a specific root view type. -#[derive(Copy, Clone, PartialEq, Eq, Hash)] +#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] pub struct AnyWindowHandle { pub(crate) id: WindowId, state_type: TypeId, @@ -5082,6 +5164,18 @@ impl From for ElementId { } } +impl From for ElementId { + fn from(name: String) -> Self { + ElementId::Name(name.into()) + } +} + +impl From> for ElementId { + fn from(name: Arc) -> Self { + ElementId::Name(name.into()) + } +} + impl From> for ElementId { fn from(path: Arc) -> Self { ElementId::Path(path) diff --git a/crates/gpui/src/window/prompts.rs b/crates/gpui/src/window/prompts.rs index 63ad1668bec298a6b59d218bf7d4ca7cdce11e8c..980c6f6812405a8fbf4f8c6e24388ab4f967a94c 100644 --- a/crates/gpui/src/window/prompts.rs +++ b/crates/gpui/src/window/prompts.rs @@ -44,10 +44,10 @@ impl PromptHandle { if let Some(sender) = sender.take() { sender.send(e.0).ok(); window_handle - .update(cx, |_, window, _cx| { + .update(cx, |_, window, cx| { window.prompt.take(); if let Some(previous_focus) = &previous_focus { - window.focus(previous_focus); + window.focus(previous_focus, cx); } }) .ok(); @@ -55,7 +55,7 @@ impl PromptHandle { }) .detach(); - window.focus(&view.focus_handle(cx)); + window.focus(&view.focus_handle(cx), cx); RenderablePromptHandle { view: Box::new(view), diff --git a/crates/gpui_macros/src/derive_visual_context.rs b/crates/gpui_macros/src/derive_visual_context.rs index f2681bb29b92f31d31599ebb7201a42a482283d8..b827e753d9678efba01d3fdd77f8e66ea62b6bbd 100644 --- a/crates/gpui_macros/src/derive_visual_context.rs +++ b/crates/gpui_macros/src/derive_visual_context.rs @@ -62,7 +62,7 @@ pub fn derive_visual_context(input: TokenStream) -> TokenStream { V: gpui::Focusable, { let focus_handle = gpui::Focusable::focus_handle(entity, self.#app_variable); - self.#window_variable.focus(&focus_handle) + self.#window_variable.focus(&focus_handle, self.#app_variable) } } }; diff --git a/crates/gpui_tokio/src/gpui_tokio.rs b/crates/gpui_tokio/src/gpui_tokio.rs index 61dcfc48efb1dfecc04c4a131ddc32691e01e255..9cfa1493af49ee95210edb9669a6ca89095f42cd 100644 --- a/crates/gpui_tokio/src/gpui_tokio.rs +++ b/crates/gpui_tokio/src/gpui_tokio.rs @@ -5,25 +5,48 @@ use util::defer; pub use tokio::task::JoinError; +/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads. +/// +/// If you need more threads (or access to the runtime outside of GPUI), you can create the runtime +/// yourself and pass a Handle to `init_from_handle`. pub fn init(cx: &mut App) { - cx.set_global(GlobalTokio::new()); + let runtime = tokio::runtime::Builder::new_multi_thread() + // Since we now have two executors, let's try to keep our footprint small + .worker_threads(2) + .enable_all() + .build() + .expect("Failed to initialize Tokio"); + + cx.set_global(GlobalTokio::new(RuntimeHolder::Owned(runtime))); +} + +/// Initializes the Tokio wrapper using a Tokio runtime handle. +pub fn init_from_handle(cx: &mut App, handle: tokio::runtime::Handle) { + cx.set_global(GlobalTokio::new(RuntimeHolder::Shared(handle))); +} + +enum RuntimeHolder { + Owned(tokio::runtime::Runtime), + Shared(tokio::runtime::Handle), +} + +impl RuntimeHolder { + pub fn handle(&self) -> &tokio::runtime::Handle { + match self { + RuntimeHolder::Owned(runtime) => runtime.handle(), + RuntimeHolder::Shared(handle) => handle, + } + } } struct GlobalTokio { - runtime: tokio::runtime::Runtime, + runtime: RuntimeHolder, } impl Global for GlobalTokio {} impl GlobalTokio { - fn new() -> Self { - let runtime = tokio::runtime::Builder::new_multi_thread() - // Since we now have two executors, let's try to keep our footprint small - .worker_threads(2) - .enable_all() - .build() - .expect("Failed to initialize Tokio"); - + fn new(runtime: RuntimeHolder) -> Self { Self { runtime } } } @@ -40,7 +63,7 @@ impl Tokio { R: Send + 'static, { cx.read_global(|tokio: &GlobalTokio, cx| { - let join_handle = tokio.runtime.spawn(f); + let join_handle = tokio.runtime.handle().spawn(f); let abort_handle = join_handle.abort_handle(); let cancel = defer(move || { abort_handle.abort(); @@ -62,7 +85,7 @@ impl Tokio { R: Send + 'static, { cx.read_global(|tokio: &GlobalTokio, cx| { - let join_handle = tokio.runtime.spawn(f); + let join_handle = tokio.runtime.handle().spawn(f); let abort_handle = join_handle.abort_handle(); let cancel = defer(move || { abort_handle.abort(); diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index 16600627a77f6a73fa913340f29f5a2da0875de9..177f8639ca1a5d75bd0130979f4d550e3622a1b4 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -28,7 +28,6 @@ http-body.workspace = true http.workspace = true log.workspace = true parking_lot.workspace = true -reqwest.workspace = true serde.workspace = true serde_json.workspace = true serde_urlencoded.workspace = true diff --git a/crates/http_client/src/async_body.rs b/crates/http_client/src/async_body.rs index 6b99a54a7d941c290f2680bc2a599bc63251e24b..8fb49f218568ea36078d772a7225229f31a916c4 100644 --- a/crates/http_client/src/async_body.rs +++ b/crates/http_client/src/async_body.rs @@ -88,17 +88,6 @@ impl From<&'static str> for AsyncBody { } } -impl TryFrom for AsyncBody { - type Error = anyhow::Error; - - fn try_from(value: reqwest::Body) -> Result { - value - .as_bytes() - .ok_or_else(|| anyhow::anyhow!("Underlying data is a stream")) - .map(|bytes| Self::from_bytes(Bytes::copy_from_slice(bytes))) - } -} - impl> From> for AsyncBody { fn from(body: Option) -> Self { match body { diff --git a/crates/http_client/src/github_download.rs b/crates/http_client/src/github_download.rs index 3c16d5e692786282c32217108277faf2b42cf220..02dee08b215e547d632caaf5f94b0872aa6aa20d 100644 --- a/crates/http_client/src/github_download.rs +++ b/crates/http_client/src/github_download.rs @@ -1,4 +1,4 @@ -use std::{future::Future, path::Path, pin::Pin, task::Poll}; +use std::{path::Path, pin::Pin, task::Poll}; use anyhow::{Context, Result}; use async_compression::futures::bufread::GzipDecoder; @@ -85,65 +85,6 @@ pub async fn download_server_binary( Ok(()) } -pub async fn fetch_github_binary_with_digest_check( - binary_path: &Path, - metadata_path: &Path, - expected_digest: Option, - url: &str, - asset_kind: AssetKind, - download_destination: &Path, - http_client: &dyn HttpClient, - validity_check: ValidityCheck, -) -> Result<()> -where - ValidityCheck: FnOnce() -> ValidityCheckFuture, - ValidityCheckFuture: Future>, -{ - let metadata = GithubBinaryMetadata::read_from_file(metadata_path) - .await - .ok(); - - if let Some(metadata) = metadata { - let validity_check_result = validity_check().await; - - if let (Some(actual_digest), Some(expected_digest_ref)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest_ref { - if validity_check_result.is_ok() { - return Ok(()); - } - } else { - log::info!( - "SHA-256 mismatch for {binary_path:?} asset, downloading new asset. Expected: {expected_digest_ref}, Got: {actual_digest}" - ); - } - } else if validity_check_result.is_ok() { - return Ok(()); - } - } - - download_server_binary( - http_client, - url, - expected_digest.as_deref(), - download_destination, - asset_kind, - ) - .await?; - - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, - digest: expected_digest, - }, - metadata_path, - ) - .await?; - - Ok(()) -} - async fn stream_response_archive( response: impl AsyncRead + Unpin, url: &str, diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index 98c67f4e27a8e8b20489cc3c4ad4a1207e8b848f..1182ef74ca3d59a2d59419e185ff5bd673c5d505 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -8,10 +8,7 @@ use derive_more::Deref; use http::HeaderValue; pub use http::{self, Method, Request, Response, StatusCode, Uri, request::Builder}; -use futures::{ - FutureExt as _, - future::{self, BoxFuture}, -}; +use futures::future::BoxFuture; use parking_lot::Mutex; use serde::Serialize; use std::sync::Arc; @@ -110,14 +107,6 @@ pub trait HttpClient: 'static + Send + Sync { fn as_fake(&self) -> &FakeHttpClient { panic!("called as_fake on {}", type_name::()) } - - fn send_multipart_form<'a>( - &'a self, - _url: &str, - _request: reqwest::multipart::Form, - ) -> BoxFuture<'a, anyhow::Result>> { - future::ready(Err(anyhow!("not implemented"))).boxed() - } } /// An [`HttpClient`] that may have a proxy. @@ -165,14 +154,6 @@ impl HttpClient for HttpClientWithProxy { fn as_fake(&self) -> &FakeHttpClient { self.client.as_fake() } - - fn send_multipart_form<'a>( - &'a self, - url: &str, - form: reqwest::multipart::Form, - ) -> BoxFuture<'a, anyhow::Result>> { - self.client.send_multipart_form(url, form) - } } /// An [`HttpClient`] that has a base URL. @@ -306,14 +287,6 @@ impl HttpClient for HttpClientWithUrl { fn as_fake(&self) -> &FakeHttpClient { self.client.as_fake() } - - fn send_multipart_form<'a>( - &'a self, - url: &str, - request: reqwest::multipart::Form, - ) -> BoxFuture<'a, anyhow::Result>> { - self.client.send_multipart_form(url, request) - } } pub fn read_proxy_from_env() -> Option { @@ -408,6 +381,7 @@ impl FakeHttpClient { } pub fn with_404_response() -> Arc { + log::warn!("Using fake HTTP client with 404 response"); Self::create(|_| async move { Ok(Response::builder() .status(404) @@ -417,6 +391,7 @@ impl FakeHttpClient { } pub fn with_200_response() -> Arc { + log::warn!("Using fake HTTP client with 200 response"); Self::create(|_| async move { Ok(Response::builder() .status(200) diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index da3b298751d9c1921d14722490e3cbc680292099..23ae7a6d928d98aafe48d28cfe5626bbf76d29b8 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -34,8 +34,8 @@ pub enum IconName { ArrowRightLeft, ArrowUp, ArrowUpRight, - Attach, AtSign, + Attach, AudioOff, AudioOn, Backspace, @@ -45,10 +45,11 @@ pub enum IconName { BellRing, Binary, Blocks, - BoltOutlined, BoltFilled, + BoltOutlined, Book, BookCopy, + Box, CaseSensitive, Chat, Check, @@ -80,13 +81,12 @@ pub enum IconName { Debug, DebugBreakpoint, DebugContinue, + DebugDetach, DebugDisabledBreakpoint, DebugDisabledLogBreakpoint, - DebugDetach, DebugIgnoreBreakpoints, DebugLogBreakpoint, DebugPause, - DebugStepBack, DebugStepInto, DebugStepOut, DebugStepOver, @@ -137,10 +137,12 @@ pub enum IconName { GenericRestore, GitBranch, GitBranchAlt, + GitBranchPlus, Github, Hash, HistoryRerun, Image, + Inception, Indicator, Info, Json, @@ -148,6 +150,7 @@ pub enum IconName { Library, LineHeight, Link, + Linux, ListCollapse, ListFilter, ListTodo, @@ -173,8 +176,8 @@ pub enum IconName { PencilUnavailable, Person, Pin, - PlayOutlined, PlayFilled, + PlayOutlined, Plus, Power, Public, @@ -257,18 +260,18 @@ pub enum IconName { XCircle, XCircleFilled, ZedAgent, + ZedAgentTwo, ZedAssistant, ZedBurnMode, ZedBurnModeOn, - ZedSrcCustom, - ZedSrcExtension, ZedPredict, ZedPredictDisabled, ZedPredictDown, ZedPredictError, ZedPredictUp, + ZedSrcCustom, + ZedSrcExtension, ZedXCopilot, - Linux, } impl IconName { diff --git a/crates/image_viewer/src/image_info.rs b/crates/image_viewer/src/image_info.rs index 6e8956abc67868457f071e04f3c2a1957ff6c19c..6eedb13ed1a150094ae4882718f2384b06cfe6a7 100644 --- a/crates/image_viewer/src/image_info.rs +++ b/crates/image_viewer/src/image_info.rs @@ -77,9 +77,7 @@ impl Render for ImageInfo { .to_string(), ); - div().child( - Button::new("image-metadata", components.join(" • ")).label_size(LabelSize::Small), - ) + div().child(Label::new(components.join(" • ")).size(LabelSize::Small)) } } diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index d7c2341723e798b18d559895d6ea478b491eeaf7..4474b2a9bf7d8d4fe117575d9323e7fee8df1eb2 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -11,7 +11,7 @@ use gpui::{ InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Task, WeakEntity, Window, canvas, div, fill, img, opaque_grey, point, size, }; -use language::{DiskState, File as _}; +use language::File as _; use persistence::IMAGE_VIEWER; use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; use settings::Settings; @@ -195,7 +195,7 @@ impl Item for ImageView { } fn has_deleted_file(&self, cx: &App) -> bool { - self.image_item.read(cx).file.disk_state() == DiskState::Deleted + self.image_item.read(cx).file.disk_state().is_deleted() } fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind { workspace::item::ItemBufferKind::Singleton diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs index 35a5d2786f8d044dd9c41a3a4605538ea8f37c37..9b145e920e48605f19f566ca14a7caf63aff8f0a 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -686,7 +686,6 @@ impl CompletionProvider for RustStyleCompletionProvider { position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { completion_replace_range(&buffer.read(cx).snapshot(), &position).is_some() diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index 7f7985df9b98ee286c79e18a665802b1f73fbc1e..a82d27b6d015bef97b50983e05f3e2096a1ef8c7 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -33,6 +33,7 @@ pub fn init(app_state: Arc, cx: &mut App) { app_state.languages.clone(), app_state.fs.clone(), None, + false, cx, ); diff --git a/crates/json_schema_store/Cargo.toml b/crates/json_schema_store/Cargo.toml index efb1b36e7978805ec9c5a07baf9339f66a9d2f9f..2225b7c5aa68b43d45dacf404c038248ab2c897f 100644 --- a/crates/json_schema_store/Cargo.toml +++ b/crates/json_schema_store/Cargo.toml @@ -20,6 +20,7 @@ dap.workspace = true extension.workspace = true gpui.workspace = true language.workspace = true +lsp.workspace = true paths.workspace = true project.workspace = true schemars.workspace = true diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs index b44efb8b1b135850ab78460a428b5088e5fa0928..a6d07a6ac368ed47c6ecf215efe5560727dadf05 100644 --- a/crates/json_schema_store/src/json_schema_store.rs +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -2,9 +2,12 @@ use std::{str::FromStr, sync::Arc}; use anyhow::{Context as _, Result}; -use gpui::{App, AsyncApp, BorrowAppContext as _, Entity, WeakEntity}; -use language::LanguageRegistry; -use project::LspStore; +use gpui::{App, AsyncApp, BorrowAppContext as _, Entity, Task, WeakEntity}; +use language::{LanguageRegistry, language_settings::all_language_settings}; +use lsp::LanguageServerBinaryOptions; +use project::{LspStore, lsp_store::LocalLspAdapterDelegate}; +use settings::LSP_SETTINGS_SCHEMA_URL_PREFIX; +use util::schemars::{AllowTrailingCommas, DefaultDenyUnknownFields}; // Origin: https://github.com/SchemaStore/schemastore const TSCONFIG_SCHEMA: &str = include_str!("schemas/tsconfig.json"); @@ -74,23 +77,28 @@ fn handle_schema_request( lsp_store: Entity, uri: String, cx: &mut AsyncApp, -) -> Result { - let languages = lsp_store.read_with(cx, |lsp_store, _| lsp_store.languages.clone())?; - let schema = resolve_schema_request(&languages, uri, cx)?; - serde_json::to_string(&schema).context("Failed to serialize schema") +) -> Task> { + let languages = lsp_store.read_with(cx, |lsp_store, _| lsp_store.languages.clone()); + cx.spawn(async move |cx| { + let languages = languages?; + let schema = resolve_schema_request(&languages, lsp_store, uri, cx).await?; + serde_json::to_string(&schema).context("Failed to serialize schema") + }) } -pub fn resolve_schema_request( +pub async fn resolve_schema_request( languages: &Arc, + lsp_store: Entity, uri: String, cx: &mut AsyncApp, ) -> Result { let path = uri.strip_prefix("zed://schemas/").context("Invalid URI")?; - resolve_schema_request_inner(languages, path, cx) + resolve_schema_request_inner(languages, lsp_store, path, cx).await } -pub fn resolve_schema_request_inner( +pub async fn resolve_schema_request_inner( languages: &Arc, + lsp_store: Entity, path: &str, cx: &mut AsyncApp, ) -> Result { @@ -98,37 +106,106 @@ pub fn resolve_schema_request_inner( let schema_name = schema_name.unwrap_or(path); let schema = match schema_name { - "settings" => cx.update(|cx| { - let font_names = &cx.text_system().all_font_names(); - let language_names = &languages - .language_names() + "settings" if rest.is_some_and(|r| r.starts_with("lsp/")) => { + let lsp_name = rest + .and_then(|r| { + r.strip_prefix( + LSP_SETTINGS_SCHEMA_URL_PREFIX + .strip_prefix("zed://schemas/settings/") + .unwrap(), + ) + }) + .context("Invalid LSP schema path")?; + + let adapter = languages + .all_lsp_adapters() + .into_iter() + .find(|adapter| adapter.name().as_ref() as &str == lsp_name) + .with_context(|| format!("LSP adapter not found: {}", lsp_name))?; + + let delegate = cx.update(|inner_cx| { + lsp_store.update(inner_cx, |lsp_store, inner_cx| { + let Some(local) = lsp_store.as_local() else { + return None; + }; + let Some(worktree) = local.worktree_store.read(inner_cx).worktrees().next() else { + return None; + }; + Some(LocalLspAdapterDelegate::from_local_lsp( + local, &worktree, inner_cx, + )) + }) + })?.context("Failed to create adapter delegate - either LSP store is not in local mode or no worktree is available")?; + + let adapter_for_schema = adapter.clone(); + + let binary = adapter + .get_language_server_command( + delegate, + None, + LanguageServerBinaryOptions { + allow_path_lookup: true, + allow_binary_download: false, + pre_release: false, + }, + cx, + ) + .await + .await + .0.with_context(|| format!("Failed to find language server {lsp_name} to generate initialization params schema"))?; + + adapter_for_schema + .adapter + .clone() + .initialization_options_schema(&binary) + .await + .unwrap_or_else(|| { + serde_json::json!({ + "type": "object", + "additionalProperties": true + }) + }) + } + "settings" => { + let lsp_adapter_names = languages + .all_lsp_adapters() .into_iter() - .map(|name| name.to_string()) + .map(|adapter| adapter.name().to_string()) .collect::>(); - let mut icon_theme_names = vec![]; - let mut theme_names = vec![]; - if let Some(registry) = theme::ThemeRegistry::try_global(cx) { - icon_theme_names.extend( - registry - .list_icon_themes() - .into_iter() - .map(|icon_theme| icon_theme.name), - ); - theme_names.extend(registry.list_names()); - } - let icon_theme_names = icon_theme_names.as_slice(); - let theme_names = theme_names.as_slice(); - - cx.global::().json_schema( - &settings::SettingsJsonSchemaParams { - language_names, - font_names, - theme_names, - icon_theme_names, - }, - ) - })?, + cx.update(|cx| { + let font_names = &cx.text_system().all_font_names(); + let language_names = &languages + .language_names() + .into_iter() + .map(|name| name.to_string()) + .collect::>(); + + let mut icon_theme_names = vec![]; + let mut theme_names = vec![]; + if let Some(registry) = theme::ThemeRegistry::try_global(cx) { + icon_theme_names.extend( + registry + .list_icon_themes() + .into_iter() + .map(|icon_theme| icon_theme.name), + ); + theme_names.extend(registry.list_names()); + } + let icon_theme_names = icon_theme_names.as_slice(); + let theme_names = theme_names.as_slice(); + + cx.global::().json_schema( + &settings::SettingsJsonSchemaParams { + language_names, + font_names, + theme_names, + icon_theme_names, + lsp_adapter_names: &lsp_adapter_names, + }, + ) + })? + } "keymap" => cx.update(settings::KeymapFile::generate_json_schema_for_registered_actions)?, "action" => { let normalized_action_name = rest.context("No Action name provided")?; @@ -159,14 +236,35 @@ pub fn resolve_schema_request_inner( } } "snippets" => snippet_provider::format::VsSnippetsFile::generate_json_schema(), + "jsonc" => jsonc_schema(), _ => { - anyhow::bail!("Unrecognized builtin JSON schema: {}", schema_name); + anyhow::bail!("Unrecognized builtin JSON schema: {schema_name}"); } }; Ok(schema) } -pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value { +const JSONC_LANGUAGE_NAME: &str = "JSONC"; + +pub fn all_schema_file_associations( + languages: &Arc, + cx: &mut App, +) -> serde_json::Value { + let extension_globs = languages + .available_language_for_name(JSONC_LANGUAGE_NAME) + .map(|language| language.matcher().path_suffixes.clone()) + .into_iter() + .flatten() + // Path suffixes can be entire file names or just their extensions. + .flat_map(|path_suffix| [format!("*.{path_suffix}"), path_suffix]); + let override_globs = all_language_settings(None, cx) + .file_types + .get(JSONC_LANGUAGE_NAME) + .into_iter() + .flat_map(|(_, glob_strings)| glob_strings) + .cloned(); + let jsonc_globs = extension_globs.chain(override_globs).collect::>(); + let mut file_associations = serde_json::json!([ { "fileMatch": [ @@ -211,6 +309,10 @@ pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value { "fileMatch": ["package.json"], "url": "zed://schemas/package_json" }, + { + "fileMatch": &jsonc_globs, + "url": "zed://schemas/jsonc" + }, ]); #[cfg(debug_assertions)] @@ -233,7 +335,7 @@ pub fn all_schema_file_associations(cx: &mut App) -> serde_json::Value { let file_name = normalized_action_name_to_file_name(normalized_name.clone()); serde_json::json!({ "fileMatch": [file_name], - "url": format!("zed://schemas/action/{}", normalized_name) + "url": format!("zed://schemas/action/{normalized_name}") }) }), ); @@ -249,6 +351,26 @@ fn package_json_schema() -> serde_json::Value { serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap() } +fn jsonc_schema() -> serde_json::Value { + let generator = schemars::generate::SchemaSettings::draft2019_09() + .with_transform(DefaultDenyUnknownFields) + .with_transform(AllowTrailingCommas) + .into_generator(); + let meta_schema = generator + .settings() + .meta_schema + .as_ref() + .expect("meta_schema should be present in schemars settings") + .to_string(); + let defs = generator.definitions(); + let schema = schemars::json_schema!({ + "$schema": meta_schema, + "allowTrailingCommas": true, + "$defs": defs, + }); + serde_json::to_value(schema).unwrap() +} + fn generate_inspector_style_schema() -> serde_json::Value { let schema = schemars::generate::SchemaSettings::draft2019_09() .with_transform(util::schemars::DefaultDenyUnknownFields) diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index ce78a1d60ac610bbf10383377fef667c0a4eaa36..be20feaf5f8c1feea5b08fa3a6a3b542b26ef5ce 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -81,50 +81,61 @@ pub fn init(cx: &mut App) { let keymap_event_channel = KeymapEventChannel::new(); cx.set_global(keymap_event_channel); - fn common(filter: Option, cx: &mut App) { - workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| { - workspace - .with_local_workspace(window, cx, move |workspace, window, cx| { - let existing = workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()); - - let keymap_editor = if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - existing - } else { - let keymap_editor = - cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); - workspace.add_item_to_active_pane( - Box::new(keymap_editor.clone()), - None, - true, - window, - cx, - ); - keymap_editor - }; - - if let Some(filter) = filter { - keymap_editor.update(cx, |editor, cx| { - editor.filter_editor.update(cx, |editor, cx| { - editor.clear(window, cx); - editor.insert(&filter, window, cx); - }); - if !editor.has_binding_for(&filter) { - open_binding_modal_after_loading(cx) - } - }) - } - }) - .detach(); - }) + fn open_keymap_editor( + filter: Option, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + workspace + .with_local_workspace(window, cx, |workspace, window, cx| { + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); + + let keymap_editor = if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + existing + } else { + let keymap_editor = + cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); + workspace.add_item_to_active_pane( + Box::new(keymap_editor.clone()), + None, + true, + window, + cx, + ); + keymap_editor + }; + + if let Some(filter) = filter { + keymap_editor.update(cx, |editor, cx| { + editor.filter_editor.update(cx, |editor, cx| { + editor.clear(window, cx); + editor.insert(&filter, window, cx); + }); + if !editor.has_binding_for(&filter) { + open_binding_modal_after_loading(cx) + } + }) + } + }) + .detach_and_log_err(cx); } - cx.on_action(|_: &OpenKeymap, cx| common(None, cx)); - cx.on_action(|action: &ChangeKeybinding, cx| common(Some(action.action.clone()), cx)); + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace + .register_action(|workspace, _: &OpenKeymap, window, cx| { + open_keymap_editor(None, workspace, window, cx); + }) + .register_action(|workspace, action: &ChangeKeybinding, window, cx| { + open_keymap_editor(Some(action.action.clone()), workspace, window, cx); + }); + }) + .detach(); register_serializable_item::(cx); } @@ -900,7 +911,7 @@ impl KeymapEditor { .focus_handle(cx) .contains_focused(window, cx) { - window.focus(&self.filter_editor.focus_handle(cx)); + window.focus(&self.filter_editor.focus_handle(cx), cx); } else { self.filter_editor.update(cx, |editor, cx| { editor.select_all(&Default::default(), window, cx); @@ -937,7 +948,7 @@ impl KeymapEditor { if let Some(scroll_strategy) = scroll { self.scroll_to_item(index, scroll_strategy, cx); } - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); cx.notify(); } } @@ -987,7 +998,7 @@ impl KeymapEditor { }); let context_menu_handle = context_menu.focus_handle(cx); - window.defer(cx, move |window, _cx| window.focus(&context_menu_handle)); + window.defer(cx, move |window, cx| window.focus(&context_menu_handle, cx)); let subscription = cx.subscribe_in( &context_menu, window, @@ -1003,7 +1014,7 @@ impl KeymapEditor { fn dismiss_context_menu(&mut self, window: &mut Window, cx: &mut Context) { self.context_menu.take(); - window.focus(&self.focus_handle); + window.focus(&self.focus_handle, cx); cx.notify(); } @@ -1219,7 +1230,7 @@ impl KeymapEditor { window, cx, ); - window.focus(&modal.focus_handle(cx)); + window.focus(&modal.focus_handle(cx), cx); modal }); }) @@ -1327,7 +1338,7 @@ impl KeymapEditor { editor.stop_recording(&StopRecording, window, cx); editor.clear_keystrokes(&ClearKeystrokes, window, cx); }); - window.focus(&self.filter_editor.focus_handle(cx)); + window.focus(&self.filter_editor.focus_handle(cx), cx); } } } @@ -2687,32 +2698,32 @@ impl KeybindingEditorModalFocusState { .map(|i| i as i32) } - fn focus_index(&self, mut index: i32, window: &mut Window) { + fn focus_index(&self, mut index: i32, window: &mut Window, cx: &mut App) { if index < 0 { index = self.handles.len() as i32 - 1; } if index >= self.handles.len() as i32 { index = 0; } - window.focus(&self.handles[index as usize]); + window.focus(&self.handles[index as usize], cx); } - fn focus_next(&self, window: &mut Window, cx: &App) { + fn focus_next(&self, window: &mut Window, cx: &mut App) { let index_to_focus = if let Some(index) = self.focused_index(window, cx) { index + 1 } else { 0 }; - self.focus_index(index_to_focus, window); + self.focus_index(index_to_focus, window, cx); } - fn focus_previous(&self, window: &mut Window, cx: &App) { + fn focus_previous(&self, window: &mut Window, cx: &mut App) { let index_to_focus = if let Some(index) = self.focused_index(window, cx) { index - 1 } else { self.handles.len() as i32 - 1 }; - self.focus_index(index_to_focus, window); + self.focus_index(index_to_focus, window, cx); } } @@ -2746,7 +2757,7 @@ impl ActionArgumentsEditor { ) -> Self { let focus_handle = cx.focus_handle(); cx.on_focus_in(&focus_handle, window, |this, window, cx| { - this.editor.focus_handle(cx).focus(window); + this.editor.focus_handle(cx).focus(window, cx); }) .detach(); let editor = cx.new(|cx| { @@ -2799,7 +2810,7 @@ impl ActionArgumentsEditor { this.update_in(cx, |this, window, cx| { if this.editor.focus_handle(cx).is_focused(window) { - editor.focus_handle(cx).focus(window); + editor.focus_handle(cx).focus(window, cx); } this.editor = editor; this.backup_temp_dir = backup_temp_dir; @@ -3001,7 +3012,6 @@ impl CompletionProvider for KeyContextCompletionProvider { _position: language::Anchor, text: &str, _trigger_in_words: bool, - _menu_is_open: bool, _cx: &mut Context, ) -> bool { text.chars() diff --git a/crates/keymap_editor/src/ui_components/keystroke_input.rs b/crates/keymap_editor/src/ui_components/keystroke_input.rs index 6936de784f9d5c16b218d0952c41d6336299a0f9..496a8ae7e6359bc169845542a0f05800008a4786 100644 --- a/crates/keymap_editor/src/ui_components/keystroke_input.rs +++ b/crates/keymap_editor/src/ui_components/keystroke_input.rs @@ -388,7 +388,7 @@ impl KeystrokeInput { window: &mut Window, cx: &mut Context, ) { - window.focus(&self.inner_focus_handle); + window.focus(&self.inner_focus_handle, cx); self.clear_keystrokes(&ClearKeystrokes, window, cx); self.previous_modifiers = window.modifiers(); #[cfg(test)] @@ -407,7 +407,7 @@ impl KeystrokeInput { if !self.is_recording(window) { return; } - window.focus(&self.outer_focus_handle); + window.focus(&self.outer_focus_handle, cx); if let Some(close_keystrokes_start) = self.close_keystrokes_start.take() && close_keystrokes_start < self.keystrokes.len() { diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 49ea681290c3edc878391a337c5423fa795dba4f..06d41e729bfabbf4f7e050409d2675dd909941d6 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -32,6 +32,7 @@ async-trait.workspace = true clock.workspace = true collections.workspace = true ec4rs.workspace = true +encoding_rs.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true @@ -48,6 +49,7 @@ rand = { workspace = true, optional = true } regex.workspace = true rpc.workspace = true schemars.workspace = true +semver.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 46d7f655627e1c01612b703a64bc0ab58d1b6669..c919aeca15714864e5a54f8b56d3f8517994cb56 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1,15 +1,15 @@ pub mod row_chunk; use crate::{ - DebuggerTextObject, LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, - TextObject, TreeSitterOptions, + DebuggerTextObject, LanguageScope, Outline, OutlineConfig, PLAIN_TEXT, RunnableCapture, + RunnableTag, TextObject, TreeSitterOptions, diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}, language_settings::{LanguageSettings, language_settings}, outline::OutlineItem, row_chunk::RowChunks, syntax_map::{ - SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatch, - SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint, + MAX_BYTES_TO_QUERY, SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, + SyntaxMapMatch, SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint, }, task_context::RunnableRange, text_diff::text_diff, @@ -22,9 +22,10 @@ pub use crate::{ proto, }; use anyhow::{Context as _, Result}; +use clock::Lamport; pub use clock::ReplicaId; -use clock::{Global, Lamport}; use collections::{HashMap, HashSet}; +use encoding_rs::Encoding; use fs::MTime; use futures::channel::oneshot; use gpui::{ @@ -32,9 +33,8 @@ use gpui::{ Task, TaskLabel, TextStyle, }; -use itertools::Itertools; use lsp::{LanguageServerId, NumberOrString}; -use parking_lot::{Mutex, RawMutex, lock_api::MutexGuard}; +use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use serde_json::Value; use settings::WorktreeId; @@ -45,7 +45,7 @@ use std::{ borrow::Cow, cell::Cell, cmp::{self, Ordering, Reverse}, - collections::{BTreeMap, BTreeSet, hash_map}, + collections::{BTreeMap, BTreeSet}, future::Future, iter::{self, Iterator, Peekable}, mem, @@ -131,29 +131,39 @@ pub struct Buffer { has_unsaved_edits: Cell<(clock::Global, bool)>, change_bits: Vec>>, _subscriptions: Vec, - tree_sitter_data: Arc>, + tree_sitter_data: Arc, + encoding: &'static Encoding, + has_bom: bool, } -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct TreeSitterData { chunks: RowChunks, - brackets_by_chunks: Vec>>>, + brackets_by_chunks: Mutex>>>>, } const MAX_ROWS_IN_A_CHUNK: u32 = 50; impl TreeSitterData { - fn clear(&mut self) { - self.brackets_by_chunks = vec![None; self.chunks.len()]; + fn clear(&mut self, snapshot: text::BufferSnapshot) { + self.chunks = RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK); + self.brackets_by_chunks.get_mut().clear(); + self.brackets_by_chunks + .get_mut() + .resize(self.chunks.len(), None); } fn new(snapshot: text::BufferSnapshot) -> Self { let chunks = RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK); Self { - brackets_by_chunks: vec![None; chunks.len()], + brackets_by_chunks: Mutex::new(vec![None; chunks.len()]), chunks, } } + + fn version(&self) -> &clock::Global { + self.chunks.version() + } } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -177,7 +187,7 @@ pub struct BufferSnapshot { remote_selections: TreeMap, language: Option>, non_text_state_update_count: usize, - tree_sitter_data: Arc>, + tree_sitter_data: Arc, } /// The kind and amount of indentation in a particular line. For now, @@ -238,6 +248,8 @@ struct SelectionSet { 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, @@ -352,7 +364,8 @@ pub enum BufferEvent { /// The buffer is in need of a reload ReloadNeeded, /// The buffer's language was changed. - LanguageChanged, + /// The boolean indicates whether this buffer did not have a language before, but does now. + LanguageChanged(bool), /// The buffer's syntax trees were updated. Reparsed, /// The buffer's diagnostics were updated. @@ -414,6 +427,9 @@ pub enum DiskState { Present { mtime: MTime }, /// Deleted file that was previously present. Deleted, + /// An old version of a file that was previously present + /// usually from a version control system. e.g. A git blob + Historic { was_deleted: bool }, } impl DiskState { @@ -423,6 +439,7 @@ impl DiskState { DiskState::New => None, DiskState::Present { mtime } => Some(mtime), DiskState::Deleted => None, + DiskState::Historic { .. } => None, } } @@ -431,6 +448,16 @@ impl DiskState { DiskState::New => false, DiskState::Present { .. } => true, DiskState::Deleted => false, + DiskState::Historic { .. } => false, + } + } + + /// Returns true if this state represents a deleted file. + pub fn is_deleted(&self) -> bool { + match self { + DiskState::Deleted => true, + DiskState::Historic { was_deleted } => *was_deleted, + _ => false, } } } @@ -1060,7 +1087,7 @@ impl Buffer { let tree_sitter_data = TreeSitterData::new(snapshot); Self { saved_mtime, - tree_sitter_data: Arc::new(Mutex::new(tree_sitter_data)), + tree_sitter_data: Arc::new(tree_sitter_data), saved_version: buffer.version(), preview_version: buffer.version(), reload_task: None, @@ -1090,6 +1117,8 @@ impl Buffer { has_conflict: false, change_bits: Default::default(), _subscriptions: Vec::new(), + encoding: encoding_rs::UTF_8, + has_bom: false, } } @@ -1117,7 +1146,7 @@ impl Buffer { file: None, diagnostics: Default::default(), remote_selections: Default::default(), - tree_sitter_data: Arc::new(Mutex::new(tree_sitter_data)), + tree_sitter_data: Arc::new(tree_sitter_data), language, non_text_state_update_count: 0, } @@ -1139,7 +1168,7 @@ impl Buffer { BufferSnapshot { text, syntax, - tree_sitter_data: Arc::new(Mutex::new(tree_sitter_data)), + tree_sitter_data: Arc::new(tree_sitter_data), file: None, diagnostics: Default::default(), remote_selections: Default::default(), @@ -1168,7 +1197,7 @@ impl Buffer { BufferSnapshot { text, syntax, - tree_sitter_data: Arc::new(Mutex::new(tree_sitter_data)), + tree_sitter_data: Arc::new(tree_sitter_data), file: None, diagnostics: Default::default(), remote_selections: Default::default(), @@ -1185,10 +1214,16 @@ impl Buffer { syntax_map.interpolate(&text); let syntax = syntax_map.snapshot(); + let tree_sitter_data = if self.text.version() != *self.tree_sitter_data.version() { + Arc::new(TreeSitterData::new(text.clone())) + } else { + self.tree_sitter_data.clone() + }; + BufferSnapshot { text, syntax, - tree_sitter_data: self.tree_sitter_data.clone(), + tree_sitter_data, file: self.file.clone(), remote_selections: self.remote_selections.clone(), diagnostics: self.diagnostics.clone(), @@ -1367,6 +1402,26 @@ impl Buffer { self.saved_mtime } + /// Returns the character encoding of the buffer's file. + pub fn encoding(&self) -> &'static Encoding { + self.encoding + } + + /// Sets the character encoding of the buffer. + pub fn set_encoding(&mut self, encoding: &'static Encoding) { + self.encoding = encoding; + } + + /// Returns whether the buffer has a Byte Order Mark. + pub fn has_bom(&self) -> bool { + self.has_bom + } + + /// Sets whether the buffer has a Byte Order Mark. + pub fn set_has_bom(&mut self, has_bom: bool) { + self.has_bom = has_bom; + } + /// Assign a language to the buffer. pub fn set_language_async(&mut self, language: Option>, cx: &mut Context) { self.set_language_(language, cfg!(any(test, feature = "test-support")), cx); @@ -1385,10 +1440,12 @@ impl Buffer { ) { self.non_text_state_update_count += 1; self.syntax_map.lock().clear(&self.text); - self.language = language; + let old_language = std::mem::replace(&mut self.language, language); self.was_changed(); self.reparse(cx, may_block); - cx.emit(BufferEvent::LanguageChanged); + let has_fresh_language = + self.language.is_some() && old_language.is_none_or(|old| old == *PLAIN_TEXT); + cx.emit(BufferEvent::LanguageChanged(has_fresh_language)); } /// Assign a language registry to the buffer. This allows the buffer to retrieve @@ -1447,19 +1504,23 @@ impl Buffer { let (tx, rx) = futures::channel::oneshot::channel(); let prev_version = self.text.version(); self.reload_task = Some(cx.spawn(async move |this, cx| { - let Some((new_mtime, new_text)) = this.update(cx, |this, cx| { + let Some((new_mtime, load_bytes_task, encoding)) = this.update(cx, |this, cx| { let file = this.file.as_ref()?.as_local()?; - - Some((file.disk_state().mtime(), file.load(cx))) + Some(( + file.disk_state().mtime(), + file.load_bytes(cx), + this.encoding, + )) })? else { return Ok(()); }; - let new_text = new_text.await?; - let diff = this - .update(cx, |this, cx| this.diff(new_text.clone(), cx))? - .await; + let bytes = load_bytes_task.await?; + let (cow, _encoding_used, _has_errors) = encoding.decode(&bytes); + let new_text = cow.into_owned(); + + let diff = this.update(cx, |this, cx| this.diff(new_text, cx))?.await; this.update(cx, |this, cx| { if this.version() == diff.base_version { this.finalize_last_transaction(); @@ -1620,6 +1681,16 @@ impl Buffer { self.sync_parse_timeout = timeout; } + fn invalidate_tree_sitter_data(&mut self, snapshot: text::BufferSnapshot) { + match Arc::get_mut(&mut self.tree_sitter_data) { + Some(tree_sitter_data) => tree_sitter_data.clear(snapshot), + None => { + let tree_sitter_data = TreeSitterData::new(snapshot); + self.tree_sitter_data = Arc::new(tree_sitter_data) + } + } + } + /// Called after an edit to synchronize the buffer's main parse tree with /// the buffer's new underlying state. /// @@ -1644,6 +1715,9 @@ impl Buffer { /// for the same buffer, we only initiate a new parse if we are not already /// parsing in the background. pub fn reparse(&mut self, cx: &mut Context, may_block: bool) { + if self.text.version() != *self.tree_sitter_data.version() { + self.invalidate_tree_sitter_data(self.text.snapshot()); + } if self.reparse.is_some() { return; } @@ -1745,7 +1819,7 @@ impl Buffer { self.syntax_map.lock().did_parse(syntax_snapshot); self.request_autoindent(cx); self.parse_status.0.send(ParseStatus::Idle).unwrap(); - self.tree_sitter_data.lock().clear(); + self.invalidate_tree_sitter_data(self.text.snapshot()); cx.emit(BufferEvent::Reparsed); cx.notify(); } @@ -2214,6 +2288,7 @@ impl Buffer { None => true, }, DiskState::Deleted => false, + DiskState::Historic { .. } => false, } } @@ -3183,15 +3258,22 @@ impl BufferSnapshot { struct StartPosition { start: Point, suffix: SharedString, + language: Arc, } // Find the suggested indentation ranges based on the syntax tree. let start = Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0); let end = Point::new(row_range.end, 0); let range = (start..end).to_offset(&self.text); - let mut matches = self.syntax.matches(range.clone(), &self.text, |grammar| { - Some(&grammar.indents_config.as_ref()?.query) - }); + let mut matches = self.syntax.matches_with_options( + range.clone(), + &self.text, + TreeSitterOptions { + max_bytes_to_query: Some(MAX_BYTES_TO_QUERY), + max_start_depth: None, + }, + |grammar| Some(&grammar.indents_config.as_ref()?.query), + ); let indent_configs = matches .grammars() .iter() @@ -3220,6 +3302,7 @@ impl BufferSnapshot { start_positions.push(StartPosition { start: Point::from_ts_point(capture.node.start_position()), suffix: suffix.clone(), + language: mat.language.clone(), }); } } @@ -3270,8 +3353,7 @@ impl BufferSnapshot { // set its end to the outdent position if let Some(range_to_truncate) = indent_ranges .iter_mut() - .filter(|indent_range| indent_range.contains(&outdent_position)) - .next_back() + .rfind(|indent_range| indent_range.contains(&outdent_position)) { range_to_truncate.end = outdent_position; } @@ -3281,7 +3363,7 @@ impl BufferSnapshot { // Find the suggested indentation increases and decreased based on regexes. let mut regex_outdent_map = HashMap::default(); - let mut last_seen_suffix: HashMap> = HashMap::default(); + let mut last_seen_suffix: HashMap> = HashMap::default(); let mut start_positions_iter = start_positions.iter().peekable(); let mut indent_change_rows = Vec::<(u32, Ordering)>::new(); @@ -3289,14 +3371,21 @@ impl BufferSnapshot { Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0) ..Point::new(row_range.end, 0), |row, line| { - if config + let indent_len = self.indent_size_for_line(row).len; + let row_language = self.language_at(Point::new(row, indent_len)).cloned(); + let row_language_config = row_language + .as_ref() + .map(|lang| lang.config()) + .unwrap_or(config); + + if row_language_config .decrease_indent_pattern .as_ref() .is_some_and(|regex| regex.is_match(line)) { indent_change_rows.push((row, Ordering::Less)); } - if config + if row_language_config .increase_indent_pattern .as_ref() .is_some_and(|regex| regex.is_match(line)) @@ -3305,16 +3394,16 @@ impl BufferSnapshot { } while let Some(pos) = start_positions_iter.peek() { if pos.start.row < row { - let pos = start_positions_iter.next().unwrap(); + let pos = start_positions_iter.next().unwrap().clone(); last_seen_suffix .entry(pos.suffix.to_string()) .or_default() - .push(pos.start); + .push(pos); } else { break; } } - for rule in &config.decrease_indent_patterns { + for rule in &row_language_config.decrease_indent_patterns { if rule.pattern.as_ref().is_some_and(|r| r.is_match(line)) { let row_start_column = self.indent_size_for_line(row).len; let basis_row = rule @@ -3322,10 +3411,16 @@ impl BufferSnapshot { .iter() .filter_map(|valid_suffix| last_seen_suffix.get(valid_suffix)) .flatten() - .filter(|start_point| start_point.column <= row_start_column) - .max_by_key(|start_point| start_point.row); - if let Some(outdent_to_row) = basis_row { - regex_outdent_map.insert(row, outdent_to_row.row); + .filter(|pos| { + row_language + .as_ref() + .or(self.language.as_ref()) + .is_some_and(|lang| Arc::ptr_eq(lang, &pos.language)) + }) + .filter(|pos| pos.start.column <= row_start_column) + .max_by_key(|pos| pos.start.row); + if let Some(outdent_to) = basis_row { + regex_outdent_map.insert(row, outdent_to.start.row); } break; } @@ -4021,6 +4116,20 @@ impl BufferSnapshot { }) } + pub fn outline_items_as_offsets_containing( + &self, + range: Range, + include_extra_context: bool, + theme: Option<&SyntaxTheme>, + ) -> Vec> { + self.outline_items_containing_internal( + range, + include_extra_context, + theme, + |buffer, range| range.to_offset(buffer), + ) + } + fn outline_items_containing_internal( &self, range: Range, @@ -4263,177 +4372,125 @@ impl BufferSnapshot { pub fn fetch_bracket_ranges( &self, range: Range, - known_chunks: Option<(&Global, &HashSet>)>, + known_chunks: Option<&HashSet>>, ) -> HashMap, Vec>> { - let mut tree_sitter_data = self.latest_tree_sitter_data().clone(); - - let known_chunks = match known_chunks { - Some((known_version, known_chunks)) => { - if !tree_sitter_data - .chunks - .version() - .changed_since(known_version) - { - known_chunks.clone() - } else { - HashSet::default() - } - } - None => HashSet::default(), - }; - - let mut new_bracket_matches = HashMap::default(); let mut all_bracket_matches = HashMap::default(); - let mut bracket_matches_to_color = HashMap::default(); - for chunk in tree_sitter_data + for chunk in self + .tree_sitter_data .chunks - .applicable_chunks(&[self.anchor_before(range.start)..self.anchor_after(range.end)]) + .applicable_chunks(&[range.to_point(self)]) { - if known_chunks.contains(&chunk.row_range()) { + if known_chunks.is_some_and(|chunks| chunks.contains(&chunk.row_range())) { continue; } - let Some(chunk_range) = tree_sitter_data.chunks.chunk_range(chunk) else { + let chunk_range = chunk.anchor_range(); + let chunk_range = chunk_range.to_offset(&self); + + if let Some(cached_brackets) = + &self.tree_sitter_data.brackets_by_chunks.lock()[chunk.id] + { + all_bracket_matches.insert(chunk.row_range(), cached_brackets.clone()); continue; - }; - let chunk_range = chunk_range.to_offset(&tree_sitter_data.chunks.snapshot); - - let bracket_matches = match tree_sitter_data.brackets_by_chunks[chunk.id].take() { - Some(cached_brackets) => cached_brackets, - None => { - let mut bracket_pairs_ends = Vec::new(); - let mut matches = - self.syntax - .matches(chunk_range.clone(), &self.text, |grammar| { - grammar.brackets_config.as_ref().map(|c| &c.query) - }); - let configs = matches - .grammars() - .iter() - .map(|grammar| grammar.brackets_config.as_ref().unwrap()) - .collect::>(); - - let chunk_range = chunk_range.clone(); - let tree_sitter_matches = iter::from_fn(|| { - while let Some(mat) = matches.peek() { - let mut open = None; - let mut close = None; - let depth = mat.depth; - let config = configs[mat.grammar_index]; - let pattern = &config.patterns[mat.pattern_index]; - for capture in mat.captures { - if capture.index == config.open_capture_ix { - open = Some(capture.node.byte_range()); - } else if capture.index == config.close_capture_ix { - close = Some(capture.node.byte_range()); - } - } + } - matches.advance(); + let mut all_brackets = Vec::new(); + let mut opens = Vec::new(); + let mut color_pairs = Vec::new(); - let Some((open_range, close_range)) = open.zip(close) else { - continue; - }; + let mut matches = self.syntax.matches_with_options( + chunk_range.clone(), + &self.text, + TreeSitterOptions { + max_bytes_to_query: Some(MAX_BYTES_TO_QUERY), + max_start_depth: None, + }, + |grammar| grammar.brackets_config.as_ref().map(|c| &c.query), + ); + let configs = matches + .grammars() + .iter() + .map(|grammar| grammar.brackets_config.as_ref().unwrap()) + .collect::>(); + + while let Some(mat) = matches.peek() { + let mut open = None; + let mut close = None; + let syntax_layer_depth = mat.depth; + let config = configs[mat.grammar_index]; + let pattern = &config.patterns[mat.pattern_index]; + for capture in mat.captures { + if capture.index == config.open_capture_ix { + open = Some(capture.node.byte_range()); + } else if capture.index == config.close_capture_ix { + close = Some(capture.node.byte_range()); + } + } - let bracket_range = open_range.start..=close_range.end; - if !bracket_range.overlaps(&chunk_range) { - continue; - } + matches.advance(); - if !pattern.rainbow_exclude - // Also, certain languages have "brackets" that are not brackets, e.g. tags. and such - // bracket will match the entire tag with all text inside. - // For now, avoid highlighting any pair that has more than single char in each bracket. - // We need to colorize `` bracket pairs, so cannot make this check stricter. - && (open_range.len() == 1 || close_range.len() == 1) - { - // Certain tree-sitter grammars may return more bracket pairs than needed: - // see `test_markdown_bracket_colorization` for a set-up that returns pairs with the same start bracket and different end one. - // Pick the pair with the shortest range in case of ambiguity. - match bracket_matches_to_color.entry(open_range.clone()) { - hash_map::Entry::Vacant(v) => { - v.insert(close_range.clone()); - } - hash_map::Entry::Occupied(mut o) => { - let previous_close_range = o.get(); - let previous_length = - previous_close_range.end - open_range.start; - let new_length = close_range.end - open_range.start; - if new_length < previous_length { - o.insert(close_range.clone()); - } - } - } - } - return Some((open_range, close_range, pattern, depth)); - } - None - }) - .sorted_by_key(|(open_range, _, _, _)| open_range.start) - .collect::>(); + let Some((open_range, close_range)) = open.zip(close) else { + continue; + }; - let new_matches = tree_sitter_matches - .into_iter() - .map(|(open_range, close_range, pattern, syntax_layer_depth)| { - let participates_in_colorizing = - bracket_matches_to_color.get(&open_range).is_some_and( - |close_range_to_color| close_range_to_color == &close_range, - ); - let color_index = if participates_in_colorizing { - while let Some(&last_bracket_end) = bracket_pairs_ends.last() { - if last_bracket_end <= open_range.start { - bracket_pairs_ends.pop(); - } else { - break; - } - } + let bracket_range = open_range.start..=close_range.end; + if !bracket_range.overlaps(&chunk_range) { + continue; + } - let bracket_depth = bracket_pairs_ends.len(); - bracket_pairs_ends.push(close_range.end); - Some(bracket_depth) - } else { - None - }; + let index = all_brackets.len(); + all_brackets.push(BracketMatch { + open_range: open_range.clone(), + close_range: close_range.clone(), + newline_only: pattern.newline_only, + syntax_layer_depth, + color_index: None, + }); - BracketMatch { - open_range, - close_range, - syntax_layer_depth, - newline_only: pattern.newline_only, - color_index, - } - }) - .collect::>(); + // Certain languages have "brackets" that are not brackets, e.g. tags. and such + // bracket will match the entire tag with all text inside. + // For now, avoid highlighting any pair that has more than single char in each bracket. + // We need to colorize `` bracket pairs, so cannot make this check stricter. + let should_color = + !pattern.rainbow_exclude && (open_range.len() == 1 || close_range.len() == 1); + if should_color { + opens.push(open_range.clone()); + color_pairs.push((open_range, close_range, index)); + } + } - new_bracket_matches.insert(chunk.id, new_matches.clone()); - new_matches + opens.sort_by_key(|r| (r.start, r.end)); + opens.dedup_by(|a, b| a.start == b.start && a.end == b.end); + color_pairs.sort_by_key(|(_, close, _)| close.end); + + let mut open_stack = Vec::new(); + let mut open_index = 0; + for (open, close, index) in color_pairs { + while open_index < opens.len() && opens[open_index].start < close.start { + open_stack.push(opens[open_index].clone()); + open_index += 1; } - }; - all_bracket_matches.insert(chunk.row_range(), bracket_matches); - } - let mut latest_tree_sitter_data = self.latest_tree_sitter_data(); - if latest_tree_sitter_data.chunks.version() == &self.version { - for (chunk_id, new_matches) in new_bracket_matches { - let old_chunks = &mut latest_tree_sitter_data.brackets_by_chunks[chunk_id]; - if old_chunks.is_none() { - *old_chunks = Some(new_matches); + if open_stack.last() == Some(&open) { + let depth_index = open_stack.len() - 1; + all_brackets[index].color_index = Some(depth_index); + open_stack.pop(); } } - } - all_bracket_matches - } + all_brackets.sort_by_key(|bracket_match| { + (bracket_match.open_range.start, bracket_match.open_range.end) + }); - fn latest_tree_sitter_data(&self) -> MutexGuard<'_, RawMutex, TreeSitterData> { - let mut tree_sitter_data = self.tree_sitter_data.lock(); - if self - .version - .changed_since(tree_sitter_data.chunks.version()) - { - *tree_sitter_data = TreeSitterData::new(self.text.clone()); + if let empty_slot @ None = + &mut self.tree_sitter_data.brackets_by_chunks.lock()[chunk.id] + { + *empty_slot = Some(all_brackets.clone()); + } + all_bracket_matches.insert(chunk.row_range(), all_brackets); } - tree_sitter_data + + all_bracket_matches } pub fn all_bracket_ranges( @@ -5413,6 +5470,7 @@ impl Default for Diagnostic { is_unnecessary: false, underline: true, data: None, + registration_id: None, } } } diff --git a/crates/language/src/buffer/row_chunk.rs b/crates/language/src/buffer/row_chunk.rs index 7589c5ac078b9443c3dfd501abb0e6d79cb74581..0f3c0b5afb1cc1a2d60a2a568fe00403733ef5c6 100644 --- a/crates/language/src/buffer/row_chunk.rs +++ b/crates/language/src/buffer/row_chunk.rs @@ -3,7 +3,6 @@ use std::{ops::Range, sync::Arc}; -use clock::Global; use text::{Anchor, OffsetRangeExt as _, Point}; use util::RangeExt; @@ -19,14 +18,13 @@ use crate::BufferRow; /// #[derive(Clone)] pub struct RowChunks { - pub(crate) snapshot: text::BufferSnapshot, chunks: Arc<[RowChunk]>, + version: clock::Global, } impl std::fmt::Debug for RowChunks { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RowChunks") - .field("version", self.snapshot.version()) .field("chunks", &self.chunks) .finish() } @@ -38,34 +36,45 @@ impl RowChunks { let last_row = buffer_point_range.end.row; let chunks = (buffer_point_range.start.row..=last_row) .step_by(max_rows_per_chunk as usize) + .collect::>(); + let last_chunk_id = chunks.len() - 1; + let chunks = chunks + .into_iter() .enumerate() - .map(|(id, chunk_start)| RowChunk { - id, - start: chunk_start, - end_exclusive: (chunk_start + max_rows_per_chunk).min(last_row), + .map(|(id, chunk_start)| { + let start = Point::new(chunk_start, 0); + let end_exclusive = (chunk_start + max_rows_per_chunk).min(last_row); + let end = if id == last_chunk_id { + Point::new(end_exclusive, snapshot.line_len(end_exclusive)) + } else { + Point::new(end_exclusive, 0) + }; + RowChunk { + id, + start: chunk_start, + end_exclusive, + start_anchor: snapshot.anchor_before(start), + end_anchor: snapshot.anchor_after(end), + } }) .collect::>(); Self { - snapshot, chunks: Arc::from(chunks), + version: snapshot.version().clone(), } } - pub fn version(&self) -> &Global { - self.snapshot.version() + pub fn version(&self) -> &clock::Global { + &self.version } pub fn len(&self) -> usize { self.chunks.len() } - pub fn applicable_chunks( - &self, - ranges: &[Range], - ) -> impl Iterator { + pub fn applicable_chunks(&self, ranges: &[Range]) -> impl Iterator { let row_ranges = ranges .iter() - .map(|range| range.to_point(&self.snapshot)) // Be lenient and yield multiple chunks if they "touch" the exclusive part of the range. // This will result in LSP hints [re-]queried for more ranges, but also more hints already visible when scrolling around. .map(|point_range| point_range.start.row..point_range.end.row + 1) @@ -81,23 +90,6 @@ impl RowChunks { .copied() } - pub fn chunk_range(&self, chunk: RowChunk) -> Option> { - if !self.chunks.contains(&chunk) { - return None; - } - - let start = Point::new(chunk.start, 0); - let end = if self.chunks.last() == Some(&chunk) { - Point::new( - chunk.end_exclusive, - self.snapshot.line_len(chunk.end_exclusive), - ) - } else { - Point::new(chunk.end_exclusive, 0) - }; - Some(self.snapshot.anchor_before(start)..self.snapshot.anchor_after(end)) - } - pub fn previous_chunk(&self, chunk: RowChunk) -> Option { if chunk.id == 0 { None @@ -112,10 +104,16 @@ pub struct RowChunk { pub id: usize, pub start: BufferRow, pub end_exclusive: BufferRow, + pub start_anchor: Anchor, + pub end_anchor: Anchor, } impl RowChunk { pub fn row_range(&self) -> Range { self.start..self.end_exclusive } + + pub fn anchor_range(&self) -> Range { + self.start_anchor..self.end_anchor + } } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index efef0a08127bc66f9c6d8f21fe5a545dbee20fb1..2c2d93c8239f0f3fcb1de0956de2d3400f13e96b 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -6,6 +6,7 @@ use futures::FutureExt as _; use gpui::{App, AppContext as _, BorrowAppContext, Entity}; use gpui::{HighlightStyle, TestAppContext}; use indoc::indoc; +use pretty_assertions::assert_eq; use proto::deserialize_operation; use rand::prelude::*; use regex::RegexBuilder; @@ -46,8 +47,7 @@ fn test_line_endings(cx: &mut gpui::App) { init_settings(cx, |_| {}); cx.new(|cx| { - let mut buffer = - Buffer::local("one\r\ntwo\rthree", cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local("one\r\ntwo\rthree", cx).with_language(rust_lang(), cx); assert_eq!(buffer.text(), "one\ntwo\nthree"); assert_eq!(buffer.line_ending(), LineEnding::Windows); @@ -151,7 +151,7 @@ fn test_select_language(cx: &mut App) { let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); registry.add(Arc::new(Language::new( LanguageConfig { - name: LanguageName::new("Rust"), + name: LanguageName::new_static("Rust"), matcher: LanguageMatcher { path_suffixes: vec!["rs".to_string()], ..Default::default() @@ -173,7 +173,7 @@ fn test_select_language(cx: &mut App) { ))); registry.add(Arc::new(Language::new( LanguageConfig { - name: LanguageName::new("Make"), + name: LanguageName::new_static("Make"), matcher: LanguageMatcher { path_suffixes: vec!["Makefile".to_string(), "mk".to_string()], ..Default::default() @@ -608,7 +608,7 @@ async fn test_normalize_whitespace(cx: &mut gpui::TestAppContext) { #[gpui::test] async fn test_reparse(cx: &mut gpui::TestAppContext) { let text = "fn a() {}"; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); // Wait for the initial text to parse cx.executor().run_until_parked(); @@ -735,7 +735,7 @@ async fn test_reparse(cx: &mut gpui::TestAppContext) { #[gpui::test] async fn test_resetting_language(cx: &mut gpui::TestAppContext) { let buffer = cx.new(|cx| { - let mut buffer = Buffer::local("{}", cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local("{}", cx).with_language(rust_lang(), cx); buffer.set_sync_parse_timeout(Duration::ZERO); buffer }); @@ -783,29 +783,49 @@ async fn test_outline(cx: &mut gpui::TestAppContext) { "# .unindent(); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + let outline = snapshot.outline(None); assert_eq!( outline .items .iter() - .map(|item| (item.text.as_str(), item.depth)) + .map(|item| ( + item.text.as_str(), + item.depth, + item.to_point(&snapshot).body_range(&snapshot) + .map(|range| minimize_space(&snapshot.text_for_range(range).collect::())) + )) .collect::>(), &[ - ("struct Person", 0), - ("name", 1), - ("age", 1), - ("mod module", 0), - ("enum LoginState", 1), - ("LoggedOut", 2), - ("LoggingOn", 2), - ("LoggedIn", 2), - ("person", 3), - ("time", 3), - ("impl Eq for Person", 0), - ("impl Drop for Person", 0), - ("fn drop", 1), + ("struct Person", 0, Some("name: String, age: usize,".to_string())), + ("name", 1, None), + ("age", 1, None), + ( + "mod module", + 0, + Some( + "enum LoginState { LoggedOut, LoggingOn, LoggedIn { person: Person, time: Instant, } }".to_string() + ) + ), + ( + "enum LoginState", + 1, + Some("LoggedOut, LoggingOn, LoggedIn { person: Person, time: Instant, }".to_string()) + ), + ("LoggedOut", 2, None), + ("LoggingOn", 2, None), + ("LoggedIn", 2, Some("person: Person, time: Instant,".to_string())), + ("person", 3, None), + ("time", 3, None), + ("impl Eq for Person", 0, Some("".to_string())), + ( + "impl Drop for Person", + 0, + Some("fn drop(&mut self) { println!(\"bye\"); }".to_string()) + ), + ("fn drop", 1, Some("println!(\"bye\");".to_string())), ] ); @@ -840,6 +860,11 @@ async fn test_outline(cx: &mut gpui::TestAppContext) { ] ); + fn minimize_space(text: &str) -> String { + static WHITESPACE: LazyLock = LazyLock::new(|| Regex::new("[\\n\\s]+").unwrap()); + WHITESPACE.replace_all(text, " ").trim().to_string() + } + async fn search<'a>( outline: &'a Outline, query: &'a str, @@ -865,7 +890,7 @@ async fn test_outline_nodes_with_newlines(cx: &mut gpui::TestAppContext) { "# .unindent(); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( @@ -945,7 +970,7 @@ fn test_outline_annotations(cx: &mut App) { "# .unindent(); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let outline = buffer.update(cx, |buffer, _| buffer.snapshot().outline(None)); assert_eq!( @@ -993,7 +1018,7 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) { "# .unindent(); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); // point is at the start of an item @@ -1068,7 +1093,7 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) { " .unindent(), ); - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); // note, it would be nice to actually return the method test in this @@ -1087,8 +1112,7 @@ fn test_text_objects(cx: &mut App) { false, ); - let buffer = - cx.new(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx).with_language(rust_lang(), cx)); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); let matches = snapshot @@ -1105,10 +1129,116 @@ fn test_text_objects(cx: &mut App) { "fn say() -> u8 { return /* hi */ 1 }", TextObject::AroundFunction ), + ( + "fn say() -> u8 { return /* hi */ 1 }", + TextObject::InsideClass + ), + ( + "impl Hello {\n fn say() -> u8 { return /* hi */ 1 }\n}", + TextObject::AroundClass + ), ], ) } +#[gpui::test] +fn test_text_objects_with_has_parent_predicate(cx: &mut App) { + use std::borrow::Cow; + + // Create a language with a custom text_objects query that uses #has-parent? + // This query only matches closure_expression when it's inside a call_expression + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + text_objects: Some(Cow::from(indoc! {r#" + ; Only match closures that are arguments to function calls + (closure_expression) @function.around + (#has-parent? @function.around arguments) + "#})), + ..Default::default() + }) + .expect("Could not parse queries"); + + let (text, ranges) = marked_text_ranges( + indoc! {r#" + fn main() { + let standalone = |x| x + 1; + let result = foo(|y| y * ˇ2); + }"# + }, + false, + ); + + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(language), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + + let matches = snapshot + .text_object_ranges(ranges[0].clone(), TreeSitterOptions::default()) + .map(|(range, text_object)| (&text[range], text_object)) + .collect::>(); + + // Should only match the closure inside foo(), not the standalone closure + assert_eq!(matches, &[("|y| y * 2", TextObject::AroundFunction),]); +} + +#[gpui::test] +fn test_text_objects_with_not_has_parent_predicate(cx: &mut App) { + use std::borrow::Cow; + + // Create a language with a custom text_objects query that uses #not-has-parent? + // This query only matches closure_expression when it's NOT inside a call_expression + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + text_objects: Some(Cow::from(indoc! {r#" + ; Only match closures that are NOT arguments to function calls + (closure_expression) @function.around + (#not-has-parent? @function.around arguments) + "#})), + ..Default::default() + }) + .expect("Could not parse queries"); + + let (text, ranges) = marked_text_ranges( + indoc! {r#" + fn main() { + let standalone = |x| x +ˇ 1; + let result = foo(|y| y * 2); + }"# + }, + false, + ); + + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(language), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + + let matches = snapshot + .text_object_ranges(ranges[0].clone(), TreeSitterOptions::default()) + .map(|(range, text_object)| (&text[range], text_object)) + .collect::>(); + + // Should only match the standalone closure, not the one inside foo() + assert_eq!(matches, &[("|x| x + 1", TextObject::AroundFunction),]); +} + #[gpui::test] fn test_enclosing_bracket_ranges(cx: &mut App) { #[track_caller] @@ -1235,7 +1365,12 @@ fn test_enclosing_bracket_ranges(cx: &mut App) { #[gpui::test] fn test_enclosing_bracket_ranges_where_brackets_are_not_outermost_children(cx: &mut App) { let mut assert = |selection_text, bracket_pair_texts| { - assert_bracket_pairs(selection_text, bracket_pair_texts, javascript_lang(), cx) + assert_bracket_pairs( + selection_text, + bracket_pair_texts, + Arc::new(javascript_lang()), + cx, + ) }; assert( @@ -1268,7 +1403,7 @@ fn test_enclosing_bracket_ranges_where_brackets_are_not_outermost_children(cx: & fn test_range_for_syntax_ancestor(cx: &mut App) { cx.new(|cx| { let text = "fn a() { b(|c| {}) }"; - let buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); let snapshot = buffer.snapshot(); assert_eq!( @@ -1320,7 +1455,7 @@ fn test_autoindent_with_soft_tabs(cx: &mut App) { cx.new(|cx| { let text = "fn a() {}"; - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit([(8..8, "\n\n")], Some(AutoindentMode::EachLine), cx); assert_eq!(buffer.text(), "fn a() {\n \n}"); @@ -1362,7 +1497,7 @@ fn test_autoindent_with_hard_tabs(cx: &mut App) { cx.new(|cx| { let text = "fn a() {}"; - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit([(8..8, "\n\n")], Some(AutoindentMode::EachLine), cx); assert_eq!(buffer.text(), "fn a() {\n\t\n}"); @@ -1411,7 +1546,7 @@ fn test_autoindent_does_not_adjust_lines_with_unchanged_suggestion(cx: &mut App) .unindent(), cx, ) - .with_language(Arc::new(rust_lang()), cx); + .with_language(rust_lang(), cx); // Lines 2 and 3 don't match the indentation suggestion. When editing these lines, // their indentation is not adjusted. @@ -1552,7 +1687,7 @@ fn test_autoindent_does_not_adjust_lines_with_unchanged_suggestion(cx: &mut App) .unindent(), cx, ) - .with_language(Arc::new(rust_lang()), cx); + .with_language(rust_lang(), cx); // Insert a closing brace. It is outdented. buffer.edit_via_marked_text( @@ -1615,7 +1750,7 @@ fn test_autoindent_does_not_adjust_lines_within_newly_created_errors(cx: &mut Ap .unindent(), cx, ) - .with_language(Arc::new(rust_lang()), cx); + .with_language(rust_lang(), cx); // Regression test: line does not get outdented due to syntax error buffer.edit_via_marked_text( @@ -1674,7 +1809,7 @@ fn test_autoindent_adjusts_lines_when_only_text_changes(cx: &mut App) { .unindent(), cx, ) - .with_language(Arc::new(rust_lang()), cx); + .with_language(rust_lang(), cx); buffer.edit_via_marked_text( &" @@ -1724,7 +1859,7 @@ fn test_autoindent_with_edit_at_end_of_buffer(cx: &mut App) { cx.new(|cx| { let text = "a\nb"; - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit( [(0..1, "\n"), (2..3, "\n")], Some(AutoindentMode::EachLine), @@ -1750,7 +1885,7 @@ fn test_autoindent_multi_line_insertion(cx: &mut App) { " .unindent(); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit( [(Point::new(3, 0)..Point::new(3, 0), "e(\n f()\n);\n")], Some(AutoindentMode::EachLine), @@ -1787,7 +1922,7 @@ fn test_autoindent_block_mode(cx: &mut App) { } "# .unindent(); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); // When this text was copied, both of the quotation marks were at the same // indent level, but the indentation of the first line was not included in @@ -1870,7 +2005,7 @@ fn test_autoindent_block_mode_with_newline(cx: &mut App) { } "# .unindent(); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); // First line contains just '\n', it's indentation is stored in "original_indent_columns" let original_indent_columns = vec![Some(4)]; @@ -1922,7 +2057,7 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut App) { } "# .unindent(); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); // The original indent columns are not known, so this text is // auto-indented in a block as if the first line was copied in @@ -2013,7 +2148,7 @@ fn test_autoindent_block_mode_multiple_adjacent_ranges(cx: &mut App) { false, ); - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); buffer.edit( [ @@ -2027,7 +2162,7 @@ fn test_autoindent_block_mode_multiple_adjacent_ranges(cx: &mut App) { cx, ); - pretty_assertions::assert_eq!( + assert_eq!( buffer.text(), " mod numbers { @@ -2221,7 +2356,7 @@ async fn test_async_autoindents_preserve_preview(cx: &mut TestAppContext) { // Then we request that a preview tab be preserved for the new version, even though it's edited. let buffer = cx.new(|cx| { let text = "fn a() {}"; - let mut buffer = Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx); + let mut buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); // This causes autoindent to be async. buffer.set_sync_parse_timeout(Duration::ZERO); @@ -2679,7 +2814,7 @@ fn test_language_at_with_hidden_languages(cx: &mut App) { .unindent(); let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - language_registry.add(Arc::new(markdown_lang())); + language_registry.add(markdown_lang()); language_registry.add(Arc::new(markdown_inline_lang())); let mut buffer = Buffer::local(text, cx); @@ -2721,9 +2856,9 @@ fn test_language_at_for_markdown_code_block(cx: &mut App) { .unindent(); let language_registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - language_registry.add(Arc::new(markdown_lang())); + language_registry.add(markdown_lang()); language_registry.add(Arc::new(markdown_inline_lang())); - language_registry.add(Arc::new(rust_lang())); + language_registry.add(rust_lang()); let mut buffer = Buffer::local(text, cx); buffer.set_language_registry(language_registry.clone()); @@ -3120,7 +3255,7 @@ async fn test_preview_edits(cx: &mut TestAppContext) { cx: &mut TestAppContext, assert_fn: impl Fn(HighlightedText), ) { - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); let edits = buffer.read_with(cx, |buffer, _| { edits .into_iter() @@ -3531,7 +3666,7 @@ let word=öäpple.bar你 Öäpple word2-öÄpPlE-Pizza-word ÖÄPPLE word "#; let buffer = cx.new(|cx| { - let buffer = Buffer::local(contents, cx).with_language(Arc::new(rust_lang()), cx); + let buffer = Buffer::local(contents, cx).with_language(rust_lang(), cx); assert_eq!(buffer.text(), contents); buffer.check_invariants(); buffer @@ -3691,7 +3826,7 @@ fn ruby_lang() -> Language { fn html_lang() -> Language { Language::new( LanguageConfig { - name: LanguageName::new("HTML"), + name: LanguageName::new_static("HTML"), block_comment: Some(BlockCommentConfig { start: " /Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42\r\n"; thread_local! { static TEST_TERM_AND_POINT: (Term, AlacPoint) = - build_test_term(LINE); + build_test_term(LINE, 500, 50); } TEST_TERM_AND_POINT.with(|(term, point)| { - assert!( - find_from_grid_point_bench(term, *point).is_some(), + assert_eq!( + find_from_grid_point_bench(term, *point) + .map(|(path, ..)| path) + .unwrap_or_default(), + "/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42", "Hyperlink should have been found" ); }); @@ -998,11 +1084,111 @@ mod tests { const LINE: &str = "Cargo.toml experiments notebooks rust-toolchain.toml tooling\r\n"; thread_local! { static TEST_TERM_AND_POINT: (Term, AlacPoint) = - build_test_term(LINE); + build_test_term(LINE, 500, 60); } TEST_TERM_AND_POINT.with(|(term, point)| { - assert!( - find_from_grid_point_bench(term, *point).is_some(), + assert_eq!( + find_from_grid_point_bench(term, *point) + .map(|(path, ..)| path) + .unwrap_or_default(), + "rust-toolchain.toml", + "Hyperlink should have been found" + ); + }); + } + + #[perf] + // https://github.com/zed-industries/zed/pull/44407 + pub fn pr_44407_hyperlink_benchmark() { + const LINE: &str = "-748, 706, 163, 222, -980, 949, 381, -568, 199, 501, 760, -821, 90, -451, 183, 867, -351, -810, -762, -109, 423, 84, 14, -77, -820, -345, 74, -791, 930, -618, -900, 862, -959, 289, -19, 471, -757, 793, 155, -554, 249, 830, 402, 732, -731, -866, -720, -703, -257, -439, 731, 872, -489, 676, -167, 613, -698, 415, -80, -453, -896, 333, -511, 621, -450, 624, -309, -575, 177, 141, 891, -104, -97, -367, -599, -675, 607, -225, -760, 552, -465, 804, 55, 282, 104, -929, -252,\ +-311, 900, 550, 599, -80, 774, 553, 837, -395, 541, 953, 154, -396, -596, -111, -802, -221, -337, -633, -73, -527, -82, -658, -264, 222, 375, 434, 204, -756, -703, 303, 239, -257, -365, -351, 904, 364, -743, -484, 655, -542, 446, 888, 632, -167, -260, 716, 150, 806, 723, 513, -118, -323, -683, 983, -564, 358, -16, -287, 277, -607, 87, 365, -1, 164, 401, 257, 369, -893, 145, -969, 375, -53, 541, -408, -865, 753, 258, 337, -886, 593, -378, -528, 191, 204, 566, -61, -621, 769, 524, -628, 6,\ +249, 896, -785, -776, 321, -681, 604, -740, 886, 426, -480, -983, 23, -247, 125, -666, 913, 842, -460, -797, -483, -58, -565, -587, -206, 197, 715, 764, -97, 457, -149, -226, 261, 194, -390, 431, 180, -778, 829, -657, -668, 397, 859, 152, -178, 677, -18, 687, -247, 96, 466, -572, 478, 622, -143, -25, -471, 265, 335, 957, 152, -951, -647, 670, 57, 152, -115, 206, 87, 629, -798, -125, -725, -31, 844, 398, -876, 44, 963, -211, 518, -8, -103, -999, 948, 823, 149, -803, 769, -236, -683, 527,\ +-108, -36, 18, -437, 687, -305, -526, 972, -965, 276, 420, -259, -379, -142, -747, 600, -578, 197, 673, 890, 324, -931, 755, -765, -422, 785, -369, -110, -505, 532, -208, -438, 713, 110, 853, 996, -360, 823, 289, -699, 629, -661, 560, -329, -323, 439, 571, -537, 644, -84, 25, -536, -161, 112, 169, -922, -537, -734, -423, 37, 451, -149, 408, 18, -672, 206, -784, 444, 593, -241, 502, -259, -798, -352, -658, 712, -675, -734, 627, -620, 64, -554, 999, -537, -160, -641, 464, 894, 29, 322, 566,\ +-510, -749, 982, 204, 967, -261, -986, -136, 251, -598, 995, -831, 891, 22, 761, -783, -415, 125, 470, -919, -97, -668, 85, 205, -175, -550, 502, 652, -468, 798, 775, -216, 89, -433, -24, -621, 877, -126, 951, 809, 782, 156, -618, -841, -463, 19, -723, -904, 550, 263, 991, -758, -114, 446, -731, -623, -634, 462, 48, 851, 333, -846, 480, 892, -966, -910, -436, 317, -711, -341, -294, 124, 238, -214, -281, 467, -950, -342, 913, -90, -388, -573, 740, -883, -451, 493, -500, 863, 930, 127, 530,\ +-810, 540, 541, -664, -951, -227, -420, -476, -581, -534, 549, 253, 984, -985, -84, -521, 538, 484, -440, 371, 784, -306, -850, 530, -133, 251, -799, 446, -170, -243, -674, 769, 646, 778, -680, -714, -442, 804, 901, -774, 69, 307, -293, 755, 443, 224, -918, -771, 723, 40, 132, 568, -847, -47, 844, 69, 986, -293, -459, 313, 155, 331, 69, 280, -637, 569, 104, -119, -988, 252, 857, -590, 810, -891, 484, 566, -934, -587, -290, 566, 587, 489, 870, 280, 454, -252, 613, -701, -278, 195, -198,\ +683, 533, -372, 707, -152, 371, 866, 609, -5, -372, -30, -694, 552, 192, 452, -663, 350, -985, 10, 884, 813, -592, -331, -470, 711, -941, 928, 379, -339, 220, 999, 376, 507, 179, 916, 84, 104, 392, 192, 299, -860, 218, -698, -919, -452, 37, 850, 5, -874, 287, 123, -746, -575, 776, -909, 118, 903, -275, 450, -996, -591, -920, -850, 453, -896, 73, 83, -535, -20, 287, -765, 442, 808, 45, 445, 202, 917, -208, 783, 790, -534, 373, -129, 556, -757, -69, 459, -163, -59, 265, -563, -889, 635,\ +-583, -261, -790, 799, 826, 953, 85, 619, 334, 842, 672, -869, -4, -833, 315, 942, -524, 579, 926, 628, -404, 128, -629, 161, 568, -117, -526, 223, -876, 906, 176, -549, -317, 381, 375, -801, -416, 647, 335, 253, -386, -375, -254, 635, 352, 317, 398, -422, 111, 201, 220, 554, -972, 853, 378, 956, 942, -857, -289, -333, -180, 488, -814, -42, -595, 721, 39, 644, 721, -242, -44, 643, -457, -419, 560, -863, 974, 458, 222, -882, 526, -243, -318, -343, -707, -401, 117, 677, -489, 546, -903,\ +-960, -881, -684, 125, -928, -995, -692, -773, 647, -718, -862, -814, 671, 664, -130, -856, -674, 653, 711, 194, -685, -160, 138, -27, -128, -671, -242, 526, 494, -674, 424, -921, -778, 313, -237, 332, 913, 252, 808, -936, 289, 755, 52, -139, 57, -19, -827, -775, -561, -14, 107, -84, 622, -303, -747, 258, -942, 290, 211, -919, -207, 797, 95, 794, -830, -181, -788, 757, 75, -946, -949, -988, 152, 340, 732, 886, -891, -642, -666, 321, -910, 841, 632, 298, 55, -349, 498, 287, -711, 97, 305,\ +-974, -987, 790, -64, 605, -583, -821, 345, 887, -861, 548, 894, 288, 452, 556, -448, 813, 420, 545, 967, 127, -947, 19, -314, -607, -513, -851, 254, -290, -938, -783, -93, 474, 368, -485, -935, -539, 81, 404, -283, 779, 345, -164, 53, 563, -771, 911, -323, 522, -998, 315, 415, 460, 58, -541, -878, -152, -886, 201, -446, -810, 549, -142, -575, -632, 521, 549, 209, -681, 998, 798, -611, -919, -708, -4, 677, -172, 588, 750, -435, 508, 609, 498, -535, -691, -738, 85, 615, 705, 169, 425,\ +-669, -491, -783, 73, -847, 228, -981, -812, -229, 950, -904, 175, -438, 632, -556, 910, 173, 576, -751, -53, -169, 635, 607, -944, -13, -84, 105, -644, 984, 935, 259, -445, 620, -405, 832, 167, 114, 209, -181, -944, -496, 693, -473, 137, 38, -873, -334, -353, -57, 397, 944, 698, 811, -401, 712, -667, 905, 276, -653, 368, -543, -349, 414, 287, 894, 935, 461, 55, 741, -623, -660, -773, 617, 834, 278, -121, 52, 495, -855, -440, -210, -99, 279, -661, 540, 934, 540, 784, 895, 268, -503, 513,\ +-484, -352, 528, 341, -451, 885, -71, 799, -195, -885, -585, -233, 92, 453, 994, 464, 694, 190, -561, -116, 675, -775, -236, 556, -110, -465, 77, -781, 507, -960, -410, 229, -632, 717, 597, 429, 358, -430, -692, -825, 576, 571, 758, -891, 528, -267, 190, -869, 132, -811, 796, 750, -596, -681, 870, 360, 969, 860, -412, -567, 694, -86, -498, 38, -178, -583, -778, 412, 842, -586, 722, -192, 350, 363, 81, -677, -163, 564, 543, 671, 110, 314, 739, -552, -224, -644, 922, 685, 134, 613, 793,\ +-363, -244, -284, -257, -561, 418, 988, 333, 110, -966, 790, 927, 536, -620, -309, -358, 895, -867, -796, -357, 308, -740, 287, -732, -363, -969, 658, 711, 511, 256, 590, -574, 815, -845, -84, 546, -581, -71, -334, -890, 652, -959, 320, -236, 445, -851, 825, -756, -4, 877, 308, 573, -117, 293, 686, -483, 391, 342, -550, -982, 713, 886, 552, 474, -673, 283, -591, -383, 988, 435, -131, 708, -326, -884, 87, 680, -818, -408, -486, 813, -307, -799, 23, -497, 802, -146, -100, 541, 7, -493, 577,\ +50, -270, 672, 834, 111, -788, 247, 337, 628, -33, -964, -519, 683, 54, -703, 633, -127, -448, 759, -975, 696, 2, -870, -760, 67, 696, 306, 750, 615, 155, -933, -568, 399, 795, 164, -460, 205, 439, -526, -691, 35, -136, -481, -63, 73, -598, 748, 133, 874, -29, 4, -73, 472, 389, 962, 231, -328, 240, 149, 959, 46, -207, 72, -514, -608, 0, -14, 32, 374, -478, -806, 919, -729, -286, 652, 109, 509, -879, -979, -865, 584, -92, -346, -992, 781, 401, 575, 993, -746, -33, 684, -683, 750, -105,\ +-425, -508, -627, 27, 770, -45, 338, 921, -139, -392, -933, 634, 563, 224, -780, 921, 991, 737, 22, 64, 414, -249, -687, 869, 50, 759, -97, 515, 20, -775, -332, 957, 138, -542, -835, 591, -819, 363, -715, -146, -950, -641, -35, -435, -407, -548, -984, 383, -216, -559, 853, 4, -410, -319, -831, -459, -628, -819, -324, 755, 696, -192, 238, -234, -724, -445, 915, 302, -708, 484, 224, -641, 25, -771, 528, -106, -744, -588, 913, -554, -515, -239, -843, -812, -171, 721, 543, -269, 440, 151,\ +996, -723, -557, -522, -280, -514, -593, 208, 715, 404, 353, 270, -483, -785, 318, -313, 798, 638, 764, 748, -929, -827, -318, -56, 389, -546, -958, -398, 463, -700, 461, 311, -787, -488, 877, 456, 166, 535, -995, -189, -715, 244, 40, 484, 212, -329, -351, 638, -69, -446, -292, 801, -822, 490, -486, -185, 790, 370, -340, 401, -656, 584, 561, -749, 269, -19, -294, -111, 975, 874, -73, 851, 231, -331, -684, 460, 765, -654, -76, 10, 733, 520, 521, 416, -958, -202, -186, -167, 175, 343, -50,\ +673, -763, -854, -977, -17, -853, -122, -25, 180, 149, 268, 874, -816, -745, 747, -303, -959, 390, 509, 18, -66, 275, -277, 9, 837, -124, 989, -542, -649, -845, 894, 926, 997, -847, -809, -579, -96, -372, 766, 238, -251, 503, 559, 276, -281, -102, -735, 815, 109, 175, -10, 128, 543, -558, -707, 949, 996, -422, -506, 252, 702, -930, 552, -961, 584, -79, -177, 341, -275, 503, -21, 677, -545, 8, -956, -795, -870, -254, 170, -502, -880, 106, 174, 459, 603, -600, -963, 164, -136, -641, -309,\ +-380, -707, -727, -10, 727, 952, 997, -731, -133, 269, 287, 855, 716, -650, 479, 299, -839, -308, -782, 769, 545, 663, -536, -115, 904, -986, -258, -562, 582, 664, 408, -525, -889, 471, -370, -534, -220, 310, 766, 931, -193, -897, -192, -74, -365, -256, -359, -328, 658, -691, -431, 406, 699, 425, 713, -584, -45, -588, 289, 658, -290, -880, -987, -444, 371, 904, -155, 81, -278, -708, -189, -78, 655, 342, -998, -647, -734, -218, 726, 619, 663, 744, 518, 60, -409, 561, -727, -961, -306,\ +-147, -550, 240, -218, -393, 267, 724, 791, -548, 480, 180, -631, 825, -170, 107, 227, -691, 905, -909, 359, 227, 287, 909, 632, -89, -522, 80, -429, 37, 561, -732, -474, 565, -798, -460, 188, 507, -511, -654, 212, -314, -376, -997, -114, -708, 512, -848, 781, 126, -956, -298, 354, -400, -121, 510, 445, 926, 27, -708, 676, 248, 834, 542, 236, -105, -153, 102, 128, 96, -348, -626, 598, 8, 978, -589, -461, -38, 381, -232, -817, 467, 356, -151, -460, 429, -408, 425, 618, -611, -247, 819,\ +963, -160, 1000, 141, -647, -875, 108, 790, -127, 463, -37, -195, -542, 12, 845, -384, 770, -129, 315, 826, -942, 430, 146, -170, -583, -903, -489, 497, -559, -401, -29, -129, -411, 166, 942, -646, -862, -404, 785, 777, -111, -481, -738, 490, 741, -398, 846, -178, -509, -661, 748, 297, -658, -567, 531, 427, -201, -41, -808, -668, 782, -860, -324, 249, 835, -234, 116, 542, -201, 328, 675, 480, -906, 188, 445, 63, -525, 811, 277, 133, 779, -680, 950, -477, -306, -64, 552, -890, -956, 169,\ +442, 44, -169, -243, -242, 423, -884, -757, -403, 739, -350, 383, 429, 153, -702, -725, 51, 310, 857, -56, 538, 46, -311, 132, -620, -297, -124, 534, 884, -629, -117, 506, -837, -100, -27, -381, -735, 262, 843, 703, 260, -457, 834, 469, 9, 950, 59, 127, -820, 518, 64, -783, 659, -608, -676, 802, 30, 589, 246, -369, 361, 347, 534, -376, 68, 941, 709, 264, 384, 481, 628, 199, -568, -342, -337, 853, -804, -858, -169, -270, 641, -344, 112, 530, -773, -349, -135, -367, -350, -756, -911, 180,\ +-660, 116, -478, -265, -581, 510, 520, -986, 935, 219, 522, 744, 47, -145, 917, 638, 301, 296, 858, -721, 511, -816, 328, 473, 441, 697, -260, -673, -379, 893, 458, 154, 86, 905, 590, 231, -717, -179, 79, 272, -439, -192, 178, -200, 51, 717, -256, -358, -626, -518, -314, -825, -325, 588, 675, -892, -798, 448, -518, 603, -23, 668, -655, 845, -314, 783, -347, -496, 921, 893, -163, -748, -906, 11, -143, -64, 300, 336, 882, 646, 533, 676, -98, -148, -607, -952, -481, -959, -874, 764, 537,\ +736, -347, 646, -843, 966, -916, -718, -391, -648, 740, 755, 919, -608, 388, -655, 68, 201, 675, -855, 7, -503, 881, 760, 669, 831, 721, -564, -445, 217, 331, 970, 521, 486, -254, 25, -259, 336, -831, 252, -995, 908, -412, -240, 123, -478, 366, 264, -504, -843, 632, -288, 896, 301, 423, 185, 318, 380, 457, -450, -162, -313, 673, -963, 570, 433, -548, 107, -39, -142, -98, -884, -3, 599, -486, -926, 923, -82, 686, 290, 99, -382, -789, 16, 495, 570, 284, 474, -504, -201, -178, -1, 592, 52,\ +827, -540, -151, -991, 130, 353, -420, -467, -661, 417, -690, 942, 936, 814, -566, -251, -298, 341, -139, 786, 129, 525, -861, 680, 955, -245, -50, 331, 412, -38, -66, 611, -558, 392, -629, -471, -68, -535, 744, 495, 87, 558, 695, 260, -308, 215, -464, 239, -50, 193, -540, 184, -8, -194, 148, 898, -557, -21, 884, 644, -785, -689, -281, -737, 267, 50, 206, 292, 265, 380, -511, 310, 53, 375, -497, -40, 312, -606, -395, 142, 422, 662, -584, 72, 144, 40, -679, -593, 581, 689, -829, 442, 822,\ +977, -832, -134, -248, -207, 248, 29, 259, 189, 592, -834, -866, 102, 0, 340, 25, -354, -239, 420, -730, -992, -925, -314, 420, 914, 607, -296, -415, -30, 813, 866, 153, -90, 150, -81, 636, -392, -222, -835, 482, -631, -962, -413, -727, 280, 686, -382, 157, -404, -511, -432, 455, 58, 108, -408, 290, -829, -252, 113, 550, -935, 925, 422, 38, 789, 361, 487, -460, -769, -963, -285, 206, -799, -488, -233, 416, 143, -456, 753, 520, 599, 621, -168, 178, -841, 51, 952, 374, 166, -300, -576, 844,\ +-656, 90, 780, 371, 730, -896, -895, -386, -662, 467, -61, 130, -362, -675, -113, 135, -761, -55, 408, 822, 675, -347, 725, 114, 952, -510, -972, 390, -413, -277, -52, 315, -80, 401, -712, 147, -202, 84, 214, -178, 970, -571, -210, 525, -887, -863, 504, 192, 837, -594, 203, -876, -209, 305, -826, 377, 103, -928, -803, -956, 949, -868, -547, 824, -994, 516, 93, -524, -866, -890, -988, -501, 15, -6, 413, -825, 304, -818, -223, 525, 176, 610, 828, 391, 940, 540, -831, 650, 438, 589, 941, 57,\ +523, 126, 221, 860, -282, -262, -226, 764, 743, -640, 390, 384, -434, 608, -983, 566, -446, 618, 456, -176, -278, 215, 871, -180, 444, -931, -200, -781, 404, 881, 780, -782, 517, -739, -548, -811, 201, -95, -249, -228, 491, -299, 700, 964, -550, 108, 334, -653, 245, -293, -552, 350, -685, -415, -818, 216, -194, -255, 295, 249, 408, 351, 287, 379, 682, 231, -693, 902, -902, 574, 937, -708, -402, -460, 827, -268, 791, 343, -780, -150, -738, 920, -430, -88, -361, -588, -727, -47, -297, 662,\ +-840, -637, -635, 916, -857, 938, 132, -553, 391, -522, 640, 626, 690, 833, 867, -555, 577, 226, 686, -44, 0, -965, 651, -1, 909, 595, -646, 740, -821, -648, -962, 927, -193, 159, 490, 594, -189, 707, -884, 759, -278, -160, -566, -340, 19, 862, -440, 445, -598, 341, 664, -311, 309, -159, 19, -672, 705, -646, 976, 247, 686, -830, -27, -667, 81, 399, -423, -567, 945, 38, 51, 740, 621, 204, -199, -908, -593, 424, 250, -561, 695, 9, 520, 878, 120, -109, 42, -375, -635, -711, -687, 383, -278,\ +36, 970, 925, 864, 836, 309, 117, 89, 654, -387, 346, -53, 617, -164, -624, 184, -45, 852, 498, -513, 794, -682, -576, 13, -147, 285, -776, -886, -96, 483, 994, -188, 346, -629, -848, 738, 51, 128, -898, -753, -906, 270, -203, -577, 48, -243, -210, 666, 353, 636, -954, 862, 560, -944, -877, -137, 440, -945, -316, 274, -211, -435, 615, -635, -468, 744, 948, -589, 525, 757, -191, -431, 42, 451, -160, -827, -991, 324, 697, 342, -610, 894, -787, -384, 872, 734, 878, 70, -260, 57, 397, -518,\ +629, -510, -94, 207, 214, -625, 106, -882, -575, 908, -650, 723, -154, 45, 108, -69, -565, 927, -68, -351, 707, -282, 429, -889, -596, 848, 578, -492, 41, -822, -992, 168, -286, -780, 970, 597, -293, -12, 367, 708, -415, 194, -86, -390, 224, 69, -368, -674, 1000, -672, 356, -202, -169, 826, 476, -285, 29, -448, 545, 186, 319, 67, 705, 412, 225, -212, -351, -391, -783, -9, 875, -59, -159, -123, -151, -296, 871, -638, 359, 909, -945, 345, -16, -562, -363, -183, -625, -115, -571, -329, 514,\ +99, 263, 463, -39, 597, -652, -349, 246, 77, -127, -563, -879, -30, 756, 777, -865, 675, -813, -501, 871, -406, -627, 834, -609, -205, -812, 643, -204, 291, -251, -184, -584, -541, 410, -573, -600, 908, -871, -687, 296, -713, -139, -778, -790, 347, -52, -400, 407, -653, 670, 39, -856, 904, 433, 392, 590, -271, -144, -863, 443, 353, 468, -544, 486, -930, 458, -596, -890, 163, 822, 768, 980, -783, -792, 126, 386, 367, -264, 603, -61, 728, 160, -4, -837, 832, 591, 436, 518, 796, -622, -867,\ +-669, -947, 253, 100, -792, 841, 413, 833, -249, -550, 282, -825, 936, -348, 898, -451, -283, 818, -237, 630, 216, -499, -637, -511, 767, -396, 221, 958, -586, -920, 401, -313, -580, -145, -270, 118, 497, 426, -975, 480, -445, -150, -721, -929, 439, -893, 902, 960, -525, -793, 924, 563, 683, -727, -86, 309, 432, -762, -345, 371, -617, 149, -215, -228, 505, 593, -20, -292, 704, -999, 149, -104, 819, -414, -443, 517, -599, -5, 145, -24, -993, -283, 904, 174, -112, -276, -860, 44, -257,\ +-931, -821, -667, 540, 421, 485, 531, 407, 833, 431, -415, 878, 503, -901, 639, -608, 896, 860, 927, 424, 113, -808, -323, 729, 382, -922, 548, -791, -379, 207, 203, 559, 537, 137, 999, -913, -240, 942, 249, 616, 775, -4, 915, 855, -987, -234, -384, 948, -310, -542, 125, -289, -599, 967, -492, -349, -552, 562, -926, 632, -164, 217, -165, -496, 847, 684, -884, 457, -748, -745, -38, 93, 961, 934, 588, 366, -130, 851, -803, -811, -211, 428, 183, -469, 888, 596, -475, -899, -681, 508, 184,\ +921, 863, -610, -416, -119, -966, -686, 210, 733, 715, -889, -925, -434, -566, -455, 596, -514, 983, 755, -194, -802, -313, 91, -541, 808, -834, 243, -377, 256, 966, -402, -773, -308, -605, 266, 866, 118, -425, -531, 498, 666, 813, -267, 830, 69, -869, -496, 735, 28, 488, -645, -493, -689, 170, -940, 532, 844, -658, -617, 408, -200, 764, -665, 568, 342, 621, 908, 471, 280, 859, 709, 898, 81, -547, 406, 514, -595, 43, -824, -696, -746, -429, -59, -263, -813, 233, 279, -125, 687, -418,\ +-530, 409, 614, 803, -407, 78, -676, -39, -887, -141, -292, 270, -343, 400, 907, 588, 668, 899, 973, 103, -101, -11, 397, -16, 165, 705, -410, -585, 316, 391, -346, -336, 957, -118, -538, -441, -845, 121, 591, -359, -188, -362, -208, 27, -925, -157, -495, -177, -580, 9, 531, -752, 94, 107, 820, 769, -500, 852, 617, 145, 355, 34, -463, -265, -709, -111, -855, -405, 560, 470, 3, -177, -164, -249, 450, 662, 841, -689, -509, 987, -33, 769, 234, -2, 203, 780, 744, -895, 497, -432, -406, -264,\ +-71, 124, 778, -897, 495, 127, -76, 52, -768, 205, 464, -992, 801, -83, -806, 545, -316, 146, 772, 786, 289, -936, 145, -30, -722, -455, 270, 444, 427, -482, 383, -861, 36, 630, -404, 83, 864, 743, -351, -846, 315, -837, 357, -195, 450, -715, 227, -942, 740, -519, 476, 716, 713, 169, 492, -112, -49, -931, 866, 95, -725, 198, -50, -17, -660, 356, -142, -781, 53, 431, 720, 143, -416, 446, -497, 490, -96, 157, 239, 487, -337, -224, -445, 813, 92, -22, 603, 424, 952, -632, -367, 898, -927,\ +884, -277, -187, -777, 537, -575, -313, 347, -33, 800, 672, -919, -541, 5, -270, -94, -265, -793, -183, -761, -516, -608, -218, 57, -889, -912, 508, 93, -90, 34, 530, 201, 999, -37, -186, -62, -980, 239, 902, 983, -287, -634, 524, -772, 470, -961, 32, 162, 315, -411, 400, -235, -283, -787, -703, 869, 792, 543, -274, 239, 733, -439, 306, 349, 579, -200, -201, -824, 384, -246, 133, -508, 770, -102, 957, -825, 740, 748, -376, 183, -426, 46, 668, -886, -43, -174, 672, -419, 390, 927, 1000,\ +318, 886, 47, 908, -540, -825, -5, 314, -999, 354, -603, 966, -633, -689, 985, 534, -290, 167, -652, -797, -612, -79, 488, 622, -464, -950, 595, 897, 704, -238, -395, 125, 831, -180, 226, -379, 310, 564, 56, -978, 895, -61, 686, -251, 434, -417, 161, -512, 752, 528, -589, -425, 66, -925, -157, 1000, 96, 256, -239, -784, -882, -464, -909, 663, -177, -678, -441, 669, -564, -201, -121, -743, 187, -107, -768, -682, 355, 161, 411, 984, -954, 166, -842, -755, 267, -709, 372, -699, -272, -850,\ +403, -839, 949, 622, -62, 51, 917, 70, 528, -558, -632, 832, 276, 61, -445, -195, 960, 846, -474, 764, 879, -411, 948, -62, -592, -123, -96, -551, -555, -724, 849, 250, -808, -732, 797, -839, -554, 306, -919, 888, 484, -728, 152, -122, -287, 16, -345, -396, -268, -963, -500, 433, 343, 418, -480, 828, 594, 821, -9, 933, -230, 707, -847, -610, -748, -234, 688, 935, 713, 865, -743, 293, -143, -20, 928, -906, -762, 528, 722, 412, -70, 622, -245, 539, -686, 730, -866, -705, 28, -916, -623,\ +-768, -614, -915, -123, -183, 680, -223, 515, -37, -235, -5, 260, 347, -239, -322, -861, -848, -936, 945, 721, -580, -639, 780, -153, -26, 685, 177, 587, 307, -915, 435, 658, 539, -229, -719, -171, -858, 162, 734, -539, -437, 246, 639, 765, -477, -342, -209, -284, -779, -414, -452, 914, 338, -83, 759, 567, 266, -485, 14, 225, 347, -432, -242, 997, -365, -764, 119, -641, -416, -388, -436, -388, -54, -649, -571, -920, -477, 714, -363, 836, 369, 702, 869, 503, -287, -679, 46, -666, -202,\ +-602, 71, -259, 967, 601, -571, -830, -993, -271, 281, -494, 482, -180, 572, 587, -651, -566, -448, -228, 511, -924, 832, -52, -712, 402, -644, -533, -865, 269, 965, 56, 675, 179, -338, -272, 614, 602, -283, 303, -70, 909, -942, 117, 839, 468, 813, -765, 884, -697, -813, 352, 374, -705, -295, 633, 211, -754, 597, -941, -142, -393, -469, -653, 688, 996, 911, 214, 431, 453, -141, 874, -81, -258, -735, -3, -110, -338, -929, -182, -306, -104, -840, -588, -759, -157, -801, 848, -698, 627, 914,\ +-33, -353, 425, 150, -798, 553, 934, -778, -196, -132, 808, 745, -894, 144, 213, 662, 273, -79, 454, -60, -467, 48, -15, -807, 69, -930, 749, 559, -867, -103, 258, -677, 750, -303, 846, -227, -936, 744, -770, 770, -434, 594, -477, 589, -612, 535, 357, -623, 683, 369, 905, 980, -410, -663, 762, -888, -563, -845, 843, 353, -491, 996, -255, -336, -132, 695, -823, 289, -143, 365, 916, 877, 245, -530, -848, -804, -118, -108, 847, 620, -355, 499, 881, 92, -640, 542, 38, 626, -260, -34, -378,\ +598, 890, 305, -118, 711, -385, 600, -570, 27, -129, -893, 354, 459, 374, 816, 470, 356, 661, 877, 735, -286, -780, 620, 943, -169, -888, 978, 441, -667, -399, 662, 249, 137, 598, -863, -453, 722, -815, -251, -995, -294, -707, 901, 763, 977, 137, 431, -994, 905, 593, 694, 444, -626, -816, 252, 282, 616, 841, 360, -932, 817, -908, 50, 394, -120, -786, -338, 499, -982, -95, -454, 838, -312, 320, -127, -653, 53, 16, 988, -968, -151, -369, -836, 293, -271, 483, 18, 724, -204, -965, 245, 310,\ +987, 552, -835, -912, -861, 254, 560, 124, 145, 798, 178, 476, 138, -311, 151, -907, -886, -592, 728, -43, -489, 873, -422, -439, -489, 375, -703, -459, 338, 418, -25, 332, -454, 730, -604, -800, 37, -172, -197, -568, -563, -332, 228, -182, 994, -123, 444, -567, 98, 78, 0, -504, -150, 88, -936, 199, -651, -776, 192, 46, 526, -727, -991, 534, -659, -738, 256, -894, 965, -76, 816, 435, -418, 800, 838, 67, -733, 570, 112, -514, -416\r\ +"; + thread_local! { + static TEST_TERM_AND_POINT: (Term, AlacPoint) = + build_test_term(&LINE, 5, 50); + } + TEST_TERM_AND_POINT.with(|(term, point)| { + assert_eq!( + find_from_grid_point_bench(term, *point) + .map(|(path, ..)| path) + .unwrap_or_default(), + "392", + "Hyperlink should have been found" + ); + }); + } + + #[perf] + // https://github.com/zed-industries/zed/issues/44510 + pub fn issue_44510_hyperlink_benchmark() { + const LINE: &str = "..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +..............................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................\ +...............................................E.\r\ +"; + thread_local! { + static TEST_TERM_AND_POINT: (Term, AlacPoint) = + build_test_term(&LINE, 5, 50); + } + TEST_TERM_AND_POINT.with(|(term, point)| { + assert_eq!( + find_from_grid_point_bench(term, *point) + .map(|(path, ..)| path) + .unwrap_or_default(), + LINE.trim_end_matches(['.', '\r', '\n']), "Hyperlink should have been found" ); }); @@ -1038,8 +1224,9 @@ mod tests { } mod file_iri { - // File IRIs have a ton of use cases, most of which we currently do not support. A few of - // those cases are documented here as tests which are expected to fail. + // File IRIs have a ton of use cases. Absolute file URIs are supported on all platforms, + // including Windows drive letters (e.g., file:///C:/path) and percent-encoded characters. + // Some cases like relative file IRIs are not supported. // See https://en.wikipedia.org/wiki/File_URI_scheme /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns** @@ -1059,7 +1246,6 @@ mod tests { mod issues { #[cfg(not(target_os = "windows"))] #[test] - #[should_panic(expected = "Path = «/test/Ῥόδος/», at grid cells (0, 0)..=(15, 1)")] fn issue_file_iri_with_percent_encoded_characters() { // Non-space characters // file:///test/Ῥόδος/ @@ -1088,18 +1274,12 @@ mod tests { // See https://en.wikipedia.org/wiki/File_URI_scheme // https://github.com/zed-industries/zed/issues/39189 #[test] - #[should_panic( - expected = r#"Path = «C:\\test\\cool\\index.rs», at grid cells (0, 0)..=(9, 1)"# - )] fn issue_39189() { test_file_iri!("file:///C:/test/cool/index.rs"); test_file_iri!("file:///C:/test/cool/"); } #[test] - #[should_panic( - expected = r#"Path = «C:\\test\\Ῥόδος\\», at grid cells (0, 0)..=(16, 1)"# - )] fn issue_file_iri_with_percent_encoded_characters() { // Non-space characters // file:///test/Ῥόδος/ diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 3b3070c6f680452b43d398786fa2a705a06d3404..3d70d85f35239778bee61113ebc51eea7d87adcb 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -95,7 +95,7 @@ impl settings::Settings for TerminalSettings { ) }), font_features: user_content.font_features, - font_weight: user_content.font_weight.map(FontWeight), + font_weight: user_content.font_weight, line_height: user_content.line_height.unwrap(), env: project_content.env.unwrap(), cursor_shape: user_content.cursor_shape.unwrap().into(), diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index fd9568b0c582d4c191267183e296976f3d429eb3..c5289a34d6cbc9ecc4ddcdaacb6aaf2ce0831846 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -151,7 +151,14 @@ impl BatchedTextRun { std::slice::from_ref(&self.style), Some(dimensions.cell_width), ) - .paint(pos, dimensions.line_height, window, cx); + .paint( + pos, + dimensions.line_height, + gpui::TextAlign::Left, + None, + window, + cx, + ); } } @@ -632,7 +639,7 @@ impl TerminalElement { ) -> impl Fn(&E, &mut Window, &mut App) { move |event, window, cx| { if steal_focus { - window.focus(&focus_handle); + window.focus(&focus_handle, cx); } else if !focus_handle.is_focused(window) { return; } @@ -661,7 +668,7 @@ impl TerminalElement { let terminal_view = terminal_view.clone(); move |e, window, cx| { - window.focus(&focus); + window.focus(&focus, cx); let scroll_top = terminal_view.read(cx).scroll_top; terminal.update(cx, |terminal, cx| { @@ -1326,8 +1333,14 @@ impl Element for TerminalElement { }], None ); - shaped_line - .paint(ime_position, layout.dimensions.line_height, window, cx) + shaped_line.paint( + ime_position, + layout.dimensions.line_height, + gpui::TextAlign::Left, + None, + window, + cx, + ) .log_err(); } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 941689514d806acffb54fc0bfb8ecad86c1d2e70..2c8779275ae57b708a3d6303ebc98bd7b9552b91 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -30,8 +30,8 @@ use workspace::{ ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight, ActivatePaneUp, ActivatePreviousPane, DraggedSelection, DraggedTab, ItemId, MoveItemToPane, MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, NewTerminal, - Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitRight, SplitUp, SwapPaneDown, - SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, + Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitMode, SplitRight, SplitUp, + SwapPaneDown, SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, dock::{DockPosition, Panel, PanelEvent, PanelHandle}, item::SerializableItem, move_active_item, move_item, pane, @@ -167,7 +167,7 @@ impl TerminalPanel { // hence we focus that first. Otherwise, we'd end up without a focused element, as // context menu will be gone the moment we spawn the modal. .action( - "Spawn task", + "Spawn Task", zed_actions::Spawn::modal().boxed_clone(), ) }); @@ -192,10 +192,10 @@ impl TerminalPanel { split_context.clone(), |menu, split_context| menu.context(split_context), ) - .action("Split Right", SplitRight.boxed_clone()) - .action("Split Left", SplitLeft.boxed_clone()) - .action("Split Up", SplitUp.boxed_clone()) - .action("Split Down", SplitDown.boxed_clone()) + .action("Split Right", SplitRight::default().boxed_clone()) + .action("Split Left", SplitLeft::default().boxed_clone()) + .action("Split Up", SplitUp::default().boxed_clone()) + .action("Split Down", SplitDown::default().boxed_clone()) }) .into() } @@ -342,7 +342,7 @@ impl TerminalPanel { pane::Event::RemovedItem { .. } => self.serialize(cx), pane::Event::Remove { focus_on_pane } => { let pane_count_before_removal = self.center.panes().len(); - let _removal_result = self.center.remove(pane); + let _removal_result = self.center.remove(pane, cx); if pane_count_before_removal == 1 { self.center.first_pane().update(cx, |pane, cx| { pane.set_zoomed(false, cx); @@ -351,7 +351,7 @@ impl TerminalPanel { } else if let Some(focus_on_pane) = focus_on_pane.as_ref().or_else(|| self.center.panes().pop()) { - focus_on_pane.focus_handle(cx).focus(window); + focus_on_pane.focus_handle(cx).focus(window, cx); } } pane::Event::ZoomIn => { @@ -380,44 +380,49 @@ impl TerminalPanel { } self.serialize(cx); } - &pane::Event::Split { - direction, - clone_active_item, - } => { - if clone_active_item { - let fut = self.new_pane_with_cloned_active_terminal(window, cx); - let pane = pane.clone(); - cx.spawn_in(window, async move |panel, cx| { - let Some(new_pane) = fut.await else { + &pane::Event::Split { direction, mode } => { + match mode { + SplitMode::ClonePane | SplitMode::EmptyPane => { + let clone = matches!(mode, SplitMode::ClonePane); + let new_pane = self.new_pane_with_active_terminal(clone, window, cx); + let pane = pane.clone(); + cx.spawn_in(window, async move |panel, cx| { + let Some(new_pane) = new_pane.await else { + return; + }; + panel + .update_in(cx, |panel, window, cx| { + panel + .center + .split(&pane, &new_pane, direction, cx) + .log_err(); + window.focus(&new_pane.focus_handle(cx), cx); + }) + .ok(); + }) + .detach(); + } + SplitMode::MovePane => { + let Some(item) = + pane.update(cx, |pane, cx| pane.take_active_item(window, cx)) + else { return; }; - panel - .update_in(cx, |panel, window, cx| { - panel.center.split(&pane, &new_pane, direction).log_err(); - window.focus(&new_pane.focus_handle(cx)); - }) - .ok(); - }) - .detach(); - } else { - let Some(item) = pane.update(cx, |pane, cx| pane.take_active_item(window, cx)) - else { - return; - }; - let Ok(project) = self - .workspace - .update(cx, |workspace, _| workspace.project().clone()) - else { - return; - }; - let new_pane = - new_terminal_pane(self.workspace.clone(), project, false, window, cx); - new_pane.update(cx, |pane, cx| { - pane.add_item(item, true, true, None, window, cx); - }); - self.center.split(&pane, &new_pane, direction).log_err(); - window.focus(&new_pane.focus_handle(cx)); - } + let Ok(project) = self + .workspace + .update(cx, |workspace, _| workspace.project().clone()) + else { + return; + }; + let new_pane = + new_terminal_pane(self.workspace.clone(), project, false, window, cx); + new_pane.update(cx, |pane, cx| { + pane.add_item(item, true, true, None, window, cx); + }); + self.center.split(&pane, &new_pane, direction, cx).log_err(); + window.focus(&new_pane.focus_handle(cx), cx); + } + }; } pane::Event::Focus => { self.active_pane = pane.clone(); @@ -430,8 +435,9 @@ impl TerminalPanel { } } - fn new_pane_with_cloned_active_terminal( + fn new_pane_with_active_terminal( &mut self, + clone: bool, window: &mut Window, cx: &mut Context, ) -> Task>> { @@ -443,21 +449,34 @@ impl TerminalPanel { let weak_workspace = self.workspace.clone(); let project = workspace.project().clone(); let active_pane = &self.active_pane; - let terminal_view = active_pane - .read(cx) - .active_item() - .and_then(|item| item.downcast::()); - let working_directory = terminal_view - .as_ref() - .and_then(|terminal_view| { - terminal_view - .read(cx) - .terminal() - .read(cx) - .working_directory() - }) - .or_else(|| default_working_directory(workspace, cx)); - let is_zoomed = active_pane.read(cx).is_zoomed(); + let terminal_view = if clone { + active_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + } else { + None + }; + let working_directory = if clone { + terminal_view + .as_ref() + .and_then(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .working_directory() + }) + .or_else(|| default_working_directory(workspace, cx)) + } else { + default_working_directory(workspace, cx) + }; + + let is_zoomed = if clone { + active_pane.read(cx).is_zoomed() + } else { + false + }; cx.spawn_in(window, async move |panel, cx| { let terminal = project .update(cx, |project, cx| match terminal_view { @@ -550,7 +569,7 @@ impl TerminalPanel { let builder = ShellBuilder::new(&shell, is_windows); let command_label = builder.command_label(task.command.as_deref().unwrap_or("")); - let (command, args) = builder.build(task.command.clone(), &task.args); + let (command, args) = builder.build_no_quote(task.command.clone(), &task.args); let task = SpawnInTerminal { command_label, @@ -787,8 +806,7 @@ impl TerminalPanel { } pane.update(cx, |pane, cx| { - let focus = pane.has_focus(window, cx) - || matches!(reveal_strategy, RevealStrategy::Always); + let focus = matches!(reveal_strategy, RevealStrategy::Always); pane.add_item(terminal_view, true, focus, None, window, cx); }); @@ -850,8 +868,7 @@ impl TerminalPanel { } pane.update(cx, |pane, cx| { - let focus = pane.has_focus(window, cx) - || matches!(reveal_strategy, RevealStrategy::Always); + let focus = matches!(reveal_strategy, RevealStrategy::Always); pane.add_item(terminal_view, true, focus, None, window, cx); }); @@ -938,7 +955,6 @@ impl TerminalPanel { cx: &mut Context, ) -> Task>> { let reveal = spawn_task.reveal; - let reveal_target = spawn_task.reveal_target; let task_workspace = self.workspace.clone(); cx.spawn_in(window, async move |terminal_panel, cx| { let project = terminal_panel.update(cx, |this, cx| { @@ -954,6 +970,14 @@ impl TerminalPanel { terminal_to_replace.set_terminal(new_terminal.clone(), window, cx); })?; + let reveal_target = terminal_panel.update(cx, |panel, _| { + if panel.center.panes().iter().any(|p| **p == task_pane) { + RevealTarget::Dock + } else { + RevealTarget::Center + } + })?; + match reveal { RevealStrategy::Always => match reveal_target { RevealTarget::Center => { @@ -995,7 +1019,7 @@ impl TerminalPanel { RevealStrategy::NoFocus => match reveal_target { RevealTarget::Center => { task_workspace.update_in(cx, |workspace, window, cx| { - workspace.active_pane().focus_handle(cx).focus(window); + workspace.active_pane().focus_handle(cx).focus(window, cx); })?; } RevealTarget::Dock => { @@ -1050,7 +1074,7 @@ impl TerminalPanel { .center .find_pane_in_direction(&self.active_pane, direction, cx) { - window.focus(&pane.focus_handle(cx)); + window.focus(&pane.focus_handle(cx), cx); } else { self.workspace .update(cx, |workspace, cx| { @@ -1066,7 +1090,7 @@ impl TerminalPanel { .find_pane_in_direction(&self.active_pane, direction, cx) .cloned() { - self.center.swap(&self.active_pane, &to); + self.center.swap(&self.active_pane, &to, cx); cx.notify(); } } @@ -1074,7 +1098,7 @@ impl TerminalPanel { fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { if self .center - .move_to_border(&self.active_pane, direction) + .move_to_border(&self.active_pane, direction, cx) .unwrap() { cx.notify(); @@ -1168,63 +1192,67 @@ pub fn new_terminal_pane( let source = tab.pane.clone(); let item_id_to_move = item.item_id(); - let Ok(new_split_pane) = pane - .drag_split_direction() - .map(|split_direction| { - drop_closure_terminal_panel.update(cx, |terminal_panel, cx| { - let is_zoomed = if terminal_panel.active_pane == this_pane { - pane.is_zoomed() - } else { - terminal_panel.active_pane.read(cx).is_zoomed() - }; - let new_pane = new_terminal_pane( - workspace.clone(), - project.clone(), - is_zoomed, - window, - cx, - ); - terminal_panel.apply_tab_bar_buttons(&new_pane, cx); - terminal_panel.center.split( - &this_pane, - &new_pane, - split_direction, - )?; - anyhow::Ok(new_pane) - }) - }) - .transpose() - else { - return ControlFlow::Break(()); + // If no split direction, let the regular pane drop handler take care of it + let Some(split_direction) = pane.drag_split_direction() else { + return ControlFlow::Continue(()); }; - match new_split_pane.transpose() { - // Source pane may be the one currently updated, so defer the move. - Ok(Some(new_pane)) => cx - .spawn_in(window, async move |_, cx| { - cx.update(|window, cx| { - move_item( - &source, + // Gather data synchronously before deferring + let is_zoomed = drop_closure_terminal_panel + .upgrade() + .map(|terminal_panel| { + let terminal_panel = terminal_panel.read(cx); + if terminal_panel.active_pane == this_pane { + pane.is_zoomed() + } else { + terminal_panel.active_pane.read(cx).is_zoomed() + } + }) + .unwrap_or(false); + + let workspace = workspace.clone(); + let terminal_panel = drop_closure_terminal_panel.clone(); + + // Defer the split operation to avoid re-entrancy panic. + // The pane may be the one currently being updated, so we cannot + // call mark_positions (via split) synchronously. + cx.spawn_in(window, async move |_, cx| { + cx.update(|window, cx| { + let Ok(new_pane) = + terminal_panel.update(cx, |terminal_panel, cx| { + let new_pane = new_terminal_pane( + workspace, project, is_zoomed, window, cx, + ); + terminal_panel.apply_tab_bar_buttons(&new_pane, cx); + terminal_panel.center.split( + &this_pane, &new_pane, - item_id_to_move, - new_pane.read(cx).active_item_index(), - true, - window, + split_direction, cx, - ); + )?; + anyhow::Ok(new_pane) }) - .ok(); - }) - .detach(), - // If we drop into existing pane or current pane, - // regular pane drop handler will take care of it, - // using the right tab index for the operation. - Ok(None) => return ControlFlow::Continue(()), - err @ Err(_) => { - err.log_err(); - return ControlFlow::Break(()); - } - }; + else { + return; + }; + + let Some(new_pane) = new_pane.log_err() else { + return; + }; + + move_item( + &source, + &new_pane, + item_id_to_move, + new_pane.read(cx).active_item_index(), + true, + window, + cx, + ); + }) + .ok(); + }) + .detach(); } else if let Some(project_path) = item.project_path(cx) && let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx) { @@ -1293,7 +1321,7 @@ fn add_paths_to_terminal( .active_item() .and_then(|item| item.downcast::()) { - window.focus(&terminal_view.focus_handle(cx)); + window.focus(&terminal_view.focus_handle(cx), cx); let mut new_text = paths.iter().map(|path| format!(" {path:?}")).join(""); new_text.push(' '); terminal_view.update(cx, |terminal_view, cx| { @@ -1447,7 +1475,7 @@ impl Render for TerminalPanel { .position(|pane| **pane == terminal_panel.active_pane) { let next_ix = (ix + 1) % panes.len(); - window.focus(&panes[next_ix].focus_handle(cx)); + window.focus(&panes[next_ix].focus_handle(cx), cx); } }), ) @@ -1459,7 +1487,7 @@ impl Render for TerminalPanel { .position(|pane| **pane == terminal_panel.active_pane) { let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1); - window.focus(&panes[prev_ix].focus_handle(cx)); + window.focus(&panes[prev_ix].focus_handle(cx), cx); } }, )) @@ -1467,10 +1495,10 @@ impl Render for TerminalPanel { cx.listener(|terminal_panel, action: &ActivatePane, window, cx| { let panes = terminal_panel.center.panes(); if let Some(&pane) = panes.get(action.0) { - window.focus(&pane.read(cx).focus_handle(cx)); + window.focus(&pane.read(cx).focus_handle(cx), cx); } else { let future = - terminal_panel.new_pane_with_cloned_active_terminal(window, cx); + terminal_panel.new_pane_with_active_terminal(true, window, cx); cx.spawn_in(window, async move |terminal_panel, cx| { if let Some(new_pane) = future.await { _ = terminal_panel.update_in( @@ -1482,10 +1510,11 @@ impl Render for TerminalPanel { &terminal_panel.active_pane, &new_pane, SplitDirection::Right, + cx, ) .log_err(); let new_pane = new_pane.read(cx); - window.focus(&new_pane.focus_handle(cx)); + window.focus(&new_pane.focus_handle(cx), cx); }, ); } diff --git a/crates/terminal_view/src/terminal_scrollbar.rs b/crates/terminal_view/src/terminal_scrollbar.rs index 871bb602306cccc92b8cffe62c4912c42b7a87e2..82ca0b4097dad1be899879b0241aed50d8e60bfa 100644 --- a/crates/terminal_view/src/terminal_scrollbar.rs +++ b/crates/terminal_view/src/terminal_scrollbar.rs @@ -50,28 +50,24 @@ impl ScrollableHandle for TerminalScrollHandle { let state = self.state.borrow(); size( Pixels::ZERO, - state - .total_lines - .checked_sub(state.viewport_lines) - .unwrap_or(0) as f32 - * state.line_height, + state.total_lines.saturating_sub(state.viewport_lines) as f32 * state.line_height, ) } fn offset(&self) -> Point { let state = self.state.borrow(); - let scroll_offset = state.total_lines - state.viewport_lines - state.display_offset; - Point::new( - Pixels::ZERO, - -(scroll_offset as f32 * self.state.borrow().line_height), - ) + let scroll_offset = state + .total_lines + .saturating_sub(state.viewport_lines) + .saturating_sub(state.display_offset); + Point::new(Pixels::ZERO, -(scroll_offset as f32 * state.line_height)) } fn set_offset(&self, point: Point) { let state = self.state.borrow(); let offset_delta = (point.y / state.line_height).round() as i32; - let max_offset = state.total_lines - state.viewport_lines; + let max_offset = state.total_lines.saturating_sub(state.viewport_lines); let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32); self.future_display_offset diff --git a/crates/terminal_view/src/terminal_tab_tooltip.rs b/crates/terminal_view/src/terminal_tab_tooltip.rs deleted file mode 100644 index 6324c0999a8231bb1e633ef39343944783029895..0000000000000000000000000000000000000000 --- a/crates/terminal_view/src/terminal_tab_tooltip.rs +++ /dev/null @@ -1,36 +0,0 @@ -use gpui::{IntoElement, Render}; -use ui::{Divider, prelude::*, tooltip_container}; - -pub struct TerminalTooltip { - title: SharedString, - pid: u32, -} - -impl TerminalTooltip { - pub fn new(title: impl Into, pid: u32) -> Self { - Self { - title: title.into(), - pid, - } - } -} - -impl Render for TerminalTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, move |this, _cx| { - this.occlude() - .on_mouse_move(|_, _window, cx| cx.stop_propagation()) - .child( - v_flex() - .gap_1() - .child(Label::new(self.title.clone())) - .child(Divider::horizontal()) - .child( - Label::new(format!("Process ID (PID): {}", self.pid)) - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - }) - } -} diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 2a5213ce7ebc3326c7f4a0b5a8291e098e65cd78..e7e60ff4b31dfbdd16b7de8841285d81fc311fc5 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -4,13 +4,12 @@ pub mod terminal_panel; mod terminal_path_like_target; pub mod terminal_scrollbar; mod terminal_slash_command; -pub mod terminal_tab_tooltip; use assistant_slash_command::SlashCommandRegistry; use editor::{EditorSettings, actions::SelectAll, blink_manager::BlinkManager}; use gpui::{ - Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render, + Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div, }; use persistence::TERMINAL_DB; @@ -32,9 +31,8 @@ use terminal_panel::TerminalPanel; use terminal_path_like_target::{hover_path_like_target, open_path_like_target}; use terminal_scrollbar::TerminalScrollHandle; use terminal_slash_command::TerminalSlashCommand; -use terminal_tab_tooltip::TerminalTooltip; use ui::{ - ContextMenu, Icon, IconName, Label, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, h_flex, + ContextMenu, Divider, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, prelude::*, scrollbars::{self, GlobalSetting, ScrollbarVisibility}, }; @@ -411,7 +409,7 @@ impl TerminalView { ) }); - window.focus(&context_menu.focus_handle(cx)); + window.focus(&context_menu.focus_handle(cx), cx); let subscription = cx.subscribe_in( &context_menu, window, @@ -689,12 +687,32 @@ impl TerminalView { ///Attempt to paste the clipboard into the terminal fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { - if let Some(clipboard_string) = cx.read_from_clipboard().and_then(|item| item.text()) { + let Some(clipboard) = cx.read_from_clipboard() else { + return; + }; + + if clipboard.entries().iter().any(|entry| match entry { + ClipboardEntry::Image(image) => !image.bytes.is_empty(), + _ => false, + }) { + self.forward_ctrl_v(cx); + return; + } + + if let Some(text) = clipboard.text() { self.terminal - .update(cx, |terminal, _cx| terminal.paste(&clipboard_string)); + .update(cx, |terminal, _cx| terminal.paste(&text)); } } + /// Emits a raw Ctrl+V so TUI agents can read the OS clipboard directly + /// and attach images using their native workflows. + fn forward_ctrl_v(&self, cx: &mut Context) { + self.terminal.update(cx, |term, _| { + term.input(vec![0x16]); + }); + } + fn send_text(&mut self, text: &SendText, _: &mut Window, cx: &mut Context) { self.clear_bell(cx); self.terminal.update(cx, |term, _| { @@ -1140,14 +1158,24 @@ impl Item for TerminalView { type Event = ItemEvent; fn tab_tooltip_content(&self, cx: &App) -> Option { - let terminal = self.terminal().read(cx); - let title = terminal.title(false); - let pid = terminal.pid_getter()?.fallback_pid(); - - Some(TabTooltipContent::Custom(Box::new(move |_window, cx| { - cx.new(|_| TerminalTooltip::new(title.clone(), pid.as_u32())) - .into() - }))) + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + let terminal = self.terminal().read(cx); + let title = terminal.title(false); + let pid = terminal.pid_getter()?.fallback_pid(); + + move |_, _| { + v_flex() + .gap_1() + .child(Label::new(title.clone())) + .child(h_flex().flex_grow().child(Divider::horizontal())) + .child( + Label::new(format!("Process ID (PID): {}", pid)) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element() + } + })))) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { @@ -1434,6 +1462,7 @@ impl SearchableItem for TerminalView { fn update_matches( &mut self, matches: &[Self::Match], + _active_match_index: Option, _window: &mut Window, cx: &mut Context, ) { diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index c6d47a1e233b2fdf58fbc73adb622fc801832335..bf660b1302466e2b244a86b3d1e58ea2b6991067 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -8,10 +8,14 @@ use sum_tree::{Bias, Dimensions}; /// A timestamped position in a buffer #[derive(Copy, Clone, Eq, PartialEq, Hash)] pub struct Anchor { + /// The timestamp of the operation that inserted the text + /// in which this anchor is located. pub timestamp: clock::Lamport, - /// The byte offset in the buffer + /// The byte offset into the text inserted in the operation + /// at `timestamp`. pub offset: usize, - /// Describes which character the anchor is biased towards + /// Whether this anchor stays attached to the character *before* or *after* + /// the offset. pub bias: Bias, pub buffer_id: Option, } diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index a719c4c1da181c2238b8882fb5ebf92a621eee22..663be407fc1a297da9c5a854d234d6977864afb7 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -39,6 +39,7 @@ pub use subscription::*; pub use sum_tree::Bias; use sum_tree::{Dimensions, FilterCursor, SumTree, TreeMap, TreeSet}; use undo_map::UndoMap; +use util::debug_panic; #[cfg(any(test, feature = "test-support"))] use util::RandomCharIter; @@ -2320,8 +2321,13 @@ impl BufferSnapshot { } else if anchor.is_max() { self.visible_text.len() } else { - debug_assert!(anchor.buffer_id == Some(self.remote_id)); - debug_assert!(self.version.observed(anchor.timestamp)); + debug_assert_eq!(anchor.buffer_id, Some(self.remote_id)); + debug_assert!( + self.version.observed(anchor.timestamp), + "Anchor timestamp {:?} not observed by buffer {:?}", + anchor.timestamp, + self.version + ); let anchor_key = InsertionFragmentKey { timestamp: anchor.timestamp, split_offset: anchor.offset, @@ -2439,7 +2445,7 @@ impl BufferSnapshot { if bias == Bias::Left && offset == 0 { Anchor::min_for_buffer(self.remote_id) } else if bias == Bias::Right - && ((cfg!(debug_assertions) && offset >= self.len()) || offset == self.len()) + && ((!cfg!(debug_assertions) && offset >= self.len()) || offset == self.len()) { Anchor::max_for_buffer(self.remote_id) } else { @@ -2453,7 +2459,15 @@ impl BufferSnapshot { }; } let (start, _, item) = self.fragments.find::(&None, &offset, bias); - let fragment = item.unwrap(); + let Some(fragment) = item else { + // We got a bad offset, likely out of bounds + debug_panic!( + "Failed to find fragment at offset {} (len: {})", + offset, + self.len() + ); + return Anchor::max_for_buffer(self.remote_id); + }; let overshoot = offset - start; Anchor { timestamp: fragment.timestamp, @@ -3374,6 +3388,25 @@ impl LineEnding { } } +pub fn chunks_with_line_ending(rope: &Rope, line_ending: LineEnding) -> impl Iterator { + rope.chunks().flat_map(move |chunk| { + let mut newline = false; + let end_with_newline = chunk.ends_with('\n').then_some(line_ending.as_str()); + chunk + .lines() + .flat_map(move |line| { + let ending = if newline { + Some(line_ending.as_str()) + } else { + None + }; + newline = true; + ending.into_iter().chain([line]) + }) + .chain(end_with_newline) + }) +} + #[cfg(debug_assertions)] pub mod debug { use super::*; diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 50da8c72b63443f2c70df59ccb9f5f5caf777ca8..82be2896c67f155ac61de1ca6afb058adbf5ea9c 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -91,6 +91,7 @@ impl ThemeColors { tab_inactive_background: neutral().light().step_2(), tab_active_background: neutral().light().step_1(), search_match_background: neutral().light().step_5(), + search_active_match_background: neutral().light().step_7(), panel_background: neutral().light().step_2(), panel_focused_border: blue().light().step_10(), panel_indent_guide: neutral().light_alpha().step_5(), @@ -228,6 +229,7 @@ impl ThemeColors { tab_inactive_background: neutral().dark().step_2(), tab_active_background: neutral().dark().step_1(), search_match_background: neutral().dark().step_5(), + search_active_match_background: neutral().dark().step_3(), panel_background: neutral().dark().step_2(), panel_focused_border: blue().dark().step_8(), panel_indent_guide: neutral().dark_alpha().step_4(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 2351ed6bcbd2297ebb5a173d17c095d92bb27c20..6bfcb1c86811136388eb5a557458f88c65d0ac09 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -152,6 +152,7 @@ pub(crate) fn zed_default_dark() -> Theme { tab_inactive_background: bg, tab_active_background: editor, search_match_background: bg, + search_active_match_background: bg, editor_background: editor, editor_gutter_background: editor, diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index 9c9cfbffef681890a802d21b8bcff85d358a64b8..f52b2cf0e50bc5d8b26de9457432aba9218a17b9 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -287,6 +287,15 @@ pub fn theme_colors_refinement( .panel_background .as_ref() .and_then(|color| try_parse_color(color).ok()); + let search_match_background = this + .search_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let search_active_match_background = this + .search_active_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(search_match_background); ThemeColorsRefinement { border, border_variant: this @@ -442,10 +451,8 @@ pub fn theme_colors_refinement( .tab_active_background .as_ref() .and_then(|color| try_parse_color(color).ok()), - search_match_background: this - .search_match_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), + search_match_background: search_match_background, + search_active_match_background: search_active_match_background, panel_background, panel_focused_border: this .panel_focused_border diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index c6766ca955700e2b7c3cd0e86ab16535fca8d852..905f2245e03ad7a8ce7a4eb8be6799e5ded379c4 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -128,6 +128,7 @@ pub struct ThemeColors { pub tab_inactive_background: Hsla, pub tab_active_background: Hsla, pub search_match_background: Hsla, + pub search_active_match_background: Hsla, pub panel_background: Hsla, pub panel_focused_border: Hsla, pub panel_indent_guide: Hsla, @@ -352,6 +353,7 @@ pub enum ThemeColorField { TabInactiveBackground, TabActiveBackground, SearchMatchBackground, + SearchActiveMatchBackground, PanelBackground, PanelFocusedBorder, PanelIndentGuide, @@ -467,6 +469,7 @@ impl ThemeColors { ThemeColorField::TabInactiveBackground => self.tab_inactive_background, ThemeColorField::TabActiveBackground => self.tab_active_background, ThemeColorField::SearchMatchBackground => self.search_match_background, + ThemeColorField::SearchActiveMatchBackground => self.search_active_match_background, ThemeColorField::PanelBackground => self.panel_background, ThemeColorField::PanelFocusedBorder => self.panel_focused_border, ThemeColorField::PanelIndentGuide => self.panel_indent_guide, diff --git a/crates/title_bar/build.rs b/crates/title_bar/build.rs new file mode 100644 index 0000000000000000000000000000000000000000..ef70268ad3127baf113824348cb3e8685392a52b --- /dev/null +++ b/crates/title_bar/build.rs @@ -0,0 +1,28 @@ +#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] + +fn main() { + println!("cargo::rustc-check-cfg=cfg(macos_sdk_26)"); + + #[cfg(target_os = "macos")] + { + use std::process::Command; + + let output = Command::new("xcrun") + .args(["--sdk", "macosx", "--show-sdk-version"]) + .output() + .unwrap(); + + let sdk_version = String::from_utf8(output.stdout).unwrap(); + let major_version: Option = sdk_version + .trim() + .split('.') + .next() + .and_then(|v| v.parse().ok()); + + if let Some(major) = major_version + && major >= 26 + { + println!("cargo:rustc-cfg=macos_sdk_26"); + } + } +} diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 01a12260ad03284d77dfda19fdf2286cf6196ca8..579e4dadbd590981a4aee15019bbe73e2bb28d5c 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -1,12 +1,7 @@ -use gpui::{Entity, OwnedMenu, OwnedMenuItem}; +use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, actions}; use settings::Settings; -#[cfg(not(target_os = "macos"))] -use gpui::{Action, actions}; - -#[cfg(not(target_os = "macos"))] use schemars::JsonSchema; -#[cfg(not(target_os = "macos"))] use serde::Deserialize; use smallvec::SmallVec; @@ -14,18 +9,23 @@ use ui::{ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; use crate::title_bar_settings::TitleBarSettings; -#[cfg(not(target_os = "macos"))] actions!( app_menu, [ - /// Navigates to the menu item on the right. + /// Activates the menu on the right in the client-side application menu. + /// + /// Does not apply to platform menu bars (e.g. on macOS). ActivateMenuRight, - /// Navigates to the menu item on the left. + /// Activates the menu on the left in the client-side application menu. + /// + /// Does not apply to platform menu bars (e.g. on macOS). ActivateMenuLeft ] ); -#[cfg(not(target_os = "macos"))] +/// Opens the named menu in the client-side application menu. +/// +/// Does not apply to platform menu bars (e.g. on macOS). #[derive(Clone, Deserialize, JsonSchema, PartialEq, Default, Action)] #[action(namespace = app_menu)] pub struct OpenApplicationMenu(String); @@ -151,10 +151,10 @@ impl ApplicationMenu { // Application menu must have same ids as first menu item in standard menu div() - .id(SharedString::from(format!("{}-menu-item", menu_name))) + .id(format!("{}-menu-item", menu_name)) .occlude() .child( - PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name))) + PopoverMenu::new(format!("{}-menu-popover", menu_name)) .menu(move |window, cx| { Self::build_menu_from_items(entry.clone(), window, cx).into() }) @@ -184,10 +184,10 @@ impl ApplicationMenu { .collect(); div() - .id(SharedString::from(format!("{}-menu-item", menu_name))) + .id(format!("{}-menu-item", menu_name)) .occlude() .child( - PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name))) + PopoverMenu::new(format!("{}-menu-popover", menu_name)) .menu(move |window, cx| { Self::build_menu_from_items(entry.clone(), window, cx).into() }) diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index 16a0389efa46429d91c79f4eb1e99f62d01753b5..8a2d23dd26f81da469fe229eeed586ea8fe49189 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -8,7 +8,7 @@ use gpui::{ AnyElement, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, WeakEntity, canvas, point, }; -use gpui::{App, Task, Window, actions}; +use gpui::{App, Task, Window}; use project::WorktreeSettings; use rpc::proto::{self}; use settings::{Settings as _, SettingsLocation}; @@ -22,19 +22,7 @@ use workspace::notifications::DetachAndPromptErr; use crate::TitleBar; -actions!( - collab, - [ - /// Toggles screen sharing on or off. - ToggleScreenSharing, - /// Toggles microphone mute. - ToggleMute, - /// Toggles deafen mode (mute both microphone and speakers). - ToggleDeafen - ] -); - -fn toggle_screen_sharing( +pub fn toggle_screen_sharing( screen: anyhow::Result>>, window: &mut Window, cx: &mut App, @@ -90,7 +78,7 @@ fn toggle_screen_sharing( toggle_screen_sharing.detach_and_prompt_err("Sharing Screen Failed", window, cx, |e, _, _| Some(format!("{:?}\n\nPlease check that you have given Zed permissions to record your screen in Settings.", e))); } -fn toggle_mute(_: &ToggleMute, cx: &mut App) { +pub fn toggle_mute(cx: &mut App) { let call = ActiveCall::global(cx).read(cx); if let Some(room) = call.room().cloned() { room.update(cx, |room, cx| { @@ -110,7 +98,7 @@ fn toggle_mute(_: &ToggleMute, cx: &mut App) { } } -fn toggle_deafen(_: &ToggleDeafen, cx: &mut App) { +pub fn toggle_deafen(cx: &mut App) { if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { room.update(cx, |room, cx| room.toggle_deafen(cx)); } @@ -182,7 +170,9 @@ impl TitleBar { this.children(current_user_face_pile.map(|face_pile| { v_flex() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) .child(face_pile) .child(render_color_ribbon(player_colors.local().cursor)) })) @@ -217,6 +207,9 @@ impl TitleBar { .child(facepile) .child(render_color_ribbon(player_color.cursor)) .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) .on_click({ let peer_id = collaborator.peer_id; cx.listener(move |this, _, window, cx| { @@ -453,9 +446,7 @@ impl TitleBar { .icon_size(IconSize::Small) .toggle_state(is_muted) .selected_style(ButtonStyle::Tinted(TintColor::Error)) - .on_click(move |_, _window, cx| { - toggle_mute(&Default::default(), cx); - }) + .on_click(move |_, _window, cx| toggle_mute(cx)) .into_any_element(), ); } @@ -492,7 +483,7 @@ impl TitleBar { } } }) - .on_click(move |_, _, cx| toggle_deafen(&Default::default(), cx)) + .on_click(move |_, _, cx| toggle_deafen(cx)) .into_any_element(), ); diff --git a/crates/title_bar/src/platforms/platform_mac.rs b/crates/title_bar/src/platforms/platform_mac.rs index c7becde6c1af48bf37e06c0d2dcf991ad3c9f19f..5e8e4e5087054e59f66527915ae97e352a9ff525 100644 --- a/crates/title_bar/src/platforms/platform_mac.rs +++ b/crates/title_bar/src/platforms/platform_mac.rs @@ -1,6 +1,10 @@ -/// Use pixels here instead of a rem-based size because the macOS traffic -/// lights are a static size, and don't scale with the rest of the UI. -/// -/// Magic number: There is one extra pixel of padding on the left side due to -/// the 1px border around the window on macOS apps. +// Use pixels here instead of a rem-based size because the macOS traffic +// lights are a static size, and don't scale with the rest of the UI. +// +// Magic number: There is one extra pixel of padding on the left side due to +// the 1px border around the window on macOS apps. +#[cfg(macos_sdk_26)] +pub const TRAFFIC_LIGHT_PADDING: f32 = 78.; + +#[cfg(not(macos_sdk_26))] pub const TRAFFIC_LIGHT_PADDING: f32 = 71.; diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 0652744a36b2a9e0b09347553a6d16f6c5344dbe..9b75d35eccafa3c30f23329d9c0ee890ed2b2405 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -1,5 +1,5 @@ mod application_menu; -mod collab; +pub mod collab; mod onboarding_banner; pub mod platform_title_bar; mod platforms; @@ -30,18 +30,20 @@ use gpui::{ Subscription, WeakEntity, Window, actions, div, }; use onboarding_banner::OnboardingBanner; -use project::{Project, WorktreeSettings, git_store::GitStoreEvent}; +use project::{ + Project, WorktreeSettings, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees, +}; use remote::RemoteConnectionOptions; use settings::{Settings, SettingsLocation}; use std::sync::Arc; use theme::ActiveTheme; use title_bar_settings::TitleBarSettings; use ui::{ - Avatar, Button, ButtonLike, ButtonStyle, Chip, ContextMenu, Icon, IconName, IconSize, - IconWithIndicator, Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, h_flex, prelude::*, + Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu, + PopoverMenuHandle, TintColor, Tooltip, prelude::*, }; use util::{ResultExt, rel_path::RelPath}; -use workspace::{Workspace, notifications::NotifyResultExt}; +use workspace::{ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt}; use zed_actions::{OpenRecent, OpenRemote}; pub use onboarding_banner::restore_banner; @@ -163,11 +165,12 @@ impl Render for TitleBar { title_bar .when(title_bar_settings.show_project_items, |title_bar| { title_bar - .children(self.render_project_host(cx)) - .child(self.render_project_name(cx)) + .children(self.render_restricted_mode(cx)) + .children(self.render_project_host(window, cx)) + .child(self.render_project_name(window, cx)) }) .when(title_bar_settings.show_branch_name, |title_bar| { - title_bar.children(self.render_project_branch(cx)) + title_bar.children(self.render_project_repo(window, cx)) }) }) }) @@ -202,9 +205,11 @@ impl Render for TitleBar { .children(self.render_connection_status(status, cx)) .when( user.is_none() && TitleBarSettings::get_global(cx).show_sign_in, - |el| el.child(self.render_sign_in_button(cx)), + |this| this.child(self.render_sign_in_button(cx)), ) - .child(self.render_app_menu_button(cx)) + .when(TitleBarSettings::get_global(cx).show_user_menu, |this| { + this.child(self.render_user_menu_button(cx)) + }) .into_any_element(), ); @@ -289,7 +294,12 @@ impl TitleBar { _ => {} }), ); - subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify())); + subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify())); + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| { + cx.notify(); + })); + } let banner = cx.new(|cx| { OnboardingBanner::new( @@ -315,20 +325,54 @@ impl TitleBar { client, _subscriptions: subscriptions, banner, - screen_share_popover_handle: Default::default(), + screen_share_popover_handle: PopoverMenuHandle::default(), } } - fn render_remote_project_connection(&self, cx: &mut Context) -> Option { + fn project_name(&self, cx: &Context) -> Option { + self.project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| { + let worktree = worktree.read(cx); + let settings_location = SettingsLocation { + worktree_id: worktree.id(), + path: RelPath::empty(), + }; + + let settings = WorktreeSettings::get(Some(settings_location), cx); + let name = match &settings.project_name { + Some(name) => name.as_str(), + None => worktree.root_name_str(), + }; + SharedString::new(name) + }) + .next() + } + + fn render_remote_project_connection( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let workspace = self.workspace.clone(); + let is_picker_open = self.is_picker_open(window, cx); + let options = self.project.read(cx).remote_connection_options(cx)?; let host: SharedString = options.display_name().into(); - let (nickname, icon) = match options { - RemoteConnectionOptions::Ssh(options) => { - (options.nickname.map(|nick| nick.into()), IconName::Server) + let (nickname, tooltip_title, icon) = match options { + RemoteConnectionOptions::Ssh(options) => ( + options.nickname.map(|nick| nick.into()), + "Remote Project", + IconName::Server, + ), + RemoteConnectionOptions::Wsl(_) => (None, "Remote Project", IconName::Linux), + RemoteConnectionOptions::Docker(_dev_container_connection) => { + (None, "Dev Container", IconName::Box) } - RemoteConnectionOptions::Wsl(_) => (None, IconName::Linux), }; + let nickname = nickname.unwrap_or_else(|| host.clone()); let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? { @@ -358,7 +402,7 @@ impl TitleBar { let meta = SharedString::from(meta); Some( - ButtonLike::new("ssh-server-icon") + ButtonLike::new("remote_project") .child( h_flex() .gap_2() @@ -373,34 +417,93 @@ impl TitleBar { ) .child(Label::new(nickname).size(LabelSize::Small).truncate()), ) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Remote Project", - Some(&OpenRemote { - from_existing_connection: false, - create_new_window: false, - }), - meta.clone(), - cx, - ) + .when(!is_picker_open, |this| { + this.tooltip(move |_window, cx| { + Tooltip::with_meta( + tooltip_title, + Some(&OpenRemote { + from_existing_connection: false, + create_new_window: false, + }), + meta.clone(), + cx, + ) + }) }) - .on_click(|_, window, cx| { - window.dispatch_action( - OpenRemote { - from_existing_connection: false, - create_new_window: false, - } - .boxed_clone(), - cx, - ); + .on_click(move |event, window, cx| { + let position = event.position(); + let _ = workspace.update(cx, |this, cx| { + this.set_next_modal_placement(workspace::ModalPlacement::Anchored { + position, + }); + + window.dispatch_action( + OpenRemote { + from_existing_connection: false, + create_new_window: false, + } + .boxed_clone(), + cx, + ); + }); }) .into_any_element(), ) } - pub fn render_project_host(&self, cx: &mut Context) -> Option { + pub fn render_restricted_mode(&self, cx: &mut Context) -> Option { + let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx) + .map(|trusted_worktrees| { + trusted_worktrees + .read(cx) + .has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx) + }) + .unwrap_or(false); + if !has_restricted_worktrees { + return None; + } + + let button = Button::new("restricted_mode_trigger", "Restricted Mode") + .style(ButtonStyle::Tinted(TintColor::Warning)) + .label_size(LabelSize::Small) + .color(Color::Warning) + .icon(IconName::Warning) + .icon_color(Color::Warning) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .tooltip(|_, cx| { + Tooltip::with_meta( + "You're in Restricted Mode", + Some(&ToggleWorktreeSecurity), + "Mark this project as trusted and unlock all features", + cx, + ) + }) + .on_click({ + cx.listener(move |this, _, window, cx| { + this.workspace + .update(cx, |workspace, cx| { + workspace.show_worktree_trust_security_modal(true, window, cx) + }) + .log_err(); + }) + }); + + if cfg!(macos_sdk_26) { + // Make up for Tahoe's traffic light buttons having less spacing around them + Some(div().child(button).ml_0p5().into_any_element()) + } else { + Some(button.into_any_element()) + } + } + + pub fn render_project_host( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { if self.project.read(cx).is_via_remote_server() { - return self.render_remote_project_connection(cx); + return self.render_remote_project_connection(window, cx); } if self.project.read(cx).is_disconnected(cx) { @@ -408,7 +511,6 @@ impl TitleBar { Button::new("disconnected", "Disconnected") .disabled(true) .color(Color::Disabled) - .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) .into_any_element(), ); @@ -421,15 +523,19 @@ impl TitleBar { .read(cx) .participant_indices() .get(&host_user.id)?; + Some( Button::new("project_owner_trigger", host_user.github_login.clone()) .color(Color::Player(participant_index.0)) - .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) - .tooltip(Tooltip::text(format!( - "{} is sharing this project. Click to follow.", - host_user.github_login - ))) + .tooltip(move |_, cx| { + let tooltip_title = format!( + "{} is sharing this project. Click to follow.", + host_user.github_login + ); + + Tooltip::with_meta(tooltip_title, None, "Click to Follow", cx) + }) .on_click({ let host_peer_id = host.peer_id; cx.listener(move |this, _, window, cx| { @@ -444,46 +550,42 @@ impl TitleBar { ) } - pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { - let name = self - .project - .read(cx) - .visible_worktrees(cx) - .map(|worktree| { - let worktree = worktree.read(cx); - let settings_location = SettingsLocation { - worktree_id: worktree.id(), - path: RelPath::empty(), - }; + pub fn render_project_name( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let workspace = self.workspace.clone(); + let is_picker_open = self.is_picker_open(window, cx); - let settings = WorktreeSettings::get(Some(settings_location), cx); - match &settings.project_name { - Some(name) => name.as_str(), - None => worktree.root_name_str(), - } - }) - .next(); + let name = self.project_name(cx); let is_project_selected = name.is_some(); let name = if let Some(name) = name { - util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH) + util::truncate_and_trailoff(&name, MAX_PROJECT_NAME_LENGTH) } else { - "Open recent project".to_string() + "Open Recent Project".to_string() }; Button::new("project_name_trigger", name) - .when(!is_project_selected, |b| b.color(Color::Muted)) - .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) - .tooltip(move |_window, cx| { - Tooltip::for_action( - "Recent Projects", - &zed_actions::OpenRecent { - create_new_window: false, - }, - cx, - ) + .when(!is_project_selected, |s| s.color(Color::Muted)) + .when(!is_picker_open, |this| { + this.tooltip(move |_window, cx| { + Tooltip::for_action( + "Recent Projects", + &zed_actions::OpenRecent { + create_new_window: false, + }, + cx, + ) + }) }) - .on_click(cx.listener(move |_, _, window, cx| { + .on_click(move |event, window, cx| { + let position = event.position(); + let _ = workspace.update(cx, |this, _cx| { + this.set_next_modal_placement(workspace::ModalPlacement::Anchored { position }) + }); + window.dispatch_action( OpenRecent { create_new_window: false, @@ -491,70 +593,102 @@ impl TitleBar { .boxed_clone(), cx, ); - })) + }) } - pub fn render_project_branch(&self, cx: &mut Context) -> Option { - let settings = TitleBarSettings::get_global(cx); + pub fn render_project_repo( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { let repository = self.project.read(cx).active_repository(cx)?; + let repository_count = self.project.read(cx).repositories(cx).len(); let workspace = self.workspace.upgrade()?; - let repo = repository.read(cx); - let branch_name = repo - .branch - .as_ref() - .map(|branch| branch.name()) - .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH)) - .or_else(|| { - repo.head_commit.as_ref().map(|commit| { - commit - .sha - .chars() - .take(MAX_SHORT_SHA_LENGTH) - .collect::() - }) - })?; + + let (branch_name, icon_info) = { + let repo = repository.read(cx); + let branch_name = repo + .branch + .as_ref() + .map(|branch| branch.name()) + .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH)) + .or_else(|| { + repo.head_commit.as_ref().map(|commit| { + commit + .sha + .chars() + .take(MAX_SHORT_SHA_LENGTH) + .collect::() + }) + }); + + let branch_name = branch_name?; + + let project_name = self.project_name(cx); + let repo_name = repo + .work_directory_abs_path + .file_name() + .and_then(|name| name.to_str()) + .map(SharedString::new); + let show_repo_name = + repository_count > 1 && repo.branch.is_some() && repo_name != project_name; + let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) { + format!("{repo_name}/{branch_name}") + } else { + branch_name + }; + + let status = repo.status_summary(); + let tracked = status.index + status.worktree; + let icon_info = if status.conflict > 0 { + (IconName::Warning, Color::VersionControlConflict) + } else if tracked.modified > 0 { + (IconName::SquareDot, Color::VersionControlModified) + } else if tracked.added > 0 || status.untracked > 0 { + (IconName::SquarePlus, Color::VersionControlAdded) + } else if tracked.deleted > 0 { + (IconName::SquareMinus, Color::VersionControlDeleted) + } else { + (IconName::GitBranch, Color::Muted) + }; + + (branch_name, icon_info) + }; + + let is_picker_open = self.is_picker_open(window, cx); + let settings = TitleBarSettings::get_global(cx); Some( Button::new("project_branch_trigger", branch_name) - .color(Color::Muted) - .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - "Recent Branches", - Some(&zed_actions::git::Branch), - "Local branches only", - cx, - ) - }) - .on_click(move |_, window, cx| { - let _ = workspace.update(cx, |this, cx| { - window.focus(&this.active_pane().focus_handle(cx)); - window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); - }); + .color(Color::Muted) + .when(!is_picker_open, |this| { + this.tooltip(move |_window, cx| { + Tooltip::with_meta( + "Recent Branches", + Some(&zed_actions::git::Branch), + "Local branches only", + cx, + ) + }) }) .when(settings.show_branch_icon, |branch_button| { - let (icon, icon_color) = { - let status = repo.status_summary(); - let tracked = status.index + status.worktree; - if status.conflict > 0 { - (IconName::Warning, Color::VersionControlConflict) - } else if tracked.modified > 0 { - (IconName::SquareDot, Color::VersionControlModified) - } else if tracked.added > 0 || status.untracked > 0 { - (IconName::SquarePlus, Color::VersionControlAdded) - } else if tracked.deleted > 0 { - (IconName::SquareMinus, Color::VersionControlDeleted) - } else { - (IconName::GitBranch, Color::Muted) - } - }; - + let (icon, icon_color) = icon_info; branch_button .icon(icon) .icon_position(IconPosition::Start) .icon_color(icon_color) .icon_size(IconSize::Indicator) + }) + .on_click(move |event, window, cx| { + let position = event.position(); + let _ = workspace.update(cx, |this, cx| { + this.set_next_modal_placement(workspace::ModalPlacement::Anchored { + position, + }); + window.focus(&this.active_pane().focus_handle(cx), cx); + window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); + }); }), ) } @@ -646,7 +780,7 @@ impl TitleBar { pub fn render_sign_in_button(&mut self, _: &mut Context) -> Button { let client = self.client.clone(); - Button::new("sign_in", "Sign in") + Button::new("sign_in", "Sign In") .label_size(LabelSize::Small) .on_click(move |_, window, cx| { let client = client.clone(); @@ -661,7 +795,7 @@ impl TitleBar { }) } - pub fn render_app_menu_button(&mut self, cx: &mut Context) -> impl Element { + pub fn render_user_menu_button(&mut self, cx: &mut Context) -> impl Element { let user_store = self.user_store.read(cx); let user = user_store.current_user(); @@ -768,4 +902,10 @@ impl TitleBar { }) .anchor(gpui::Corner::TopRight) } + + fn is_picker_open(&self, window: &mut Window, cx: &mut Context) -> bool { + self.workspace + .update(cx, |workspace, cx| workspace.has_active_modal(window, cx)) + .unwrap_or(false) + } } diff --git a/crates/title_bar/src/title_bar_settings.rs b/crates/title_bar/src/title_bar_settings.rs index 29fae4d31eb33ac70a22c21010f09350847439c2..155b7b7bc797567927a70b12c677372cb92c9453 100644 --- a/crates/title_bar/src/title_bar_settings.rs +++ b/crates/title_bar/src/title_bar_settings.rs @@ -8,6 +8,7 @@ pub struct TitleBarSettings { pub show_branch_name: bool, pub show_project_items: bool, pub show_sign_in: bool, + pub show_user_menu: bool, pub show_menus: bool, } @@ -21,6 +22,7 @@ impl Settings for TitleBarSettings { show_branch_name: content.show_branch_name.unwrap(), show_project_items: content.show_project_items.unwrap(), show_sign_in: content.show_sign_in.unwrap(), + show_user_menu: content.show_user_menu.unwrap(), show_menus: content.show_menus.unwrap(), } } diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index 122aa9f22b74c33dd8f148f2bf3b65f04da478a9..06f7d1cdf3e27f43bdb5013038b943b9e5193680 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -124,7 +124,7 @@ impl ActiveToolchain { &buffer, window, |this, _, event: &BufferEvent, window, cx| { - if matches!(event, BufferEvent::LanguageChanged) { + if matches!(event, BufferEvent::LanguageChanged(_)) { this._update_toolchain_task = Self::spawn_tracker_task(window, cx); } }, @@ -198,10 +198,17 @@ impl ActiveToolchain { .or_else(|| toolchains.toolchains.first()) .cloned(); if let Some(toolchain) = &default_choice { + let worktree_root_path = project + .read_with(cx, |this, cx| { + this.worktree_for_id(worktree_id, cx) + .map(|worktree| worktree.read(cx).abs_path()) + }) + .ok() + .flatten()?; workspace::WORKSPACE_DB .set_toolchain( workspace_id, - worktree_id, + worktree_root_path, relative_path.clone(), toolchain.clone(), ) diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index 96f692694dcf6b1adaa6494a4c1cbf6905c57c7c..36ef2b960a8abfe684628cea465b68e6eab5e463 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -1,6 +1,7 @@ mod active_toolchain; pub use active_toolchain::ActiveToolchain; +use anyhow::Context as _; use convert_case::Casing as _; use editor::Editor; use file_finder::OpenPathDelegate; @@ -62,6 +63,7 @@ struct AddToolchainState { language_name: LanguageName, root_path: ProjectPath, weak: WeakEntity, + worktree_root_path: Arc, } struct ScopePickerState { @@ -99,12 +101,17 @@ impl AddToolchainState { root_path: ProjectPath, window: &mut Window, cx: &mut Context, - ) -> Entity { + ) -> anyhow::Result> { let weak = cx.weak_entity(); - - cx.new(|cx| { + let worktree_root_path = project + .read(cx) + .worktree_for_id(root_path.worktree_id, cx) + .map(|worktree| worktree.read(cx).abs_path()) + .context("Could not find worktree")?; + Ok(cx.new(|cx| { let (lister, rx) = Self::create_path_browser_delegate(project.clone(), cx); let picker = cx.new(|cx| Picker::uniform_list(lister, window, cx)); + Self { state: AddState::Path { _subscription: cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| { @@ -118,8 +125,9 @@ impl AddToolchainState { language_name, root_path, weak, + worktree_root_path, } - }) + })) } fn create_path_browser_delegate( @@ -128,67 +136,61 @@ impl AddToolchainState { ) -> (OpenPathDelegate, oneshot::Receiver>>) { let (tx, rx) = oneshot::channel(); let weak = cx.weak_entity(); - let path_style = project.read(cx).path_style(cx); - let lister = - OpenPathDelegate::new(tx, DirectoryLister::Project(project), false, path_style) - .show_hidden() - .with_footer(Arc::new(move |_, cx| { - let error = weak - .read_with(cx, |this, _| { - if let AddState::Path { error, .. } = &this.state { - error.clone() - } else { - None + let lister = OpenPathDelegate::new(tx, DirectoryLister::Project(project), false, cx) + .show_hidden() + .with_footer(Arc::new(move |_, cx| { + let error = weak + .read_with(cx, |this, _| { + if let AddState::Path { error, .. } = &this.state { + error.clone() + } else { + None + } + }) + .ok() + .flatten(); + let is_loading = weak + .read_with(cx, |this, _| { + matches!( + this.state, + AddState::Path { + input_state: PathInputState::Resolving(_), + .. } - }) - .ok() - .flatten(); - let is_loading = weak - .read_with(cx, |this, _| { - matches!( - this.state, - AddState::Path { - input_state: PathInputState::Resolving(_), - .. - } - ) - }) - .unwrap_or_default(); - Some( - v_flex() - .child(Divider::horizontal()) - .child( - h_flex() - .p_1() - .justify_between() - .gap_2() - .child( - Label::new("Select Toolchain Path") - .color(Color::Muted) - .map(|this| { - if is_loading { - this.with_animation( - "select-toolchain-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between( - 0.4, 0.8, - )), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - this.into_any_element() - } - }), - ) - .when_some(error, |this, error| { - this.child(Label::new(error).color(Color::Error)) - }), - ) - .into_any(), - ) - })); + ) + }) + .unwrap_or_default(); + Some( + v_flex() + .child(Divider::horizontal()) + .child( + h_flex() + .p_1() + .justify_between() + .gap_2() + .child(Label::new("Select Toolchain Path").color(Color::Muted).map( + |this| { + if is_loading { + this.with_animation( + "select-toolchain-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any() + } else { + this.into_any_element() + } + }, + )) + .when_some(error, |this, error| { + this.child(Label::new(error).color(Color::Error)) + }), + ) + .into_any(), + ) + })); (lister, rx) } @@ -231,7 +233,7 @@ impl AddToolchainState { ); }); *input_state = Self::wait_for_path(rx, window, cx); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); } }); return Err(anyhow::anyhow!("Failed to resolve toolchain")); @@ -243,7 +245,15 @@ impl AddToolchainState { // Suggest a default scope based on the applicability. let scope = if let Some(project_path) = resolved_toolchain_path { if !root_path.path.as_ref().is_empty() && project_path.starts_with(&root_path) { - ToolchainScope::Subproject(root_path.worktree_id, root_path.path) + let worktree_root_path = project + .read_with(cx, |this, cx| { + this.worktree_for_id(root_path.worktree_id, cx) + .map(|worktree| worktree.read(cx).abs_path()) + }) + .ok() + .flatten() + .context("Could not find a worktree with a given worktree ID")?; + ToolchainScope::Subproject(worktree_root_path, root_path.path) } else { ToolchainScope::Project } @@ -266,7 +276,7 @@ impl AddToolchainState { toolchain, scope_picker, }; - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); }); Result::<_, anyhow::Error>::Ok(()) @@ -339,7 +349,7 @@ impl AddToolchainState { }); _ = self.weak.update(cx, |this, cx| { this.state = State::Search((this.create_search_state)(window, cx)); - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); cx.notify(); }); } @@ -389,7 +399,7 @@ impl Render for AddToolchainState { &weak, |this: &mut ToolchainSelector, _: &menu::Cancel, window, cx| { this.state = State::Search((this.create_search_state)(window, cx)); - this.state.focus_handle(cx).focus(window); + this.state.focus_handle(cx).focus(window, cx); cx.notify(); }, )) @@ -406,7 +416,7 @@ impl Render for AddToolchainState { ToolchainScope::Global, ToolchainScope::Project, ToolchainScope::Subproject( - self.root_path.worktree_id, + self.worktree_root_path.clone(), self.root_path.path.clone(), ), ]; @@ -588,19 +598,20 @@ impl ToolchainSelector { .worktree_for_id(worktree_id, cx)? .read(cx) .abs_path(); - let workspace_id = workspace.database_id()?; let weak = workspace.weak_handle(); cx.spawn_in(window, async move |workspace, cx| { - let active_toolchain = workspace::WORKSPACE_DB - .toolchain( - workspace_id, - worktree_id, - relative_path.clone(), - language_name.clone(), - ) - .await - .ok() - .flatten(); + let active_toolchain = project + .read_with(cx, |this, cx| { + this.active_toolchain( + ProjectPath { + worktree_id, + path: relative_path.clone(), + }, + language_name.clone(), + cx, + ) + })? + .await; workspace .update_in(cx, |this, window, cx| { this.toggle_modal(window, cx, move |window, cx| { @@ -618,6 +629,7 @@ impl ToolchainSelector { }); }) .ok(); + anyhow::Ok(()) }) .detach(); @@ -697,7 +709,7 @@ impl ToolchainSelector { cx: &mut Context, ) { if matches!(self.state, State::Search(_)) { - self.state = State::AddToolchain(AddToolchainState::new( + let Ok(state) = AddToolchainState::new( self.project.clone(), self.language_name.clone(), ProjectPath { @@ -706,8 +718,11 @@ impl ToolchainSelector { }, window, cx, - )); - self.state.focus_handle(cx).focus(window); + ) else { + return; + }; + self.state = State::AddToolchain(state); + self.state.focus_handle(cx).focus(window, cx); cx.notify(); } } @@ -903,11 +918,17 @@ impl PickerDelegate for ToolchainSelectorDelegate { { let workspace = self.workspace.clone(); let worktree_id = self.worktree_id; + let worktree_abs_path_root = self.worktree_abs_path_root.clone(); let path = self.relative_path.clone(); let relative_path = self.relative_path.clone(); cx.spawn_in(window, async move |_, cx| { workspace::WORKSPACE_DB - .set_toolchain(workspace_id, worktree_id, relative_path, toolchain.clone()) + .set_toolchain( + workspace_id, + worktree_abs_path_root, + relative_path, + toolchain.clone(), + ) .await .log_err(); workspace diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index b6318f18c973ca5ca7eefa1ba39517ef65cad6df..c08e46c5882cf3c9e0a8e205c8b23224d3a7a8e1 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -1,3 +1,4 @@ +mod ai; mod avatar; mod banner; mod button; @@ -43,6 +44,7 @@ mod tree_view_item; #[cfg(feature = "stories")] mod stories; +pub use ai::*; pub use avatar::*; pub use banner::*; pub use button::*; diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs new file mode 100644 index 0000000000000000000000000000000000000000..e36361b7b06559c1442b86acf26b6694bb950d82 --- /dev/null +++ b/crates/ui/src/components/ai.rs @@ -0,0 +1,3 @@ +mod configured_api_card; + +pub use configured_api_card::*; diff --git a/crates/language_models/src/ui/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs similarity index 84% rename from crates/language_models/src/ui/configured_api_card.rs rename to crates/ui/src/components/ai/configured_api_card.rs index 063ac1717f3aa5de1a448e26c94df7530fec588f..37f9ac7602d676906565a911f1bbca6d2b40f755 100644 --- a/crates/language_models/src/ui/configured_api_card.rs +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -1,10 +1,11 @@ +use crate::{Tooltip, prelude::*}; use gpui::{ClickEvent, IntoElement, ParentElement, SharedString}; -use ui::{Tooltip, prelude::*}; #[derive(IntoElement)] pub struct ConfiguredApiCard { label: SharedString, button_label: Option, + button_tab_index: Option, tooltip_label: Option, disabled: bool, on_click: Option>, @@ -15,6 +16,7 @@ impl ConfiguredApiCard { Self { label: label.into(), button_label: None, + button_tab_index: None, tooltip_label: None, disabled: false, on_click: None, @@ -43,6 +45,11 @@ impl ConfiguredApiCard { self.disabled = disabled; self } + + pub fn button_tab_index(mut self, tab_index: isize) -> Self { + self.button_tab_index = Some(tab_index); + self + } } impl RenderOnce for ConfiguredApiCard { @@ -51,23 +58,27 @@ impl RenderOnce for ConfiguredApiCard { let button_id = SharedString::new(format!("id-{}", button_label)); h_flex() + .min_w_0() .mt_0p5() .p_1() .justify_between() .rounded_md() + .flex_wrap() .border_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().background) .child( h_flex() - .flex_1() .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(self.label).truncate()), + .child(Label::new(self.label)), ) .child( Button::new(button_id, button_label) + .when_some(self.button_tab_index, |elem, tab_index| { + elem.tab_index(tab_index) + }) .label_size(LabelSize::Small) .icon(IconName::Undo) .icon_size(IconSize::Small) diff --git a/crates/languages/src/tsx/highlights-jsx.scm b/crates/ui/src/components/ai/copilot_configuration_callout.rs similarity index 100% rename from crates/languages/src/tsx/highlights-jsx.scm rename to crates/ui/src/components/ai/copilot_configuration_callout.rs diff --git a/crates/ui/src/components/button.rs b/crates/ui/src/components/button.rs index 23e7702f6241b6ca0d4074936ee20da26531fbed..d56a9c09d3b57ba607b6837b16af31d240e58663 100644 --- a/crates/ui/src/components/button.rs +++ b/crates/ui/src/components/button.rs @@ -1,12 +1,14 @@ mod button; mod button_icon; mod button_like; +mod button_link; mod icon_button; mod split_button; mod toggle_button; pub use button::*; pub use button_like::*; +pub use button_link::*; pub use icon_button::*; pub use split_button::*; pub use toggle_button::*; diff --git a/crates/ui/src/components/button/button_link.rs b/crates/ui/src/components/button/button_link.rs new file mode 100644 index 0000000000000000000000000000000000000000..caffe2772bce394be6899b1f9b3b686c3927a530 --- /dev/null +++ b/crates/ui/src/components/button/button_link.rs @@ -0,0 +1,102 @@ +use gpui::{IntoElement, Window, prelude::*}; + +use crate::{ButtonLike, prelude::*}; + +/// A button that takes an underline to look like a regular web link. +/// It also contains an arrow icon to communicate the link takes you out of Zed. +/// +/// # Usage Example +/// +/// ``` +/// use ui::ButtonLink; +/// +/// let button_link = ButtonLink::new("Click me", "https://example.com"); +/// ``` +#[derive(IntoElement, RegisterComponent)] +pub struct ButtonLink { + label: SharedString, + label_size: LabelSize, + label_color: Color, + link: String, + no_icon: bool, +} + +impl ButtonLink { + pub fn new(label: impl Into, link: impl Into) -> Self { + Self { + link: link.into(), + label: label.into(), + label_size: LabelSize::Default, + label_color: Color::Default, + no_icon: false, + } + } + + pub fn no_icon(mut self, no_icon: bool) -> Self { + self.no_icon = no_icon; + self + } + + pub fn label_size(mut self, label_size: LabelSize) -> Self { + self.label_size = label_size; + self + } + + pub fn label_color(mut self, label_color: Color) -> Self { + self.label_color = label_color; + self + } +} + +impl RenderOnce for ButtonLink { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = format!("{}-{}", self.label, self.link); + + ButtonLike::new(id) + .size(ButtonSize::None) + .child( + h_flex() + .gap_0p5() + .child( + Label::new(self.label) + .size(self.label_size) + .color(self.label_color) + .underline(), + ) + .when(!self.no_icon, |this| { + this.child( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) + }), + ) + .on_click(move |_, _, cx| cx.open_url(&self.link)) + .into_any_element() + } +} + +impl Component for ButtonLink { + fn scope() -> ComponentScope { + ComponentScope::Navigation + } + + fn description() -> Option<&'static str> { + Some("A button that opens a URL.") + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .child( + example_group(vec![single_example( + "Simple", + ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(), + )]) + .vertical(), + ) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index 14b9fd153cd5ad662467c75ff81700587667cee3..48f06ff3789e69b6d19cde2322932f4bd6e89f97 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/crates/ui/src/components/button/split_button.rs @@ -4,7 +4,7 @@ use gpui::{ }; use theme::ActiveTheme; -use crate::{ElevationIndex, h_flex}; +use crate::{ElevationIndex, IconButton, h_flex}; use super::ButtonLike; @@ -15,6 +15,23 @@ pub enum SplitButtonStyle { Transparent, } +pub enum SplitButtonKind { + ButtonLike(ButtonLike), + IconButton(IconButton), +} + +impl From for SplitButtonKind { + fn from(icon_button: IconButton) -> Self { + Self::IconButton(icon_button) + } +} + +impl From for SplitButtonKind { + fn from(button_like: ButtonLike) -> Self { + Self::ButtonLike(button_like) + } +} + /// /// A button with two parts: a primary action on the left and a secondary action on the right. /// /// The left side is a [`ButtonLike`] with the main action, while the right side can contain @@ -23,15 +40,15 @@ pub enum SplitButtonStyle { /// The two sections are visually separated by a divider, but presented as a unified control. #[derive(IntoElement)] pub struct SplitButton { - pub left: ButtonLike, - pub right: AnyElement, + left: SplitButtonKind, + right: AnyElement, style: SplitButtonStyle, } impl SplitButton { - pub fn new(left: ButtonLike, right: AnyElement) -> Self { + pub fn new(left: impl Into, right: AnyElement) -> Self { Self { - left, + left: left.into(), right, style: SplitButtonStyle::Filled, } @@ -56,7 +73,10 @@ impl RenderOnce for SplitButton { this.border_1() .border_color(cx.theme().colors().border.opacity(0.8)) }) - .child(div().flex_grow().child(self.left)) + .child(div().flex_grow().child(match self.left { + SplitButtonKind::ButtonLike(button) => button.into_any_element(), + SplitButtonKind::IconButton(icon) => icon.into_any_element(), + })) .child( div() .h_full() diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index 4eb849d7f640aca78b70645f5f93301281ca6627..de95e5db2bcee2e7acbadf5570de09d9cdedbf4d 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -121,7 +121,7 @@ impl RenderOnce for Callout { Severity::Info => ( IconName::Info, Color::Muted, - cx.theme().colors().panel_background.opacity(0.), + cx.theme().status().info_background.opacity(0.1), ), Severity::Success => ( IconName::Check, diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index a4bae647408f860ec8425266a26efc173099f225..7e5e9032c9d4b0521f972b47d90d24cd502faf7b 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -562,7 +562,7 @@ impl ContextMenu { action: Some(action.boxed_clone()), handler: Rc::new(move |context, window, cx| { if let Some(context) = &context { - window.focus(context); + window.focus(context, cx); } window.dispatch_action(action.boxed_clone(), cx); }), @@ -594,7 +594,7 @@ impl ContextMenu { action: Some(action.boxed_clone()), handler: Rc::new(move |context, window, cx| { if let Some(context) = &context { - window.focus(context); + window.focus(context, cx); } window.dispatch_action(action.boxed_clone(), cx); }), @@ -893,39 +893,57 @@ impl ContextMenu { entry_render, handler, selectable, + documentation_aside, .. } => { let handler = handler.clone(); let menu = cx.entity().downgrade(); let selectable = *selectable; - ListItem::new(ix) - .inset(true) - .toggle_state(if selectable { - Some(ix) == self.selected_index - } else { - false + + div() + .id(("context-menu-child", ix)) + .when_some(documentation_aside.clone(), |this, documentation_aside| { + this.occlude() + .on_hover(cx.listener(move |menu, hovered, _, cx| { + if *hovered { + menu.documentation_aside = Some((ix, documentation_aside.clone())); + } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) + { + menu.documentation_aside = None; + } + cx.notify(); + })) }) - .selectable(selectable) - .when(selectable, |item| { - item.on_click({ - let context = self.action_context.clone(); - let keep_open_on_confirm = self.keep_open_on_confirm; - move |_, window, cx| { - handler(context.as_ref(), window, cx); - menu.update(cx, |menu, cx| { - menu.clicked = true; - - if keep_open_on_confirm { - menu.rebuild(window, cx); - } else { - cx.emit(DismissEvent); + .child( + ListItem::new(ix) + .inset(true) + .toggle_state(if selectable { + Some(ix) == self.selected_index + } else { + false + }) + .selectable(selectable) + .when(selectable, |item| { + item.on_click({ + let context = self.action_context.clone(); + let keep_open_on_confirm = self.keep_open_on_confirm; + move |_, window, cx| { + handler(context.as_ref(), window, cx); + menu.update(cx, |menu, cx| { + menu.clicked = true; + + if keep_open_on_confirm { + menu.rebuild(window, cx); + } else { + cx.emit(DismissEvent); + } + }) + .ok(); } }) - .ok(); - } - }) - }) - .child(entry_render(window, cx)) + }) + .child(entry_render(window, cx)), + ) .into_any_element() } } diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index f7cce2b85ffa3aeb9f97634c6c0fa65c46f4a8e7..9cd2a5cb7a0d802d170fcfbe6a812027c779d942 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -485,6 +485,7 @@ pub struct Table { interaction_state: Option>, col_widths: Option>, map_row: Option), &mut Window, &mut App) -> AnyElement>>, + use_ui_font: bool, empty_table_callback: Option AnyElement>>, } @@ -498,6 +499,7 @@ impl Table { rows: TableContents::Vec(Vec::new()), interaction_state: None, map_row: None, + use_ui_font: true, empty_table_callback: None, col_widths: None, } @@ -590,6 +592,11 @@ impl Table { self } + pub fn no_ui_font(mut self) -> Self { + self.use_ui_font = false; + self + } + pub fn map_row( mut self, callback: impl Fn((usize, Stateful
), &mut Window, &mut App) -> AnyElement + 'static, @@ -618,8 +625,8 @@ fn base_cell_style(width: Option) -> Div { .overflow_hidden() } -fn base_cell_style_text(width: Option, cx: &App) -> Div { - base_cell_style(width).text_ui(cx) +fn base_cell_style_text(width: Option, use_ui_font: bool, cx: &App) -> Div { + base_cell_style(width).when(use_ui_font, |el| el.text_ui(cx)) } pub fn render_table_row( @@ -656,7 +663,12 @@ pub fn render_table_row( .map(IntoElement::into_any_element) .into_iter() .zip(column_widths) - .map(|(cell, width)| base_cell_style_text(width, cx).px_1().py_0p5().child(cell)), + .map(|(cell, width)| { + base_cell_style_text(width, table_context.use_ui_font, cx) + .px_1() + .py_0p5() + .child(cell) + }), ); let row = if let Some(map_row) = table_context.map_row { @@ -700,7 +712,7 @@ pub fn render_table_header( .border_color(cx.theme().colors().border) .children(headers.into_iter().enumerate().zip(column_widths).map( |((header_idx, h), width)| { - base_cell_style_text(width, cx) + base_cell_style_text(width, table_context.use_ui_font, cx) .child(h) .id(ElementId::NamedInteger( shared_element_id.clone(), @@ -739,6 +751,7 @@ pub struct TableRenderContext { pub total_row_count: usize, pub column_widths: Option<[Length; COLS]>, pub map_row: Option), &mut Window, &mut App) -> AnyElement>>, + pub use_ui_font: bool, } impl TableRenderContext { @@ -748,6 +761,7 @@ impl TableRenderContext { total_row_count: table.rows.len(), column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)), map_row: table.map_row.clone(), + use_ui_font: table.use_ui_font, } } } diff --git a/crates/ui/src/components/divider.rs b/crates/ui/src/components/divider.rs index d6101f23203072a27febd0f8b8391af75b41d7f3..5ad2187cfae36f3cc45cbecb42f115f0742abed4 100644 --- a/crates/ui/src/components/divider.rs +++ b/crates/ui/src/components/divider.rs @@ -144,12 +144,16 @@ impl Divider { impl RenderOnce for Divider { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let base = match self.direction { - DividerDirection::Horizontal => { - div().h_px().w_full().when(self.inset, |this| this.mx_1p5()) - } - DividerDirection::Vertical => { - div().w_px().h_full().when(self.inset, |this| this.my_1p5()) - } + DividerDirection::Horizontal => div() + .min_w_0() + .h_px() + .w_full() + .when(self.inset, |this| this.mx_1p5()), + DividerDirection::Vertical => div() + .min_w_0() + .w_px() + .h_full() + .when(self.inset, |this| this.my_1p5()), }; match self.style { diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 1c8e36ec18d6184b38eb6772e8f5a13be181ae00..9d2c7ae3b515744125879f4a2c0e0d3e9a4fb841 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -126,17 +126,6 @@ enum IconSource { ExternalSvg(SharedString), } -impl IconSource { - fn from_path(path: impl Into) -> Self { - let path = path.into(); - if path.starts_with("icons/") { - Self::Embedded(path) - } else { - Self::External(Arc::from(PathBuf::from(path.as_ref()))) - } - } -} - #[derive(IntoElement, RegisterComponent)] pub struct Icon { source: IconSource, @@ -155,9 +144,18 @@ impl Icon { } } + /// Create an icon from a path. Uses a heuristic to determine if it's embedded or external: + /// - Paths starting with "icons/" are treated as embedded SVGs + /// - Other paths are treated as external raster images (from icon themes) pub fn from_path(path: impl Into) -> Self { + let path = path.into(); + let source = if path.starts_with("icons/") { + IconSource::Embedded(path) + } else { + IconSource::External(Arc::from(PathBuf::from(path.as_ref()))) + }; Self { - source: IconSource::from_path(path), + source, color: Color::default(), size: IconSize::default().rems(), transformation: Transformation::default(), diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs index c998e29f0ed6f5bccab976b11080320d4d65a7dd..7c19953ca43c907070829f7140f97a4fde495b57 100644 --- a/crates/ui/src/components/keybinding_hint.rs +++ b/crates/ui/src/components/keybinding_hint.rs @@ -234,9 +234,7 @@ impl RenderOnce for KeybindingHint { let mut base = h_flex(); - base.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Italic); + base.text_style().font_style = Some(FontStyle::Italic); base.gap_1() .font_buffer(cx) diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 49e2de94a1f86196c10e41879797b02070517e65..d0f50c00336eb971621e2da7bbaf53cf09569caa 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -56,6 +56,12 @@ impl Label { pub fn set_text(&mut self, text: impl Into) { self.label = text.into(); } + + /// Truncates the label from the start, keeping the end visible. + pub fn truncate_start(mut self) -> Self { + self.base = self.base.truncate_start(); + self + } } // Style methods. @@ -256,7 +262,8 @@ impl Component for Label { "Special Cases", vec![ single_example("Single Line", Label::new("Line 1\nLine 2\nLine 3").single_line().into_any_element()), - single_example("Text Ellipsis", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()), + single_example("Regular Truncation", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()), + single_example("Start Truncation", div().max_w_24().child(Label::new("zed/crates/ui/src/components/label/truncate/label/label.rs").truncate_start()).into_any_element()), ], ), ]) diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 1fa6b14c83d8359df234f33ecb9318c88e3a2714..03fde4083d5e9a8e07f38c830edd5116f14e6d70 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -56,7 +56,7 @@ pub trait LabelCommon { /// Sets the alpha property of the label, overwriting the alpha value of the color. fn alpha(self, alpha: f32) -> Self; - /// Truncates overflowing text with an ellipsis (`…`) if needed. + /// Truncates overflowing text with an ellipsis (`…`) at the end if needed. fn truncate(self) -> Self; /// Sets the label to render as a single line. @@ -88,6 +88,7 @@ pub struct LabelLike { underline: bool, single_line: bool, truncate: bool, + truncate_start: bool, } impl Default for LabelLike { @@ -113,6 +114,7 @@ impl LabelLike { underline: false, single_line: false, truncate: false, + truncate_start: false, } } } @@ -126,6 +128,12 @@ impl LabelLike { gpui::margin_style_methods!({ visibility: pub }); + + /// Truncates overflowing text with an ellipsis (`…`) at the start if needed. + pub fn truncate_start(mut self) -> Self { + self.truncate_start = true; + self + } } impl LabelCommon for LabelLike { @@ -169,7 +177,7 @@ impl LabelCommon for LabelLike { self } - /// Truncates overflowing text with an ellipsis (`…`) if needed. + /// Truncates overflowing text with an ellipsis (`…`) at the end if needed. fn truncate(mut self) -> Self { self.truncate = true; self @@ -223,11 +231,9 @@ impl RenderOnce for LabelLike { }) .when(self.italic, |this| this.italic()) .when(self.underline, |mut this| { - this.text_style() - .get_or_insert_with(Default::default) - .underline = Some(UnderlineStyle { + this.text_style().underline = Some(UnderlineStyle { thickness: px(1.), - color: None, + color: Some(cx.theme().colors().text_muted.opacity(0.4)), wavy: false, }); this @@ -235,7 +241,16 @@ impl RenderOnce for LabelLike { .when(self.strikethrough, |this| this.line_through()) .when(self.single_line, |this| this.whitespace_nowrap()) .when(self.truncate, |this| { - this.overflow_x_hidden().text_ellipsis() + this.min_w_0() + .overflow_x_hidden() + .whitespace_nowrap() + .text_ellipsis() + }) + .when(self.truncate_start, |this| { + this.min_w_0() + .overflow_x_hidden() + .whitespace_nowrap() + .text_ellipsis_start() }) .text_color(color) .font_weight( diff --git a/crates/ui/src/components/list/list_bullet_item.rs b/crates/ui/src/components/list/list_bullet_item.rs index 17731488f7139522bf19aeaab18fb395d1eb68b0..934f0853dbe18b8231e15073766b6c84c1896546 100644 --- a/crates/ui/src/components/list/list_bullet_item.rs +++ b/crates/ui/src/components/list/list_bullet_item.rs @@ -1,18 +1,33 @@ -use crate::{ListItem, prelude::*}; -use component::{Component, ComponentScope, example_group_with_title, single_example}; +use crate::{ButtonLink, ListItem, prelude::*}; +use component::{Component, ComponentScope, example_group, single_example}; use gpui::{IntoElement, ParentElement, SharedString}; #[derive(IntoElement, RegisterComponent)] pub struct ListBulletItem { label: SharedString, + label_color: Option, + children: Vec, } impl ListBulletItem { pub fn new(label: impl Into) -> Self { Self { label: label.into(), + label_color: None, + children: Vec::new(), } } + + pub fn label_color(mut self, color: Color) -> Self { + self.label_color = Some(color); + self + } +} + +impl ParentElement for ListBulletItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } } impl RenderOnce for ListBulletItem { @@ -34,7 +49,18 @@ impl RenderOnce for ListBulletItem { .color(Color::Hidden), ), ) - .child(div().w_full().min_w_0().child(Label::new(self.label))), + .map(|this| { + if !self.children.is_empty() { + this.child(h_flex().gap_0p5().flex_wrap().children(self.children)) + } else { + this.child( + div().w_full().min_w_0().child( + Label::new(self.label) + .color(self.label_color.unwrap_or(Color::Default)), + ), + ) + } + }), ) .into_any_element() } @@ -46,37 +72,43 @@ impl Component for ListBulletItem { } fn description() -> Option<&'static str> { - Some("A list item with a bullet point indicator for unordered lists.") + Some("A list item with a dash indicator for unordered lists.") } fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let basic_examples = vec![ + single_example( + "Simple", + ListBulletItem::new("First bullet item").into_any_element(), + ), + single_example( + "Multiple Lines", + v_flex() + .child(ListBulletItem::new("First item")) + .child(ListBulletItem::new("Second item")) + .child(ListBulletItem::new("Third item")) + .into_any_element(), + ), + single_example( + "Long Text", + ListBulletItem::new( + "A longer bullet item that demonstrates text wrapping behavior", + ) + .into_any_element(), + ), + single_example( + "With Link", + ListBulletItem::new("") + .child(Label::new("Create a Zed account by")) + .child(ButtonLink::new("visiting the website", "https://zed.dev")) + .into_any_element(), + ), + ]; + Some( v_flex() .gap_6() - .child(example_group_with_title( - "Bullet Items", - vec![ - single_example( - "Simple", - ListBulletItem::new("First bullet item").into_any_element(), - ), - single_example( - "Multiple Lines", - v_flex() - .child(ListBulletItem::new("First item")) - .child(ListBulletItem::new("Second item")) - .child(ListBulletItem::new("Third item")) - .into_any_element(), - ), - single_example( - "Long Text", - ListBulletItem::new( - "A longer bullet item that demonstrates text wrapping behavior", - ) - .into_any_element(), - ), - ], - )) + .child(example_group(basic_examples).vertical()) .into_any_element(), ) } diff --git a/crates/ui/src/components/navigable.rs b/crates/ui/src/components/navigable.rs index a592bcc36f4cc490c4676a83660ace050025ee39..07e761f9c0c14daf551d272c1a1894da84e1b3cf 100644 --- a/crates/ui/src/components/navigable.rs +++ b/crates/ui/src/components/navigable.rs @@ -75,7 +75,7 @@ impl RenderOnce for Navigable { }) .unwrap_or(0); if let Some(entry) = children.get(target) { - entry.focus_handle.focus(window); + entry.focus_handle.focus(window, cx); if let Some(anchor) = &entry.scroll_anchor { anchor.scroll_to(window, cx); } @@ -89,7 +89,7 @@ impl RenderOnce for Navigable { .and_then(|index| index.checked_sub(1)) .or(children.len().checked_sub(1)); if let Some(entry) = target.and_then(|target| children.get(target)) { - entry.focus_handle.focus(window); + entry.focus_handle.focus(window, cx); if let Some(anchor) = &entry.scroll_anchor { anchor.scroll_to(window, cx); } diff --git a/crates/ui/src/components/notification/alert_modal.rs b/crates/ui/src/components/notification/alert_modal.rs index 9990dc1ce5f13e6834a009c4b8d7c14b594ccf36..52a084c847887a4dea7fd8b9a3fbad8390f68863 100644 --- a/crates/ui/src/components/notification/alert_modal.rs +++ b/crates/ui/src/components/notification/alert_modal.rs @@ -1,73 +1,161 @@ use crate::component_prelude::*; use crate::prelude::*; +use crate::{Checkbox, ListBulletItem, ToggleState}; +use gpui::Action; +use gpui::FocusHandle; use gpui::IntoElement; +use gpui::Stateful; use smallvec::{SmallVec, smallvec}; +use theme::ActiveTheme; + +type ActionHandler = Box) -> Stateful
>; #[derive(IntoElement, RegisterComponent)] pub struct AlertModal { id: ElementId, + header: Option, children: SmallVec<[AnyElement; 2]>, - title: SharedString, - primary_action: SharedString, - dismiss_label: SharedString, + footer: Option, + title: Option, + primary_action: Option, + dismiss_label: Option, + width: Option, + key_context: Option, + action_handlers: Vec, + focus_handle: Option, } impl AlertModal { - pub fn new(id: impl Into, title: impl Into) -> Self { + pub fn new(id: impl Into) -> Self { Self { id: id.into(), + header: None, children: smallvec![], - title: title.into(), - primary_action: "Ok".into(), - dismiss_label: "Cancel".into(), + footer: None, + title: None, + primary_action: None, + dismiss_label: None, + width: None, + key_context: None, + action_handlers: Vec::new(), + focus_handle: None, } } + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn header(mut self, header: impl IntoElement) -> Self { + self.header = Some(header.into_any_element()); + self + } + + pub fn footer(mut self, footer: impl IntoElement) -> Self { + self.footer = Some(footer.into_any_element()); + self + } + pub fn primary_action(mut self, primary_action: impl Into) -> Self { - self.primary_action = primary_action.into(); + self.primary_action = Some(primary_action.into()); self } pub fn dismiss_label(mut self, dismiss_label: impl Into) -> Self { - self.dismiss_label = dismiss_label.into(); + self.dismiss_label = Some(dismiss_label.into()); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.width = Some(width.into()); + self + } + + pub fn key_context(mut self, key_context: impl Into) -> Self { + self.key_context = Some(key_context.into()); + self + } + + pub fn on_action( + mut self, + listener: impl Fn(&A, &mut Window, &mut App) + 'static, + ) -> Self { + self.action_handlers + .push(Box::new(move |div| div.on_action(listener))); + self + } + + pub fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self { + self.focus_handle = Some(focus_handle.clone()); self } } impl RenderOnce for AlertModal { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - v_flex() + let width = self.width.unwrap_or_else(|| px(440.).into()); + let has_default_footer = self.primary_action.is_some() || self.dismiss_label.is_some(); + + let mut modal = v_flex() + .when_some(self.key_context, |this, key_context| { + this.key_context(key_context.as_str()) + }) + .when_some(self.focus_handle, |this, focus_handle| { + this.track_focus(&focus_handle) + }) .id(self.id) .elevation_3(cx) - .w(px(440.)) - .p_5() - .child( + .w(width) + .bg(cx.theme().colors().elevated_surface_background) + .overflow_hidden(); + + for handler in self.action_handlers { + modal = handler(modal); + } + + if let Some(header) = self.header { + modal = modal.child(header); + } else if let Some(title) = self.title { + modal = modal.child( + v_flex() + .pt_3() + .pr_3() + .pl_3() + .pb_1() + .child(Headline::new(title).size(HeadlineSize::Small)), + ); + } + + if !self.children.is_empty() { + modal = modal.child( v_flex() + .p_3() .text_ui(cx) .text_color(Color::Muted.color(cx)) .gap_1() - .child(Headline::new(self.title).size(HeadlineSize::Small)) .children(self.children), - ) - .child( + ); + } + + if let Some(footer) = self.footer { + modal = modal.child(footer); + } else if has_default_footer { + let primary_action = self.primary_action.unwrap_or_else(|| "Ok".into()); + let dismiss_label = self.dismiss_label.unwrap_or_else(|| "Cancel".into()); + + modal = modal.child( h_flex() - .h(rems(1.75)) + .p_3() .items_center() - .child(div().flex_1()) - .child( - h_flex() - .items_center() - .gap_1() - .child( - Button::new(self.dismiss_label.clone(), self.dismiss_label.clone()) - .color(Color::Muted), - ) - .child(Button::new( - self.primary_action.clone(), - self.primary_action, - )), - ), - ) + .justify_end() + .gap_1() + .child(Button::new(dismiss_label.clone(), dismiss_label).color(Color::Muted)) + .child(Button::new(primary_action.clone(), primary_action)), + ); + } + + modal } } @@ -90,24 +178,75 @@ impl Component for AlertModal { Some("A modal dialog that presents an alert message with primary and dismiss actions.") } - fn preview(_window: &mut Window, _cx: &mut App) -> Option { + fn preview(_window: &mut Window, cx: &mut App) -> Option { Some( v_flex() .gap_6() .p_4() - .children(vec![example_group( - vec![ - single_example( - "Basic Alert", - AlertModal::new("simple-modal", "Do you want to leave the current call?") - .child("The current window will be closed, and connections to any shared projects will be terminated." - ) - .primary_action("Leave Call") - .into_any_element(), - ) - ], - )]) - .into_any_element() + .children(vec![ + example_group(vec![single_example( + "Basic Alert", + AlertModal::new("simple-modal") + .title("Do you want to leave the current call?") + .child( + "The current window will be closed, and connections to any shared projects will be terminated." + ) + .primary_action("Leave Call") + .dismiss_label("Cancel") + .into_any_element(), + )]), + example_group(vec![single_example( + "Custom Header", + AlertModal::new("custom-header-modal") + .header( + v_flex() + .p_3() + .bg(cx.theme().colors().background) + .gap_1() + .child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Warning).color(Color::Warning)) + .child(Headline::new("Unrecognized Workspace").size(HeadlineSize::Small)) + ) + .child( + h_flex() + .pl(IconSize::default().rems() + rems(0.5)) + .child(Label::new("~/projects/my-project").color(Color::Muted)) + ) + ) + .child( + "Untrusted workspaces are opened in Restricted Mode to protect your system. +Review .zed/settings.json for any extensions or commands configured by this project.", + ) + .child( + v_flex() + .mt_1() + .child(Label::new("Restricted mode prevents:").color(Color::Muted)) + .child(ListBulletItem::new("Project settings from being applied")) + .child(ListBulletItem::new("Language servers from running")) + .child(ListBulletItem::new("MCP integrations from installing")) + ) + .footer( + h_flex() + .p_3() + .justify_between() + .child( + Checkbox::new("trust-parent", ToggleState::Unselected) + .label("Trust all projects in parent directory") + ) + .child( + h_flex() + .gap_1() + .child(Button::new("restricted", "Stay in Restricted Mode").color(Color::Muted)) + .child(Button::new("trust", "Trust and Continue").style(ButtonStyle::Filled)) + ) + ) + .width(rems(40.)) + .into_any_element(), + )]), + ]) + .into_any_element(), ) } } diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index b1a52bec8fdf1f7030b5b321bed7702d602ff212..cd79e50ce01b1f4e697b252801c2ae76765726d2 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -281,13 +281,25 @@ fn show_menu( if modal.focus_handle(cx).contains_focused(window, cx) && let Some(previous_focus_handle) = previous_focus_handle.as_ref() { - window.focus(previous_focus_handle); + window.focus(previous_focus_handle, cx); } *menu2.borrow_mut() = None; window.refresh(); }) .detach(); - window.focus(&new_menu.focus_handle(cx)); + + // Since menus are rendered in a deferred fashion, their focus handles are + // not linked in the dispatch tree until after the deferred draw callback + // runs. We need to wait for that to happen before focusing it, so that + // calling `contains_focused` on the parent's focus handle returns `true` + // when the menu is focused. This prevents the pane's tab bar buttons from + // flickering when opening popover menus. + let focus_handle = new_menu.focus_handle(cx); + window.on_next_frame(move |window, _cx| { + window.on_next_frame(move |window, cx| { + window.focus(&focus_handle, cx); + }); + }); *menu.borrow_mut() = Some(new_menu); window.refresh(); diff --git a/crates/ui/src/components/right_click_menu.rs b/crates/ui/src/components/right_click_menu.rs index dff423073710121bb0bc0fafdb8ab3108b746bde..faf2cb3429b610727209e13188656c174aefb655 100644 --- a/crates/ui/src/components/right_click_menu.rs +++ b/crates/ui/src/components/right_click_menu.rs @@ -253,13 +253,25 @@ impl Element for RightClickMenu { && let Some(previous_focus_handle) = previous_focus_handle.as_ref() { - window.focus(previous_focus_handle); + window.focus(previous_focus_handle, cx); } *menu2.borrow_mut() = None; window.refresh(); }) .detach(); - window.focus(&new_menu.focus_handle(cx)); + + // Since menus are rendered in a deferred fashion, their focus handles are + // not linked in the dispatch tree until after the deferred draw callback + // runs. We need to wait for that to happen before focusing it, so that + // calling `contains_focused` on the parent's focus handle returns `true` + // when the menu is focused. This prevents the pane's tab bar buttons from + // flickering when opening menus. + let focus_handle = new_menu.focus_handle(cx); + window.on_next_frame(move |window, _cx| { + window.on_next_frame(move |window, cx| { + window.focus(&focus_handle, cx); + }); + }); *menu.borrow_mut() = Some(new_menu); *position.borrow_mut() = if let Some(child_bounds) = child_bounds { if let Some(attach) = attach { diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 5d41466e3caadf6697b3c1681a405dafa2fb3101..86598b8c6f1ab3a479313c7775405863e9e3b49b 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -10,6 +10,7 @@ pub struct TabBar { start_children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>, end_children: SmallVec<[AnyElement; 2]>, + pre_end_children: SmallVec<[AnyElement; 2]>, scroll_handle: Option, } @@ -20,6 +21,7 @@ impl TabBar { start_children: SmallVec::new(), children: SmallVec::new(), end_children: SmallVec::new(), + pre_end_children: SmallVec::new(), scroll_handle: None, } } @@ -70,6 +72,15 @@ impl TabBar { self } + pub fn pre_end_child(mut self, end_child: impl IntoElement) -> Self + where + Self: Sized, + { + self.pre_end_children + .push(end_child.into_element().into_any()); + self + } + pub fn end_children(mut self, end_children: impl IntoIterator) -> Self where Self: Sized, @@ -137,18 +148,32 @@ impl RenderOnce for TabBar { .children(self.children), ), ) - .when(!self.end_children.is_empty(), |this| { - this.child( - h_flex() - .flex_none() - .gap(DynamicSpacing::Base04.rems(cx)) - .px(DynamicSpacing::Base06.rems(cx)) - .border_b_1() - .border_l_1() - .border_color(cx.theme().colors().border) - .children(self.end_children), - ) - }) + .when( + !self.end_children.is_empty() || !self.pre_end_children.is_empty(), + |this| { + this.child( + h_flex() + .flex_none() + .gap(DynamicSpacing::Base04.rems(cx)) + .px(DynamicSpacing::Base06.rems(cx)) + .children(self.pre_end_children) + .border_color(cx.theme().colors().border) + .border_b_1() + .when(!self.end_children.is_empty(), |div| { + div.child( + h_flex() + .h_full() + .flex_none() + .pl(DynamicSpacing::Base04.rems(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) + .children(self.end_children), + ) + }), + ) + }, + ) } } diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index 1e637f24194b86f0c06aef806975df635f45a6cd..86ff1d8eff8691a2610a4a7e2268aaf47502e306 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -44,15 +44,16 @@ pub enum ToggleStyle { pub struct Checkbox { id: ElementId, toggle_state: ToggleState, + style: ToggleStyle, disabled: bool, placeholder: bool, - on_click: Option>, filled: bool, - style: ToggleStyle, - tooltip: Option AnyView>>, + visualization: bool, label: Option, label_size: LabelSize, label_color: Color, + tooltip: Option AnyView>>, + on_click: Option>, } impl Checkbox { @@ -61,15 +62,16 @@ impl Checkbox { Self { id: id.into(), toggle_state: checked, + style: ToggleStyle::default(), disabled: false, - on_click: None, + placeholder: false, filled: false, - style: ToggleStyle::default(), - tooltip: None, + visualization: false, label: None, label_size: LabelSize::Default, label_color: Color::Muted, - placeholder: false, + tooltip: None, + on_click: None, } } @@ -110,6 +112,13 @@ impl Checkbox { self } + /// Makes the checkbox look enabled but without pointer cursor and hover styles. + /// Primarily used for uninteractive markdown previews. + pub fn visualization_only(mut self, visualization: bool) -> Self { + self.visualization = visualization; + self + } + /// Sets the style of the checkbox using the specified [`ToggleStyle`]. pub fn style(mut self, style: ToggleStyle) -> Self { self.style = style; @@ -209,11 +218,10 @@ impl RenderOnce for Checkbox { let size = Self::container_size(); let checkbox = h_flex() + .group(group_id.clone()) .id(self.id.clone()) - .justify_center() - .items_center() .size(size) - .group(group_id.clone()) + .justify_center() .child( div() .flex() @@ -230,7 +238,7 @@ impl RenderOnce for Checkbox { .when(self.disabled, |this| { this.bg(cx.theme().colors().element_disabled.opacity(0.6)) }) - .when(!self.disabled, |this| { + .when(!self.disabled && !self.visualization, |this| { this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color)) }) .when(self.placeholder, |this| { @@ -250,20 +258,14 @@ impl RenderOnce for Checkbox { .map(|this| { if self.disabled { this.cursor_not_allowed() + } else if self.visualization { + this.cursor_default() } else { this.cursor_pointer() } }) .gap(DynamicSpacing::Base06.rems(cx)) .child(checkbox) - .when_some( - self.on_click.filter(|_| !self.disabled), - |this, on_click| { - this.on_click(move |click, window, cx| { - on_click(&self.toggle_state.inverse(), click, window, cx) - }) - }, - ) .when_some(self.label, |this, label| { this.child( Label::new(label) @@ -274,6 +276,14 @@ impl RenderOnce for Checkbox { .when_some(self.tooltip, |this, tooltip| { this.tooltip(move |window, cx| tooltip(window, cx)) }) + .when_some( + self.on_click.filter(|_| !self.disabled), + |this, on_click| { + this.on_click(move |click, window, cx| { + on_click(&self.toggle_state.inverse(), click, window, cx) + }) + }, + ) } } @@ -281,11 +291,7 @@ impl RenderOnce for Checkbox { #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum SwitchColor { #[default] - Default, Accent, - Error, - Warning, - Success, Custom(Hsla), } @@ -299,27 +305,10 @@ impl SwitchColor { } match self { - SwitchColor::Default => { - let colors = cx.theme().colors(); - let base_color = colors.text; - let bg_color = colors.element_background.blend(base_color.opacity(0.08)); - (bg_color, colors.border_variant) - } SwitchColor::Accent => { let status = cx.theme().status(); - (status.info.opacity(0.4), status.info.opacity(0.2)) - } - SwitchColor::Error => { - let status = cx.theme().status(); - (status.error.opacity(0.4), status.error.opacity(0.2)) - } - SwitchColor::Warning => { - let status = cx.theme().status(); - (status.warning.opacity(0.4), status.warning.opacity(0.2)) - } - SwitchColor::Success => { - let status = cx.theme().status(); - (status.success.opacity(0.4), status.success.opacity(0.2)) + let colors = cx.theme().colors(); + (status.info.opacity(0.4), colors.text_accent.opacity(0.2)) } SwitchColor::Custom(color) => (*color, color.opacity(0.6)), } @@ -329,11 +318,7 @@ impl SwitchColor { impl From for Color { fn from(color: SwitchColor) -> Self { match color { - SwitchColor::Default => Color::Default, SwitchColor::Accent => Color::Accent, - SwitchColor::Error => Color::Error, - SwitchColor::Warning => Color::Warning, - SwitchColor::Success => Color::Success, SwitchColor::Custom(_) => Color::Default, } } @@ -939,6 +924,15 @@ impl Component for Checkbox { .into_any_element(), )], ), + example_group_with_title( + "Extra", + vec![single_example( + "Visualization-Only", + Checkbox::new("viz_only", ToggleState::Selected) + .visualization_only(true) + .into_any_element(), + )], + ), ]) .into_any_element(), ) @@ -980,37 +974,8 @@ impl Component for Switch { "Colors", vec![ single_example( - "Default", - Switch::new("switch_default_style", ToggleState::Selected) - .color(SwitchColor::Default) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Accent", + "Accent (Default)", Switch::new("switch_accent_style", ToggleState::Selected) - .color(SwitchColor::Accent) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Error", - Switch::new("switch_error_style", ToggleState::Selected) - .color(SwitchColor::Error) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Warning", - Switch::new("switch_warning_style", ToggleState::Selected) - .color(SwitchColor::Warning) - .on_click(|_, _, _cx| {}) - .into_any_element(), - ), - single_example( - "Success", - Switch::new("switch_success_style", ToggleState::Selected) - .color(SwitchColor::Success) .on_click(|_, _, _cx| {}) .into_any_element(), ), diff --git a/crates/ui_input/src/number_field.rs b/crates/ui_input/src/number_field.rs index ee5c57b43b7c44db1c2ded122d3d4272a541c32e..b07cc5d9dc17dc6c761d02222807101d19cecc33 100644 --- a/crates/ui_input/src/number_field.rs +++ b/crates/ui_input/src/number_field.rs @@ -5,8 +5,11 @@ use std::{ str::FromStr, }; -use editor::{Editor, EditorStyle}; -use gpui::{ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers}; +use editor::{Editor, actions::MoveDown, actions::MoveUp}; +use gpui::{ + ClickEvent, Entity, FocusHandle, Focusable, FontWeight, Modifiers, TextAlign, + TextStyleRefinement, WeakEntity, +}; use settings::{CenteredPaddingSettings, CodeFade, DelayMs, InactiveOpacity, MinimumContrast}; use ui::prelude::*; @@ -235,12 +238,14 @@ impl_numeric_stepper_nonzero_int!(NonZeroU32, u32); impl_numeric_stepper_nonzero_int!(NonZeroU64, u64); impl_numeric_stepper_nonzero_int!(NonZero, usize); -#[derive(RegisterComponent)] -pub struct NumberField { +#[derive(IntoElement, RegisterComponent)] +pub struct NumberField { id: ElementId, value: T, focus_handle: FocusHandle, mode: Entity, + /// Stores a weak reference to the editor when in edit mode, so buttons can update its text + edit_editor: Entity>>, format: Box String>, large_step: T, small_step: T, @@ -256,15 +261,17 @@ impl NumberField { pub fn new(id: impl Into, value: T, window: &mut Window, cx: &mut App) -> Self { let id = id.into(); - let (mode, focus_handle) = window.with_id(id.clone(), |window| { + let (mode, focus_handle, edit_editor) = window.with_id(id.clone(), |window| { let mode = window.use_state(cx, |_, _| NumberFieldMode::default()); let focus_handle = window.use_state(cx, |_, cx| cx.focus_handle()); - (mode, focus_handle) + let edit_editor = window.use_state(cx, |_, _| None); + (mode, focus_handle, edit_editor) }); Self { id, mode, + edit_editor, value, focus_handle: focus_handle.read(cx).clone(), format: Box::new(T::default_format), @@ -309,6 +316,11 @@ impl NumberField { self } + pub fn mode(self, mode: NumberFieldMode, cx: &mut App) -> Self { + self.mode.write(cx, mode); + self + } + pub fn on_reset( mut self, on_reset: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -328,17 +340,16 @@ impl NumberField { } } -impl IntoElement for NumberField { - type Element = gpui::Component; - - fn into_element(self) -> Self::Element { - gpui::Component::new(self) - } +#[derive(Clone, Copy)] +enum ValueChangeDirection { + Increment, + Decrement, } impl RenderOnce for NumberField { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let mut tab_index = self.tab_index; + let is_edit_mode = matches!(*self.mode.read(cx), NumberFieldMode::Edit); let get_step = { let large_step = self.large_step; @@ -355,6 +366,67 @@ impl RenderOnce for NumberField { } }; + let clamp_value = { + let min = self.min_value; + let max = self.max_value; + move |value: T| -> T { + if value < min { + min + } else if value > max { + max + } else { + value + } + } + }; + + let change_value = { + move |current: T, step: T, direction: ValueChangeDirection| -> T { + let new_value = match direction { + ValueChangeDirection::Increment => current.saturating_add(step), + ValueChangeDirection::Decrement => current.saturating_sub(step), + }; + clamp_value(new_value) + } + }; + + let get_current_value = { + let value = self.value; + let edit_editor = self.edit_editor.clone(); + + Rc::new(move |cx: &App| -> T { + if !is_edit_mode { + return value; + } + edit_editor + .read(cx) + .as_ref() + .and_then(|weak| weak.upgrade()) + .and_then(|editor| editor.read(cx).text(cx).parse::().ok()) + .unwrap_or(value) + }) + }; + + let update_editor_text = { + let edit_editor = self.edit_editor.clone(); + + Rc::new(move |new_value: T, window: &mut Window, cx: &mut App| { + if !is_edit_mode { + return; + } + let Some(editor) = edit_editor + .read(cx) + .as_ref() + .and_then(|weak| weak.upgrade()) + else { + return; + }; + editor.update(cx, |editor, cx| { + editor.set_text(format!("{}", new_value), window, cx); + }); + }) + }; + let bg_color = cx.theme().colors().surface_background; let hover_bg_color = cx.theme().colors().element_hover; @@ -395,13 +467,20 @@ impl RenderOnce for NumberField { h_flex() .map(|decrement| { let decrement_handler = { - let value = self.value; let on_change = self.on_change.clone(); - let min = self.min_value; + let get_current_value = get_current_value.clone(); + let update_editor_text = update_editor_text.clone(); + move |click: &ClickEvent, window: &mut Window, cx: &mut App| { + let current_value = get_current_value(cx); let step = get_step(click.modifiers()); - let new_value = value.saturating_sub(step); - let new_value = if new_value < min { min } else { new_value }; + let new_value = change_value( + current_value, + step, + ValueChangeDirection::Decrement, + ); + + update_editor_text(new_value, window, cx); on_change(&new_value, window, cx); } }; @@ -438,46 +517,97 @@ impl RenderOnce for NumberField { .justify_center() .child(Label::new((self.format)(&self.value))) .into_any_element(), - // Edit mode is disabled until we implement center text alignment for editor - // mode.write(cx, NumberFieldMode::Edit); - // - // When we get to making Edit mode work, we shouldn't even focus the decrement/increment buttons. - // Focus should go instead straight to the editor, avoiding any double-step focus. - // In this world, the buttons become a mouse-only interaction, given users should be able - // to do everything they'd do with the buttons straight in the editor anyway. NumberFieldMode::Edit => h_flex() .flex_1() .child(window.use_state(cx, { |window, cx| { - let previous_focus_handle = window.focused(cx); let mut editor = Editor::single_line(window, cx); - let mut style = EditorStyle::default(); - style.text.text_align = gpui::TextAlign::Right; - editor.set_style(style, window, cx); + + editor.set_text_style_refinement(TextStyleRefinement { + text_align: Some(TextAlign::Center), + ..Default::default() + }); editor.set_text(format!("{}", self.value), window, cx); + + let editor_weak = cx.entity().downgrade(); + + self.edit_editor.update(cx, |state, _| { + *state = Some(editor_weak); + }); + + editor + .register_action::({ + let on_change = self.on_change.clone(); + let editor_handle = cx.entity().downgrade(); + move |_, window, cx| { + let Some(editor) = editor_handle.upgrade() + else { + return; + }; + editor.update(cx, |editor, cx| { + if let Ok(current_value) = + editor.text(cx).parse::() + { + let step = + get_step(window.modifiers()); + let new_value = change_value( + current_value, + step, + ValueChangeDirection::Increment, + ); + editor.set_text( + format!("{}", new_value), + window, + cx, + ); + on_change(&new_value, window, cx); + } + }); + } + }) + .detach(); + + editor + .register_action::({ + let on_change = self.on_change.clone(); + let editor_handle = cx.entity().downgrade(); + move |_, window, cx| { + let Some(editor) = editor_handle.upgrade() + else { + return; + }; + editor.update(cx, |editor, cx| { + if let Ok(current_value) = + editor.text(cx).parse::() + { + let step = + get_step(window.modifiers()); + let new_value = change_value( + current_value, + step, + ValueChangeDirection::Decrement, + ); + editor.set_text( + format!("{}", new_value), + window, + cx, + ); + on_change(&new_value, window, cx); + } + }); + } + }) + .detach(); + cx.on_focus_out(&editor.focus_handle(cx), window, { let mode = self.mode.clone(); - let min = self.min_value; - let max = self.max_value; let on_change = self.on_change.clone(); move |this, _, window, cx| { - if let Ok(new_value) = + if let Ok(parsed_value) = this.text(cx).parse::() { - let new_value = if new_value < min { - min - } else if new_value > max { - max - } else { - new_value - }; - - if let Some(previous) = - previous_focus_handle.as_ref() - { - window.focus(previous); - } + let new_value = clamp_value(parsed_value); on_change(&new_value, window, cx); }; mode.write(cx, NumberFieldMode::Read); @@ -485,7 +615,7 @@ impl RenderOnce for NumberField { }) .detach(); - window.focus(&editor.focus_handle(cx)); + window.focus(&editor.focus_handle(cx), cx); editor } @@ -500,13 +630,20 @@ impl RenderOnce for NumberField { ) .map(|increment| { let increment_handler = { - let value = self.value; let on_change = self.on_change.clone(); - let max = self.max_value; + let get_current_value = get_current_value.clone(); + let update_editor_text = update_editor_text.clone(); + move |click: &ClickEvent, window: &mut Window, cx: &mut App| { + let current_value = get_current_value(cx); let step = get_step(click.modifiers()); - let new_value = value.saturating_add(step); - let new_value = if new_value > max { max } else { new_value }; + let new_value = change_value( + current_value, + step, + ValueChangeDirection::Increment, + ); + + update_editor_text(new_value, window, cx); on_change(&new_value, window, cx); } }; @@ -541,36 +678,42 @@ impl Component for NumberField { "Number Field" } - fn sort_name() -> &'static str { - Self::name() - } - fn description() -> Option<&'static str> { Some("A numeric input element with increment and decrement buttons.") } fn preview(window: &mut Window, cx: &mut App) -> Option { - let stepper_example = window.use_state(cx, |_, _| 100.0); + let default_ex = window.use_state(cx, |_, _| 100.0); + let edit_ex = window.use_state(cx, |_, _| 500.0); Some( v_flex() .gap_6() - .children(vec![single_example( - "Default Numeric Stepper", - NumberField::new( - "numeric-stepper-component-preview", - *stepper_example.read(cx), - window, - cx, - ) - .on_change({ - let stepper_example = stepper_example.clone(); - move |value, _, cx| stepper_example.write(cx, *value) - }) - .min(1.0) - .max(100.0) - .into_any_element(), - )]) + .children(vec![ + single_example( + "Button-Only Number Field", + NumberField::new("number-field", *default_ex.read(cx), window, cx) + .on_change({ + let default_ex = default_ex.clone(); + move |value, _, cx| default_ex.write(cx, *value) + }) + .min(1.0) + .max(100.0) + .into_any_element(), + ), + single_example( + "Editable Number Field", + NumberField::new("editable-number-field", *edit_ex.read(cx), window, cx) + .on_change({ + let edit_ex = edit_ex.clone(); + move |value, _, cx| edit_ex.write(cx, *value) + }) + .min(100.0) + .max(500.0) + .mode(NumberFieldMode::Edit, cx) + .into_any_element(), + ), + ]) .into_any_element(), ) } diff --git a/crates/util/src/archive.rs b/crates/util/src/archive.rs index 5a5dc777722c67d3e5bb96ed7115ccd2a71b8cbe..bd4f01f953c306a06c098f6dad0a87b0a9ae2c5c 100644 --- a/crates/util/src/archive.rs +++ b/crates/util/src/archive.rs @@ -109,7 +109,9 @@ pub async fn extract_seekable_zip( .await .with_context(|| format!("extracting into file {path:?}"))?; - if let Some(perms) = entry.unix_permissions() { + if let Some(perms) = entry.unix_permissions() + && perms != 0o000 + { use std::os::unix::fs::PermissionsExt; let permissions = std::fs::Permissions::from_mode(u32::from(perms)); file.set_permissions(permissions) @@ -132,7 +134,8 @@ mod tests { use super::*; - async fn compress_zip(src_dir: &Path, dst: &Path) -> Result<()> { + #[allow(unused_variables)] + async fn compress_zip(src_dir: &Path, dst: &Path, keep_file_permissions: bool) -> Result<()> { let mut out = smol::fs::File::create(dst).await?; let mut writer = ZipFileWriter::new(&mut out); @@ -155,8 +158,8 @@ mod tests { ZipEntryBuilder::new(filename.into(), async_zip::Compression::Deflate); use std::os::unix::fs::PermissionsExt; let metadata = std::fs::metadata(path)?; - let perms = metadata.permissions().mode() as u16; - builder = builder.unix_permissions(perms); + let perms = keep_file_permissions.then(|| metadata.permissions().mode() as u16); + builder = builder.unix_permissions(perms.unwrap_or_default()); writer.write_entry_whole(builder, &data).await?; } #[cfg(not(unix))] @@ -206,7 +209,9 @@ mod tests { let zip_file = test_dir.path().join("test.zip"); smol::block_on(async { - compress_zip(test_dir.path(), &zip_file).await.unwrap(); + compress_zip(test_dir.path(), &zip_file, true) + .await + .unwrap(); let reader = read_archive(&zip_file).await; let dir = tempfile::tempdir().unwrap(); @@ -237,7 +242,9 @@ mod tests { // Create zip let zip_file = test_dir.path().join("test.zip"); - compress_zip(test_dir.path(), &zip_file).await.unwrap(); + compress_zip(test_dir.path(), &zip_file, true) + .await + .unwrap(); // Extract to new location let extract_dir = tempfile::tempdir().unwrap(); @@ -251,4 +258,39 @@ mod tests { assert_eq!(extracted_perms.mode() & 0o777, 0o755); }); } + + #[cfg(unix)] + #[test] + fn test_extract_zip_sets_default_permissions() { + use std::os::unix::fs::PermissionsExt; + + smol::block_on(async { + let test_dir = tempfile::tempdir().unwrap(); + let executable_path = test_dir.path().join("my_script"); + + // Create an executable file + std::fs::write(&executable_path, "#!/bin/bash\necho 'Hello'").unwrap(); + + // Create zip + let zip_file = test_dir.path().join("test.zip"); + compress_zip(test_dir.path(), &zip_file, false) + .await + .unwrap(); + + // Extract to new location + let extract_dir = tempfile::tempdir().unwrap(); + let reader = read_archive(&zip_file).await; + extract_zip(extract_dir.path(), reader).await.unwrap(); + + // Check permissions are preserved + let extracted_path = extract_dir.path().join("my_script"); + assert!(extracted_path.exists()); + let extracted_perms = std::fs::metadata(&extracted_path).unwrap().permissions(); + assert_eq!( + extracted_perms.mode() & 0o777, + 0o644, + "Expected default set of permissions for unzipped file with no permissions set." + ); + }); + } } diff --git a/crates/util/src/command.rs b/crates/util/src/command.rs index dde1603dfe29df0315caf6d99f1d9e1d03b131c1..40f1ec323f6dd799bdda07da2540741d46c99cea 100644 --- a/crates/util/src/command.rs +++ b/crates/util/src/command.rs @@ -58,7 +58,7 @@ pub fn new_smol_command(program: impl AsRef) -> smol::process::Command { } #[cfg(target_os = "macos")] -fn reset_exception_ports() { +pub fn reset_exception_ports() { use mach2::exception_types::{ EXC_MASK_ALL, EXCEPTION_DEFAULT, exception_behavior_t, exception_mask_t, }; diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index f8e3e557152a24a6be8bb4cdad3a86d2256a764e..a54f91c7a0392748cb64c984559cf1ce25c2a7d8 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -227,9 +227,16 @@ impl SanitizedPath { #[cfg(not(target_os = "windows"))] return unsafe { mem::transmute::, Arc>(path) }; - // TODO: could avoid allocating here if dunce::simplified results in the same path #[cfg(target_os = "windows")] - return Self::new(&path).into(); + { + let simplified = dunce::simplified(path.as_ref()); + if simplified == path.as_ref() { + // safe because `Path` and `SanitizedPath` have the same repr and Drop impl + unsafe { mem::transmute::, Arc>(path) } + } else { + Self::unchecked_new(simplified).into() + } + } } pub fn new_arc + ?Sized>(path: &T) -> Arc { diff --git a/crates/util/src/redact.rs b/crates/util/src/redact.rs index 6b297dfb58bb0b4537d4032d8f9cf4db845f9d78..ad11f7618b1cf57c27e7367845cf66e9d0e6bd0b 100644 --- a/crates/util/src/redact.rs +++ b/crates/util/src/redact.rs @@ -1,3 +1,9 @@ +use std::sync::LazyLock; + +static REDACT_REGEX: LazyLock = LazyLock::new(|| { + regex::Regex::new(r#"([A-Z_][A-Z0-9_]*)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)"#).unwrap() +}); + /// Whether a given environment variable name should have its value redacted pub fn should_redact(env_var_name: &str) -> bool { const REDACTED_SUFFIXES: &[&str] = &[ @@ -13,3 +19,31 @@ pub fn should_redact(env_var_name: &str) -> bool { .iter() .any(|suffix| env_var_name.ends_with(suffix)) } + +/// Redact a string which could include a command with environment variables +pub fn redact_command(command: &str) -> String { + REDACT_REGEX + .replace_all(command, |caps: ®ex::Captures| { + let var_name = &caps[1]; + let value = &caps[2]; + if should_redact(var_name) { + format!(r#"{}="[REDACTED]""#, var_name) + } else { + format!("{}={}", var_name, value) + } + }) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_redact_string_with_multiple_env_vars() { + let input = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="sk-ant-api03-WOOOO" COMMAND_MODE="unix2003" GEMINI_API_KEY="AIGEMINIFACE" HOME="/Users/foo""#; + let result = redact_command(input); + let expected = r#"failed to spawn command cd "/code/something" && ANTHROPIC_API_KEY="[REDACTED]" COMMAND_MODE="unix2003" GEMINI_API_KEY="[REDACTED]" HOME="/Users/foo""#; + assert_eq!(result, expected); + } +} diff --git a/crates/util/src/rel_path.rs b/crates/util/src/rel_path.rs index 8b6ae6e49a67ae1ef2d21b3b1e0c4708f407a5e3..5e20aacad5fe177cd1af65dc98aeb45565a3082e 100644 --- a/crates/util/src/rel_path.rs +++ b/crates/util/src/rel_path.rs @@ -161,7 +161,7 @@ impl RelPath { false } - pub fn strip_prefix<'a>(&'a self, other: &Self) -> Result<&'a Self> { + pub fn strip_prefix<'a>(&'a self, other: &Self) -> Result<&'a Self, StripPrefixError> { if other.is_empty() { return Ok(self); } @@ -172,7 +172,7 @@ impl RelPath { return Ok(Self::empty()); } } - Err(anyhow!("failed to strip prefix: {other:?} from {self:?}")) + Err(StripPrefixError) } pub fn len(&self) -> usize { @@ -251,6 +251,9 @@ impl RelPath { } } +#[derive(Debug)] +pub struct StripPrefixError; + impl ToOwned for RelPath { type Owned = RelPathBuf; diff --git a/crates/util/src/schemars.rs b/crates/util/src/schemars.rs index 9314eda4ac4d5003d7186c3115137e2e54c66794..8124ca8cfef62cb4ea320da6423d7ad95a09eb78 100644 --- a/crates/util/src/schemars.rs +++ b/crates/util/src/schemars.rs @@ -53,3 +53,20 @@ impl schemars::transform::Transform for DefaultDenyUnknownFields { transform_subschemas(self, schema); } } + +/// Defaults `allowTrailingCommas` to `true`, for use with `json-language-server`. +/// This can be applied to any schema that will be treated as `jsonc`. +/// +/// Note that this is non-recursive and only applied to the root schema. +#[derive(Clone)] +pub struct AllowTrailingCommas; + +impl schemars::transform::Transform for AllowTrailingCommas { + fn transform(&mut self, schema: &mut schemars::Schema) { + if let Some(object) = schema.as_object_mut() + && !object.contains_key("allowTrailingCommas") + { + object.insert("allowTrailingCommas".to_string(), true.into()); + } + } +} diff --git a/crates/util/src/shell.rs b/crates/util/src/shell.rs index d6cf5e1d380109aa4fcfc4e55a4c469ba1903add..d51cb39aedd89908db9608f5961688d4b30afc9b 100644 --- a/crates/util/src/shell.rs +++ b/crates/util/src/shell.rs @@ -56,7 +56,10 @@ pub enum ShellKind { Tcsh, Rc, Fish, + /// Pre-installed "legacy" powershell for windows PowerShell, + /// PowerShell 7.x + Pwsh, Nushell, Cmd, Xonsh, @@ -238,6 +241,7 @@ impl fmt::Display for ShellKind { ShellKind::Tcsh => write!(f, "tcsh"), ShellKind::Fish => write!(f, "fish"), ShellKind::PowerShell => write!(f, "powershell"), + ShellKind::Pwsh => write!(f, "pwsh"), ShellKind::Nushell => write!(f, "nu"), ShellKind::Cmd => write!(f, "cmd"), ShellKind::Rc => write!(f, "rc"), @@ -260,7 +264,8 @@ impl ShellKind { .to_string_lossy(); match &*program { - "powershell" | "pwsh" => ShellKind::PowerShell, + "powershell" => ShellKind::PowerShell, + "pwsh" => ShellKind::Pwsh, "cmd" => ShellKind::Cmd, "nu" => ShellKind::Nushell, "fish" => ShellKind::Fish, @@ -279,7 +284,7 @@ impl ShellKind { pub fn to_shell_variable(self, input: &str) -> String { match self { - Self::PowerShell => Self::to_powershell_variable(input), + Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input), Self::Cmd => Self::to_cmd_variable(input), Self::Posix => input.to_owned(), Self::Fish => input.to_owned(), @@ -407,8 +412,12 @@ impl ShellKind { pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec { match self { - ShellKind::PowerShell => vec!["-C".to_owned(), combined_command], - ShellKind::Cmd => vec!["/C".to_owned(), combined_command], + ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command], + ShellKind::Cmd => vec![ + "/S".to_owned(), + "/C".to_owned(), + format!("\"{combined_command}\""), + ], ShellKind::Posix | ShellKind::Nushell | ShellKind::Fish @@ -426,7 +435,7 @@ impl ShellKind { pub const fn command_prefix(&self) -> Option { match self { - ShellKind::PowerShell => Some('&'), + ShellKind::PowerShell | ShellKind::Pwsh => Some('&'), ShellKind::Nushell => Some('^'), ShellKind::Posix | ShellKind::Csh @@ -457,6 +466,7 @@ impl ShellKind { | ShellKind::Rc | ShellKind::Fish | ShellKind::PowerShell + | ShellKind::Pwsh | ShellKind::Nushell | ShellKind::Xonsh | ShellKind::Elvish => ';', @@ -471,6 +481,7 @@ impl ShellKind { | ShellKind::Tcsh | ShellKind::Rc | ShellKind::Fish + | ShellKind::Pwsh | ShellKind::PowerShell | ShellKind::Xonsh => "&&", ShellKind::Nushell | ShellKind::Elvish => ";", @@ -478,11 +489,10 @@ impl ShellKind { } pub fn try_quote<'a>(&self, arg: &'a str) -> Option> { - shlex::try_quote(arg).ok().map(|arg| match self { - // If we are running in PowerShell, we want to take extra care when escaping strings. - // In particular, we want to escape strings with a backtick (`) rather than a backslash (\). - ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"").replace("\\\\", "\\")), - ShellKind::Cmd => Cow::Owned(arg.replace("\\\\", "\\")), + match self { + ShellKind::PowerShell => Some(Self::quote_powershell(arg)), + ShellKind::Pwsh => Some(Self::quote_pwsh(arg)), + ShellKind::Cmd => Some(Self::quote_cmd(arg)), ShellKind::Posix | ShellKind::Csh | ShellKind::Tcsh @@ -490,8 +500,173 @@ impl ShellKind { | ShellKind::Fish | ShellKind::Nushell | ShellKind::Xonsh - | ShellKind::Elvish => arg, - }) + | ShellKind::Elvish => shlex::try_quote(arg).ok(), + } + } + + fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> { + if arg.is_empty() { + return Cow::Borrowed("\"\""); + } + + let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"'); + if !needs_quoting { + return Cow::Borrowed(arg); + } + + let mut result = String::with_capacity(arg.len() + 2); + + if enclose { + result.push('"'); + } + + let chars: Vec = arg.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '\\' { + let mut num_backslashes = 0; + while i < chars.len() && chars[i] == '\\' { + num_backslashes += 1; + i += 1; + } + + if i < chars.len() && chars[i] == '"' { + // Backslashes followed by quote: double the backslashes and escape the quote + for _ in 0..(num_backslashes * 2 + 1) { + result.push('\\'); + } + result.push('"'); + i += 1; + } else if i >= chars.len() { + // Trailing backslashes: double them (they precede the closing quote) + for _ in 0..(num_backslashes * 2) { + result.push('\\'); + } + } else { + // Backslashes not followed by quote: output as-is + for _ in 0..num_backslashes { + result.push('\\'); + } + } + } else if chars[i] == '"' { + // Quote not preceded by backslash: escape it + result.push('\\'); + result.push('"'); + i += 1; + } else { + result.push(chars[i]); + i += 1; + } + } + + if enclose { + result.push('"'); + } + Cow::Owned(result) + } + + fn needs_quoting_powershell(s: &str) -> bool { + s.is_empty() + || s.chars().any(|c| { + c.is_whitespace() + || matches!( + c, + '"' | '`' + | '$' + | '&' + | '|' + | '<' + | '>' + | ';' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | ',' + | '\'' + | '@' + ) + }) + } + + fn need_quotes_powershell(arg: &str) -> bool { + let mut quote_count = 0; + for c in arg.chars() { + if c == '"' { + quote_count += 1; + } else if c.is_whitespace() && (quote_count % 2 == 0) { + return true; + } + } + false + } + + fn escape_powershell_quotes(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + result.push('\''); + for c in s.chars() { + if c == '\'' { + result.push('\''); + } + result.push(c); + } + result.push('\''); + result + } + + pub fn quote_powershell(arg: &str) -> Cow<'_, str> { + let ps_will_quote = Self::need_quotes_powershell(arg); + let crt_quoted = Self::quote_windows(arg, !ps_will_quote); + + if !Self::needs_quoting_powershell(arg) { + return crt_quoted; + } + + Cow::Owned(Self::escape_powershell_quotes(&crt_quoted)) + } + + pub fn quote_pwsh(arg: &str) -> Cow<'_, str> { + if arg.is_empty() { + return Cow::Borrowed("''"); + } + + if !Self::needs_quoting_powershell(arg) { + return Cow::Borrowed(arg); + } + + Cow::Owned(Self::escape_powershell_quotes(arg)) + } + + pub fn quote_cmd(arg: &str) -> Cow<'_, str> { + let crt_quoted = Self::quote_windows(arg, true); + + let needs_cmd_escaping = crt_quoted.contains('"') + || crt_quoted.contains('%') + || crt_quoted + .chars() + .any(|c| matches!(c, '^' | '<' | '>' | '&' | '|' | '(' | ')')); + + if !needs_cmd_escaping { + return crt_quoted; + } + + let mut result = String::with_capacity(crt_quoted.len() * 2); + for c in crt_quoted.chars() { + match c { + '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => { + result.push('^'); + result.push(c); + } + '%' => { + result.push_str("%%cd:~,%"); + } + _ => result.push(c), + } + } + Cow::Owned(result) } /// Quotes the given argument if necessary, taking into account the command prefix. @@ -527,7 +702,10 @@ impl ShellKind { .map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned())); } } - self.try_quote(arg) + self.try_quote(arg).map(|quoted| match quoted { + unquoted @ Cow::Borrowed(_) => unquoted, + Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix("ed).into_owned()), + }) } pub fn split(&self, input: &str) -> Option> { @@ -538,7 +716,7 @@ impl ShellKind { match self { ShellKind::Cmd => "", ShellKind::Nushell => "overlay use", - ShellKind::PowerShell => ".", + ShellKind::PowerShell | ShellKind::Pwsh => ".", ShellKind::Fish | ShellKind::Csh | ShellKind::Tcsh @@ -558,6 +736,7 @@ impl ShellKind { | ShellKind::Rc | ShellKind::Fish | ShellKind::PowerShell + | ShellKind::Pwsh | ShellKind::Nushell | ShellKind::Xonsh | ShellKind::Elvish => "clear", @@ -576,6 +755,7 @@ impl ShellKind { | ShellKind::Rc | ShellKind::Fish | ShellKind::PowerShell + | ShellKind::Pwsh | ShellKind::Nushell | ShellKind::Xonsh | ShellKind::Elvish => true, @@ -605,7 +785,7 @@ mod tests { .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"") .unwrap() .into_owned(), - "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest `\"test_foo.py::test_foo`\"\"".to_string() + "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string() ); } @@ -617,7 +797,113 @@ mod tests { .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"") .unwrap() .into_owned(), - "\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"".to_string() + "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string() + ); + } + + #[test] + fn test_try_quote_powershell_edge_cases() { + let shell_kind = ShellKind::PowerShell; + + // Empty string + assert_eq!( + shell_kind.try_quote("").unwrap().into_owned(), + "'\"\"'".to_string() + ); + + // String without special characters (no quoting needed) + assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple"); + + // String with spaces + assert_eq!( + shell_kind.try_quote("hello world").unwrap().into_owned(), + "'hello world'".to_string() + ); + + // String with dollar signs + assert_eq!( + shell_kind.try_quote("$variable").unwrap().into_owned(), + "'$variable'".to_string() + ); + + // String with backticks + assert_eq!( + shell_kind.try_quote("test`command").unwrap().into_owned(), + "'test`command'".to_string() + ); + + // String with multiple special characters + assert_eq!( + shell_kind + .try_quote("test `\"$var`\" end") + .unwrap() + .into_owned(), + "'test `\\\"$var`\\\" end'".to_string() + ); + + // String with backslashes and colon (path without spaces doesn't need quoting) + assert_eq!( + shell_kind.try_quote("C:\\path\\to\\file").unwrap(), + "C:\\path\\to\\file" + ); + } + + #[test] + fn test_try_quote_cmd_edge_cases() { + let shell_kind = ShellKind::Cmd; + + // Empty string + assert_eq!( + shell_kind.try_quote("").unwrap().into_owned(), + "^\"^\"".to_string() + ); + + // String without special characters (no quoting needed) + assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple"); + + // String with spaces + assert_eq!( + shell_kind.try_quote("hello world").unwrap().into_owned(), + "^\"hello world^\"".to_string() + ); + + // String with space and backslash (backslash not at end, so not doubled) + assert_eq!( + shell_kind.try_quote("path\\ test").unwrap().into_owned(), + "^\"path\\ test^\"".to_string() + ); + + // String ending with backslash (must be doubled before closing quote) + assert_eq!( + shell_kind.try_quote("test path\\").unwrap().into_owned(), + "^\"test path\\\\^\"".to_string() + ); + + // String ending with multiple backslashes (all doubled before closing quote) + assert_eq!( + shell_kind.try_quote("test path\\\\").unwrap().into_owned(), + "^\"test path\\\\\\\\^\"".to_string() + ); + + // String with embedded quote (quote is escaped, backslash before it is doubled) + assert_eq!( + shell_kind.try_quote("test\\\"quote").unwrap().into_owned(), + "^\"test\\\\\\^\"quote^\"".to_string() + ); + + // String with multiple backslashes before embedded quote (all doubled) + assert_eq!( + shell_kind + .try_quote("test\\\\\"quote") + .unwrap() + .into_owned(), + "^\"test\\\\\\\\\\^\"quote^\"".to_string() + ); + + // String with backslashes not before quotes (path without spaces doesn't need quoting) + assert_eq!( + shell_kind.try_quote("C:\\path\\to\\file").unwrap(), + "C:\\path\\to\\file" ); } @@ -633,7 +919,7 @@ mod tests { .try_quote_prefix_aware("'uname'") .unwrap() .into_owned(), - "\"'uname'\"".to_string() + "^\"'uname'\"".to_string() ); assert_eq!( shell_kind.try_quote("^uname").unwrap().into_owned(), @@ -666,7 +952,7 @@ mod tests { .try_quote_prefix_aware("'uname a'") .unwrap() .into_owned(), - "\"'uname a'\"".to_string() + "^\"'uname a'\"".to_string() ); assert_eq!( shell_kind.try_quote("^'uname a'").unwrap().into_owned(), diff --git a/crates/util/src/shell_builder.rs b/crates/util/src/shell_builder.rs index a4a0d21018447d229a6a95c4bf897804b5d6eaf9..436c07172368793e685d1ba4b1014ac38be13b73 100644 --- a/crates/util/src/shell_builder.rs +++ b/crates/util/src/shell_builder.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use crate::shell::get_system_shell; use crate::shell::{Shell, ShellKind}; @@ -42,7 +44,7 @@ impl ShellBuilder { self.program.clone() } else { match self.kind { - ShellKind::PowerShell => { + ShellKind::PowerShell | ShellKind::Pwsh => { format!("{} -C '{}'", self.program, command_to_use_in_label) } ShellKind::Cmd => { @@ -76,6 +78,64 @@ impl ShellBuilder { mut self, task_command: Option, task_args: &[String], + ) -> (String, Vec) { + if let Some(task_command) = task_command { + let task_command = if !task_args.is_empty() { + match self.kind.try_quote_prefix_aware(&task_command) { + Some(task_command) => task_command.into_owned(), + None => task_command, + } + } else { + task_command + }; + let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| { + command.push(' '); + let shell_variable = self.kind.to_shell_variable(arg); + command.push_str(&match self.kind.try_quote(&shell_variable) { + Some(shell_variable) => shell_variable, + None => Cow::Owned(shell_variable), + }); + command + }); + if self.redirect_stdin { + match self.kind { + ShellKind::Fish => { + combined_command.insert_str(0, "begin; "); + combined_command.push_str("; end { + combined_command.insert(0, '('); + combined_command.push_str(") { + combined_command.insert_str(0, "$null | & {"); + combined_command.push_str("}"); + } + ShellKind::Cmd => { + combined_command.push_str("< NUL"); + } + } + } + + self.args + .extend(self.kind.args_for_shell(self.interactive, combined_command)); + } + + (self.program, self.args) + } + + // This should not exist, but our task infra is broken beyond repair right now + #[doc(hidden)] + pub fn build_no_quote( + mut self, + task_command: Option, + task_args: &[String], ) -> (String, Vec) { if let Some(task_command) = task_command { let mut combined_command = task_args.iter().fold(task_command, |mut command, arg| { @@ -99,7 +159,7 @@ impl ShellBuilder { combined_command.insert(0, '('); combined_command.push_str(") { + ShellKind::PowerShell | ShellKind::Pwsh => { combined_command.insert_str(0, "$null | & {"); combined_command.push_str("}"); } @@ -115,6 +175,48 @@ impl ShellBuilder { (self.program, self.args) } + + /// Builds a command with the given task command and arguments. + /// + /// Prefer this over manually constructing a command with the output of `Self::build`, + /// as this method handles `cmd` weirdness on windows correctly. + pub fn build_command( + self, + mut task_command: Option, + task_args: &[String], + ) -> smol::process::Command { + #[cfg(windows)] + let kind = self.kind; + if task_args.is_empty() { + task_command = task_command + .as_ref() + .map(|cmd| self.kind.try_quote_prefix_aware(&cmd).map(Cow::into_owned)) + .unwrap_or(task_command); + } + let (program, args) = self.build(task_command, task_args); + + let mut child = crate::command::new_smol_command(program); + + #[cfg(windows)] + if kind == ShellKind::Cmd { + use smol::process::windows::CommandExt; + + for arg in args { + child.raw_arg(arg); + } + } else { + child.args(args); + } + + #[cfg(not(windows))] + child.args(args); + + child + } + + pub fn kind(&self) -> ShellKind { + self.kind + } } #[cfg(test)] @@ -144,7 +246,7 @@ mod test { vec![ "-i", "-c", - "echo $env.hello $env.world nothing --($env.something) $ ${test" + "echo '$env.hello' '$env.world' nothing '--($env.something)' '$' '${test'" ] ); } @@ -174,4 +276,23 @@ mod test { assert_eq!(program, "fish"); assert_eq!(args, vec!["-i", "-c", "begin; echo test; end Result> { use std::process::Stdio; @@ -141,17 +141,17 @@ async fn capture_windows( std::env::current_exe().context("Failed to determine current zed executable path.")?; let shell_kind = ShellKind::new(shell_path, true); - if let ShellKind::Csh | ShellKind::Tcsh | ShellKind::Rc | ShellKind::Fish | ShellKind::Xonsh = - shell_kind - { - return Err(anyhow::anyhow!("unsupported shell kind")); - } let mut cmd = crate::command::new_smol_command(shell_path); + cmd.args(args); let cmd = match shell_kind { - ShellKind::Csh | ShellKind::Tcsh | ShellKind::Rc | ShellKind::Fish | ShellKind::Xonsh => { - unreachable!() - } - ShellKind::Posix => cmd.args([ + ShellKind::Csh + | ShellKind::Tcsh + | ShellKind::Rc + | ShellKind::Fish + | ShellKind::Xonsh + | ShellKind::Posix => cmd.args([ + "-l", + "-i", "-c", &format!( "cd '{}'; '{}' --printenv", @@ -159,7 +159,7 @@ async fn capture_windows( zed_path.display() ), ]), - ShellKind::PowerShell => cmd.args([ + ShellKind::PowerShell | ShellKind::Pwsh => cmd.args([ "-NonInteractive", "-NoProfile", "-Command", diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 169da43b5282456ab4b056149bcaf3dbda5b4534..4ea35901963523180eb6df7534565fd77ebb2585 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -390,6 +390,8 @@ pub fn set_pre_exec_to_start_new_session( use std::os::unix::process::CommandExt; command.pre_exec(|| { libc::setsid(); + #[cfg(target_os = "macos")] + crate::command::reset_exception_ports(); Ok(()) }); }; diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index a5834f855034ef97b7d0d34b01a7d13f50369a1e..2db1b51e72fcd862ccb1c35ff920fec7dbd47995 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -63,8 +63,10 @@ indoc.workspace = true language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } +markdown_preview.workspace = true parking_lot.workspace = true project_panel.workspace = true +outline_panel.workspace = true release_channel.workspace = true semver.workspace = true settings_ui.workspace = true diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 5bf0fca041cf274f38c84031e35903c9e339cc24..caa90406c9a2ac70eb81cd7705c8223799e59f18 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -230,6 +230,14 @@ struct VimEdit { pub filename: String, } +/// Pastes the specified file's contents. +#[derive(Clone, PartialEq, Action)] +#[action(namespace = vim, no_json, no_register)] +struct VimRead { + pub range: Option, + pub filename: String, +} + #[derive(Clone, PartialEq, Action)] #[action(namespace = vim, no_json, no_register)] struct VimNorm { @@ -330,10 +338,12 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { let Some(range) = range.buffer_range(vim, editor, window, cx).ok() else { return; }; - let Some((line_ending, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| { + let Some((line_ending, encoding, has_bom, text, whole_buffer)) = editor.buffer().update(cx, |multi, cx| { Some(multi.as_singleton()?.update(cx, |buffer, _| { ( buffer.line_ending(), + buffer.encoding(), + buffer.has_bom(), buffer.as_rope().slice_rows(range.start.0..range.end.0 + 1), range.start.0 == 0 && range.end.0 + 1 >= buffer.row_count(), ) @@ -429,7 +439,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { return; }; worktree - .write_file(path.into_arc(), text.clone(), line_ending, cx) + .write_file(path.into_arc(), text.clone(), line_ending, encoding, has_bom, cx) .detach_and_prompt_err("Failed to write lines", window, cx, |_, _, _| None); }); }) @@ -641,6 +651,107 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); }); + Vim::action(editor, cx, |vim, action: &VimRead, window, cx| { + vim.update_editor(cx, |vim, editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let end = if let Some(range) = action.range.clone() { + let Some(multi_range) = range.buffer_range(vim, editor, window, cx).log_err() + else { + return; + }; + + match &range.start { + // inserting text above the first line uses the command ":0r {name}" + Position::Line { row: 0, offset: 0 } if range.end.is_none() => { + snapshot.clip_point(Point::new(0, 0), Bias::Right) + } + _ => snapshot.clip_point(Point::new(multi_range.end.0 + 1, 0), Bias::Right), + } + } else { + let end_row = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .range() + .end + .row; + snapshot.clip_point(Point::new(end_row + 1, 0), Bias::Right) + }; + let is_end_of_file = end == snapshot.max_point(); + let edit_range = snapshot.anchor_before(end)..snapshot.anchor_before(end); + + let mut text = if is_end_of_file { + String::from('\n') + } else { + String::new() + }; + + let mut task = None; + if action.filename.is_empty() { + text.push_str( + &editor + .buffer() + .read(cx) + .as_singleton() + .map(|buffer| buffer.read(cx).text()) + .unwrap_or_default(), + ); + } else { + if let Some(project) = editor.project().cloned() { + project.update(cx, |project, cx| { + let Some(worktree) = project.visible_worktrees(cx).next() else { + return; + }; + let path_style = worktree.read(cx).path_style(); + let Some(path) = + RelPath::new(Path::new(&action.filename), path_style).log_err() + else { + return; + }; + task = + Some(worktree.update(cx, |worktree, cx| worktree.load_file(&path, cx))); + }); + } else { + return; + } + }; + + cx.spawn_in(window, async move |editor, cx| { + if let Some(task) = task { + text.push_str( + &task + .await + .log_err() + .map(|loaded_file| loaded_file.text) + .unwrap_or_default(), + ); + } + + if !text.is_empty() && !is_end_of_file { + text.push('\n'); + } + + let _ = editor.update_in(cx, |editor, window, cx| { + editor.transact(window, cx, |editor, window, cx| { + editor.edit([(edit_range.clone(), text)], cx); + let snapshot = editor.buffer().read(cx).snapshot(cx); + editor.change_selections(Default::default(), window, cx, |s| { + let point = if is_end_of_file { + Point::new( + edit_range.start.to_point(&snapshot).row.saturating_add(1), + 0, + ) + } else { + Point::new(edit_range.start.to_point(&snapshot).row, 0) + }; + s.select_ranges([point..point]); + }) + }); + }); + }) + .detach(); + }); + }); + Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| { let keystrokes = action .command @@ -1336,24 +1447,49 @@ fn generate_commands(_: &App) -> Vec { VimCommand::new(("e", "dit"), editor::actions::ReloadFile) .bang(editor::actions::ReloadFile) .filename(|_, filename| Some(VimEdit { filename }.boxed_clone())), - VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| { - Some( - VimSplit { - vertical: false, - filename, - } - .boxed_clone(), - ) - }), - VimCommand::new(("vs", "plit"), workspace::SplitVertical).filename(|_, filename| { + VimCommand::new( + ("r", "ead"), + VimRead { + range: None, + filename: "".into(), + }, + ) + .filename(|_, filename| { Some( - VimSplit { - vertical: true, + VimRead { + range: None, filename, } .boxed_clone(), ) + }) + .range(|action, range| { + let mut action: VimRead = action.as_any().downcast_ref::().unwrap().clone(); + action.range.replace(range.clone()); + Some(Box::new(action)) }), + VimCommand::new(("sp", "lit"), workspace::SplitHorizontal::default()).filename( + |_, filename| { + Some( + VimSplit { + vertical: false, + filename, + } + .boxed_clone(), + ) + }, + ), + VimCommand::new(("vs", "plit"), workspace::SplitVertical::default()).filename( + |_, filename| { + Some( + VimSplit { + vertical: true, + filename, + } + .boxed_clone(), + ) + }, + ), VimCommand::new(("tabe", "dit"), workspace::NewFile) .filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())), VimCommand::new(("tabnew", ""), workspace::NewFile) @@ -2573,6 +2709,76 @@ mod test { assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n"); } + #[gpui::test] + async fn test_command_read(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + let path = Path::new(path!("/root/dir/other.rs")); + fs.as_fake().insert_file(path, "1\n2\n3".into()).await; + + cx.workspace(|workspace, _, cx| { + assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx); + }); + + // File without trailing newline + cx.set_state("one\ntwo\nthreeˇ", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal); + + cx.set_state("oneˇ\ntwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal); + + cx.set_state("one\nˇtwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal); + + cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal); + + // Empty filename + cx.set_state("oneˇ\ntwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": r"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal); + + // File with trailing newline + fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await; + cx.set_state("one\ntwo\nthreeˇ", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal); + + cx.set_state("oneˇ\ntwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal); + + cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal); + + cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal); + + // Empty file + fs.as_fake().insert_file(path, "".into()).await; + cx.set_state("ˇone\ntwo\nthree", Mode::Normal); + cx.simulate_keystrokes(": r space d i r / o t h e r . r s"); + cx.simulate_keystrokes("enter"); + cx.assert_state("one\nˇtwo\nthree", Mode::Normal); + } + #[gpui::test] async fn test_command_quit(cx: &mut TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index fae2bda578c6844c33290d059248b895ebde4c3d..f902a8ff6e9f08475fb6ce8323a924730d3621d1 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1389,11 +1389,12 @@ mod test { Mode::HelixNormal, ); cx.simulate_keystrokes("x"); + // Adjacent line selections stay separate (not merged) cx.assert_state( indoc! {" «line one line two - line three + ˇ»«line three line four ˇ»line five"}, Mode::HelixNormal, diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index dc108b0957d993b2229e8c04fed5923e9de250d4..f2e629faf2dd4a5d1ff47a49278cdd022f75d8d4 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1,5 +1,5 @@ use editor::{ - Anchor, Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, RowExt, ToOffset, ToPoint, + Anchor, Bias, BufferOffset, DisplayPoint, Editor, MultiBufferOffset, RowExt, ToOffset, display_map::{DisplayRow, DisplaySnapshot, FoldPoint, ToDisplayPoint}, movement::{ self, FindRange, TextLayoutDetails, find_boundary, find_preceding_boundary_display_point, @@ -2262,7 +2262,6 @@ fn go_to_line(map: &DisplaySnapshot, display_point: DisplayPoint, line: usize) - .offset_to_point(excerpt.map_offset_from_buffer(BufferOffset(offset))); return map.clip_point(map.point_to_display_point(point, Bias::Left), Bias::Left); } - let mut last_position = None; for (excerpt, buffer, range) in map.buffer_snapshot().excerpts() { let excerpt_range = language::ToOffset::to_offset(&range.context.start, buffer) ..language::ToOffset::to_offset(&range.context.end, buffer); @@ -2273,14 +2272,9 @@ fn go_to_line(map: &DisplaySnapshot, display_point: DisplayPoint, line: usize) - } else if offset <= excerpt_range.start { let anchor = Anchor::in_buffer(excerpt, range.context.start); return anchor.to_display_point(map); - } else { - last_position = Some(Anchor::in_buffer(excerpt, range.context.end)); } } - let mut last_point = last_position.unwrap().to_point(&map.buffer_snapshot()); - last_point.column = point.column; - map.clip_point( map.point_to_display_point( map.buffer_snapshot().clip_point(point, Bias::Left), @@ -2388,10 +2382,16 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint .or_else(|| snapshot.innermost_enclosing_bracket_ranges(offset..offset, None)); if let Some((opening_range, closing_range)) = bracket_ranges { - if opening_range.contains(&offset) { - return closing_range.start.to_display_point(map); - } else if closing_range.contains(&offset) { - return opening_range.start.to_display_point(map); + let mut chars = map.buffer_snapshot().chars_at(offset); + match chars.next() { + Some('/') => {} + _ => { + if opening_range.contains(&offset) { + return closing_range.start.to_display_point(map); + } else if closing_range.contains(&offset) { + return opening_range.start.to_display_point(map); + } + } } } @@ -3443,6 +3443,23 @@ mod test { test = "test" /> "#}); + + // test nested closing tag + cx.set_shared_state(indoc! {r#" + + + "#}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r#" + + <ˇ/body> + "#}); + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r#" + <ˇbody> + + "#}); } #[gpui::test] diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 4735c64792f3639b2c0d6581e6179484e842f386..b0b0bddae19b27fa382d4c84c3fdd4df8ba83a43 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -121,7 +121,11 @@ impl Vim { }); }); if objects_found { - vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx); + let kind = match object.target_visual_mode(vim.mode, around) { + Mode::VisualLine => MotionKind::Linewise, + _ => MotionKind::Exclusive, + }; + vim.copy_selections_content(editor, kind, window, cx); editor.insert("", window, cx); editor.refresh_edit_prediction(true, false, window, cx); } diff --git a/crates/vim/src/normal/mark.rs b/crates/vim/src/normal/mark.rs index 3bb040511fdd7fa53dd97198ae02b492b0e7359d..a4d85e87b24fa6e2753f0dbcfcbb43be9488f41a 100644 --- a/crates/vim/src/normal/mark.rs +++ b/crates/vim/src/normal/mark.rs @@ -372,9 +372,12 @@ pub fn jump_motion( #[cfg(test)] mod test { + use crate::test::{NeovimBackedTestContext, VimTestContext}; + use editor::Editor; use gpui::TestAppContext; - - use crate::test::NeovimBackedTestContext; + use std::path::Path; + use util::path; + use workspace::{CloseActiveItem, OpenOptions}; #[gpui::test] async fn test_quote_mark(cx: &mut TestAppContext) { @@ -394,4 +397,69 @@ mod test { cx.simulate_shared_keystrokes("^ ` `").await; cx.shared_state().await.assert_eq("Hello, worldˇ!"); } + + #[gpui::test] + async fn test_global_mark_overwrite(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + let path = Path::new(path!("/first.rs")); + let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone()); + fs.as_fake().insert_file(path, "one".into()).await; + let path = Path::new(path!("/second.rs")); + fs.as_fake().insert_file(path, "two".into()).await; + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.open_abs_path( + path!("/first.rs").into(), + OpenOptions::default(), + window, + cx, + ) + }) + .await; + + cx.simulate_keystrokes("m A"); + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.open_abs_path( + path!("/second.rs").into(), + OpenOptions::default(), + window, + cx, + ) + }) + .await; + + cx.simulate_keystrokes("m A"); + + let _ = cx + .workspace(|workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem::default(), window, cx) + }) + }) + .await; + + cx.simulate_keystrokes("m B"); + + cx.simulate_keystrokes("' A"); + + cx.workspace(|workspace, _, cx| { + let active_editor = workspace.active_item_as::(cx).unwrap(); + + let buffer = active_editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap(); + + let file = buffer.read(cx).file().unwrap(); + let file_path = file.as_local().unwrap().abs_path(cx); + + assert_eq!(file_path.to_str().unwrap(), path!("/second.rs")); + }) + } } diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index dffabde8fd84081762d7cb62bfa14a2f4ab4a59d..82af828deb85e6e0ef36ea2853a251547051feed 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -717,7 +717,7 @@ mod test { cx.update_global(|store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| { settings.project.all_languages.languages.0.insert( - LanguageName::new("Rust").0, + LanguageName::new_static("Rust").0, LanguageSettingsContent { auto_indent_on_paste: Some(false), ..Default::default() @@ -773,6 +773,52 @@ mod test { "}); } + #[gpui::test] + async fn test_paste_system_clipboard_never(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |s| { + s.vim.get_or_insert_default().use_system_clipboard = Some(UseSystemClipboard::Never) + }); + }); + + cx.set_state( + indoc! {" + ˇThe quick brown + fox jumps over + the lazy dog"}, + Mode::Normal, + ); + + cx.write_to_clipboard(ClipboardItem::new_string("something else".to_string())); + + cx.simulate_keystrokes("d d"); + cx.assert_state( + indoc! {" + ˇfox jumps over + the lazy dog"}, + Mode::Normal, + ); + + cx.simulate_keystrokes("shift-v p"); + cx.assert_state( + indoc! {" + ˇThe quick brown + the lazy dog"}, + Mode::Normal, + ); + + cx.simulate_keystrokes("shift-v"); + cx.dispatch_action(editor::actions::Paste); + cx.assert_state( + indoc! {" + ˇsomething else + the lazy dog"}, + Mode::Normal, + ); + } + #[gpui::test] async fn test_numbered_registers(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index ff884e3b7393b39b86114338fe2af11e384e1fa0..73209c88735a59bb2dc5c2b73bb3ba0c7d03dd56 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -294,11 +294,10 @@ mod test { async fn test_scroll(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; - let (line_height, visible_line_count) = cx.editor(|editor, window, _cx| { + let (line_height, visible_line_count) = cx.update_editor(|editor, window, cx| { ( editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()), editor.visible_line_count().unwrap(), diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index d5a45fca544d61735f62a8f46e849db2c009847f..9920b8fc88d86625a1eb6642f59c894730905c77 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -11,7 +11,6 @@ use editor::{ClipboardSelection, Editor, SelectionEffects}; use gpui::Context; use gpui::Window; use language::Point; -use multi_buffer::MultiBufferRow; use settings::Settings; struct HighlightOnYank; @@ -81,7 +80,11 @@ impl Vim { start_positions.insert(selection.id, start_position); }); }); - vim.yank_selections_content(editor, MotionKind::Exclusive, window, cx); + let kind = match object.target_visual_mode(vim.mode, around) { + Mode::VisualLine => MotionKind::Linewise, + _ => MotionKind::Exclusive, + }; + vim.yank_selections_content(editor, kind, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(|_, selection| { let (head, goal) = start_positions.remove(&selection.id).unwrap(); @@ -194,11 +197,14 @@ impl Vim { if kind.linewise() { text.push('\n'); } - clipboard_selections.push(ClipboardSelection { - len: text.len() - initial_len, - is_entire_line: false, - first_line_indent: buffer.indent_size_for_line(MultiBufferRow(start.row)).len, - }); + clipboard_selections.push(ClipboardSelection::for_buffer( + text.len() - initial_len, + false, + start..end, + &buffer, + editor.project(), + cx, + )); } } @@ -223,7 +229,7 @@ impl Vim { editor.highlight_background::( &ranges_to_highlight, - |colors| colors.colors().editor_document_highlight_read_background, + |_, colors| colors.colors().editor_document_highlight_read_background, cx, ); cx.spawn(async move |this, cx| { diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 2f5ccac07bfe5f6f11b048e317523292dd74294d..e9a2f4fc63d31f78a9a7abce8aac785b56eb1fd4 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -911,7 +911,7 @@ pub fn surrounding_html_tag( while let Some(cur_node) = last_child_node { if cur_node.child_count() >= 2 { let first_child = cur_node.child(0); - let last_child = cur_node.child(cur_node.child_count() - 1); + let last_child = cur_node.child(cur_node.child_count() as u32 - 1); if let (Some(first_child), Some(last_child)) = (first_child, last_child) { let open_tag = open_tag(buffer.chars_for_range(first_child.byte_range())); let close_tag = close_tag(buffer.chars_for_range(last_child.byte_range())); @@ -2382,9 +2382,10 @@ mod test { Mode::Insert, ); - cx.set_state("let a = (test::call(), 'p', my_macro!{ˇ});", Mode::Normal); - cx.simulate_keystrokes("c a a"); - cx.assert_state("let a = (test::call(), 'p'ˇ);", Mode::Insert); + // TODO regressed with the up-to-date Rust grammar. + // cx.set_state("let a = (test::call(), 'p', my_macro!{ˇ});", Mode::Normal); + // cx.simulate_keystrokes("c a a"); + // cx.assert_state("let a = (test::call(), 'p'ˇ);", Mode::Insert); cx.set_state("let a = [test::call(ˇ), 300];", Mode::Normal); cx.simulate_keystrokes("c i a"); @@ -2806,9 +2807,8 @@ mod test { for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES { cx.set_state(initial_state, Mode::Normal); - + cx.buffer(|buffer, _| buffer.parsing_idle()).await; cx.simulate_keystrokes(keystrokes); - cx.assert_state(expected_state, *expected_mode); } @@ -2829,9 +2829,8 @@ mod test { for (keystrokes, initial_state, mode) in INVALID_CASES { cx.set_state(initial_state, Mode::Normal); - + cx.buffer(|buffer, _| buffer.parsing_idle()).await; cx.simulate_keystrokes(keystrokes); - cx.assert_state(initial_state, *mode); } } @@ -3184,9 +3183,8 @@ mod test { for (keystrokes, initial_state, expected_state, expected_mode) in TEST_CASES { cx.set_state(initial_state, Mode::Normal); - + cx.buffer(|buffer, _| buffer.parsing_idle()).await; cx.simulate_keystrokes(keystrokes); - cx.assert_state(expected_state, *expected_mode); } @@ -3207,9 +3205,8 @@ mod test { for (keystrokes, initial_state, mode) in INVALID_CASES { cx.set_state(initial_state, Mode::Normal); - + cx.buffer(|buffer, _| buffer.parsing_idle()).await; cx.simulate_keystrokes(keystrokes); - cx.assert_state(initial_state, *mode); } } @@ -3410,4 +3407,390 @@ mod test { .assert_eq(" ˇf = (x: unknown) => {"); cx.shared_clipboard().await.assert_eq("const "); } + + #[gpui::test] + async fn test_arrow_function_text_object(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new_typescript(cx).await; + + cx.set_state( + indoc! {" + const foo = () => { + return ˇ1; + }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const foo = () => { + return 1; + };ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + arr.map(() => { + return ˇ1; + }); + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + arr.map(«() => { + return 1; + }ˇ»); + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const foo = () => { + return ˇ1; + }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v i f"); + cx.assert_state( + indoc! {" + const foo = () => { + «return 1;ˇ» + }; + "}, + Mode::Visual, + ); + + cx.set_state( + indoc! {" + (() => { + console.log(ˇ1); + })(); + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + («() => { + console.log(1); + }ˇ»)(); + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const foo = () => { + return ˇ1; + }; + export { foo }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const foo = () => { + return 1; + };ˇ» + export { foo }; + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + let bar = () => { + return ˇ2; + }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «let bar = () => { + return 2; + };ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + var baz = () => { + return ˇ3; + }; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «var baz = () => { + return 3; + };ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const add = (a, b) => a + ˇb; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const add = (a, b) => a + b;ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const add = ˇ(a, b) => a + b; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const add = (a, b) => a + b;ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const add = (a, b) => a + bˇ; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const add = (a, b) => a + b;ˇ» + "}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {" + const add = (a, b) =ˇ> a + b; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {" + «const add = (a, b) => a + b;ˇ» + "}, + Mode::VisualLine, + ); + } + + #[gpui::test] + async fn test_arrow_function_in_jsx(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new_tsx(cx).await; + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
{ + alert("Hello world!"); + console.log(ˇ"clicked"); + }}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
{ + alert("Hello world!"); + console.log("clicked"); + }ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clickˇed")}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked"ˇ)}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
console.log("clicked")ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
{ + console.log("cliˇcked"); + }}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
{ + console.log("clicked"); + }ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + + cx.set_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
fˇoo()}>Hello world!
+
+ ); + }; + "#}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a f"); + cx.assert_state( + indoc! {r#" + export const MyComponent = () => { + return ( +
+
foo()ˇ»}>Hello world!
+
+ ); + }; + "#}, + Mode::VisualLine, + ); + } } diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index 93c30141daeac21805e8ea1aab610988a09a9635..63d452f84bfd5ee1cea8970698962169dc8fe94a 100644 --- a/crates/vim/src/replace.rs +++ b/crates/vim/src/replace.rs @@ -273,7 +273,7 @@ impl Vim { let ranges = [new_range]; editor.highlight_background::( &ranges, - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index e96fd3a329e95311eeb73b87b53acbe76939f0cd..2a8aa91063be89ebd616a2f9601f90c912cee8b5 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -550,6 +550,10 @@ impl MarksState { let buffer = multibuffer.read(cx).as_singleton(); let abs_path = buffer.as_ref().and_then(|b| self.path_for_buffer(b, cx)); + if self.is_global_mark(&name) && self.global_marks.contains_key(&name) { + self.delete_mark(name.clone(), multibuffer, cx); + } + let Some(abs_path) = abs_path else { self.multibuffer_marks .entry(multibuffer.entity_id()) @@ -573,7 +577,7 @@ impl MarksState { let buffer_id = buffer.read(cx).remote_id(); self.buffer_marks.entry(buffer_id).or_default().insert( - name, + name.clone(), anchors .into_iter() .map(|anchor| anchor.text_anchor) @@ -582,6 +586,10 @@ impl MarksState { if !self.watched_buffers.contains_key(&buffer_id) { self.watch_buffer(MarkLocation::Path(abs_path.clone()), &buffer, cx) } + if self.is_global_mark(&name) { + self.global_marks + .insert(name, MarkLocation::Path(abs_path.clone())); + } self.serialize_buffer_marks(abs_path, &buffer, cx) } diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 5932a740945becae9d15025d358a52d5a4e279dd..4c61479157268e4f0276bddf9dd1eb913284d27e 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -2253,6 +2253,79 @@ async fn test_paragraph_multi_delete(cx: &mut gpui::TestAppContext) { cx.shared_state().await.assert_eq(indoc! {"ˇ"}); } +#[perf] +#[gpui::test] +async fn test_yank_paragraph_with_paste(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + " + first paragraph + ˇstill first + + second paragraph + still second + + third paragraph + " + }) + .await; + + cx.simulate_shared_keystrokes("y a p").await; + cx.shared_clipboard() + .await + .assert_eq("first paragraph\nstill first\n\n"); + + cx.simulate_shared_keystrokes("j j p").await; + cx.shared_state().await.assert_eq(indoc! { + " + first paragraph + still first + + ˇfirst paragraph + still first + + second paragraph + still second + + third paragraph + " + }); +} + +#[perf] +#[gpui::test] +async fn test_change_paragraph(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + " + first paragraph + ˇstill first + + second paragraph + still second + + third paragraph + " + }) + .await; + + cx.simulate_shared_keystrokes("c a p").await; + cx.shared_clipboard() + .await + .assert_eq("first paragraph\nstill first\n\n"); + + cx.simulate_shared_keystrokes("escape").await; + cx.shared_state().await.assert_eq(indoc! { + " + ˇ + second paragraph + still second + + third paragraph + " + }); +} + #[perf] #[gpui::test] async fn test_multi_cursor_replay(cx: &mut gpui::TestAppContext) { @@ -2326,7 +2399,7 @@ async fn test_clipping_on_mode_change(cx: &mut gpui::TestAppContext) { .end; editor.last_bounds().unwrap().origin + editor - .display_to_pixel_point(current_head, &snapshot, window) + .display_to_pixel_point(current_head, &snapshot, window, cx) .unwrap() }); pixel_position.x += px(100.); diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 21cdda111c4fdacaf0871dd087bca01de6f83957..d20464ccc4b36c8f7024db6bd63558a6292e7c68 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -304,11 +304,10 @@ impl NeovimBackedTestContext { self.neovim.set_option(&format!("scrolloff={}", 3)).await; // +2 to account for the vim command UI at the bottom. self.neovim.set_option(&format!("lines={}", rows + 2)).await; - let (line_height, visible_line_count) = self.editor(|editor, window, _cx| { + let (line_height, visible_line_count) = self.update_editor(|editor, window, cx| { ( editor - .style() - .unwrap() + .style(cx) .text .line_height_in_pixels(window.rem_size()), editor.visible_line_count().unwrap(), diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 80208fb23ee229c4dc90a7d792ce0348f59ed950..2d5ed4227dcc263f56cfa0bcb337f5673df8ef3c 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -23,11 +23,13 @@ impl VimTestContext { release_channel::init(Version::new(0, 0, 0), cx); command_palette::init(cx); project_panel::init(cx); + outline_panel::init(cx); git_ui::init(cx); crate::init(cx); search::init(cx); theme::init(theme::LoadThemes::JustBase, cx); settings_ui::init(cx); + markdown_preview::init(cx); }); } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 1ffcf7e2224341affc7498032fd5a181e256943d..26fec968fb261fbb80a9f84211357623147ca0f4 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -924,6 +924,7 @@ impl Vim { |vim, _: &editor::actions::Paste, window, cx| match vim.mode { Mode::Replace => vim.paste_replace(window, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { + vim.selected_register.replace('+'); vim.paste(&VimPaste::default(), window, cx); } _ => { @@ -1942,6 +1943,7 @@ impl Vim { editor.set_collapse_matches(collapse_matches); editor.set_input_enabled(vim.editor_input_enabled()); editor.set_autoindent(vim.should_autoindent()); + editor.set_cursor_offset_on_selection(vim.mode.is_visual()); editor .selections .set_line_mode(matches!(vim.mode, Mode::VisualLine)); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 3c6f237435e3924a907e059ed1a878641c287e7e..5667190bb7239ee3e534a5556d96452a7c68b1ef 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -522,12 +522,16 @@ impl Vim { selection.start = original_point.to_display_point(map) } } else { - selection.end = movement::saturating_right( - map, - original_point.to_display_point(map), - ); - if original_point.column > 0 { - selection.reversed = true + let original_display_point = + original_point.to_display_point(map); + if selection.end <= original_display_point { + selection.end = movement::saturating_right( + map, + original_display_point, + ); + if original_point.column > 0 { + selection.reversed = true + } } } } diff --git a/crates/vim/test_data/test_change_paragraph.json b/crates/vim/test_data/test_change_paragraph.json new file mode 100644 index 0000000000000000000000000000000000000000..6d235d9f367d5c375df59f3567b2ac1435f6a0a7 --- /dev/null +++ b/crates/vim/test_data/test_change_paragraph.json @@ -0,0 +1,8 @@ +{"Put":{"state":"first paragraph\nˇstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"ˇ\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Insert"}} +{"ReadRegister":{"name":"\"","value":"first paragraph\nstill first\n\n"}} +{"Key":"escape"} +{"Get":{"state":"ˇ\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_matching_tags.json b/crates/vim/test_data/test_matching_tags.json index bb4f5fd450dee78319a23e8026b2cb1c4d224b19..b401033a941f201ddcf9c3a4128659ae27d787b4 100644 --- a/crates/vim/test_data/test_matching_tags.json +++ b/crates/vim/test_data/test_matching_tags.json @@ -13,3 +13,8 @@ {"Put":{"state":"\n \n"}} {"Key":"%"} {"Get":{"state":"\n ˇ\n","mode":"Normal"}} +{"Put":{"state":"\n \n \n"}} +{"Key":"%"} +{"Get":{"state":"\n \n <ˇ/body>\n","mode":"Normal"}} +{"Key":"%"} +{"Get":{"state":"\n <ˇbody>\n \n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_yank_paragraph_with_paste.json b/crates/vim/test_data/test_yank_paragraph_with_paste.json new file mode 100644 index 0000000000000000000000000000000000000000..d73d1f6d3b36e7b1df17559dd525238f13606976 --- /dev/null +++ b/crates/vim/test_data/test_yank_paragraph_with_paste.json @@ -0,0 +1,10 @@ +{"Put":{"state":"first paragraph\nˇstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n"}} +{"Key":"y"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"ˇfirst paragraph\nstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}} +{"ReadRegister":{"name":"\"","value":"first paragraph\nstill first\n\n"}} +{"Key":"j"} +{"Key":"j"} +{"Key":"p"} +{"Get":{"state":"first paragraph\nstill first\n\nˇfirst paragraph\nstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}} diff --git a/crates/which_key/Cargo.toml b/crates/which_key/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..f53ba45dd71abc972ce23efb8871f485dfe47207 --- /dev/null +++ b/crates/which_key/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "which_key" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/which_key.rs" +doctest = false + +[dependencies] +command_palette.workspace = true +gpui.workspace = true +serde.workspace = true +settings.workspace = true +theme.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true diff --git a/crates/which_key/LICENSE-GPL b/crates/which_key/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/which_key/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/which_key/src/which_key.rs b/crates/which_key/src/which_key.rs new file mode 100644 index 0000000000000000000000000000000000000000..70889c100f33020a3ceaa8af1ba8812d5e7d4adb --- /dev/null +++ b/crates/which_key/src/which_key.rs @@ -0,0 +1,98 @@ +//! Which-key support for Zed. + +mod which_key_modal; +mod which_key_settings; + +use gpui::{App, Keystroke}; +use settings::Settings; +use std::{sync::LazyLock, time::Duration}; +use util::ResultExt; +use which_key_modal::WhichKeyModal; +use which_key_settings::WhichKeySettings; +use workspace::Workspace; + +pub fn init(cx: &mut App) { + WhichKeySettings::register(cx); + + cx.observe_new(|_: &mut Workspace, window, cx| { + let Some(window) = window else { + return; + }; + let mut timer = None; + cx.observe_pending_input(window, move |workspace, window, cx| { + if window.pending_input_keystrokes().is_none() { + if let Some(modal) = workspace.active_modal::(cx) { + modal.update(cx, |modal, cx| modal.dismiss(cx)); + }; + timer.take(); + return; + } + + let which_key_settings = WhichKeySettings::get_global(cx); + if !which_key_settings.enabled { + return; + } + + let delay_ms = which_key_settings.delay_ms; + + timer.replace(cx.spawn_in(window, async move |workspace_handle, cx| { + cx.background_executor() + .timer(Duration::from_millis(delay_ms)) + .await; + workspace_handle + .update_in(cx, |workspace, window, cx| { + if workspace.active_modal::(cx).is_some() { + return; + }; + + workspace.toggle_modal(window, cx, |window, cx| { + WhichKeyModal::new(workspace_handle.clone(), window, cx) + }); + }) + .log_err(); + })); + }) + .detach(); + }) + .detach(); +} + +// Hard-coded list of keystrokes to filter out from which-key display +pub static FILTERED_KEYSTROKES: LazyLock>> = LazyLock::new(|| { + [ + // Modifiers on normal vim commands + "g h", + "g j", + "g k", + "g l", + "g $", + "g ^", + // Duplicate keys with "ctrl" held, e.g. "ctrl-w ctrl-a" is duplicate of "ctrl-w a" + "ctrl-w ctrl-a", + "ctrl-w ctrl-c", + "ctrl-w ctrl-h", + "ctrl-w ctrl-j", + "ctrl-w ctrl-k", + "ctrl-w ctrl-l", + "ctrl-w ctrl-n", + "ctrl-w ctrl-o", + "ctrl-w ctrl-p", + "ctrl-w ctrl-q", + "ctrl-w ctrl-s", + "ctrl-w ctrl-v", + "ctrl-w ctrl-w", + "ctrl-w ctrl-]", + "ctrl-w ctrl-shift-w", + "ctrl-w ctrl-g t", + "ctrl-w ctrl-g shift-t", + ] + .iter() + .filter_map(|s| { + let keystrokes: Result, _> = s + .split(' ') + .map(|keystroke_str| Keystroke::parse(keystroke_str)) + .collect(); + keystrokes.ok() + }) + .collect() +}); diff --git a/crates/which_key/src/which_key_modal.rs b/crates/which_key/src/which_key_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..238431b90a8eafdd0e085a3f109e8f812fbe709b --- /dev/null +++ b/crates/which_key/src/which_key_modal.rs @@ -0,0 +1,308 @@ +//! Modal implementation for the which-key display. + +use gpui::prelude::FluentBuilder; +use gpui::{ + App, Context, DismissEvent, EventEmitter, FocusHandle, Focusable, FontWeight, Keystroke, + ScrollHandle, Subscription, WeakEntity, Window, +}; +use settings::Settings; +use std::collections::HashMap; +use theme::ThemeSettings; +use ui::{ + Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*, + text_for_keystrokes, +}; +use workspace::{ModalView, Workspace}; + +use crate::FILTERED_KEYSTROKES; + +pub struct WhichKeyModal { + _workspace: WeakEntity, + focus_handle: FocusHandle, + scroll_handle: ScrollHandle, + bindings: Vec<(SharedString, SharedString)>, + pending_keys: SharedString, + _pending_input_subscription: Subscription, + _focus_out_subscription: Subscription, +} + +impl WhichKeyModal { + pub fn new( + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // Keep focus where it currently is + let focus_handle = window.focused(cx).unwrap_or(cx.focus_handle()); + + let handle = cx.weak_entity(); + let mut this = Self { + _workspace: workspace, + focus_handle: focus_handle.clone(), + scroll_handle: ScrollHandle::new(), + bindings: Vec::new(), + pending_keys: SharedString::new_static(""), + _pending_input_subscription: cx.observe_pending_input( + window, + |this: &mut Self, window, cx| { + this.update_pending_keys(window, cx); + }, + ), + _focus_out_subscription: window.on_focus_out(&focus_handle, cx, move |_, _, cx| { + handle.update(cx, |_, cx| cx.emit(DismissEvent)).ok(); + }), + }; + this.update_pending_keys(window, cx); + this + } + + pub fn dismiss(&self, cx: &mut Context) { + cx.emit(DismissEvent) + } + + fn update_pending_keys(&mut self, window: &mut Window, cx: &mut Context) { + let Some(pending_keys) = window.pending_input_keystrokes() else { + cx.emit(DismissEvent); + return; + }; + let bindings = window.possible_bindings_for_input(pending_keys); + + let mut binding_data = bindings + .iter() + .map(|binding| { + // Map to keystrokes + ( + binding + .keystrokes() + .iter() + .map(|k| k.inner().to_owned()) + .collect::>(), + binding.action(), + ) + }) + .filter(|(keystrokes, _action)| { + // Check if this binding matches any filtered keystroke pattern + !FILTERED_KEYSTROKES.iter().any(|filtered| { + keystrokes.len() >= filtered.len() + && keystrokes[..filtered.len()] == filtered[..] + }) + }) + .map(|(keystrokes, action)| { + // Map to remaining keystrokes and action name + let remaining_keystrokes = keystrokes[pending_keys.len()..].to_vec(); + let action_name: SharedString = + command_palette::humanize_action_name(action.name()).into(); + (remaining_keystrokes, action_name) + }) + .collect(); + + binding_data = group_bindings(binding_data); + + // Sort bindings from shortest to longest, with groups last + // Using stable sort to preserve relative order of equal elements + binding_data.sort_by(|(keystrokes_a, action_a), (keystrokes_b, action_b)| { + // Groups (actions starting with "+") should go last + let is_group_a = action_a.starts_with('+'); + let is_group_b = action_b.starts_with('+'); + + // First, separate groups from non-groups + let group_cmp = is_group_a.cmp(&is_group_b); + if group_cmp != std::cmp::Ordering::Equal { + return group_cmp; + } + + // Then sort by keystroke count + let keystroke_cmp = keystrokes_a.len().cmp(&keystrokes_b.len()); + if keystroke_cmp != std::cmp::Ordering::Equal { + return keystroke_cmp; + } + + // Finally sort by text length, then lexicographically for full stability + let text_a = text_for_keystrokes(keystrokes_a, cx); + let text_b = text_for_keystrokes(keystrokes_b, cx); + let text_len_cmp = text_a.len().cmp(&text_b.len()); + if text_len_cmp != std::cmp::Ordering::Equal { + return text_len_cmp; + } + text_a.cmp(&text_b) + }); + binding_data.dedup(); + self.pending_keys = text_for_keystrokes(&pending_keys, cx).into(); + self.bindings = binding_data + .into_iter() + .map(|(keystrokes, action)| (text_for_keystrokes(&keystrokes, cx).into(), action)) + .collect(); + } +} + +impl Render for WhichKeyModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_rows = !self.bindings.is_empty(); + let viewport_size = window.viewport_size(); + + let max_panel_width = px((f32::from(viewport_size.width) * 0.5).min(480.0)); + let max_content_height = px(f32::from(viewport_size.height) * 0.4); + + // Push above status bar when visible + let status_height = self + ._workspace + .upgrade() + .and_then(|workspace| { + workspace.read_with(cx, |workspace, cx| { + if workspace.status_bar_visible(cx) { + Some( + DynamicSpacing::Base04.px(cx) * 2.0 + + ThemeSettings::get_global(cx).ui_font_size(cx), + ) + } else { + None + } + }) + }) + .unwrap_or(px(0.)); + + let margin_bottom = px(16.); + let bottom_offset = margin_bottom + status_height; + + // Title section + let title_section = { + let mut column = v_flex().gap(px(0.)).child( + div() + .child( + Label::new(self.pending_keys.clone()) + .size(LabelSize::Default) + .weight(FontWeight::MEDIUM) + .color(Color::Accent), + ) + .mb(px(2.)), + ); + + if has_rows { + column = column.child( + div() + .child(Divider::horizontal().color(DividerColor::BorderFaded)) + .mb(px(2.)), + ); + } + + column + }; + + let content = h_flex() + .items_start() + .id("which-key-content") + .gap(px(8.)) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .h_full() + .max_h(max_content_height) + .child( + // Keystrokes column + v_flex() + .gap(px(4.)) + .flex_shrink_0() + .children(self.bindings.iter().map(|(keystrokes, _)| { + div() + .child( + Label::new(keystrokes.clone()) + .size(LabelSize::Default) + .color(Color::Accent), + ) + .text_align(gpui::TextAlign::Right) + })), + ) + .child( + // Actions column + v_flex() + .gap(px(4.)) + .flex_1() + .min_w_0() + .children(self.bindings.iter().map(|(_, action_name)| { + let is_group = action_name.starts_with('+'); + let label_color = if is_group { + Color::Success + } else { + Color::Default + }; + + div().child( + Label::new(action_name.clone()) + .size(LabelSize::Default) + .color(label_color) + .single_line() + .truncate(), + ) + })), + ); + + div() + .id("which-key-buffer-panel-scroll") + .occlude() + .absolute() + .bottom(bottom_offset) + .right(px(16.)) + .min_w(px(220.)) + .max_w(max_panel_width) + .elevation_3(cx) + .px(px(12.)) + .child(v_flex().child(title_section).when(has_rows, |el| { + el.child( + div() + .max_h(max_content_height) + .child(content) + .vertical_scrollbar_for(&self.scroll_handle, window, cx), + ) + })) + } +} + +impl EventEmitter for WhichKeyModal {} + +impl Focusable for WhichKeyModal { + fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for WhichKeyModal { + fn render_bare(&self) -> bool { + true + } +} + +fn group_bindings( + binding_data: Vec<(Vec, SharedString)>, +) -> Vec<(Vec, SharedString)> { + let mut groups: HashMap, Vec<(Vec, SharedString)>> = + HashMap::new(); + + // Group bindings by their first keystroke + for (remaining_keystrokes, action_name) in binding_data { + let first_key = remaining_keystrokes.first().cloned(); + groups + .entry(first_key) + .or_default() + .push((remaining_keystrokes, action_name)); + } + + let mut result = Vec::new(); + + for (first_key, mut group_bindings) in groups { + // Remove duplicates within each group + group_bindings.dedup_by_key(|(keystrokes, _)| keystrokes.clone()); + + if let Some(first_key) = first_key + && group_bindings.len() > 1 + { + // This is a group - create a single entry with just the first keystroke + let first_keystroke = vec![first_key]; + let count = group_bindings.len(); + result.push((first_keystroke, format!("+{} keybinds", count).into())); + } else { + // Not a group or empty keystrokes - add all bindings as-is + result.append(&mut group_bindings); + } + } + + result +} diff --git a/crates/which_key/src/which_key_settings.rs b/crates/which_key/src/which_key_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..be19ab1521f4793305efca79b7026f79fd9064e2 --- /dev/null +++ b/crates/which_key/src/which_key_settings.rs @@ -0,0 +1,18 @@ +use settings::{RegisterSetting, Settings, SettingsContent, WhichKeySettingsContent}; + +#[derive(Debug, Clone, Copy, RegisterSetting)] +pub struct WhichKeySettings { + pub enabled: bool, + pub delay_ms: u64, +} + +impl Settings for WhichKeySettings { + fn from_settings(content: &SettingsContent) -> Self { + let which_key: &WhichKeySettingsContent = content.which_key.as_ref().unwrap(); + + Self { + enabled: which_key.enabled.unwrap(), + delay_ms: which_key.delay_ms.unwrap(), + } + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index d5d3016ab2704392c6cc9cc4bcebf6d50701d3be..956d63580404da351d34af3b5cf5fd531d5a0011 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -35,14 +35,17 @@ clock.workspace = true collections.workspace = true component.workspace = true db.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true +git.workspace = true gpui.workspace = true http_client.workspace = true itertools.workspace = true language.workspace = true log.workspace = true menu.workspace = true +markdown.workspace = true node_runtime.workspace = true parking_lot.workspace = true postage.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index dfc341db9c71fd1059853b9480a7e679109ead40..310ef8133e4a4bac1df42becec2b3d6574279431 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -3,6 +3,7 @@ use crate::{DraggedDock, Event, ModalLayer, Pane}; use crate::{Workspace, status_bar::StatusItemView}; use anyhow::Context as _; use client::proto; + use gpui::{ Action, AnyView, App, Axis, Context, Corner, Entity, EntityId, EventEmitter, FocusHandle, Focusable, IntoElement, KeyContext, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement, @@ -13,6 +14,7 @@ use settings::SettingsStore; use std::sync::Arc; use ui::{ContextMenu, Divider, DividerColor, IconButton, Tooltip, h_flex}; use ui::{prelude::*, right_click_menu}; +use util::ResultExt as _; pub(crate) const RESIZE_HANDLE_SIZE: Pixels = px(6.); @@ -25,6 +27,72 @@ pub enum PanelEvent { pub use proto::PanelId; +pub struct MinimizePane; +pub struct ClosePane; + +pub trait UtilityPane: EventEmitter + EventEmitter + Render { + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; + /// The icon to render in the adjacent pane's tab bar for toggling this utility pane + fn toggle_icon(&self, cx: &App) -> IconName; + fn expanded(&self, cx: &App) -> bool; + fn set_expanded(&mut self, expanded: bool, cx: &mut Context); + fn width(&self, cx: &App) -> Pixels; + fn set_width(&mut self, width: Option, cx: &mut Context); +} + +pub trait UtilityPaneHandle: 'static + Send + Sync { + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; + fn toggle_icon(&self, cx: &App) -> IconName; + fn expanded(&self, cx: &App) -> bool; + fn set_expanded(&self, expanded: bool, cx: &mut App); + fn width(&self, cx: &App) -> Pixels; + fn set_width(&self, width: Option, cx: &mut App); + fn to_any(&self) -> AnyView; + fn box_clone(&self) -> Box; +} + +impl UtilityPaneHandle for Entity +where + T: UtilityPane, +{ + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition { + self.read(cx).position(window, cx) + } + + fn toggle_icon(&self, cx: &App) -> IconName { + self.read(cx).toggle_icon(cx) + } + + fn expanded(&self, cx: &App) -> bool { + self.read(cx).expanded(cx) + } + + fn set_expanded(&self, expanded: bool, cx: &mut App) { + self.update(cx, |this, cx| this.set_expanded(expanded, cx)) + } + + fn width(&self, cx: &App) -> Pixels { + self.read(cx).width(cx) + } + + fn set_width(&self, width: Option, cx: &mut App) { + self.update(cx, |this, cx| this.set_width(width, cx)) + } + + fn to_any(&self) -> AnyView { + self.clone().into() + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } +} + +pub enum UtilityPanePosition { + Left, + Right, +} + pub trait Panel: Focusable + EventEmitter + Render + Sized { fn persistent_name() -> &'static str; fn panel_key() -> &'static str; @@ -281,7 +349,7 @@ impl Dock { let focus_subscription = cx.on_focus(&focus_handle, window, |dock: &mut Dock, window, cx| { if let Some(active_entry) = dock.active_panel_entry() { - active_entry.panel.panel_focus_handle(cx).focus(window) + active_entry.panel.panel_focus_handle(cx).focus(window, cx) } }); let zoom_subscription = cx.subscribe(&workspace, |dock, workspace, e: &Event, cx| { @@ -384,6 +452,13 @@ impl Dock { .position(|entry| entry.panel.remote_id() == Some(panel_id)) } + pub fn panel_for_id(&self, panel_id: EntityId) -> Option<&Arc> { + self.panel_entries + .iter() + .find(|entry| entry.panel.panel_id() == panel_id) + .map(|entry| &entry.panel) + } + pub fn first_enabled_panel_idx(&mut self, cx: &mut Context) -> anyhow::Result { self.panel_entries .iter() @@ -491,6 +566,9 @@ impl Dock { new_dock.update(cx, |new_dock, cx| { new_dock.remove_panel(&panel, window, cx); + }); + + new_dock.update(cx, |new_dock, cx| { let index = new_dock.add_panel(panel.clone(), workspace.clone(), window, cx); if was_visible { @@ -498,6 +576,12 @@ impl Dock { new_dock.activate_panel(index, window, cx); } }); + + workspace + .update(cx, |workspace, cx| { + workspace.serialize_workspace(window, cx); + }) + .ok(); } }), cx.subscribe_in( @@ -508,7 +592,7 @@ impl Dock { this.set_panel_zoomed(&panel.to_any(), true, window, cx); if !PanelHandle::panel_focus_handle(panel, cx).contains_focused(window, cx) { - window.focus(&panel.focus_handle(cx)); + window.focus(&panel.focus_handle(cx), cx); } workspace .update(cx, |workspace, cx| { @@ -540,7 +624,7 @@ impl Dock { { this.set_open(true, window, cx); this.activate_panel(ix, window, cx); - window.focus(&panel.read(cx).focus_handle(cx)); + window.focus(&panel.read(cx).focus_handle(cx), cx); } } PanelEvent::Close => { @@ -586,6 +670,7 @@ impl Dock { ); self.restore_state(window, cx); + if panel.read(cx).starts_open(window, cx) { self.activate_panel(index, window, cx); self.set_open(true, window, cx); @@ -619,7 +704,7 @@ impl Dock { panel: &Entity, window: &mut Window, cx: &mut Context, - ) { + ) -> bool { if let Some(panel_ix) = self .panel_entries .iter() @@ -637,8 +722,13 @@ impl Dock { std::cmp::Ordering::Greater => {} } } + self.panel_entries.remove(panel_ix); cx.notify(); + + true + } else { + false } } @@ -891,7 +981,13 @@ impl Render for PanelButtons { .enumerate() .filter_map(|(i, entry)| { let icon = entry.panel.icon(window, cx)?; - let icon_tooltip = entry.panel.icon_tooltip(window, cx)?; + let icon_tooltip = entry + .panel + .icon_tooltip(window, cx) + .ok_or_else(|| { + anyhow::anyhow!("can't render a panel button without an icon tooltip") + }) + .log_err()?; let name = entry.panel.persistent_name(); let panel = entry.panel.clone(); @@ -941,7 +1037,9 @@ impl Render for PanelButtons { .anchor(menu_anchor) .attach(menu_attach) .trigger(move |is_active, _window, _cx| { - IconButton::new(name, icon) + // Include active state in element ID to invalidate the cached + // tooltip when panel state changes (e.g., via keyboard shortcut) + IconButton::new((name, is_active_button as u64), icon) .icon_size(IconSize::Small) .toggle_state(is_active_button) .on_click({ @@ -952,7 +1050,7 @@ impl Render for PanelButtons { name = name, toggle_state = !is_open ); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); window.dispatch_action(action.boxed_clone(), cx) } }) diff --git a/crates/workspace/src/invalid_item_view.rs b/crates/workspace/src/invalid_item_view.rs index eb6c8f3299838c1a01777885009fa67271b924d7..08242a1ed0c86bb465c85f79a2047b89f9dc86d2 100644 --- a/crates/workspace/src/invalid_item_view.rs +++ b/crates/workspace/src/invalid_item_view.rs @@ -11,6 +11,7 @@ use zed_actions::workspace::OpenWithSystem; use crate::Item; /// A view to display when a certain buffer/image/other item fails to open. +#[derive(Debug)] pub struct InvalidItemView { /// Which path was attempted to open. pub abs_path: Arc, diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 8f459557270e7b4595e26e15f2aad3c33aea4cd8..6e415c23454388bc7931ff9d5e499924d6b8f55d 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -64,15 +64,25 @@ pub struct ItemSettings { #[derive(RegisterSetting)] pub struct PreviewTabsSettings { pub enabled: bool, + pub enable_preview_from_project_panel: bool, pub enable_preview_from_file_finder: bool, - pub enable_preview_from_code_navigation: bool, + pub enable_preview_from_multibuffer: bool, + pub enable_preview_multibuffer_from_code_navigation: bool, + pub enable_preview_file_from_code_navigation: bool, + pub enable_keep_preview_on_code_navigation: bool, } impl Settings for ItemSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let tabs = content.tabs.as_ref().unwrap(); Self { - git_status: tabs.git_status.unwrap(), + git_status: tabs.git_status.unwrap() + && content + .git + .unwrap() + .enabled + .unwrap() + .is_git_status_enabled(), close_position: tabs.close_position.unwrap(), activate_on_close: tabs.activate_on_close.unwrap(), file_icons: tabs.file_icons.unwrap(), @@ -87,9 +97,19 @@ impl Settings for PreviewTabsSettings { let preview_tabs = content.preview_tabs.as_ref().unwrap(); Self { enabled: preview_tabs.enabled.unwrap(), + enable_preview_from_project_panel: preview_tabs + .enable_preview_from_project_panel + .unwrap(), enable_preview_from_file_finder: preview_tabs.enable_preview_from_file_finder.unwrap(), - enable_preview_from_code_navigation: preview_tabs - .enable_preview_from_code_navigation + enable_preview_from_multibuffer: preview_tabs.enable_preview_from_multibuffer.unwrap(), + enable_preview_multibuffer_from_code_navigation: preview_tabs + .enable_preview_multibuffer_from_code_navigation + .unwrap(), + enable_preview_file_from_code_navigation: preview_tabs + .enable_preview_file_from_code_navigation + .unwrap(), + enable_keep_preview_on_code_navigation: preview_tabs + .enable_keep_preview_on_code_navigation .unwrap(), } } @@ -869,8 +889,18 @@ impl ItemHandle for Entity { if let Some(item) = weak_item.upgrade() && item.workspace_settings(cx).autosave == AutosaveSetting::OnFocusChange { - Pane::autosave_item(&item, workspace.project.clone(), window, cx) - .detach_and_log_err(cx); + // Only trigger autosave if focus has truly left the item. + // If focus is still within the item's hierarchy (e.g., moved to a context menu), + // don't trigger autosave to avoid unwanted formatting and cursor jumps. + // Also skip autosave if focus moved to a modal (e.g., command palette), + // since the user is still interacting with the workspace. + let focus_handle = item.item_focus_handle(cx); + if !focus_handle.contains_focused(window, cx) + && !workspace.has_active_modal(window, cx) + { + Pane::autosave_item(&item, workspace.project.clone(), window, cx) + .detach_and_log_err(cx); + } } }, ) @@ -1022,7 +1052,7 @@ impl ItemHandle for Entity { fn relay_action(&self, action: Box, window: &mut Window, cx: &mut App) { self.update(cx, |this, cx| { - this.focus_handle(cx).focus(window); + this.focus_handle(cx).focus(window, cx); window.dispatch_action(action, cx); }) } diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index bcd7db3a82aec46405927e118af86cf4a0d4912b..4087e1a398ac2b89257fea6b4dce53278d0872a8 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -1,9 +1,18 @@ use gpui::{ AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable as _, ManagedView, - MouseButton, Subscription, + MouseButton, Pixels, Point, Subscription, }; use ui::prelude::*; +#[derive(Debug, Clone, Copy, Default)] +pub enum ModalPlacement { + #[default] + Centered, + Anchored { + position: Point, + }, +} + #[derive(Debug)] pub enum DismissDecision { Dismiss(bool), @@ -22,12 +31,17 @@ pub trait ModalView: ManagedView { fn fade_out_background(&self) -> bool { false } + + fn render_bare(&self) -> bool { + false + } } trait ModalViewHandle { fn on_before_dismiss(&mut self, window: &mut Window, cx: &mut App) -> DismissDecision; fn view(&self) -> AnyView; fn fade_out_background(&self, cx: &mut App) -> bool; + fn render_bare(&self, cx: &mut App) -> bool; } impl ModalViewHandle for Entity { @@ -42,6 +56,10 @@ impl ModalViewHandle for Entity { fn fade_out_background(&self, cx: &mut App) -> bool { self.read(cx).fade_out_background() } + + fn render_bare(&self, cx: &mut App) -> bool { + self.read(cx).render_bare() + } } pub struct ActiveModal { @@ -49,6 +67,7 @@ pub struct ActiveModal { _subscriptions: [Subscription; 2], previous_focus_handle: Option, focus_handle: FocusHandle, + placement: ModalPlacement, } pub struct ModalLayer { @@ -78,6 +97,19 @@ impl ModalLayer { where V: ModalView, B: FnOnce(&mut Window, &mut Context) -> V, + { + self.toggle_modal_with_placement(window, cx, ModalPlacement::Centered, build_view); + } + + pub fn toggle_modal_with_placement( + &mut self, + window: &mut Window, + cx: &mut Context, + placement: ModalPlacement, + build_view: B, + ) where + V: ModalView, + B: FnOnce(&mut Window, &mut Context) -> V, { if let Some(active_modal) = &self.active_modal { let is_close = active_modal.modal.view().downcast::().is_ok(); @@ -87,12 +119,17 @@ impl ModalLayer { } } let new_modal = cx.new(|cx| build_view(window, cx)); - self.show_modal(new_modal, window, cx); + self.show_modal(new_modal, placement, window, cx); cx.emit(ModalOpenedEvent); } - fn show_modal(&mut self, new_modal: Entity, window: &mut Window, cx: &mut Context) - where + fn show_modal( + &mut self, + new_modal: Entity, + placement: ModalPlacement, + window: &mut Window, + cx: &mut Context, + ) where V: ModalView, { let focus_handle = cx.focus_handle(); @@ -114,9 +151,10 @@ impl ModalLayer { ], previous_focus_handle: window.focused(cx), focus_handle, + placement, }); cx.defer_in(window, move |_, window, cx| { - window.focus(&new_modal.focus_handle(cx)); + window.focus(&new_modal.focus_handle(cx), cx); }); cx.notify(); } @@ -144,7 +182,7 @@ impl ModalLayer { if let Some(previous_focus) = active_modal.previous_focus_handle && active_modal.focus_handle.contains_focused(window, cx) { - previous_focus.focus(window); + previous_focus.focus(window, cx); } cx.notify(); } @@ -167,19 +205,46 @@ impl ModalLayer { impl Render for ModalLayer { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(active_modal) = &self.active_modal else { - return div(); + return div().into_any_element(); }; - div() + if active_modal.modal.render_bare(cx) { + return active_modal.modal.view().into_any_element(); + } + + let content = h_flex() .occlude() + .child(active_modal.modal.view()) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }); + + let positioned = match active_modal.placement { + ModalPlacement::Centered => v_flex() + .h(px(0.0)) + .top_20() + .items_center() + .track_focus(&active_modal.focus_handle) + .child(content) + .into_any_element(), + ModalPlacement::Anchored { position } => div() + .absolute() + .left(position.x) + .top(position.y - px(20.)) + .track_focus(&active_modal.focus_handle) + .child(content) + .into_any_element(), + }; + + div() .absolute() .size_full() - .top_0() - .left_0() - .when(active_modal.modal.fade_out_background(cx), |el| { + .inset_0() + .occlude() + .when(active_modal.modal.fade_out_background(cx), |this| { let mut background = cx.theme().colors().elevated_surface_background; background.fade_out(0.2); - el.bg(background) + this.bg(background) }) .on_mouse_down( MouseButton::Left, @@ -187,22 +252,7 @@ impl Render for ModalLayer { this.hide_modal(window, cx); }), ) - .child( - v_flex() - .h(px(0.0)) - .top_20() - .flex() - .flex_col() - .items_center() - .track_focus(&active_modal.focus_handle) - .child( - h_flex() - .occlude() - .child(active_modal.modal.view()) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }), - ), - ) + .child(positioned) + .into_any_element() } } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index cfdc730b4db5be8e2f4a317dcf7e12072af40a88..3b126d329e7fafefa4043661c5039f1e17b09b54 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -3,9 +3,12 @@ use anyhow::Context as _; use gpui::{ AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, - Task, svg, + Task, TextStyleRefinement, UnderlineStyle, svg, }; +use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; +use settings::Settings; +use theme::ThemeSettings; use std::ops::Deref; use std::sync::{Arc, LazyLock}; @@ -41,7 +44,7 @@ pub enum NotificationId { impl NotificationId { /// Returns a unique [`NotificationId`] for the given type. - pub fn unique() -> Self { + pub const fn unique() -> Self { Self::Unique(TypeId::of::()) } @@ -216,6 +219,7 @@ pub struct LanguageServerPrompt { focus_handle: FocusHandle, request: Option, scroll_handle: ScrollHandle, + markdown: Entity, } impl Focusable for LanguageServerPrompt { @@ -228,10 +232,13 @@ impl Notification for LanguageServerPrompt {} impl LanguageServerPrompt { pub fn new(request: project::LanguageServerPromptRequest, cx: &mut App) -> Self { + let markdown = cx.new(|cx| Markdown::new(request.message.clone().into(), None, None, cx)); + Self { focus_handle: cx.focus_handle(), request: Some(request), scroll_handle: ScrollHandle::new(), + markdown, } } @@ -262,7 +269,7 @@ impl Render for LanguageServerPrompt { }; let (icon, color) = match request.level { - PromptLevel::Info => (IconName::Info, Color::Accent), + PromptLevel::Info => (IconName::Info, Color::Muted), PromptLevel::Warning => (IconName::Warning, Color::Warning), PromptLevel::Critical => (IconName::XCircle, Color::Error), }; @@ -291,16 +298,15 @@ impl Render for LanguageServerPrompt { .child( h_flex() .justify_between() - .items_start() .child( h_flex() .gap_2() - .child(Icon::new(icon).color(color)) + .child(Icon::new(icon).color(color).size(IconSize::Small)) .child(Label::new(request.lsp_name.clone())), ) .child( h_flex() - .gap_2() + .gap_1() .child( IconButton::new("copy", IconName::Copy) .on_click({ @@ -317,15 +323,17 @@ impl Render for LanguageServerPrompt { IconButton::new(close_id, close_icon) .tooltip(move |_window, cx| { if suppress { - Tooltip::for_action( - "Suppress.\nClose with click.", - &SuppressNotification, + Tooltip::with_meta( + "Suppress", + Some(&SuppressNotification), + "Click to close", cx, ) } else { - Tooltip::for_action( - "Close.\nSuppress with shift-click.", - &menu::Cancel, + Tooltip::with_meta( + "Close", + Some(&menu::Cancel), + "Suppress with shift-click", cx, ) } @@ -342,7 +350,16 @@ impl Render for LanguageServerPrompt { ), ), ) - .child(Label::new(request.message.to_string()).size(LabelSize::Small)) + .child( + MarkdownElement::new(self.markdown.clone(), markdown_style(window, cx)) + .text_size(TextSize::Small.rems(cx)) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) + .on_url_click(|link, _, cx| cx.open_url(&link)), + ) .children(request.actions.iter().enumerate().map(|(ix, action)| { let this_handle = cx.entity(); Button::new(ix, action.title.clone()) @@ -369,6 +386,42 @@ fn workspace_error_notification_id() -> NotificationId { NotificationId::unique::() } +fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let settings = ThemeSettings::get_global(cx); + let ui_font_family = settings.ui_font.family.clone(); + let ui_font_fallbacks = settings.ui_font.fallbacks.clone(); + let buffer_font_family = settings.buffer_font.family.clone(); + let buffer_font_fallbacks = settings.buffer_font.fallbacks.clone(); + + let mut base_text_style = window.text_style(); + base_text_style.refine(&TextStyleRefinement { + font_family: Some(ui_font_family), + font_fallbacks: ui_font_fallbacks, + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + MarkdownStyle { + base_text_style, + selection_background_color: cx.theme().colors().element_selection_background, + inline_code: TextStyleRefinement { + background_color: Some(cx.theme().colors().editor_background.opacity(0.5)), + font_family: Some(buffer_font_family), + font_fallbacks: buffer_font_fallbacks, + ..Default::default() + }, + link: TextStyleRefinement { + underline: Some(UnderlineStyle { + thickness: px(1.), + color: Some(cx.theme().colors().text_accent), + wavy: false, + }), + ..Default::default() + }, + ..Default::default() + } +} + #[derive(Debug, Clone)] pub struct ErrorMessagePrompt { message: SharedString, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 8182a7dd88ae2577b577ec0505638dcfcff0084c..586a6100e40a0edf3728ecb843b8aece08cb36cc 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1,7 +1,7 @@ use crate::{ CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible, SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace, - WorkspaceItemBuilder, + WorkspaceItemBuilder, ZoomIn, ZoomOut, invalid_item_view::InvalidItemView, item::{ ActivateOnClose, ClosePosition, Item, ItemBufferKind, ItemHandle, ItemSettings, @@ -11,10 +11,12 @@ use crate::{ move_item, notifications::NotifyResultExt, toolbar::Toolbar, + utility_pane::UtilityPaneSlot, workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings}, }; use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{StreamExt, stream::FuturesUnordered}; use gpui::{ Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div, @@ -45,10 +47,9 @@ use std::{ }; use theme::ThemeSettings; use ui::{ - ButtonSize, Color, ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButton, - IconButtonShape, IconDecoration, IconDecorationKind, IconName, IconSize, Indicator, Label, - PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, prelude::*, - right_click_menu, + ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButtonShape, IconDecoration, + IconDecorationKind, Indicator, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, + Tooltip, prelude::*, right_click_menu, }; use util::{ResultExt, debug_panic, maybe, paths::PathStyle, truncate_and_remove_front}; @@ -196,6 +197,41 @@ pub struct DeploySearch { pub excluded_files: Option, } +#[derive(Clone, Copy, PartialEq, Debug, Deserialize, JsonSchema, Default)] +#[serde(deny_unknown_fields)] +pub enum SplitMode { + /// Clone the current pane. + #[default] + ClonePane, + /// Create an empty new pane. + EmptyPane, + /// Move the item into a new pane. This will map to nop if only one pane exists. + MovePane, +} + +macro_rules! split_structs { + ($($name:ident => $doc:literal),* $(,)?) => { + $( + #[doc = $doc] + #[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)] + #[action(namespace = pane)] + #[serde(deny_unknown_fields, default)] + pub struct $name { + pub mode: SplitMode, + } + )* + }; +} + +split_structs!( + SplitLeft => "Splits the pane to the left.", + SplitRight => "Splits the pane to the right.", + SplitUp => "Splits the pane upward.", + SplitDown => "Splits the pane downward.", + SplitHorizontal => "Splits the pane horizontally.", + SplitVertical => "Splits the pane vertically." +); + actions!( pane, [ @@ -217,14 +253,6 @@ actions!( JoinAll, /// Reopens the most recently closed item. ReopenClosedItem, - /// Splits the pane to the left, cloning the current item. - SplitLeft, - /// Splits the pane upward, cloning the current item. - SplitUp, - /// Splits the pane to the right, cloning the current item. - SplitRight, - /// Splits the pane downward, cloning the current item. - SplitDown, /// Splits the pane to the left, moving the current item. SplitAndMoveLeft, /// Splits the pane upward, moving the current item. @@ -233,10 +261,6 @@ actions!( SplitAndMoveRight, /// Splits the pane downward, moving the current item. SplitAndMoveDown, - /// Splits the pane horizontally. - SplitHorizontal, - /// Splits the pane vertically. - SplitVertical, /// Swaps the current item with the one to the left. SwapItemLeft, /// Swaps the current item with the one to the right. @@ -278,7 +302,7 @@ pub enum Event { }, Split { direction: SplitDirection, - clone_active_item: bool, + mode: SplitMode, }, ItemPinned, ItemUnpinned, @@ -310,13 +334,10 @@ impl fmt::Debug for Event { .debug_struct("RemovedItem") .field("item", &item.item_id()) .finish(), - Event::Split { - direction, - clone_active_item, - } => f + Event::Split { direction, mode } => f .debug_struct("Split") .field("direction", direction) - .field("clone_active_item", clone_active_item) + .field("mode", mode) .finish(), Event::JoinAll => f.write_str("JoinAll"), Event::JoinIntoNext => f.write_str("JoinIntoNext"), @@ -396,6 +417,11 @@ pub struct Pane { diagnostic_summary_update: Task<()>, /// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here. pub project_item_restoration_data: HashMap>, + welcome_page: Option>, + + pub in_center_group: bool, + pub is_upper_left: bool, + pub is_upper_right: bool, } pub struct ActivationHistoryEntry { @@ -540,6 +566,10 @@ impl Pane { zoom_out_on_close: true, diagnostic_summary_update: Task::ready(()), project_item_restoration_data: HashMap::default(), + welcome_page: None, + in_center_group: false, + is_upper_left: false, + is_upper_right: false, } } @@ -615,17 +645,21 @@ impl Pane { self.last_focus_handle_by_item.get(&active_item.item_id()) && let Some(focus_handle) = weak_last_focus_handle.upgrade() { - focus_handle.focus(window); + focus_handle.focus(window, cx); return; } - active_item.item_focus_handle(cx).focus(window); + active_item.item_focus_handle(cx).focus(window, cx); } else if let Some(focused) = window.focused(cx) && !self.context_menu_focused(window, cx) { self.last_focus_handle_by_item .insert(active_item.item_id(), focused.downgrade()); } + } else if let Some(welcome_page) = self.welcome_page.as_ref() { + if self.focus_handle.is_focused(window) { + welcome_page.read(cx).focus_handle(cx).focus(window, cx); + } } } @@ -873,10 +907,35 @@ impl Pane { self.preview_item_id == Some(item_id) } + /// Promotes the item with the given ID to not be a preview item. + /// This does nothing if it wasn't already a preview item. + pub fn unpreview_item_if_preview(&mut self, item_id: EntityId) { + if self.is_active_preview_item(item_id) { + self.preview_item_id = None; + } + } + /// Marks the item with the given ID as the preview item. /// This will be ignored if the global setting `preview_tabs` is disabled. - pub fn set_preview_item_id(&mut self, item_id: Option, cx: &App) { - if PreviewTabsSettings::get_global(cx).enabled { + /// + /// The old preview item (if there was one) is closed and its index is returned. + pub fn replace_preview_item_id( + &mut self, + item_id: EntityId, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let idx = self.close_current_preview_item(window, cx); + self.set_preview_item_id(Some(item_id), cx); + idx + } + + /// Marks the item with the given ID as the preview item. + /// This will be ignored if the global setting `preview_tabs` is disabled. + /// + /// This is a low-level method. Prefer `unpreview_item_if_preview()` or `set_new_preview_item()`. + pub(crate) fn set_preview_item_id(&mut self, item_id: Option, cx: &App) { + if item_id.is_none() || PreviewTabsSettings::get_global(cx).enabled { self.preview_item_id = item_id; } } @@ -895,7 +954,7 @@ impl Pane { && preview_item.item_id() == item_id && !preview_item.preserve_preview(cx) { - self.set_preview_item_id(None, cx); + self.unpreview_item_if_preview(item_id); } } @@ -936,14 +995,8 @@ impl Pane { let set_up_existing_item = |index: usize, pane: &mut Self, window: &mut Window, cx: &mut Context| { - // If the item is already open, and the item is a preview item - // and we are not allowing items to open as preview, mark the item as persistent. - if let Some(preview_item_id) = pane.preview_item_id - && let Some(tab) = pane.items.get(index) - && tab.item_id() == preview_item_id - && !allow_preview - { - pane.set_preview_item_id(None, cx); + if !allow_preview && let Some(item) = pane.items.get(index) { + pane.unpreview_item_if_preview(item.item_id()); } if activate { pane.activate_item(index, focus_item, focus_item, window, cx); @@ -955,7 +1008,7 @@ impl Pane { window: &mut Window, cx: &mut Context| { if allow_preview { - pane.set_preview_item_id(Some(new_item.item_id()), cx); + pane.replace_preview_item_id(new_item.item_id(), window, cx); } if let Some(text) = new_item.telemetry_event_text(cx) { @@ -1036,6 +1089,7 @@ impl Pane { ) -> Option { let item_idx = self.preview_item_idx()?; let id = self.preview_item_id()?; + self.set_preview_item_id(None, cx); let prev_active_item_index = self.active_item_index; self.remove_item(id, false, false, window, cx); @@ -1277,6 +1331,25 @@ impl Pane { } } + pub fn zoom_in(&mut self, _: &ZoomIn, window: &mut Window, cx: &mut Context) { + if !self.can_toggle_zoom { + cx.propagate(); + } else if !self.zoomed && !self.items.is_empty() { + if !self.focus_handle.contains_focused(window, cx) { + cx.focus_self(window); + } + cx.emit(Event::ZoomIn); + } + } + + pub fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context) { + if !self.can_toggle_zoom { + cx.propagate(); + } else if self.zoomed { + cx.emit(Event::ZoomOut); + } + } + pub fn activate_item( &mut self, index: usize, @@ -1793,6 +1866,7 @@ impl Pane { } for item_to_close in items_to_close { + let mut should_close = true; let mut should_save = true; if save_intent == SaveIntent::Close { workspace.update(cx, |workspace, cx| { @@ -1808,7 +1882,7 @@ impl Pane { { Ok(success) => { if !success { - break; + should_close = false; } } Err(err) => { @@ -1827,23 +1901,25 @@ impl Pane { })?; match answer.await { Ok(0) => {} - Ok(1..) | Err(_) => break, + Ok(1..) | Err(_) => should_close = false, } } } } // Remove the item from the pane. - pane.update_in(cx, |pane, window, cx| { - pane.remove_item( - item_to_close.item_id(), - false, - pane.close_pane_if_empty, - window, - cx, - ); - }) - .ok(); + if should_close { + pane.update_in(cx, |pane, window, cx| { + pane.remove_item( + item_to_close.item_id(), + false, + pane.close_pane_if_empty, + window, + cx, + ); + }) + .ok(); + } } pane.update(cx, |_, cx| cx.notify()).ok(); @@ -1946,7 +2022,7 @@ impl Pane { let should_activate = activate_pane || self.has_focus(window, cx); if self.items.len() == 1 && should_activate { - self.focus_handle.focus(window); + self.focus_handle.focus(window, cx); } else { self.activate_item( index_to_activate, @@ -1981,9 +2057,7 @@ impl Pane { item.on_removed(cx); self.nav_history.set_mode(mode); - if self.is_active_preview_item(item.item_id()) { - self.set_preview_item_id(None, cx); - } + self.unpreview_item_if_preview(item.item_id()); if let Some(path) = item.project_path(cx) { let abs_path = self @@ -2194,9 +2268,7 @@ impl Pane { if can_save { pane.update_in(cx, |pane, window, cx| { - if pane.is_active_preview_item(item.item_id()) { - pane.set_preview_item_id(None, cx); - } + pane.unpreview_item_if_preview(item.item_id()); item.save( SaveOptions { format: should_format, @@ -2243,10 +2315,7 @@ impl Pane { let save_task = if let Some(project_path) = project_path { let (worktree, path) = project_path.await?; let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?; - let new_path = ProjectPath { - worktree_id, - path: path, - }; + let new_path = ProjectPath { worktree_id, path }; pane.update_in(cx, |pane, window, cx| { if let Some(item) = pane.item_for_path(new_path.clone(), cx) { @@ -2301,23 +2370,34 @@ impl Pane { pub fn focus_active_item(&mut self, window: &mut Window, cx: &mut Context) { if let Some(active_item) = self.active_item() { let focus_handle = active_item.item_focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); } } - pub fn split(&mut self, direction: SplitDirection, cx: &mut Context) { - cx.emit(Event::Split { - direction, - clone_active_item: true, - }); - } - - pub fn split_and_move(&mut self, direction: SplitDirection, cx: &mut Context) { - if self.items.len() > 1 { + pub fn split( + &mut self, + direction: SplitDirection, + mode: SplitMode, + window: &mut Window, + cx: &mut Context, + ) { + if self.items.len() <= 1 && mode == SplitMode::MovePane { + // MovePane with only one pane present behaves like a SplitEmpty in the opposite direction + let active_item = self.active_item(); cx.emit(Event::Split { - direction, - clone_active_item: false, + direction: direction.opposite(), + mode: SplitMode::EmptyPane, }); + // ensure that we focus the moved pane + // in this case we know that the window is the same as the active_item + if let Some(active_item) = active_item { + cx.defer_in(window, move |_, window, cx| { + let focus_handle = active_item.item_focus_handle(cx); + window.focus(&focus_handle, cx); + }); + } + } else { + cx.emit(Event::Split { direction, mode }); } } @@ -2450,8 +2530,8 @@ impl Pane { let id = self.item_for_index(ix)?.item_id(); let should_activate = ix == self.active_item_index; - if matches!(operation, PinOperation::Pin) && self.is_active_preview_item(id) { - self.set_preview_item_id(None, cx); + if matches!(operation, PinOperation::Pin) { + self.unpreview_item_if_preview(id); } match operation { @@ -2591,6 +2671,7 @@ impl Pane { let close_side = &settings.close_position; let show_close_button = &settings.show_close_button; let indicator = render_item_indicator(item.boxed_clone(), cx); + let tab_tooltip_content = item.tab_tooltip_content(cx); let item_id = item.item_id(); let is_first_item = ix == 0; let is_last_item = ix == self.items.len() - 1; @@ -2623,12 +2704,9 @@ impl Pane { ) .on_mouse_down( MouseButton::Left, - cx.listener(move |pane, event: &MouseDownEvent, _, cx| { - if let Some(id) = pane.preview_item_id - && id == item_id - && event.click_count > 1 - { - pane.set_preview_item_id(None, cx); + cx.listener(move |pane, event: &MouseDownEvent, _, _| { + if event.click_count > 1 { + pane.unpreview_item_if_preview(item_id); } }), ) @@ -2678,12 +2756,6 @@ impl Pane { this.drag_split_direction = None; this.handle_external_paths_drop(paths, window, cx) })) - .when_some(item.tab_tooltip_content(cx), |tab, content| match content { - TabTooltipContent::Text(text) => tab.tooltip(Tooltip::text(text)), - TabTooltipContent::Custom(element_fn) => { - tab.tooltip(move |window, cx| element_fn(window, cx)) - } - }) .start_slot::(indicator) .map(|this| { let end_slot_action: &'static dyn Action; @@ -2750,7 +2822,15 @@ impl Pane { }) .flatten(), ) - .child(label), + .child(label) + .id(("pane-tab-content", ix)) + .map(|this| match tab_tooltip_content { + Some(TabTooltipContent::Text(text)) => this.tooltip(Tooltip::text(text)), + Some(TabTooltipContent::Custom(element_fn)) => { + this.tooltip(move |window, cx| element_fn(window, cx)) + } + None => this, + }), ); let single_entry_to_resolve = (self.items[ix].buffer_kind(cx) == ItemBufferKind::Singleton) @@ -3017,7 +3097,13 @@ impl Pane { } fn render_tab_bar(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(workspace) = self.workspace.upgrade() else { + return gpui::Empty.into_any(); + }; + let focus_handle = self.focus_handle.clone(); + let is_pane_focused = self.has_focus(window, cx); + let navigate_backward = IconButton::new("navigate_backward", IconName::ArrowLeft) .icon_size(IconSize::Small) .on_click({ @@ -3041,6 +3127,70 @@ impl Pane { } }); + let open_aside_left = { + let workspace = workspace.read(cx); + workspace.utility_pane(UtilityPaneSlot::Left).map(|pane| { + let toggle_icon = pane.toggle_icon(cx); + let workspace_handle = self.workspace.clone(); + + h_flex() + .h_full() + .pr_1p5() + .border_r_1() + .border_color(cx.theme().colors().border) + .child( + IconButton::new("open_aside_left", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane( + UtilityPaneSlot::Left, + window, + cx, + ) + }) + .ok(); + }), + ) + .into_any_element() + }) + }; + + let open_aside_right = { + let workspace = workspace.read(cx); + workspace.utility_pane(UtilityPaneSlot::Right).map(|pane| { + let toggle_icon = pane.toggle_icon(cx); + let workspace_handle = self.workspace.clone(); + + h_flex() + .h_full() + .when(is_pane_focused, |this| { + this.pl(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) + }) + .child( + IconButton::new("open_aside_right", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane( + UtilityPaneSlot::Right, + window, + cx, + ) + }) + .ok(); + }), + ) + .into_any_element() + }) + }; + let navigate_forward = IconButton::new("navigate_forward", IconName::ArrowRight) .icon_size(IconSize::Small) .on_click({ @@ -3087,7 +3237,44 @@ impl Pane { let unpinned_tabs = tab_items.split_off(self.pinned_tab_count); let pinned_tabs = tab_items; + let render_aside_toggle_left = cx.has_flag::() + && self + .is_upper_left + .then(|| { + self.workspace.upgrade().and_then(|entity| { + let workspace = entity.read(cx); + workspace + .utility_pane(UtilityPaneSlot::Left) + .map(|pane| !pane.expanded(cx)) + }) + }) + .flatten() + .unwrap_or(false); + + let render_aside_toggle_right = cx.has_flag::() + && self + .is_upper_right + .then(|| { + self.workspace.upgrade().and_then(|entity| { + let workspace = entity.read(cx); + workspace + .utility_pane(UtilityPaneSlot::Right) + .map(|pane| !pane.expanded(cx)) + }) + }) + .flatten() + .unwrap_or(false); + TabBar::new("tab_bar") + .map(|tab_bar| { + if let Some(open_aside_left) = open_aside_left + && render_aside_toggle_left + { + tab_bar.start_child(open_aside_left) + } else { + tab_bar + } + }) .when( self.display_nav_history_buttons.unwrap_or_default(), |tab_bar| { @@ -3180,6 +3367,15 @@ impl Pane { })), ), ) + .map(|tab_bar| { + if let Some(open_aside_right) = open_aside_right + && render_aside_toggle_right + { + tab_bar.end_child(open_aside_right) + } else { + tab_bar + } + }) .into_any_element() } @@ -3269,11 +3465,7 @@ impl Pane { let mut to_pane = cx.entity(); let split_direction = self.drag_split_direction; let item_id = dragged_tab.item.item_id(); - if let Some(preview_item_id) = self.preview_item_id - && item_id == preview_item_id - { - self.set_preview_item_id(None, cx); - } + self.unpreview_item_if_preview(item_id); let is_clone = cfg!(target_os = "macos") && window.modifiers().alt || cfg!(not(target_os = "macos")) && window.modifiers().control; @@ -3660,16 +3852,17 @@ fn default_render_tab_bar_buttons( .with_handle(pane.split_item_context_menu_handle.clone()) .menu(move |window, cx| { ContextMenu::build(window, cx, |menu, _, _| { + let mode = SplitMode::MovePane; if can_split_move { - menu.action("Split Right", SplitAndMoveRight.boxed_clone()) - .action("Split Left", SplitAndMoveLeft.boxed_clone()) - .action("Split Up", SplitAndMoveUp.boxed_clone()) - .action("Split Down", SplitAndMoveDown.boxed_clone()) + menu.action("Split Right", SplitRight { mode }.boxed_clone()) + .action("Split Left", SplitLeft { mode }.boxed_clone()) + .action("Split Up", SplitUp { mode }.boxed_clone()) + .action("Split Down", SplitDown { mode }.boxed_clone()) } else { - menu.action("Split Right", SplitRight.boxed_clone()) - .action("Split Left", SplitLeft.boxed_clone()) - .action("Split Up", SplitUp.boxed_clone()) - .action("Split Down", SplitDown.boxed_clone()) + menu.action("Split Right", SplitRight::default().boxed_clone()) + .action("Split Left", SplitLeft::default().boxed_clone()) + .action("Split Up", SplitUp::default().boxed_clone()) + .action("Split Down", SplitDown::default().boxed_clone()) } }) .into() @@ -3728,33 +3921,35 @@ impl Render for Pane { .size_full() .flex_none() .overflow_hidden() - .on_action( - cx.listener(|pane, _: &SplitLeft, _, cx| pane.split(SplitDirection::Left, cx)), - ) - .on_action(cx.listener(|pane, _: &SplitUp, _, cx| pane.split(SplitDirection::Up, cx))) - .on_action(cx.listener(|pane, _: &SplitHorizontal, _, cx| { - pane.split(SplitDirection::horizontal(cx), cx) + .on_action(cx.listener(|pane, split: &SplitLeft, window, cx| { + pane.split(SplitDirection::Left, split.mode, window, cx) })) - .on_action(cx.listener(|pane, _: &SplitVertical, _, cx| { - pane.split(SplitDirection::vertical(cx), cx) + .on_action(cx.listener(|pane, split: &SplitUp, window, cx| { + pane.split(SplitDirection::Up, split.mode, window, cx) })) - .on_action( - cx.listener(|pane, _: &SplitRight, _, cx| pane.split(SplitDirection::Right, cx)), - ) - .on_action( - cx.listener(|pane, _: &SplitDown, _, cx| pane.split(SplitDirection::Down, cx)), - ) - .on_action(cx.listener(|pane, _: &SplitAndMoveUp, _, cx| { - pane.split_and_move(SplitDirection::Up, cx) + .on_action(cx.listener(|pane, split: &SplitHorizontal, window, cx| { + pane.split(SplitDirection::horizontal(cx), split.mode, window, cx) + })) + .on_action(cx.listener(|pane, split: &SplitVertical, window, cx| { + pane.split(SplitDirection::vertical(cx), split.mode, window, cx) + })) + .on_action(cx.listener(|pane, split: &SplitRight, window, cx| { + pane.split(SplitDirection::Right, split.mode, window, cx) + })) + .on_action(cx.listener(|pane, split: &SplitDown, window, cx| { + pane.split(SplitDirection::Down, split.mode, window, cx) })) - .on_action(cx.listener(|pane, _: &SplitAndMoveDown, _, cx| { - pane.split_and_move(SplitDirection::Down, cx) + .on_action(cx.listener(|pane, _: &SplitAndMoveUp, window, cx| { + pane.split(SplitDirection::Up, SplitMode::MovePane, window, cx) })) - .on_action(cx.listener(|pane, _: &SplitAndMoveLeft, _, cx| { - pane.split_and_move(SplitDirection::Left, cx) + .on_action(cx.listener(|pane, _: &SplitAndMoveDown, window, cx| { + pane.split(SplitDirection::Down, SplitMode::MovePane, window, cx) })) - .on_action(cx.listener(|pane, _: &SplitAndMoveRight, _, cx| { - pane.split_and_move(SplitDirection::Right, cx) + .on_action(cx.listener(|pane, _: &SplitAndMoveLeft, window, cx| { + pane.split(SplitDirection::Left, SplitMode::MovePane, window, cx) + })) + .on_action(cx.listener(|pane, _: &SplitAndMoveRight, window, cx| { + pane.split(SplitDirection::Right, SplitMode::MovePane, window, cx) })) .on_action(cx.listener(|_, _: &JoinIntoNext, _, cx| { cx.emit(Event::JoinIntoNext); @@ -3763,6 +3958,8 @@ impl Render for Pane { cx.emit(Event::JoinAll); })) .on_action(cx.listener(Pane::toggle_zoom)) + .on_action(cx.listener(Pane::zoom_in)) + .on_action(cx.listener(Pane::zoom_out)) .on_action(cx.listener(Self::navigate_backward)) .on_action(cx.listener(Self::navigate_forward)) .on_action( @@ -3785,15 +3982,17 @@ impl Render for Pane { .on_action(cx.listener(Self::toggle_pin_tab)) .on_action(cx.listener(Self::unpin_all_tabs)) .when(PreviewTabsSettings::get_global(cx).enabled, |this| { - this.on_action(cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, _, cx| { - if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) { - if pane.is_active_preview_item(active_item_id) { - pane.set_preview_item_id(None, cx); - } else { - pane.set_preview_item_id(Some(active_item_id), cx); + this.on_action( + cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, window, cx| { + if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) { + if pane.is_active_preview_item(active_item_id) { + pane.unpreview_item_if_preview(active_item_id); + } else { + pane.replace_preview_item_id(active_item_id, window, cx); + } } - } - })) + }), + ) }) .on_action( cx.listener(|pane: &mut Self, action: &CloseActiveItem, window, cx| { @@ -3901,10 +4100,15 @@ impl Render for Pane { if has_worktrees { placeholder } else { - placeholder.child( - Label::new("Open a file or project to get started.") - .color(Color::Muted), - ) + if self.welcome_page.is_none() { + let workspace = self.workspace.clone(); + self.welcome_page = Some(cx.new(|cx| { + crate::welcome::WelcomePage::new( + workspace, true, window, cx, + ) + })); + } + placeholder.child(self.welcome_page.clone().unwrap()) } } }) @@ -4270,11 +4474,14 @@ impl Render for DraggedTab { #[cfg(test)] mod tests { - use std::num::NonZero; + use std::{iter::zip, num::NonZero}; use super::*; - use crate::item::test::{TestItem, TestProjectItem}; - use gpui::{TestAppContext, VisualTestContext, size}; + use crate::{ + Member, + item::test::{TestItem, TestProjectItem}, + }; + use gpui::{AppContext, Axis, TestAppContext, VisualTestContext, size}; use project::FakeFs; use settings::SettingsStore; use theme::LoadThemes; @@ -6444,6 +6651,60 @@ mod tests { cx.simulate_prompt_answer("Discard all"); save.await.unwrap(); assert_item_labels(&pane, [], cx); + + add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(1, "A.txt", cx)) + }); + add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(2, "B.txt", cx)) + }); + add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(3, "C.txt", cx)) + }); + assert_item_labels(&pane, ["A^", "B^", "C*^"], cx); + + let close_task = pane.update_in(cx, |pane, window, cx| { + pane.close_all_items( + &CloseAllItems { + save_intent: None, + close_pinned: false, + }, + window, + cx, + ) + }); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer("Discard all"); + close_task.await.unwrap(); + assert_item_labels(&pane, [], cx); + + add_labeled_item(&pane, "Clean1", false, cx); + add_labeled_item(&pane, "Dirty", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new_dirty(1, "Dirty.txt", cx)) + }); + add_labeled_item(&pane, "Clean2", false, cx); + assert_item_labels(&pane, ["Clean1", "Dirty^", "Clean2*"], cx); + + let close_task = pane.update_in(cx, |pane, window, cx| { + pane.close_all_items( + &CloseAllItems { + save_intent: None, + close_pinned: false, + }, + window, + cx, + ) + }); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer("Cancel"); + close_task.await.unwrap(); + assert_item_labels(&pane, ["Dirty*^"], cx); } #[gpui::test] @@ -6645,13 +6906,13 @@ mod tests { let tab_bar_scroll_handle = pane.update_in(cx, |pane, _window, _cx| pane.tab_bar_scroll_handle.clone()); assert_eq!(tab_bar_scroll_handle.children_count(), 6); - let tab_bounds = cx.debug_bounds("TAB-3").unwrap(); + let tab_bounds = cx.debug_bounds("TAB-4").unwrap(); let new_tab_button_bounds = cx.debug_bounds("ICON-Plus").unwrap(); let scroll_bounds = tab_bar_scroll_handle.bounds(); let scroll_offset = tab_bar_scroll_handle.offset(); - assert!(tab_bounds.right() <= scroll_bounds.right() + scroll_offset.x); - // -39.5 is the magic number for this setup - assert_eq!(scroll_offset.x, px(-39.5)); + assert!(tab_bounds.right() <= scroll_bounds.right()); + // -43.0 is the magic number for this setup + assert_eq!(scroll_offset.x, px(-43.0)); assert!( !tab_bounds.intersects(&new_tab_button_bounds), "Tab should not overlap with the new tab button, if this is failing check if there's been a redesign!" @@ -6898,6 +7159,32 @@ mod tests { assert_item_labels(&pane, ["A", "C*", "B"], cx); } + #[gpui::test] + async fn test_split_empty(cx: &mut TestAppContext) { + for split_direction in SplitDirection::all() { + test_single_pane_split(["A"], split_direction, SplitMode::EmptyPane, cx).await; + } + } + + #[gpui::test] + async fn test_split_clone(cx: &mut TestAppContext) { + for split_direction in SplitDirection::all() { + test_single_pane_split(["A"], split_direction, SplitMode::ClonePane, cx).await; + } + } + + #[gpui::test] + async fn test_split_move_right_on_single_pane(cx: &mut TestAppContext) { + test_single_pane_split(["A"], SplitDirection::Right, SplitMode::MovePane, cx).await; + } + + #[gpui::test] + async fn test_split_move(cx: &mut TestAppContext) { + for split_direction in SplitDirection::all() { + test_single_pane_split(["A", "B"], split_direction, SplitMode::MovePane, cx).await; + } + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); @@ -6993,4 +7280,163 @@ mod tests { "pane items do not match expectation" ); } + + // Assert the item label, with the active item label expected active index + #[track_caller] + fn assert_item_labels_active_index( + pane: &Entity, + expected_states: &[&str], + expected_active_idx: usize, + cx: &mut VisualTestContext, + ) { + let actual_states = pane.update(cx, |pane, cx| { + pane.items + .iter() + .enumerate() + .map(|(ix, item)| { + let mut state = item + .to_any_view() + .downcast::() + .unwrap() + .read(cx) + .label + .clone(); + if ix == pane.active_item_index { + assert_eq!(ix, expected_active_idx); + } + if item.is_dirty(cx) { + state.push('^'); + } + if pane.is_tab_pinned(ix) { + state.push('!'); + } + state + }) + .collect::>() + }); + assert_eq!( + actual_states, expected_states, + "pane items do not match expectation" + ); + } + + #[track_caller] + fn assert_pane_ids_on_axis( + workspace: &Entity, + expected_ids: [&EntityId; COUNT], + expected_axis: Axis, + cx: &mut VisualTestContext, + ) { + workspace.read_with(cx, |workspace, _| match &workspace.center.root { + Member::Axis(axis) => { + assert_eq!(axis.axis, expected_axis); + assert_eq!(axis.members.len(), expected_ids.len()); + assert!( + zip(expected_ids, &axis.members).all(|(e, a)| { + if let Member::Pane(p) = a { + p.entity_id() == *e + } else { + false + } + }), + "pane ids do not match expectation: {expected_ids:?} != {actual_ids:?}", + actual_ids = axis.members + ); + } + Member::Pane(_) => panic!("expected axis"), + }); + } + + async fn test_single_pane_split( + pane_labels: [&str; COUNT], + direction: SplitDirection, + operation: SplitMode, + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let mut pane_before = + workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + for label in pane_labels { + add_labeled_item(&pane_before, label, false, cx); + } + pane_before.update_in(cx, |pane, window, cx| { + pane.split(direction, operation, window, cx) + }); + cx.executor().run_until_parked(); + let pane_after = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let num_labels = pane_labels.len(); + let last_as_active = format!("{}*", String::from(pane_labels[num_labels - 1])); + + // check labels for all split operations + match operation { + SplitMode::EmptyPane => { + assert_item_labels_active_index(&pane_before, &pane_labels, num_labels - 1, cx); + assert_item_labels(&pane_after, [], cx); + } + SplitMode::ClonePane => { + assert_item_labels_active_index(&pane_before, &pane_labels, num_labels - 1, cx); + assert_item_labels(&pane_after, [&last_as_active], cx); + } + SplitMode::MovePane => { + let head = &pane_labels[..(num_labels - 1)]; + if num_labels == 1 { + // We special-case this behavior and actually execute an empty pane command + // followed by a refocus of the old pane for this case. + pane_before = workspace.read_with(cx, |workspace, _cx| { + workspace + .panes() + .into_iter() + .find(|pane| *pane != &pane_after) + .unwrap() + .clone() + }); + }; + + assert_item_labels_active_index( + &pane_before, + &head, + head.len().saturating_sub(1), + cx, + ); + assert_item_labels(&pane_after, [&last_as_active], cx); + pane_after.update_in(cx, |pane, window, cx| { + window.focused(cx).is_some_and(|focus_handle| { + focus_handle == pane.active_item().unwrap().item_focus_handle(cx) + }) + }); + } + } + + // expected axis depends on split direction + let expected_axis = match direction { + SplitDirection::Right | SplitDirection::Left => Axis::Horizontal, + SplitDirection::Up | SplitDirection::Down => Axis::Vertical, + }; + + // expected ids depends on split direction + let expected_ids = match direction { + SplitDirection::Right | SplitDirection::Down => { + [&pane_before.entity_id(), &pane_after.entity_id()] + } + SplitDirection::Left | SplitDirection::Up => { + [&pane_after.entity_id(), &pane_before.entity_id()] + } + }; + + // check pane axes for all operations + match operation { + SplitMode::EmptyPane | SplitMode::ClonePane => { + assert_pane_ids_on_axis(&workspace, expected_ids, expected_axis, cx); + } + SplitMode::MovePane => { + assert_pane_ids_on_axis(&workspace, expected_ids, expected_axis, cx); + } + } + } } diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index c9d98977139ed644cf5f3bfb7eb26d94ca081d19..393ed74e30c9c34bf7cdb22aabf2de2d05aa84f8 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -28,6 +28,7 @@ const VERTICAL_MIN_SIZE: f32 = 100.; #[derive(Clone)] pub struct PaneGroup { pub root: Member, + pub is_center: bool, } pub struct PaneRenderResult { @@ -37,22 +38,31 @@ pub struct PaneRenderResult { impl PaneGroup { pub fn with_root(root: Member) -> Self { - Self { root } + Self { + root, + is_center: false, + } } pub fn new(pane: Entity) -> Self { Self { root: Member::Pane(pane), + is_center: false, } } + pub fn set_is_center(&mut self, is_center: bool) { + self.is_center = is_center; + } + pub fn split( &mut self, old_pane: &Entity, new_pane: &Entity, direction: SplitDirection, + cx: &mut App, ) -> Result<()> { - match &mut self.root { + let result = match &mut self.root { Member::Pane(pane) => { if pane == old_pane { self.root = Member::new_axis(old_pane.clone(), new_pane.clone(), direction); @@ -62,7 +72,11 @@ impl PaneGroup { } } Member::Axis(axis) => axis.split(old_pane, new_pane, direction), + }; + if result.is_ok() { + self.mark_positions(cx); } + result } pub fn bounding_box_for_pane(&self, pane: &Entity) -> Option> { @@ -90,6 +104,7 @@ impl PaneGroup { &mut self, active_pane: &Entity, direction: SplitDirection, + cx: &mut App, ) -> Result { if let Some(pane) = self.find_pane_at_border(direction) && pane == active_pane @@ -97,7 +112,7 @@ impl PaneGroup { return Ok(false); } - if !self.remove(active_pane)? { + if !self.remove_internal(active_pane)? { return Ok(false); } @@ -110,6 +125,7 @@ impl PaneGroup { 0 }; root.insert_pane(idx, active_pane); + self.mark_positions(cx); return Ok(true); } @@ -119,6 +135,7 @@ impl PaneGroup { vec![Member::Pane(active_pane.clone()), self.root.clone()] }; self.root = Member::Axis(PaneAxis::new(direction.axis(), members)); + self.mark_positions(cx); Ok(true) } @@ -133,7 +150,15 @@ impl PaneGroup { /// - Ok(true) if it found and removed a pane /// - Ok(false) if it found but did not remove the pane /// - Err(_) if it did not find the pane - pub fn remove(&mut self, pane: &Entity) -> Result { + pub fn remove(&mut self, pane: &Entity, cx: &mut App) -> Result { + let result = self.remove_internal(pane); + if let Ok(true) = result { + self.mark_positions(cx); + } + result + } + + fn remove_internal(&mut self, pane: &Entity) -> Result { match &mut self.root { Member::Pane(_) => Ok(false), Member::Axis(axis) => { @@ -151,6 +176,7 @@ impl PaneGroup { direction: Axis, amount: Pixels, bounds: &Bounds, + cx: &mut App, ) { match &mut self.root { Member::Pane(_) => {} @@ -158,22 +184,29 @@ impl PaneGroup { let _ = axis.resize(pane, direction, amount, bounds); } }; + self.mark_positions(cx); } - pub fn reset_pane_sizes(&mut self) { + pub fn reset_pane_sizes(&mut self, cx: &mut App) { match &mut self.root { Member::Pane(_) => {} Member::Axis(axis) => { let _ = axis.reset_pane_sizes(); } }; + self.mark_positions(cx); } - pub fn swap(&mut self, from: &Entity, to: &Entity) { + pub fn swap(&mut self, from: &Entity, to: &Entity, cx: &mut App) { match &mut self.root { Member::Pane(_) => {} Member::Axis(axis) => axis.swap(from, to), }; + self.mark_positions(cx); + } + + pub fn mark_positions(&mut self, cx: &mut App) { + self.root.mark_positions(self.is_center, true, true, cx); } pub fn render( @@ -232,8 +265,9 @@ impl PaneGroup { self.pane_at_pixel_position(target) } - pub fn invert_axies(&mut self) { + pub fn invert_axies(&mut self, cx: &mut App) { self.root.invert_pane_axies(); + self.mark_positions(cx); } } @@ -243,6 +277,43 @@ pub enum Member { Pane(Entity), } +impl Member { + pub fn mark_positions( + &mut self, + in_center_group: bool, + is_upper_left: bool, + is_upper_right: bool, + cx: &mut App, + ) { + match self { + Member::Axis(pane_axis) => { + let len = pane_axis.members.len(); + for (idx, member) in pane_axis.members.iter_mut().enumerate() { + let member_upper_left = match pane_axis.axis { + Axis::Vertical => is_upper_left && idx == 0, + Axis::Horizontal => is_upper_left && idx == 0, + }; + let member_upper_right = match pane_axis.axis { + Axis::Vertical => is_upper_right && idx == 0, + Axis::Horizontal => is_upper_right && idx == len - 1, + }; + member.mark_positions( + in_center_group, + member_upper_left, + member_upper_right, + cx, + ); + } + } + Member::Pane(entity) => entity.update(cx, |pane, _| { + pane.in_center_group = in_center_group; + pane.is_upper_left = is_upper_left; + pane.is_upper_right = is_upper_right; + }), + } + } +} + #[derive(Clone, Copy)] pub struct PaneRenderContext<'a> { pub project: &'a Entity, diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 3d7ddf5d2ceae40f19e4684b63f6b33c8b53b280..8d20339ec952020416e4b8d5846bf44f5f8e9b98 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -9,18 +9,25 @@ use std::{ }; use anyhow::{Context as _, Result, bail}; -use collections::{HashMap, IndexSet}; +use collections::{HashMap, HashSet, IndexSet}; use db::{ + kvp::KEY_VALUE_STORE, query, sqlez::{connection::Connection, domain::Domain}, sqlez_macros::sql, }; -use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size}; -use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}; +use gpui::{Axis, Bounds, Entity, Task, WindowBounds, WindowId, point, size}; +use project::{ + debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}, + trusted_worktrees::{PathTrust, RemoteHostLocation, find_worktree_in_store}, + worktree_store::WorktreeStore, +}; use language::{LanguageName, Toolchain, ToolchainScope}; -use project::WorktreeId; -use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions}; +use remote::{ + DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, +}; +use serde::{Deserialize, Serialize}; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, @@ -44,6 +51,11 @@ use model::{ use self::model::{DockStructure, SerializedWorkspaceLocation}; +// https://www.sqlite.org/limits.html +// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER, +// > which defaults to <..> 32766 for SQLite versions after 3.32.0. +const MAX_QUERY_PLACEHOLDERS: usize = 32000; + #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) struct SerializedAxis(pub(crate) gpui::Axis); impl sqlez::bindable::StaticColumnCount for SerializedAxis {} @@ -152,6 +164,124 @@ impl Column for SerializedWindowBounds { } } +const DEFAULT_WINDOW_BOUNDS_KEY: &str = "default_window_bounds"; + +pub fn read_default_window_bounds() -> Option<(Uuid, WindowBounds)> { + let json_str = KEY_VALUE_STORE + .read_kvp(DEFAULT_WINDOW_BOUNDS_KEY) + .log_err() + .flatten()?; + + let (display_uuid, persisted) = + serde_json::from_str::<(Uuid, WindowBoundsJson)>(&json_str).ok()?; + Some((display_uuid, persisted.into())) +} + +pub async fn write_default_window_bounds( + bounds: WindowBounds, + display_uuid: Uuid, +) -> anyhow::Result<()> { + let persisted = WindowBoundsJson::from(bounds); + let json_str = serde_json::to_string(&(display_uuid, persisted))?; + KEY_VALUE_STORE + .write_kvp(DEFAULT_WINDOW_BOUNDS_KEY.to_string(), json_str) + .await?; + Ok(()) +} + +#[derive(Serialize, Deserialize)] +pub enum WindowBoundsJson { + Windowed { + x: i32, + y: i32, + width: i32, + height: i32, + }, + Maximized { + x: i32, + y: i32, + width: i32, + height: i32, + }, + Fullscreen { + x: i32, + y: i32, + width: i32, + height: i32, + }, +} + +impl From for WindowBoundsJson { + fn from(b: WindowBounds) -> Self { + match b { + WindowBounds::Windowed(bounds) => { + let origin = bounds.origin; + let size = bounds.size; + WindowBoundsJson::Windowed { + x: f32::from(origin.x).round() as i32, + y: f32::from(origin.y).round() as i32, + width: f32::from(size.width).round() as i32, + height: f32::from(size.height).round() as i32, + } + } + WindowBounds::Maximized(bounds) => { + let origin = bounds.origin; + let size = bounds.size; + WindowBoundsJson::Maximized { + x: f32::from(origin.x).round() as i32, + y: f32::from(origin.y).round() as i32, + width: f32::from(size.width).round() as i32, + height: f32::from(size.height).round() as i32, + } + } + WindowBounds::Fullscreen(bounds) => { + let origin = bounds.origin; + let size = bounds.size; + WindowBoundsJson::Fullscreen { + x: f32::from(origin.x).round() as i32, + y: f32::from(origin.y).round() as i32, + width: f32::from(size.width).round() as i32, + height: f32::from(size.height).round() as i32, + } + } + } + } +} + +impl From for WindowBounds { + fn from(n: WindowBoundsJson) -> Self { + match n { + WindowBoundsJson::Windowed { + x, + y, + width, + height, + } => WindowBounds::Windowed(Bounds { + origin: point(px(x as f32), px(y as f32)), + size: size(px(width as f32), px(height as f32)), + }), + WindowBoundsJson::Maximized { + x, + y, + width, + height, + } => WindowBounds::Maximized(Bounds { + origin: point(px(x as f32), px(y as f32)), + size: size(px(width as f32), px(height as f32)), + }), + WindowBoundsJson::Fullscreen { + x, + y, + width, + height, + } => WindowBounds::Fullscreen(Bounds { + origin: point(px(x as f32), px(y as f32)), + size: size(px(width as f32), px(height as f32)), + }), + } + } +} + #[derive(Debug)] pub struct Breakpoint { pub position: u32, @@ -702,6 +832,56 @@ impl Domain for WorkspaceDb { sql!( DROP TABLE ssh_connections; ), + sql!( + ALTER TABLE remote_connections ADD COLUMN name TEXT; + ALTER TABLE remote_connections ADD COLUMN container_id TEXT; + ), + sql!( + CREATE TABLE IF NOT EXISTS trusted_worktrees ( + trust_id INTEGER PRIMARY KEY AUTOINCREMENT, + absolute_path TEXT, + user_name TEXT, + host_name TEXT + ) STRICT; + ), + sql!(CREATE TABLE toolchains2 ( + workspace_id INTEGER, + worktree_root_path TEXT NOT NULL, + language_name TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + raw_json TEXT NOT NULL, + relative_worktree_path TEXT NOT NULL, + PRIMARY KEY (workspace_id, worktree_root_path, language_name, relative_worktree_path)) STRICT; + INSERT OR REPLACE INTO toolchains2 + // The `instr(paths, '\n') = 0` part allows us to find all + // workspaces that have a single worktree, as `\n` is used as a + // separator when serializing the workspace paths, so if no `\n` is + // found, we know we have a single worktree. + SELECT toolchains.workspace_id, paths, language_name, name, path, raw_json, relative_worktree_path FROM toolchains INNER JOIN workspaces ON toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0; + DROP TABLE toolchains; + ALTER TABLE toolchains2 RENAME TO toolchains; + ), + sql!(CREATE TABLE user_toolchains2 ( + remote_connection_id INTEGER, + workspace_id INTEGER NOT NULL, + worktree_root_path TEXT NOT NULL, + relative_worktree_path TEXT NOT NULL, + language_name TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + raw_json TEXT NOT NULL, + + PRIMARY KEY (workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json)) STRICT; + INSERT OR REPLACE INTO user_toolchains2 + // The `instr(paths, '\n') = 0` part allows us to find all + // workspaces that have a single worktree, as `\n` is used as a + // separator when serializing the workspace paths, so if no `\n` is + // found, we know we have a single worktree. + SELECT user_toolchains.remote_connection_id, user_toolchains.workspace_id, paths, relative_worktree_path, language_name, name, path, raw_json FROM user_toolchains INNER JOIN workspaces ON user_toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0; + DROP TABLE user_toolchains; + ALTER TABLE user_toolchains2 RENAME TO user_toolchains; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -728,9 +908,9 @@ impl WorkspaceDb { pub(crate) fn remote_workspace_for_roots>( &self, worktree_roots: &[P], - ssh_project_id: RemoteConnectionId, + remote_project_id: RemoteConnectionId, ) -> Option { - self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id)) + self.workspace_for_roots_internal(worktree_roots, Some(remote_project_id)) } pub(crate) fn workspace_for_roots_internal>( @@ -806,9 +986,20 @@ impl WorkspaceDb { order: paths_order, }); + let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id { + self.remote_connection(remote_connection_id) + .context("Get remote connection") + .log_err() + } else { + None + }; + Some(SerializedWorkspace { id: workspace_id, - location: SerializedWorkspaceLocation::Local, + location: match remote_connection_options { + Some(options) => SerializedWorkspaceLocation::Remote(options), + None => SerializedWorkspaceLocation::Local, + }, paths, center_group: self .get_center_pane_group(workspace_id) @@ -876,11 +1067,11 @@ impl WorkspaceDb { workspace_id: WorkspaceId, remote_connection_id: Option, ) -> BTreeMap> { - type RowKind = (WorkspaceId, u64, String, String, String, String, String); + type RowKind = (WorkspaceId, String, String, String, String, String, String); let toolchains: Vec = self .select_bound(sql! { - SELECT workspace_id, worktree_id, relative_worktree_path, + SELECT workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json FROM user_toolchains WHERE remote_connection_id IS ?1 AND ( workspace_id IN (0, ?2) @@ -894,7 +1085,7 @@ impl WorkspaceDb { for ( _workspace_id, - worktree_id, + worktree_root_path, relative_worktree_path, language_name, name, @@ -904,22 +1095,24 @@ impl WorkspaceDb { { // INTEGER's that are primary keys (like workspace ids, remote connection ids and such) start at 1, so we're safe to let scope = if _workspace_id == WorkspaceId(0) { - debug_assert_eq!(worktree_id, u64::MAX); + debug_assert_eq!(worktree_root_path, String::default()); debug_assert_eq!(relative_worktree_path, String::default()); ToolchainScope::Global } else { debug_assert_eq!(workspace_id, _workspace_id); debug_assert_eq!( - worktree_id == u64::MAX, + worktree_root_path == String::default(), relative_worktree_path == String::default() ); let Some(relative_path) = RelPath::unix(&relative_worktree_path).log_err() else { continue; }; - if worktree_id != u64::MAX && relative_worktree_path != String::default() { + if worktree_root_path != String::default() + && relative_worktree_path != String::default() + { ToolchainScope::Subproject( - WorktreeId::from_usize(worktree_id as usize), + Arc::from(worktree_root_path.as_ref()), relative_path.into(), ) } else { @@ -1005,13 +1198,13 @@ impl WorkspaceDb { for (scope, toolchains) in workspace.user_toolchains { for toolchain in toolchains { - let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)); - let (workspace_id, worktree_id, relative_worktree_path) = match scope { - ToolchainScope::Subproject(worktree_id, ref path) => (Some(workspace.id), Some(worktree_id), Some(path.as_unix_str().to_owned())), + let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)); + let (workspace_id, worktree_root_path, relative_worktree_path) = match scope { + ToolchainScope::Subproject(ref worktree_root_path, ref path) => (Some(workspace.id), Some(worktree_root_path.to_string_lossy().into_owned()), Some(path.as_unix_str().to_owned())), ToolchainScope::Project => (Some(workspace.id), None, None), ToolchainScope::Global => (None, None, None), }; - let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_id.map_or(usize::MAX,|id| id.to_usize()), relative_worktree_path.unwrap_or_default(), + let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_root_path.unwrap_or_default(), relative_worktree_path.unwrap_or_default(), toolchain.language_name.as_ref().to_owned(), toolchain.name.to_string(), toolchain.path.to_string(), toolchain.as_json.to_string()); if let Err(err) = conn.exec_bound(query)?(args) { log::error!("{err}"); @@ -1110,14 +1303,16 @@ impl WorkspaceDb { options: RemoteConnectionOptions, ) -> Result { let kind; - let user; + let mut user = None; let mut host = None; let mut port = None; let mut distro = None; + let mut name = None; + let mut container_id = None; match options { RemoteConnectionOptions::Ssh(options) => { kind = RemoteConnectionKind::Ssh; - host = Some(options.host); + host = Some(options.host.to_string()); port = options.port; user = options.username; } @@ -1126,8 +1321,22 @@ impl WorkspaceDb { distro = Some(options.distro_name); user = options.user; } + RemoteConnectionOptions::Docker(options) => { + kind = RemoteConnectionKind::Docker; + container_id = Some(options.container_id); + name = Some(options.name); + } } - Self::get_or_create_remote_connection_query(this, kind, host, port, user, distro) + Self::get_or_create_remote_connection_query( + this, + kind, + host, + port, + user, + distro, + name, + container_id, + ) } fn get_or_create_remote_connection_query( @@ -1137,6 +1346,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + name: Option, + container_id: Option, ) -> Result { if let Some(id) = this.select_row_bound(sql!( SELECT id @@ -1146,7 +1357,9 @@ impl WorkspaceDb { host IS ? AND port IS ? AND user IS ? AND - distro IS ? + distro IS ? AND + name IS ? AND + container_id IS ? LIMIT 1 ))?(( kind.serialize(), @@ -1154,6 +1367,8 @@ impl WorkspaceDb { port, user.clone(), distro.clone(), + name.clone(), + container_id.clone(), ))? { Ok(RemoteConnectionId(id)) } else { @@ -1163,10 +1378,20 @@ impl WorkspaceDb { host, port, user, - distro - ) VALUES (?1, ?2, ?3, ?4, ?5) + distro, + name, + container_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id - ))?((kind.serialize(), host, port, user, distro))? + ))?(( + kind.serialize(), + host, + port, + user, + distro, + name, + container_id, + ))? .context("failed to insert remote project")?; Ok(RemoteConnectionId(id)) } @@ -1249,15 +1474,23 @@ impl WorkspaceDb { fn remote_connections(&self) -> Result> { Ok(self.select(sql!( SELECT - id, kind, host, port, user, distro + id, kind, host, port, user, distro, container_id, name FROM remote_connections ))?()? .into_iter() - .filter_map(|(id, kind, host, port, user, distro)| { + .filter_map(|(id, kind, host, port, user, distro, container_id, name)| { Some(( RemoteConnectionId(id), - Self::remote_connection_from_row(kind, host, port, user, distro)?, + Self::remote_connection_from_row( + kind, + host, + port, + user, + distro, + container_id, + name, + )?, )) }) .collect()) @@ -1267,13 +1500,13 @@ impl WorkspaceDb { &self, id: RemoteConnectionId, ) -> Result { - let (kind, host, port, user, distro) = self.select_row_bound(sql!( - SELECT kind, host, port, user, distro + let (kind, host, port, user, distro, container_id, name) = self.select_row_bound(sql!( + SELECT kind, host, port, user, distro, container_id, name FROM remote_connections WHERE id = ? ))?(id.0)? .context("no such remote connection")?; - Self::remote_connection_from_row(kind, host, port, user, distro) + Self::remote_connection_from_row(kind, host, port, user, distro, container_id, name) .context("invalid remote_connection row") } @@ -1283,6 +1516,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + container_id: Option, + name: Option, ) -> Option { match RemoteConnectionKind::deserialize(&kind)? { RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions { @@ -1290,32 +1525,21 @@ impl WorkspaceDb { user: user, })), RemoteConnectionKind::Ssh => Some(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host?, + host: host?.into(), port, username: user, ..Default::default() })), + RemoteConnectionKind::Docker => { + Some(RemoteConnectionOptions::Docker(DockerConnectionOptions { + container_id: container_id?, + name: name?, + upload_binary_over_docker_exec: false, + })) + } } } - pub(crate) fn last_window( - &self, - ) -> anyhow::Result<(Option, Option)> { - let mut prepared_query = - self.select::<(Option, Option)>(sql!( - SELECT - display, - window_state, window_x, window_y, window_width, window_height - FROM workspaces - WHERE paths - IS NOT NULL - ORDER BY timestamp DESC - LIMIT 1 - ))?; - let result = prepared_query()?; - Ok(result.into_iter().next().unwrap_or((None, None))) - } - query! { pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> { DELETE FROM workspaces @@ -1359,11 +1583,11 @@ impl WorkspaceDb { // If a local workspace points to WSL, this check will cause us to wait for the // WSL VM and file server to boot up. This can block for many seconds. // Supported scenarios use remote workspaces. - if !has_wsl_path - && paths.paths().iter().all(|path| path.exists()) - && paths.paths().iter().any(|path| path.is_dir()) - { - result.push((id, SerializedWorkspaceLocation::Local, paths)); + if !has_wsl_path && paths.paths().iter().all(|path| path.exists()) { + // Only show directories in recent projects + if paths.paths().iter().any(|path| path.is_dir()) { + result.push((id, SerializedWorkspaceLocation::Local, paths)); + } } else { delete_tasks.push(self.delete_workspace_by_id(id)); } @@ -1656,70 +1880,27 @@ impl WorkspaceDb { } } - pub async fn toolchain( - &self, - workspace_id: WorkspaceId, - worktree_id: WorktreeId, - relative_worktree_path: Arc, - language_name: LanguageName, - ) -> Result> { - self.write(move |this| { - let mut select = this - .select_bound(sql!( - SELECT - name, path, raw_json - FROM toolchains - WHERE - workspace_id = ? AND - language_name = ? AND - worktree_id = ? AND - relative_worktree_path = ? - )) - .context("select toolchain")?; - - let toolchain: Vec<(String, String, String)> = select(( - workspace_id, - language_name.as_ref().to_string(), - worktree_id.to_usize(), - relative_worktree_path.as_unix_str().to_string(), - ))?; - - Ok(toolchain - .into_iter() - .next() - .and_then(|(name, path, raw_json)| { - Some(Toolchain { - name: name.into(), - path: path.into(), - language_name, - as_json: serde_json::Value::from_str(&raw_json).ok()?, - }) - })) - }) - .await - } - pub(crate) async fn toolchains( &self, workspace_id: WorkspaceId, - ) -> Result)>> { + ) -> Result, Arc)>> { self.write(move |this| { let mut select = this .select_bound(sql!( SELECT - name, path, worktree_id, relative_worktree_path, language_name, raw_json + name, path, worktree_root_path, relative_worktree_path, language_name, raw_json FROM toolchains WHERE workspace_id = ? )) .context("select toolchains")?; - let toolchain: Vec<(String, String, u64, String, String, String)> = + let toolchain: Vec<(String, String, String, String, String, String)> = select(workspace_id)?; Ok(toolchain .into_iter() .filter_map( - |(name, path, worktree_id, relative_worktree_path, language, json)| { + |(name, path, worktree_root_path, relative_worktree_path, language, json)| { Some(( Toolchain { name: name.into(), @@ -1727,7 +1908,7 @@ impl WorkspaceDb { language_name: LanguageName::new(&language), as_json: serde_json::Value::from_str(&json).ok()?, }, - WorktreeId::from_proto(worktree_id), + Arc::from(worktree_root_path.as_ref()), RelPath::from_proto(&relative_worktree_path).log_err()?, )) }, @@ -1740,18 +1921,18 @@ impl WorkspaceDb { pub async fn set_toolchain( &self, workspace_id: WorkspaceId, - worktree_id: WorktreeId, + worktree_root_path: Arc, relative_worktree_path: Arc, toolchain: Toolchain, ) -> Result<()> { log::debug!( - "Setting toolchain for workspace, worktree: {worktree_id:?}, relative path: {relative_worktree_path:?}, toolchain: {}", + "Setting toolchain for workspace, worktree: {worktree_root_path:?}, relative path: {relative_worktree_path:?}, toolchain: {}", toolchain.name ); self.write(move |conn| { let mut insert = conn .exec_bound(sql!( - INSERT INTO toolchains(workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO toolchains(workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO UPDATE SET name = ?5, @@ -1762,7 +1943,7 @@ impl WorkspaceDb { insert(( workspace_id, - worktree_id.to_usize(), + worktree_root_path.to_string_lossy().into_owned(), relative_worktree_path.as_unix_str(), toolchain.language_name.as_ref(), toolchain.name.as_ref(), @@ -1773,6 +1954,135 @@ impl WorkspaceDb { Ok(()) }).await } + + pub(crate) async fn save_trusted_worktrees( + &self, + trusted_worktrees: HashMap, HashSet>, + ) -> anyhow::Result<()> { + use anyhow::Context as _; + use db::sqlez::statement::Statement; + use itertools::Itertools as _; + + DB.clear_trusted_worktrees() + .await + .context("clearing previous trust state")?; + + let trusted_worktrees = trusted_worktrees + .into_iter() + .flat_map(|(host, abs_paths)| { + abs_paths + .into_iter() + .map(move |abs_path| (Some(abs_path), host.clone())) + }) + .collect::>(); + let mut first_worktree; + let mut last_worktree = 0_usize; + for (count, placeholders) in std::iter::once("(?, ?, ?)") + .cycle() + .take(trusted_worktrees.len()) + .chunks(MAX_QUERY_PLACEHOLDERS / 3) + .into_iter() + .map(|chunk| { + let mut count = 0; + let placeholders = chunk + .inspect(|_| { + count += 1; + }) + .join(", "); + (count, placeholders) + }) + .collect::>() + { + first_worktree = last_worktree; + last_worktree = last_worktree + count; + let query = format!( + r#"INSERT INTO trusted_worktrees(absolute_path, user_name, host_name) +VALUES {placeholders};"# + ); + + let trusted_worktrees = trusted_worktrees[first_worktree..last_worktree].to_vec(); + self.write(move |conn| { + let mut statement = Statement::prepare(conn, query)?; + let mut next_index = 1; + for (abs_path, host) in trusted_worktrees { + let abs_path = abs_path.as_ref().map(|abs_path| abs_path.to_string_lossy()); + next_index = statement.bind( + &abs_path.as_ref().map(|abs_path| abs_path.as_ref()), + next_index, + )?; + next_index = statement.bind( + &host + .as_ref() + .and_then(|host| Some(host.user_name.as_ref()?.as_str())), + next_index, + )?; + next_index = statement.bind( + &host.as_ref().map(|host| host.host_identifier.as_str()), + next_index, + )?; + } + statement.exec() + }) + .await + .context("inserting new trusted state")?; + } + Ok(()) + } + + pub fn fetch_trusted_worktrees( + &self, + worktree_store: Option>, + host: Option, + cx: &App, + ) -> Result, HashSet>> { + let trusted_worktrees = DB.trusted_worktrees()?; + Ok(trusted_worktrees + .into_iter() + .filter_map(|(abs_path, user_name, host_name)| { + let db_host = match (user_name, host_name) { + (_, None) => None, + (None, Some(host_name)) => Some(RemoteHostLocation { + user_name: None, + host_identifier: SharedString::new(host_name), + }), + (Some(user_name), Some(host_name)) => Some(RemoteHostLocation { + user_name: Some(SharedString::new(user_name)), + host_identifier: SharedString::new(host_name), + }), + }; + + let abs_path = abs_path?; + Some(if db_host != host { + (db_host, PathTrust::AbsPath(abs_path)) + } else if let Some(worktree_store) = &worktree_store { + find_worktree_in_store(worktree_store.read(cx), &abs_path, cx) + .map(PathTrust::Worktree) + .map(|trusted_worktree| (host.clone(), trusted_worktree)) + .unwrap_or_else(|| (db_host.clone(), PathTrust::AbsPath(abs_path))) + } else { + (db_host, PathTrust::AbsPath(abs_path)) + }) + }) + .fold(HashMap::default(), |mut acc, (remote_host, path_trust)| { + acc.entry(remote_host) + .or_insert_with(HashSet::default) + .insert(path_trust); + acc + })) + } + + query! { + fn trusted_worktrees() -> Result, Option, Option)>> { + SELECT absolute_path, user_name, host_name + FROM trusted_worktrees + } + } + + query! { + pub async fn clear_trusted_worktrees() -> Result<()> { + DELETE FROM trusted_worktrees + } + } } pub fn delete_unloaded_items( @@ -2480,7 +2790,7 @@ mod tests { let connection_id = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: "my-host".to_string(), + host: "my-host".into(), port: Some(1234), ..Default::default() })) @@ -2669,7 +2979,7 @@ mod tests { .into_iter() .map(|(host, user)| async { let options = RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.to_string(), + host: host.into(), username: Some(user.to_string()), ..Default::default() }); @@ -2760,7 +3070,7 @@ mod tests { let connection_id = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port, username: user.clone(), ..Default::default() @@ -2771,7 +3081,7 @@ mod tests { // Test that calling the function again with the same parameters returns the same project let same_connection = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port, username: user.clone(), ..Default::default() @@ -2788,7 +3098,7 @@ mod tests { let different_connection = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host2.clone(), + host: host2.clone().into(), port: port2, username: user2.clone(), ..Default::default() @@ -2807,7 +3117,7 @@ mod tests { let connection_id = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port, username: None, ..Default::default() @@ -2817,7 +3127,7 @@ mod tests { let same_connection_id = db .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port, username: user.clone(), ..Default::default() @@ -2847,7 +3157,7 @@ mod tests { ids.push( db.get_or_create_remote_connection(RemoteConnectionOptions::Ssh( SshConnectionOptions { - host: host.clone(), + host: host.clone().into(), port: *port, username: user.clone(), ..Default::default() @@ -3025,4 +3335,53 @@ mod tests { assert_eq!(workspace.center_group, new_workspace.center_group); } + + #[gpui::test] + async fn test_empty_workspace_window_bounds() { + zlog::init_test(); + + let db = WorkspaceDb::open_test_db("test_empty_workspace_window_bounds").await; + let id = db.next_id().await.unwrap(); + + // Create a workspace with empty paths (empty workspace) + let empty_paths: &[&str] = &[]; + let display_uuid = Uuid::new_v4(); + let window_bounds = SerializedWindowBounds(WindowBounds::Windowed(Bounds { + origin: point(px(100.0), px(200.0)), + size: size(px(800.0), px(600.0)), + })); + + let workspace = SerializedWorkspace { + id, + paths: PathList::new(empty_paths), + location: SerializedWorkspaceLocation::Local, + center_group: Default::default(), + window_bounds: None, + display: None, + docks: Default::default(), + breakpoints: Default::default(), + centered_layout: false, + session_id: None, + window_id: None, + user_toolchains: Default::default(), + }; + + // Save the workspace (this creates the record with empty paths) + db.save_workspace(workspace.clone()).await; + + // Save window bounds separately (as the actual code does via set_window_open_status) + db.set_window_open_status(id, window_bounds, display_uuid) + .await + .unwrap(); + + // Retrieve it using empty paths + let retrieved = db.workspace_for_roots(empty_paths).unwrap(); + + // Verify window bounds were persisted + assert_eq!(retrieved.id, id); + assert!(retrieved.window_bounds.is_some()); + assert_eq!(retrieved.window_bounds.unwrap().0, window_bounds.0); + assert!(retrieved.display.is_some()); + assert_eq!(retrieved.display.unwrap(), display_uuid); + } } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index a37b2ebbe93efb23cad6a98f127ba1f8800a3eb3..08a3adf9ebd7fa49a5f8fb86eec65c66deb00421 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -32,6 +32,7 @@ pub(crate) struct RemoteConnectionId(pub u64); pub(crate) enum RemoteConnectionKind { Ssh, Wsl, + Docker, } #[derive(Debug, PartialEq, Clone)] @@ -75,6 +76,7 @@ impl RemoteConnectionKind { match self { RemoteConnectionKind::Ssh => "ssh", RemoteConnectionKind::Wsl => "wsl", + RemoteConnectionKind::Docker => "docker", } } @@ -82,6 +84,7 @@ impl RemoteConnectionKind { match text { "ssh" => Some(Self::Ssh), "wsl" => Some(Self::Wsl), + "docker" => Some(Self::Docker), _ => None, } } diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 64dad0345fa323eb724b6b51656b841c8d433688..badfe7d2437424c1ce18a1afde19507e7d6e1d3b 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -96,6 +96,7 @@ pub trait SearchableItem: Item + EventEmitter { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ); @@ -179,7 +180,13 @@ pub trait SearchableItemHandle: ItemHandle { handler: Box, ) -> Subscription; fn clear_matches(&self, window: &mut Window, cx: &mut App); - fn update_matches(&self, matches: &AnyVec, window: &mut Window, cx: &mut App); + fn update_matches( + &self, + matches: &AnyVec, + active_match_index: Option, + window: &mut Window, + cx: &mut App, + ); fn query_suggestion(&self, window: &mut Window, cx: &mut App) -> String; fn activate_match( &self, @@ -264,10 +271,16 @@ impl SearchableItemHandle for Entity { fn clear_matches(&self, window: &mut Window, cx: &mut App) { self.update(cx, |this, cx| this.clear_matches(window, cx)); } - fn update_matches(&self, matches: &AnyVec, window: &mut Window, cx: &mut App) { + fn update_matches( + &self, + matches: &AnyVec, + active_match_index: Option, + window: &mut Window, + cx: &mut App, + ) { let matches = matches.downcast_ref().unwrap(); self.update(cx, |this, cx| { - this.update_matches(matches.as_slice(), window, cx) + this.update_matches(matches.as_slice(), active_match_index, window, cx) }); } fn query_suggestion(&self, window: &mut Window, cx: &mut App) -> String { diff --git a/crates/workspace/src/security_modal.rs b/crates/workspace/src/security_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..bb1482d7cce2a9849a78a9512598e389a6e5eea0 --- /dev/null +++ b/crates/workspace/src/security_modal.rs @@ -0,0 +1,334 @@ +//! A UI interface for managing the [`TrustedWorktrees`] data. + +use std::{ + borrow::Cow, + path::{Path, PathBuf}, + sync::Arc, +}; + +use collections::{HashMap, HashSet}; +use gpui::{DismissEvent, EventEmitter, FocusHandle, Focusable, WeakEntity}; + +use project::{ + WorktreeId, + trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees}, + worktree_store::WorktreeStore, +}; +use smallvec::SmallVec; +use theme::ActiveTheme; +use ui::{ + AlertModal, Checkbox, FluentBuilder, KeyBinding, ListBulletItem, ToggleState, prelude::*, +}; + +use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity}; + +pub struct SecurityModal { + restricted_paths: HashMap, + home_dir: Option, + trust_parents: bool, + worktree_store: WeakEntity, + remote_host: Option, + focus_handle: FocusHandle, + trusted: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct RestrictedPath { + abs_path: Arc, + is_file: bool, + host: Option, +} + +impl Focusable for SecurityModal { + fn focus_handle(&self, _: &ui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for SecurityModal {} + +impl ModalView for SecurityModal { + fn fade_out_background(&self) -> bool { + true + } + + fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context) -> DismissDecision { + match self.trusted { + Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"), + Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"), + None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"), + } + DismissDecision::Dismiss(true) + } +} + +impl Render for SecurityModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.restricted_paths.is_empty() { + self.dismiss(cx); + return v_flex().into_any_element(); + } + + let header_label = if self.restricted_paths.len() == 1 { + "Unrecognized Project" + } else { + "Unrecognized Projects" + }; + + let trust_label = self.build_trust_label(); + + AlertModal::new("security-modal") + .width(rems(40.)) + .key_context("SecurityModal") + .track_focus(&self.focus_handle(cx)) + .on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| { + this.trust_and_dismiss(cx); + })) + .on_action(cx.listener(|security_modal, _: &ToggleWorktreeSecurity, _window, cx| { + security_modal.trusted = Some(false); + security_modal.dismiss(cx); + })) + .header( + v_flex() + .p_3() + .gap_1() + .rounded_t_md() + .bg(cx.theme().colors().editor_background.opacity(0.5)) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + h_flex() + .gap_2() + .child(Icon::new(IconName::Warning).color(Color::Warning)) + .child(Label::new(header_label)), + ) + .children(self.restricted_paths.values().filter_map(|restricted_path| { + let abs_path = if restricted_path.is_file { + restricted_path.abs_path.parent() + } else { + Some(restricted_path.abs_path.as_ref()) + }?; + let label = match &restricted_path.host { + Some(remote_host) => match &remote_host.user_name { + Some(user_name) => format!( + "{} ({}@{})", + self.shorten_path(abs_path).display(), + user_name, + remote_host.host_identifier + ), + None => format!( + "{} ({})", + self.shorten_path(abs_path).display(), + remote_host.host_identifier + ), + }, + None => self.shorten_path(abs_path).display().to_string(), + }; + Some(h_flex() + .pl(IconSize::default().rems() + rems(0.5)) + .child(Label::new(label).color(Color::Muted))) + })), + ) + .child( + v_flex() + .gap_2() + .child( + v_flex() + .child( + Label::new( + "Untrusted projects are opened in Restricted Mode to protect your system.", + ) + .color(Color::Muted), + ) + .child( + Label::new( + "Review .zed/settings.json for any extensions or commands configured by this project.", + ) + .color(Color::Muted), + ), + ) + .child( + v_flex() + .child(Label::new("Restricted Mode prevents:").color(Color::Muted)) + .child(ListBulletItem::new("Project settings from being applied")) + .child(ListBulletItem::new("Language servers from running")) + .child(ListBulletItem::new("MCP Server integrations from installing")), + ) + .map(|this| match trust_label { + Some(trust_label) => this.child( + Checkbox::new("trust-parents", ToggleState::from(self.trust_parents)) + .label(trust_label) + .on_click(cx.listener( + |security_modal, state: &ToggleState, _, cx| { + security_modal.trust_parents = state.selected(); + cx.notify(); + cx.stop_propagation(); + }, + )), + ), + None => this, + }), + ) + .footer( + h_flex() + .px_3() + .pb_3() + .gap_1() + .justify_end() + .child( + Button::new("rm", "Stay in Restricted Mode") + .key_binding( + KeyBinding::for_action( + &ToggleWorktreeSecurity, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(move |security_modal, _, _, cx| { + security_modal.trusted = Some(false); + security_modal.dismiss(cx); + cx.stop_propagation(); + })), + ) + .child( + Button::new("tc", "Trust and Continue") + .style(ButtonStyle::Filled) + .layer(ui::ElevationIndex::ModalSurface) + .key_binding( + KeyBinding::for_action(&menu::Confirm, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(move |security_modal, _, _, cx| { + security_modal.trust_and_dismiss(cx); + cx.stop_propagation(); + })), + ), + ) + .into_any_element() + } +} + +impl SecurityModal { + pub fn new( + worktree_store: WeakEntity, + remote_host: Option>, + cx: &mut Context, + ) -> Self { + let mut this = Self { + worktree_store, + remote_host: remote_host.map(|host| host.into()), + restricted_paths: HashMap::default(), + focus_handle: cx.focus_handle(), + trust_parents: false, + home_dir: std::env::home_dir(), + trusted: None, + }; + this.refresh_restricted_paths(cx); + + this + } + + fn build_trust_label(&self) -> Option> { + let mut has_restricted_files = false; + let available_parents = self + .restricted_paths + .values() + .filter(|restricted_path| { + has_restricted_files |= restricted_path.is_file; + !restricted_path.is_file + }) + .filter_map(|restricted_path| restricted_path.abs_path.parent()) + .collect::>(); + match available_parents.len() { + 0 => { + if has_restricted_files { + Some(Cow::Borrowed("Trust all single files")) + } else { + None + } + } + 1 => Some(Cow::Owned(format!( + "Trust all projects in the {:} folder", + self.shorten_path(available_parents[0]).display() + ))), + _ => Some(Cow::Borrowed("Trust all projects in the parent folders")), + } + } + + fn shorten_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> { + match &self.home_dir { + Some(home_dir) => path + .strip_prefix(home_dir) + .map(|stripped| Path::new("~").join(stripped)) + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed(path)), + None => Cow::Borrowed(path), + } + } + + fn trust_and_dismiss(&mut self, cx: &mut Context) { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + let mut paths_to_trust = self + .restricted_paths + .keys() + .copied() + .map(PathTrust::Worktree) + .collect::>(); + if self.trust_parents { + paths_to_trust.extend(self.restricted_paths.values().filter_map( + |restricted_paths| { + if restricted_paths.is_file { + None + } else { + let parent_abs_path = + restricted_paths.abs_path.parent()?.to_owned(); + Some(PathTrust::AbsPath(parent_abs_path)) + } + }, + )); + } + trusted_worktrees.trust(paths_to_trust, self.remote_host.clone(), cx); + }); + } + + self.trusted = Some(true); + self.dismiss(cx); + } + + pub fn dismiss(&mut self, cx: &mut Context) { + cx.emit(DismissEvent); + } + + pub fn refresh_restricted_paths(&mut self, cx: &mut Context) { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + if let Some(worktree_store) = self.worktree_store.upgrade() { + let new_restricted_worktrees = trusted_worktrees + .read(cx) + .restricted_worktrees(worktree_store.read(cx), cx) + .into_iter() + .filter_map(|(worktree_id, abs_path)| { + let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?; + Some(( + worktree_id, + RestrictedPath { + abs_path, + is_file: worktree.read(cx).is_single_file(), + host: self.remote_host.clone(), + }, + )) + }) + .collect::>(); + + if self.restricted_paths != new_restricted_worktrees { + self.trust_parents = false; + self.restricted_paths = new_restricted_worktrees; + cx.notify(); + } + } + } else if !self.restricted_paths.is_empty() { + self.restricted_paths.clear(); + cx.notify(); + } + } +} diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index 3c009f613ea52906649b73bb9fd657bab6906c3b..564560274699ab6685d481340c5efd4b6336ed56 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -42,6 +42,11 @@ impl SharedScreen { }) .detach(); + cx.observe_release(&room, |_, _, cx| { + cx.emit(Event::Close); + }) + .detach(); + let view = cx.new(|cx| RemoteVideoTrackView::new(track.clone(), window, cx)); cx.subscribe(&view, |_, _, ev, cx| match ev { call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close), diff --git a/crates/workspace/src/utility_pane.rs b/crates/workspace/src/utility_pane.rs new file mode 100644 index 0000000000000000000000000000000000000000..2760000216d9164367c58d41d4f1b1893dc8cd75 --- /dev/null +++ b/crates/workspace/src/utility_pane.rs @@ -0,0 +1,282 @@ +use gpui::{ + AppContext as _, EntityId, MouseButton, Pixels, Render, StatefulInteractiveElement, + Subscription, WeakEntity, deferred, px, +}; +use ui::{ + ActiveTheme as _, Context, FluentBuilder as _, InteractiveElement as _, IntoElement, + ParentElement as _, RenderOnce, Styled as _, Window, div, +}; + +use crate::{ + DockPosition, Workspace, + dock::{ClosePane, MinimizePane, UtilityPane, UtilityPaneHandle}, +}; + +pub(crate) const UTILITY_PANE_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +pub(crate) const UTILITY_PANE_MIN_WIDTH: Pixels = px(20.0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UtilityPaneSlot { + Left, + Right, +} + +struct UtilityPaneSlotState { + panel_id: EntityId, + utility_pane: Box, + _subscriptions: Vec, +} + +#[derive(Default)] +pub struct UtilityPaneState { + left_slot: Option, + right_slot: Option, +} + +#[derive(Clone)] +pub struct DraggedUtilityPane(pub UtilityPaneSlot); + +impl Render for DraggedUtilityPane { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +pub fn utility_slot_for_dock_position(position: DockPosition) -> UtilityPaneSlot { + match position { + DockPosition::Left => UtilityPaneSlot::Left, + DockPosition::Right => UtilityPaneSlot::Right, + DockPosition::Bottom => UtilityPaneSlot::Left, + } +} + +impl Workspace { + pub fn utility_pane(&self, slot: UtilityPaneSlot) -> Option<&dyn UtilityPaneHandle> { + match slot { + UtilityPaneSlot::Left => self + .utility_panes + .left_slot + .as_ref() + .map(|s| s.utility_pane.as_ref()), + UtilityPaneSlot::Right => self + .utility_panes + .right_slot + .as_ref() + .map(|s| s.utility_pane.as_ref()), + } + } + + pub fn toggle_utility_pane( + &mut self, + slot: UtilityPaneSlot, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + let current = handle.expanded(cx); + handle.set_expanded(!current, cx); + } + cx.notify(); + self.serialize_workspace(window, cx); + } + + pub fn register_utility_pane( + &mut self, + slot: UtilityPaneSlot, + panel_id: EntityId, + handle: gpui::Entity, + cx: &mut Context, + ) { + let minimize_subscription = + cx.subscribe(&handle, move |this, _, _event: &MinimizePane, cx| { + if let Some(handle) = this.utility_pane(slot) { + handle.set_expanded(false, cx); + } + cx.notify(); + }); + + let close_subscription = cx.subscribe(&handle, move |this, _, _event: &ClosePane, cx| { + this.clear_utility_pane(slot, cx); + }); + + let subscriptions = vec![minimize_subscription, close_subscription]; + let boxed_handle: Box = Box::new(handle); + + match slot { + UtilityPaneSlot::Left => { + self.utility_panes.left_slot = Some(UtilityPaneSlotState { + panel_id, + utility_pane: boxed_handle, + _subscriptions: subscriptions, + }); + } + UtilityPaneSlot::Right => { + self.utility_panes.right_slot = Some(UtilityPaneSlotState { + panel_id, + utility_pane: boxed_handle, + _subscriptions: subscriptions, + }); + } + } + cx.notify(); + } + + pub fn clear_utility_pane(&mut self, slot: UtilityPaneSlot, cx: &mut Context) { + match slot { + UtilityPaneSlot::Left => { + self.utility_panes.left_slot = None; + } + UtilityPaneSlot::Right => { + self.utility_panes.right_slot = None; + } + } + cx.notify(); + } + + pub fn clear_utility_pane_if_provider( + &mut self, + slot: UtilityPaneSlot, + provider_panel_id: EntityId, + cx: &mut Context, + ) { + let should_clear = match slot { + UtilityPaneSlot::Left => self + .utility_panes + .left_slot + .as_ref() + .is_some_and(|slot| slot.panel_id == provider_panel_id), + UtilityPaneSlot::Right => self + .utility_panes + .right_slot + .as_ref() + .is_some_and(|slot| slot.panel_id == provider_panel_id), + }; + + if should_clear { + self.clear_utility_pane(slot, cx); + } + } + + pub fn resize_utility_pane( + &mut self, + slot: UtilityPaneSlot, + new_width: Pixels, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + let max_width = self.max_utility_pane_width(window, cx); + let width = new_width.max(UTILITY_PANE_MIN_WIDTH).min(max_width); + handle.set_width(Some(width), cx); + cx.notify(); + self.serialize_workspace(window, cx); + } + } + + pub fn reset_utility_pane_width( + &mut self, + slot: UtilityPaneSlot, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + handle.set_width(None, cx); + cx.notify(); + self.serialize_workspace(window, cx); + } + } +} + +#[derive(IntoElement)] +pub struct UtilityPaneFrame { + workspace: WeakEntity, + slot: UtilityPaneSlot, + handle: Box, +} + +impl UtilityPaneFrame { + pub fn new( + slot: UtilityPaneSlot, + handle: Box, + cx: &mut Context, + ) -> Self { + let workspace = cx.weak_entity(); + Self { + workspace, + slot, + handle, + } + } +} + +impl RenderOnce for UtilityPaneFrame { + fn render(self, _window: &mut Window, cx: &mut ui::App) -> impl IntoElement { + let workspace = self.workspace.clone(); + let slot = self.slot; + let width = self.handle.width(cx); + + let create_resize_handle = || { + let workspace_handle = workspace.clone(); + let handle = div() + .id(match slot { + UtilityPaneSlot::Left => "utility-pane-resize-handle-left", + UtilityPaneSlot::Right => "utility-pane-resize-handle-right", + }) + .on_drag(DraggedUtilityPane(slot), move |pane, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| pane.clone()) + }) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + move |e: &gpui::MouseUpEvent, window, cx| { + if e.click_count == 2 { + workspace_handle + .update(cx, |workspace, cx| { + workspace.reset_utility_pane_width(slot, window, cx); + }) + .ok(); + cx.stop_propagation(); + } + }, + ) + .occlude(); + + match slot { + UtilityPaneSlot::Left => deferred( + handle + .absolute() + .right(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) + .cursor_col_resize(), + ), + UtilityPaneSlot::Right => deferred( + handle + .absolute() + .left(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) + .cursor_col_resize(), + ), + } + }; + + div() + .h_full() + .bg(cx.theme().colors().tab_bar_background) + .w(width) + .border_color(cx.theme().colors().border) + .when(self.slot == UtilityPaneSlot::Left, |this| this.border_r_1()) + .when(self.slot == UtilityPaneSlot::Right, |this| { + this.border_l_1() + }) + .child(create_resize_handle()) + .child(self.handle.to_any()) + .into_any_element() + } +} diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d84f3072f87ffa3246a313cbc749ddd61287d25 --- /dev/null +++ b/crates/workspace/src/welcome.rs @@ -0,0 +1,568 @@ +use crate::{ + NewFile, Open, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId, + item::{Item, ItemEvent}, +}; +use git::Clone as GitClone; +use gpui::WeakEntity; +use gpui::{ + Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + ParentElement, Render, Styled, Task, Window, actions, +}; +use menu::{SelectNext, SelectPrevious}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; +use util::ResultExt; +use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette}; + +#[derive(PartialEq, Clone, Debug, Deserialize, Serialize, JsonSchema, Action)] +#[action(namespace = welcome)] +#[serde(transparent)] +pub struct OpenRecentProject { + pub index: usize, +} + +actions!( + zed, + [ + /// Show the Zed welcome screen + ShowWelcome + ] +); + +#[derive(IntoElement)] +struct SectionHeader { + title: SharedString, +} + +impl SectionHeader { + fn new(title: impl Into) -> Self { + Self { + title: title.into(), + } + } +} + +impl RenderOnce for SectionHeader { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .px_1() + .mb_2() + .gap_2() + .child( + Label::new(self.title.to_ascii_uppercase()) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .child(Divider::horizontal().color(DividerColor::BorderVariant)) + } +} + +#[derive(IntoElement)] +struct SectionButton { + label: SharedString, + icon: IconName, + action: Box, + tab_index: usize, + focus_handle: FocusHandle, +} + +impl SectionButton { + fn new( + label: impl Into, + icon: IconName, + action: &dyn Action, + tab_index: usize, + focus_handle: FocusHandle, + ) -> Self { + Self { + label: label.into(), + icon, + action: action.boxed_clone(), + tab_index, + focus_handle, + } + } +} + +impl RenderOnce for SectionButton { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = format!("onb-button-{}", self.label); + let action_ref: &dyn Action = &*self.action; + + ButtonLike::new(id) + .tab_index(self.tab_index as isize) + .full_width() + .size(ButtonSize::Medium) + .child( + h_flex() + .w_full() + .justify_between() + .child( + h_flex() + .gap_2() + .child( + Icon::new(self.icon) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(Label::new(self.label)), + ) + .child( + KeyBinding::for_action_in(action_ref, &self.focus_handle, cx) + .size(rems_from_px(12.)), + ), + ) + .on_click(move |_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx)) + } +} + +struct SectionEntry { + icon: IconName, + title: &'static str, + action: &'static dyn Action, +} + +impl SectionEntry { + fn render(&self, button_index: usize, focus: &FocusHandle, _cx: &App) -> impl IntoElement { + SectionButton::new( + self.title, + self.icon, + self.action, + button_index, + focus.clone(), + ) + } +} + +const CONTENT: (Section<4>, Section<3>) = ( + Section { + title: "Get Started", + entries: [ + SectionEntry { + icon: IconName::Plus, + title: "New File", + action: &NewFile, + }, + SectionEntry { + icon: IconName::FolderOpen, + title: "Open Project", + action: &Open, + }, + SectionEntry { + icon: IconName::CloudDownload, + title: "Clone Repository", + action: &GitClone, + }, + SectionEntry { + icon: IconName::ListCollapse, + title: "Open Command Palette", + action: &command_palette::Toggle, + }, + ], + }, + Section { + title: "Configure", + entries: [ + SectionEntry { + icon: IconName::Settings, + title: "Open Settings", + action: &OpenSettings, + }, + SectionEntry { + icon: IconName::ZedAssistant, + title: "View AI Settings", + action: &agent::OpenSettings, + }, + SectionEntry { + icon: IconName::Blocks, + title: "Explore Extensions", + action: &Extensions { + category_filter: None, + id: None, + }, + }, + ], + }, +); + +struct Section { + title: &'static str, + entries: [SectionEntry; COLS], +} + +impl Section { + fn render(self, index_offset: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement { + v_flex() + .min_w_full() + .child(SectionHeader::new(self.title)) + .children( + self.entries + .iter() + .enumerate() + .map(|(index, entry)| entry.render(index_offset + index, focus, cx)), + ) + } +} + +pub struct WelcomePage { + workspace: WeakEntity, + focus_handle: FocusHandle, + fallback_to_recent_projects: bool, + recent_workspaces: Option>, +} + +impl WelcomePage { + pub fn new( + workspace: WeakEntity, + fallback_to_recent_projects: bool, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify()) + .detach(); + + if fallback_to_recent_projects { + cx.spawn_in(window, async move |this: WeakEntity, cx| { + let workspaces = WORKSPACE_DB + .recent_workspaces_on_disk() + .await + .log_err() + .unwrap_or_default(); + + this.update(cx, |this, cx| { + this.recent_workspaces = Some(workspaces); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + WelcomePage { + workspace, + focus_handle, + fallback_to_recent_projects, + recent_workspaces: None, + } + } + + fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); + cx.notify(); + } + + fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); + cx.notify(); + } + + fn open_recent_project( + &mut self, + action: &OpenRecentProject, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(recent_workspaces) = &self.recent_workspaces { + if let Some((_workspace_id, location, paths)) = recent_workspaces.get(action.index) { + let paths = paths.clone(); + let location = location.clone(); + let is_local = matches!(location, SerializedWorkspaceLocation::Local); + let workspace = self.workspace.clone(); + + if is_local { + let paths = paths.paths().to_vec(); + cx.spawn_in(window, async move |_, cx| { + let _ = workspace.update_in(cx, |workspace, window, cx| { + workspace + .open_workspace_for_paths(true, paths, window, cx) + .detach(); + }); + }) + .detach(); + } else { + use zed_actions::OpenRecent; + window.dispatch_action(OpenRecent::default().boxed_clone(), cx); + } + } + } + } + + fn render_recent_project_section( + &self, + recent_projects: Vec, + ) -> impl IntoElement { + v_flex() + .w_full() + .child(SectionHeader::new("Recent Projects")) + .children(recent_projects) + } + + fn render_recent_project( + &self, + index: usize, + location: &SerializedWorkspaceLocation, + paths: &PathList, + ) -> impl IntoElement { + let (icon, title) = match location { + SerializedWorkspaceLocation::Local => { + let path = paths.paths().first().map(|p| p.as_path()); + let name = path + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Untitled".to_string()); + (IconName::Folder, name) + } + SerializedWorkspaceLocation::Remote(_) => { + (IconName::Server, "Remote Project".to_string()) + } + }; + + SectionButton::new( + title, + icon, + &OpenRecentProject { index }, + 10, + self.focus_handle.clone(), + ) + } +} + +impl Render for WelcomePage { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (first_section, second_section) = CONTENT; + let first_section_entries = first_section.entries.len(); + let last_index = first_section_entries + second_section.entries.len(); + + let recent_projects = self + .recent_workspaces + .as_ref() + .into_iter() + .flatten() + .take(5) + .enumerate() + .map(|(index, (_, loc, paths))| self.render_recent_project(index, loc, paths)) + .collect::>(); + + let second_section = if self.fallback_to_recent_projects && !recent_projects.is_empty() { + self.render_recent_project_section(recent_projects) + .into_any_element() + } else { + second_section + .render(first_section_entries, &self.focus_handle, cx) + .into_any_element() + }; + + let welcome_label = if self.fallback_to_recent_projects { + "Welcome back to Zed" + } else { + "Welcome to Zed" + }; + + h_flex() + .key_context("Welcome") + .track_focus(&self.focus_handle(cx)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::open_recent_project)) + .size_full() + .justify_center() + .overflow_hidden() + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .relative() + .size_full() + .px_12() + .py_40() + .max_w(px(1100.)) + .child( + v_flex() + .size_full() + .max_w_128() + .mx_auto() + .gap_6() + .overflow_x_hidden() + .child( + h_flex() + .w_full() + .justify_center() + .mb_4() + .gap_4() + .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.))) + .child( + v_flex().child(Headline::new(welcome_label)).child( + Label::new("The editor for what's next") + .size(LabelSize::Small) + .color(Color::Muted) + .italic(), + ), + ), + ) + .child(first_section.render(Default::default(), &self.focus_handle, cx)) + .child(second_section) + .when(!self.fallback_to_recent_projects, |this| { + this.child( + v_flex().gap_1().child(Divider::horizontal()).child( + Button::new("welcome-exit", "Return to Onboarding") + .tab_index(last_index as isize) + .full_width() + .label_size(LabelSize::XSmall) + .on_click(|_, window, cx| { + window.dispatch_action( + OpenOnboarding.boxed_clone(), + cx, + ); + }), + ), + ) + }), + ), + ) + } +} + +impl EventEmitter for WelcomePage {} + +impl Focusable for WelcomePage { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for WelcomePage { + type Event = ItemEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Welcome".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("New Welcome Page Opened") + } + + fn show_toolbar(&self) -> bool { + false + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(crate::item::ItemEvent)) { + f(*event) + } +} + +impl crate::SerializableItem for WelcomePage { + fn serialized_item_kind() -> &'static str { + "WelcomePage" + } + + fn cleanup( + workspace_id: crate::WorkspaceId, + alive_items: Vec, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + crate::delete_unloaded_items( + alive_items, + workspace_id, + "welcome_pages", + &persistence::WELCOME_PAGES, + cx, + ) + } + + fn deserialize( + _project: Entity, + workspace: gpui::WeakEntity, + workspace_id: crate::WorkspaceId, + item_id: crate::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + if persistence::WELCOME_PAGES + .get_welcome_page(item_id, workspace_id) + .ok() + .is_some_and(|is_open| is_open) + { + Task::ready(Ok( + cx.new(|cx| WelcomePage::new(workspace, false, window, cx)) + )) + } else { + Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize"))) + } + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: crate::ItemId, + _closing: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let workspace_id = workspace.database_id()?; + Some(cx.background_spawn(async move { + persistence::WELCOME_PAGES + .save_welcome_page(item_id, workspace_id, true) + .await + })) + } + + fn should_serialize(&self, event: &Self::Event) -> bool { + event == &ItemEvent::UpdateTab + } +} + +mod persistence { + use crate::WorkspaceDb; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; + + pub struct WelcomePagesDb(ThreadSafeConnection); + + impl Domain for WelcomePagesDb { + const NAME: &str = stringify!(WelcomePagesDb); + + const MIGRATIONS: &[&str] = (&[sql!( + CREATE TABLE welcome_pages ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + is_open INTEGER DEFAULT FALSE, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]); + } + + db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]); + + impl WelcomePagesDb { + query! { + pub async fn save_welcome_page( + item_id: crate::ItemId, + workspace_id: crate::WorkspaceId, + is_open: bool + ) -> Result<()> { + INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open) + VALUES (?, ?, ?) + } + } + + query! { + pub fn get_welcome_page( + item_id: crate::ItemId, + workspace_id: crate::WorkspaceId + ) -> Result { + SELECT is_open + FROM welcome_pages + WHERE item_id = ? AND workspace_id = ? + } + } + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d5a1c3a291c8e337695b30c1e6e1f3b3b76a3a62..eb0debc929e9bc17a5d64a7c650165446d687e4e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -9,12 +9,15 @@ pub mod pane_group; mod path_list; mod persistence; pub mod searchable; +mod security_modal; pub mod shared_screen; mod status_bar; pub mod tasks; mod theme_preview; mod toast_layer; mod toolbar; +pub mod utility_pane; +pub mod welcome; mod workspace_settings; pub use crate::notifications::NotificationFrame; @@ -30,6 +33,7 @@ use client::{ }; use collections::{HashMap, HashSet, hash_map}; use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{ Future, FutureExt, StreamExt, channel::{ @@ -74,7 +78,9 @@ use project::{ DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, WorktreeSettings, debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus}, + project_settings::ProjectSettings, toolchain_store::ToolchainStoreEvent, + trusted_worktrees::{TrustedWorktrees, TrustedWorktreesEvent}, }; use remote::{ RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, @@ -83,7 +89,9 @@ use remote::{ use schemars::JsonSchema; use serde::Deserialize; use session::AppSession; -use settings::{CenteredPaddingSettings, Settings, SettingsLocation, update_settings_file}; +use settings::{ + CenteredPaddingSettings, Settings, SettingsLocation, SettingsStore, update_settings_file, +}; use shared_screen::SharedScreen; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, @@ -126,11 +134,19 @@ pub use workspace_settings::{ }; use zed_actions::{Spawn, feedback::FileBugReport}; -use crate::persistence::{ - SerializedAxis, - model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, +use crate::{ + item::ItemBufferKind, + notifications::NotificationId, + utility_pane::{UTILITY_PANE_MIN_WIDTH, utility_slot_for_dock_position}, +}; +use crate::{ + persistence::{ + SerializedAxis, + model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, + }, + security_modal::SecurityModal, + utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState}, }; -use crate::{item::ItemBufferKind, notifications::NotificationId}; pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); @@ -265,6 +281,16 @@ actions!( ToggleRightDock, /// Toggles zoom on the active pane. ToggleZoom, + /// Zooms in on the active pane. + ZoomIn, + /// Zooms out of the active pane. + ZoomOut, + /// If any worktrees are in restricted mode, shows a modal with possible actions. + /// If the modal is shown already, closes it without trusting any worktree. + ToggleWorktreeSecurity, + /// Clears all trusted worktrees, placing them in restricted mode on next open. + /// Requires restart to take effect on already opened projects. + ClearTrustedWorktrees, /// Stops following a collaborator. Unfollow, /// Restores the banner. @@ -569,44 +595,43 @@ pub fn init(app_state: Arc, cx: &mut App) { toast_layer::init(cx); history_manager::init(cx); - cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)); - cx.on_action(|_: &Reload, cx| reload(cx)); - - cx.on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &Open, cx: &mut App| { - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories: true, - multiple: true, - prompt: None, - }, - cx, - ); + cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) + .on_action(|_: &Reload, cx| reload(cx)) + .on_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &Open, cx: &mut App| { + if let Some(app_state) = app_state.upgrade() { + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories: true, + multiple: true, + prompt: None, + }, + cx, + ); + } } - } - }); - cx.on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &OpenFiles, cx: &mut App| { - let directories = cx.can_select_mixed_files_and_dirs(); - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories, - multiple: true, - prompt: None, - }, - cx, - ); + }) + .on_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &OpenFiles, cx: &mut App| { + let directories = cx.can_select_mixed_files_and_dirs(); + if let Some(app_state) = app_state.upgrade() { + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories, + multiple: true, + prompt: None, + }, + cx, + ); + } } - } - }); + }); } type BuildProjectItemFn = @@ -675,6 +700,7 @@ impl ProjectItemRegistry { Ok((project_entry_id, build_workspace_item)) } Err(e) => { + log::warn!("Failed to open a project item: {e:#}"); if e.error_code() == ErrorCode::Internal { if let Some(abs_path) = entry_abs_path.as_deref().filter(|_| is_file) @@ -962,6 +988,7 @@ impl AppState { #[cfg(any(test, feature = "test-support"))] pub fn test(cx: &mut App) -> Arc { + use fs::Fs; use node_runtime::NodeRuntime; use session::Session; use settings::SettingsStore; @@ -972,6 +999,7 @@ impl AppState { } let fs = fs::FakeFs::new(cx.background_executor().clone()); + ::set_global(fs.clone(), cx); let languages = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); let clock = Arc::new(clock::FakeSystemClock::new()); let http_client = http_client::FakeHttpClient::with_404_response(); @@ -1160,6 +1188,7 @@ pub struct Workspace { _observe_current_user: Task>, _schedule_serialize_workspace: Option>, _schedule_serialize_ssh_paths: Option>, + _schedule_serialize_worktree_trust: Task<()>, pane_history_timestamp: Arc, bounds: Bounds, pub centered_layout: bool, @@ -1174,6 +1203,8 @@ pub struct Workspace { scheduled_tasks: Vec>, last_open_dock_positions: Vec, removing: bool, + utility_panes: UtilityPaneState, + next_modal_placement: Option, } impl EventEmitter for Workspace {} @@ -1204,6 +1235,41 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) -> Self { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + cx.subscribe(&trusted_worktrees, |workspace, worktrees_store, e, cx| { + if let TrustedWorktreesEvent::Trusted(..) = e { + // Do not persist auto trusted worktrees + if !ProjectSettings::get_global(cx).session.trust_all_worktrees { + let new_trusted_worktrees = + worktrees_store.update(cx, |worktrees_store, cx| { + worktrees_store.trusted_paths_for_serialization(cx) + }); + let timeout = cx.background_executor().timer(SERIALIZATION_THROTTLE_TIME); + workspace._schedule_serialize_worktree_trust = + cx.background_spawn(async move { + timeout.await; + persistence::DB + .save_trusted_worktrees(new_trusted_worktrees) + .await + .log_err(); + }); + } + } + }) + .detach(); + + cx.observe_global::(|_, cx| { + if ProjectSettings::get_global(cx).session.trust_all_worktrees { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.auto_trust_all(cx); + }) + } + } + }) + .detach(); + } + cx.subscribe_in(&project, window, move |this, _, event, window, cx| { match event { project::Event::RemoteIdChanged(_) => { @@ -1214,11 +1280,25 @@ impl Workspace { this.collaborator_left(*peer_id, window, cx); } - project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => { - this.update_window_title(window, cx); - this.serialize_workspace(window, cx); - // This event could be triggered by `AddFolderToProject` or `RemoveFromProject`. - this.update_history(cx); + project::Event::WorktreeUpdatedEntries(worktree_id, _) => { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.can_trust(*worktree_id, cx); + }); + } + } + + project::Event::WorktreeRemoved(_) => { + this.update_worktree_data(window, cx); + } + + project::Event::WorktreeAdded(worktree_id) => { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.can_trust(*worktree_id, cx); + }); + } + this.update_worktree_data(window, cx); } project::Event::DisconnectedFromHost => { @@ -1315,7 +1395,7 @@ impl Workspace { cx.on_focus_lost(window, |this, window, cx| { let focus_handle = this.focus_handle(cx); - window.focus(&focus_handle); + window.focus(&focus_handle, cx); }) .detach(); @@ -1339,7 +1419,7 @@ impl Workspace { cx.subscribe_in(¢er_pane, window, Self::handle_pane_event) .detach(); - window.focus(¢er_pane.focus_handle(cx)); + window.focus(¢er_pane.focus_handle(cx), cx); cx.emit(Event::PaneAdded(center_pane.clone())); @@ -1430,6 +1510,15 @@ impl Workspace { && let Ok(display_uuid) = display.uuid() { let window_bounds = window.inner_window_bounds(); + let has_paths = !this.root_paths(cx).is_empty(); + if !has_paths { + cx.background_executor() + .spawn(persistence::write_default_window_bounds( + window_bounds, + display_uuid, + )) + .detach_and_log_err(cx); + } if let Some(database_id) = workspace_id { cx.background_executor() .spawn(DB.set_window_open_status( @@ -1438,6 +1527,13 @@ impl Workspace { display_uuid, )) .detach_and_log_err(cx); + } else { + cx.background_executor() + .spawn(persistence::write_default_window_bounds( + window_bounds, + display_uuid, + )) + .detach_and_log_err(cx); } } this.bounds_save_task_queued.take(); @@ -1461,16 +1557,21 @@ impl Workspace { }), ]; - cx.defer_in(window, |this, window, cx| { + cx.defer_in(window, move |this, window, cx| { this.update_window_title(window, cx); this.show_initial_notifications(cx); }); + + let mut center = PaneGroup::new(center_pane.clone()); + center.set_is_center(true); + center.mark_positions(cx); + Workspace { weak_self: weak_handle.clone(), zoomed: None, zoomed_position: None, previous_dock_drag_coordinates: None, - center: PaneGroup::new(center_pane.clone()), + center, panes: vec![center_pane.clone()], panes_by_item: Default::default(), active_pane: center_pane.clone(), @@ -1499,6 +1600,7 @@ impl Workspace { _apply_leader_updates, _schedule_serialize_workspace: None, _schedule_serialize_ssh_paths: None, + _schedule_serialize_worktree_trust: Task::ready(()), leader_updates_tx, _subscriptions: subscriptions, pane_history_timestamp, @@ -1518,6 +1620,8 @@ impl Workspace { scheduled_tasks: Vec::new(), last_open_dock_positions: Vec::new(), removing: false, + utility_panes: UtilityPaneState::default(), + next_modal_placement: None, } } @@ -1526,6 +1630,7 @@ impl Workspace { app_state: Arc, requesting_window: Option>, env: Option>, + init: Option) + Send>>, cx: &mut App, ) -> Task< anyhow::Result<( @@ -1540,6 +1645,7 @@ impl Workspace { app_state.languages.clone(), app_state.fs.clone(), env, + true, cx, ); @@ -1593,8 +1699,22 @@ impl Workspace { let toolchains = DB.toolchains(workspace_id).await?; - for (toolchain, worktree_id, path) in toolchains { + for (toolchain, worktree_path, path) in toolchains { let toolchain_path = PathBuf::from(toolchain.path.clone().to_string()); + let Some(worktree_id) = project_handle.read_with(cx, |this, cx| { + this.find_worktree(&worktree_path, cx) + .and_then(|(worktree, rel_path)| { + if rel_path.is_empty() { + Some(worktree.read(cx).id()) + } else { + None + } + }) + })? + else { + // We did not find a worktree with a given path, but that's whatever. + continue; + }; if !app_state.fs.is_file(toolchain_path.as_path()).await { continue; } @@ -1632,6 +1752,12 @@ impl Workspace { ); workspace.centered_layout = centered_layout; + + // Call init callback to add items before window renders + if let Some(init) = init { + init(&mut workspace, window, cx); + } + workspace }); })?; @@ -1641,20 +1767,18 @@ impl Workspace { let (window_bounds, display) = if let Some(bounds) = window_bounds_override { (Some(WindowBounds::Windowed(bounds)), None) + } else if let Some(workspace) = serialized_workspace.as_ref() + && let Some(display) = workspace.display + && let Some(bounds) = workspace.window_bounds.as_ref() + { + // Reopening an existing workspace - restore its saved bounds + (Some(bounds.0), Some(display)) + } else if let Some((display, bounds)) = persistence::read_default_window_bounds() { + // New or empty workspace - use the last known window bounds + (Some(bounds), Some(display)) } else { - let restorable_bounds = serialized_workspace - .as_ref() - .and_then(|workspace| Some((workspace.display?, workspace.window_bounds?))) - .or_else(|| { - let (display, window_bounds) = DB.last_window().log_err()?; - Some((display?, window_bounds?)) - }); - - if let Some((serialized_display, serialized_status)) = restorable_bounds { - (Some(serialized_status.0), Some(serialized_display)) - } else { - (None, None) - } + // New window - let GPUI's default_bounds() handle cascading + (None, None) }; // Use the serialized workspace to construct the new window @@ -1677,6 +1801,12 @@ impl Workspace { cx, ); workspace.centered_layout = centered_layout; + + // Call init callback to add items before window renders + if let Some(init) = init { + init(&mut workspace, window, cx); + } + workspace }) } @@ -1772,10 +1902,18 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { + let mut found_in_dock = None; for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] { - dock.update(cx, |dock, cx| { - dock.remove_panel(panel, window, cx); - }) + let found = dock.update(cx, |dock, cx| dock.remove_panel(panel, window, cx)); + + if found { + found_in_dock = Some(dock.clone()); + } + } + if let Some(found_in_dock) = found_in_dock { + let position = found_in_dock.read(cx).position(); + let slot = utility_slot_for_dock_position(position); + self.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx); } } @@ -1936,7 +2074,7 @@ impl Workspace { ) -> Task> { let to_load = if let Some(pane) = pane.upgrade() { pane.update(cx, |pane, cx| { - window.focus(&pane.focus_handle(cx)); + window.focus(&pane.focus_handle(cx), cx); loop { // Retrieve the weak item handle from the history. let entry = pane.nav_history_mut().pop(mode, cx)?; @@ -2242,7 +2380,7 @@ impl Workspace { Task::ready(Ok(callback(self, window, cx))) } else { let env = self.project.read(cx).cli_environment(cx); - let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, cx); + let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); cx.spawn_in(window, async move |_vh, cx| { let (workspace, _) = task.await?; workspace.update(cx, callback) @@ -2453,6 +2591,12 @@ impl Workspace { .0 .split(' ') .flat_map(|k| Keystroke::parse(k).log_err()) + .map(|k| { + cx.keyboard_mapper() + .map_key_equivalent(k, true) + .inner() + .clone() + }) .collect(); let _ = self.send_keystrokes_impl(keystrokes, window, cx); } @@ -3049,7 +3193,7 @@ impl Workspace { } } else { let focus_handle = &active_panel.panel_focus_handle(cx); - window.focus(focus_handle); + window.focus(focus_handle, cx); reveal_dock = true; } } @@ -3061,7 +3205,7 @@ impl Workspace { if focus_center { self.active_pane - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))) + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)) } cx.notify(); @@ -3229,7 +3373,7 @@ impl Workspace { if let Some(panel) = panel.as_ref() { if should_focus(&**panel, window, cx) { dock.set_open(true, window, cx); - panel.panel_focus_handle(cx).focus(window); + panel.panel_focus_handle(cx).focus(window, cx); } else { focus_center = true; } @@ -3239,7 +3383,7 @@ impl Workspace { if focus_center { self.active_pane - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))) + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)) } result_panel = panel; @@ -3313,7 +3457,7 @@ impl Workspace { if focus_center { self.active_pane - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))) + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)) } if self.zoomed_position != dock_to_reveal { @@ -3344,7 +3488,7 @@ impl Workspace { .detach(); self.panes.push(pane.clone()); - window.focus(&pane.focus_handle(cx)); + window.focus(&pane.focus_handle(cx), cx); cx.emit(Event::PaneAdded(pane.clone())); pane @@ -3636,14 +3780,33 @@ impl Workspace { project_item: Entity, activate_pane: bool, focus_item: bool, + keep_old_preview: bool, + allow_new_preview: bool, window: &mut Window, cx: &mut Context, ) -> Entity where T: ProjectItem, { + let old_item_id = pane.read(cx).active_item().map(|item| item.item_id()); + if let Some(item) = self.find_project_item(&pane, &project_item, cx) { + if !keep_old_preview + && let Some(old_id) = old_item_id + && old_id != item.item_id() + { + // switching to a different item, so unpreview old active item + pane.update(cx, |pane, _| { + pane.unpreview_item_if_preview(old_id); + }); + } + self.activate_item(&item, activate_pane, focus_item, window, cx); + if !allow_new_preview { + pane.update(cx, |pane, _| { + pane.unpreview_item_if_preview(item.item_id()); + }); + } return item; } @@ -3652,16 +3815,14 @@ impl Workspace { T::for_project_item(self.project().clone(), Some(pane), project_item, window, cx) }) }); - let item_id = item.item_id(); let mut destination_index = None; pane.update(cx, |pane, cx| { - if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation - && let Some(preview_item_id) = pane.preview_item_id() - && preview_item_id != item_id - { - destination_index = pane.close_current_preview_item(window, cx); + if !keep_old_preview && let Some(old_id) = old_item_id { + pane.unpreview_item_if_preview(old_id); + } + if allow_new_preview { + destination_index = pane.replace_preview_item_id(item.item_id(), window, cx); } - pane.set_preview_item_id(Some(item.item_id()), cx) }); self.add_item( @@ -3722,7 +3883,7 @@ impl Workspace { ) { let panes = self.center.panes(); if let Some(pane) = panes.get(action.0).map(|p| (*p).clone()) { - window.focus(&pane.focus_handle(cx)); + window.focus(&pane.focus_handle(cx), cx); } else { self.split_and_clone(self.active_pane.clone(), SplitDirection::Right, window, cx) .detach(); @@ -3749,7 +3910,7 @@ impl Workspace { let new_pane = self.add_pane(window, cx); if self .center - .split(&split_off_pane, &new_pane, direction) + .split(&split_off_pane, &new_pane, direction, cx) .log_err() .is_none() { @@ -3792,7 +3953,7 @@ impl Workspace { if let Some(ix) = panes.iter().position(|pane| **pane == self.active_pane) { let next_ix = (ix + 1) % panes.len(); let next_pane = panes[next_ix].clone(); - window.focus(&next_pane.focus_handle(cx)); + window.focus(&next_pane.focus_handle(cx), cx); } } @@ -3801,7 +3962,7 @@ impl Workspace { if let Some(ix) = panes.iter().position(|pane| **pane == self.active_pane) { let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1); let prev_pane = panes[prev_ix].clone(); - window.focus(&prev_pane.focus_handle(cx)); + window.focus(&prev_pane.focus_handle(cx), cx); } } @@ -3897,7 +4058,7 @@ impl Workspace { Some(ActivateInDirectionTarget::Pane(pane)) => { let pane = pane.read(cx); if let Some(item) = pane.active_item() { - item.item_focus_handle(cx).focus(window); + item.item_focus_handle(cx).focus(window, cx); } else { log::error!( "Could not find a focus target when in switching focus in {direction} direction for a pane", @@ -3909,7 +4070,7 @@ impl Workspace { window.defer(cx, move |window, cx| { let dock = dock.read(cx); if let Some(panel) = dock.active_panel() { - panel.panel_focus_handle(cx).focus(window); + panel.panel_focus_handle(cx).focus(window, cx); } else { log::error!("Could not find a focus target when in switching focus in {direction} direction for a {:?} dock", dock.position()); } @@ -3934,7 +4095,7 @@ impl Workspace { let new_pane = self.add_pane(window, cx); if self .center - .split(&self.active_pane, &new_pane, action.direction) + .split(&self.active_pane, &new_pane, action.direction, cx) .log_err() .is_none() { @@ -3988,7 +4149,7 @@ impl Workspace { pub fn swap_pane_in_direction(&mut self, direction: SplitDirection, cx: &mut Context) { if let Some(to) = self.find_pane_in_direction(direction, cx) { - self.center.swap(&self.active_pane, &to); + self.center.swap(&self.active_pane, &to, cx); cx.notify(); } } @@ -3996,7 +4157,7 @@ impl Workspace { pub fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { if self .center - .move_to_border(&self.active_pane, direction) + .move_to_border(&self.active_pane, direction, cx) .unwrap() { cx.notify(); @@ -4026,13 +4187,13 @@ impl Workspace { } } else { self.center - .resize(&self.active_pane, axis, amount, &self.bounds); + .resize(&self.active_pane, axis, amount, &self.bounds, cx); } cx.notify(); } pub fn reset_pane_sizes(&mut self, cx: &mut Context) { - self.center.reset_pane_sizes(); + self.center.reset_pane_sizes(cx); cx.notify(); } @@ -4078,7 +4239,7 @@ impl Workspace { cx: &mut Context, ) { self.active_pane = pane.clone(); - self.active_item_path_changed(window, cx); + self.active_item_path_changed(true, window, cx); self.last_active_center_pane = Some(pane.downgrade()); } @@ -4101,16 +4262,19 @@ impl Workspace { item: item.boxed_clone(), }); } - pane::Event::Split { - direction, - clone_active_item, - } => { - if *clone_active_item { - self.split_and_clone(pane.clone(), *direction, window, cx) - .detach(); - } else { - self.split_and_move(pane.clone(), *direction, window, cx); - } + pane::Event::Split { direction, mode } => { + match mode { + SplitMode::ClonePane => { + self.split_and_clone(pane.clone(), *direction, window, cx) + .detach(); + } + SplitMode::EmptyPane => { + self.split_pane(pane.clone(), *direction, window, cx); + } + SplitMode::MovePane => { + self.split_and_move(pane.clone(), *direction, window, cx); + } + }; } pane::Event::JoinIntoNext => { self.join_pane_into_next(pane.clone(), window, cx); @@ -4135,7 +4299,7 @@ impl Workspace { } serialize_workspace = *focus_changed || pane != self.active_pane(); if pane == self.active_pane() { - self.active_item_path_changed(window, cx); + self.active_item_path_changed(*focus_changed, window, cx); self.update_active_view_for_followers(window, cx); } else if *local { self.set_active_pane(pane, window, cx); @@ -4151,7 +4315,7 @@ impl Workspace { } pane::Event::ChangeItemTitle => { if *pane == self.active_pane { - self.active_item_path_changed(window, cx); + self.active_item_path_changed(false, window, cx); } serialize_workspace = false; } @@ -4218,7 +4382,7 @@ impl Workspace { ) -> Entity { let new_pane = self.add_pane(window, cx); self.center - .split(&pane_to_split, &new_pane, split_direction) + .split(&pane_to_split, &new_pane, split_direction, cx) .unwrap(); cx.notify(); new_pane @@ -4238,7 +4402,7 @@ impl Workspace { new_pane.update(cx, |pane, cx| { pane.add_item(item, true, true, None, window, cx) }); - self.center.split(&pane, &new_pane, direction).unwrap(); + self.center.split(&pane, &new_pane, direction, cx).unwrap(); cx.notify(); } @@ -4263,7 +4427,7 @@ impl Workspace { new_pane.update(cx, |pane, cx| { pane.add_item(clone, true, true, None, window, cx) }); - this.center.split(&pane, &new_pane, direction).unwrap(); + this.center.split(&pane, &new_pane, direction, cx).unwrap(); cx.notify(); new_pane }) @@ -4310,7 +4474,7 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - if self.center.remove(&pane).unwrap() { + if self.center.remove(&pane, cx).unwrap() { self.force_remove_pane(&pane, &focus_on, window, cx); self.unfollow_in_pane(&pane, window, cx); self.last_leaders_by_pane.remove(&pane.downgrade()); @@ -4320,7 +4484,7 @@ impl Workspace { cx.notify(); } else { - self.active_item_path_changed(window, cx); + self.active_item_path_changed(true, window, cx); } cx.emit(Event::PaneRemoved); } @@ -4529,7 +4693,7 @@ impl Workspace { // if you're already following, find the right pane and focus it. if let Some(follower_state) = self.follower_states.get(&leader_id) { - window.focus(&follower_state.pane().focus_handle(cx)); + window.focus(&follower_state.pane().focus_handle(cx), cx); return; } @@ -4574,14 +4738,19 @@ impl Workspace { self.follower_states.contains_key(&id.into()) } - fn active_item_path_changed(&mut self, window: &mut Window, cx: &mut Context) { + fn active_item_path_changed( + &mut self, + focus_changed: bool, + window: &mut Window, + cx: &mut Context, + ) { cx.emit(Event::ActiveItemChanged); let active_entry = self.active_project_path(cx); self.project.update(cx, |project, cx| { project.set_active_path(active_entry.clone(), cx) }); - if let Some(project_path) = &active_entry { + if focus_changed && let Some(project_path) = &active_entry { let git_store_entity = self.project.read(cx).git_store().clone(); git_store_entity.update(cx, |git_store, cx| { git_store.set_active_repo_for_path(project_path, cx); @@ -5341,12 +5510,12 @@ impl Workspace { ) { self.panes.retain(|p| p != pane); if let Some(focus_on) = focus_on { - focus_on.update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))); + focus_on.update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)); } else if self.active_pane() == pane { self.panes .last() .unwrap() - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))); + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)); } if self.last_active_center_pane == Some(pane.downgrade()) { self.last_active_center_pane = None; @@ -5520,12 +5689,24 @@ impl Workspace { persistence::DB.save_workspace(serialized_workspace).await; }) } - WorkspaceLocation::DetachFromSession => window.spawn(cx, async move |_| { - persistence::DB - .set_session_id(database_id, None) - .await - .log_err(); - }), + WorkspaceLocation::DetachFromSession => { + let window_bounds = SerializedWindowBounds(window.window_bounds()); + let display = window.display(cx).and_then(|d| d.uuid().ok()); + window.spawn(cx, async move |_| { + persistence::DB + .set_window_open_status( + database_id, + window_bounds, + display.unwrap_or_default(), + ) + .await + .log_err(); + persistence::DB + .set_session_id(database_id, None) + .await + .log_err(); + }) + } WorkspaceLocation::None => Task::ready(()), } } @@ -5662,6 +5843,9 @@ impl Workspace { // Swap workspace center group workspace.center = PaneGroup::with_root(center_group); + workspace.center.set_is_center(true); + workspace.center.mark_positions(cx); + if let Some(active_pane) = active_pane { workspace.set_active_pane(&active_pane, window, cx); cx.focus_self(window); @@ -5895,6 +6079,27 @@ impl Workspace { } }, )) + .on_action(cx.listener( + |workspace: &mut Workspace, _: &ToggleWorktreeSecurity, window, cx| { + workspace.show_worktree_trust_security_modal(true, window, cx); + }, + )) + .on_action( + cx.listener(|_: &mut Workspace, _: &ClearTrustedWorktrees, _, cx| { + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + trusted_worktrees.update(cx, |trusted_worktrees, _| { + trusted_worktrees.clear_trusted_paths() + }); + let clear_task = persistence::DB.clear_trusted_worktrees(); + cx.spawn(async move |_, cx| { + if clear_task.await.log_err().is_some() { + cx.update(|cx| reload(cx)).ok(); + } + }) + .detach(); + } + }), + ) .on_action(cx.listener( |workspace: &mut Workspace, _: &ReopenClosedItem, window, cx| { workspace.reopen_closed_item(window, cx).detach(); @@ -6052,6 +6257,11 @@ impl Workspace { .on_action(cx.listener(Workspace::cancel)) } + #[cfg(any(test, feature = "test-support"))] + pub fn set_random_database_id(&mut self) { + self.database_id = Some(WorkspaceId(Uuid::new_v4().as_u64_pair().0 as i64)); + } + #[cfg(any(test, feature = "test-support"))] pub fn test_new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { use node_runtime::NodeRuntime; @@ -6075,7 +6285,7 @@ impl Workspace { let workspace = Self::new(Default::default(), project, app_state, window, cx); workspace .active_pane - .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx))); + .update(cx, |pane, cx| window.focus(&pane.focus_handle(cx), cx)); workspace } @@ -6121,12 +6331,25 @@ impl Workspace { self.modal_layer.read(cx).active_modal() } + pub fn is_modal_open(&self, cx: &App) -> bool { + self.modal_layer.read(cx).active_modal::().is_some() + } + + pub fn set_next_modal_placement(&mut self, placement: ModalPlacement) { + self.next_modal_placement = Some(placement); + } + + fn take_next_modal_placement(&mut self) -> ModalPlacement { + self.next_modal_placement.take().unwrap_or_default() + } + pub fn toggle_modal(&mut self, window: &mut Window, cx: &mut App, build: B) where B: FnOnce(&mut Window, &mut Context) -> V, { + let placement = self.take_next_modal_placement(); self.modal_layer.update(cx, |modal_layer, cx| { - modal_layer.toggle_modal(window, cx, build) + modal_layer.toggle_modal_with_placement(window, cx, placement, build) }) } @@ -6282,6 +6505,7 @@ impl Workspace { left_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); } fn resize_right_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6304,6 +6528,7 @@ impl Workspace { right_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); } fn resize_bottom_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6318,6 +6543,42 @@ impl Workspace { bottom_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); + } + + fn max_utility_pane_width(&self, window: &Window, cx: &App) -> Pixels { + let left_dock_width = self + .left_dock + .read(cx) + .active_panel_size(window, cx) + .unwrap_or(px(0.0)); + let right_dock_width = self + .right_dock + .read(cx) + .active_panel_size(window, cx) + .unwrap_or(px(0.0)); + let center_pane_width = self.bounds.size.width - left_dock_width - right_dock_width; + center_pane_width - px(10.0) + } + + fn clamp_utility_pane_widths(&mut self, window: &mut Window, cx: &mut App) { + let max_width = self.max_utility_pane_width(window, cx); + + // Clamp left slot utility pane if it exists + if let Some(handle) = self.utility_pane(UtilityPaneSlot::Left) { + let current_width = handle.width(cx); + if current_width > max_width { + handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); + } + } + + // Clamp right slot utility pane if it exists + if let Some(handle) = self.utility_pane(UtilityPaneSlot::Right) { + let current_width = handle.width(cx); + if current_width > max_width { + handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); + } + } } fn toggle_edit_predictions_all_files( @@ -6332,6 +6593,48 @@ impl Workspace { file.project.all_languages.defaults.show_edit_predictions = Some(!show_edit_predictions) }); } + + pub fn show_worktree_trust_security_modal( + &mut self, + toggle: bool, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(security_modal) = self.active_modal::(cx) { + if toggle { + security_modal.update(cx, |security_modal, cx| { + security_modal.dismiss(cx); + }) + } else { + security_modal.update(cx, |security_modal, cx| { + security_modal.refresh_restricted_paths(cx); + }); + } + } else { + let has_restricted_worktrees = TrustedWorktrees::try_get_global(cx) + .map(|trusted_worktrees| { + trusted_worktrees + .read(cx) + .has_restricted_worktrees(&self.project().read(cx).worktree_store(), cx) + }) + .unwrap_or(false); + if has_restricted_worktrees { + let project = self.project().read(cx); + let remote_host = project.remote_connection_options(cx); + let worktree_store = project.worktree_store().downgrade(); + self.toggle_modal(window, cx, |_, cx| { + SecurityModal::new(worktree_store, remote_host, cx) + }); + } + } + } + + fn update_worktree_data(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) { + self.update_window_title(window, cx); + self.serialize_workspace(window, cx); + // This event could be triggered by `AddFolderToProject` or `RemoveFromProject`. + self.update_history(cx); + } } fn leader_border_for_pane( @@ -6785,6 +7088,34 @@ impl Render for Workspace { } }, )) + .on_drag_move(cx.listener( + move |workspace, + e: &DragMoveEvent, + window, + cx| { + let slot = e.drag(cx).0; + match slot { + UtilityPaneSlot::Left => { + let left_dock_width = workspace.left_dock.read(cx) + .active_panel_size(window, cx) + .unwrap_or(gpui::px(0.0)); + let new_width = e.event.position.x + - workspace.bounds.left() + - left_dock_width; + workspace.resize_utility_pane(slot, new_width, window, cx); + } + UtilityPaneSlot::Right => { + let right_dock_width = workspace.right_dock.read(cx) + .active_panel_size(window, cx) + .unwrap_or(gpui::px(0.0)); + let new_width = workspace.bounds.right() + - e.event.position.x + - right_dock_width; + workspace.resize_utility_pane(slot, new_width, window, cx); + } + } + }, + )) }) .child({ match bottom_dock_layout { @@ -6804,6 +7135,15 @@ impl Render for Workspace { window, cx, )) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6845,6 +7185,15 @@ impl Render for Workspace { ), ), ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock( DockPosition::Right, &self.right_dock, @@ -6875,6 +7224,15 @@ impl Render for Workspace { .flex_row() .flex_1() .children(self.render_dock(DockPosition::Left, &self.left_dock, window, cx)) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6902,6 +7260,13 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) + .when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) ) .child( div() @@ -6926,6 +7291,15 @@ impl Render for Workspace { window, cx, )) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6964,6 +7338,15 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock(DockPosition::Right, &self.right_dock, window, cx)) ) .child( @@ -6983,6 +7366,13 @@ impl Render for Workspace { window, cx, )) + .when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) .child( div() .flex() @@ -7020,6 +7410,15 @@ impl Render for Workspace { cx, )), ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock( DockPosition::Right, &self.right_dock, @@ -7422,7 +7821,14 @@ pub fn join_channel( // no open workspaces, make one to show the error in (blergh) let (window_handle, _) = cx .update(|cx| { - Workspace::new_local(vec![], app_state.clone(), requesting_window, None, cx) + Workspace::new_local( + vec![], + app_state.clone(), + requesting_window, + None, + None, + cx, + ) })? .await?; @@ -7488,7 +7894,7 @@ pub async fn get_any_active_workspace( // find an existing workspace to focus and show call controls let active_window = activate_any_workspace_window(&mut cx); if active_window.is_none() { - cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, cx))? + cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, cx))? .await?; } activate_any_workspace_window(&mut cx).context("could not open zed") @@ -7655,6 +8061,7 @@ pub fn open_paths( app_state.clone(), open_options.replace_window, open_options.env, + None, cx, ) })? @@ -7699,14 +8106,17 @@ pub fn open_new( cx: &mut App, init: impl FnOnce(&mut Workspace, &mut Window, &mut Context) + 'static + Send, ) -> Task> { - let task = Workspace::new_local(Vec::new(), app_state, None, open_options.env, cx); - cx.spawn(async move |cx| { - let (workspace, opened_paths) = task.await?; - workspace.update(cx, |workspace, window, cx| { - if opened_paths.is_empty() { - init(workspace, window, cx) - } - })?; + let task = Workspace::new_local( + Vec::new(), + app_state, + None, + open_options.env, + Some(Box::new(init)), + cx, + ); + cx.spawn(async move |_cx| { + let (_workspace, _opened_paths) = task.await?; + // Init callback is called synchronously during workspace creation Ok(()) }) } @@ -7759,7 +8169,7 @@ pub fn open_remote_project_with_new_connection( ) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(remote_connection.connection_options(), paths.clone(), cx) + deserialize_remote_project(remote_connection.connection_options(), paths.clone(), cx) .await?; let session = match cx @@ -7786,6 +8196,7 @@ pub fn open_remote_project_with_new_connection( app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), + true, cx, ) })?; @@ -7813,7 +8224,7 @@ pub fn open_remote_project_with_existing_connection( ) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; + deserialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; open_remote_project_inner( project, @@ -7838,9 +8249,22 @@ async fn open_remote_project_inner( cx: &mut AsyncApp, ) -> Result>>> { let toolchains = DB.toolchains(workspace_id).await?; - for (toolchain, worktree_id, path) in toolchains { + for (toolchain, worktree_path, path) in toolchains { project .update(cx, |this, cx| { + let Some(worktree_id) = + this.find_worktree(&worktree_path, cx) + .and_then(|(worktree, rel_path)| { + if rel_path.is_empty() { + Some(worktree.read(cx).id()) + } else { + None + } + }) + else { + return Task::ready(None); + }; + this.activate_toolchain(ProjectPath { worktree_id, path }, toolchain, cx) })? .await; @@ -7915,7 +8339,7 @@ async fn open_remote_project_inner( Ok(items.into_iter().map(|item| item?.ok()).collect()) } -fn serialize_remote_project( +fn deserialize_remote_project( connection_options: RemoteConnectionOptions, paths: Vec, cx: &AsyncApp, @@ -8361,7 +8785,7 @@ fn move_all_items( // This automatically removes duplicate items in the pane to_pane.update(cx, |destination, cx| { destination.add_item(item_handle, true, true, None, window, cx); - window.focus(&destination.focus_handle(cx)) + window.focus(&destination.focus_handle(cx), cx) }); } } @@ -8405,7 +8829,7 @@ pub fn move_item( cx, ); if activate { - window.focus(&destination.focus_handle(cx)) + window.focus(&destination.focus_handle(cx), cx) } }); } @@ -8507,14 +8931,13 @@ pub fn remote_workspace_position_from_db( } else { let restorable_bounds = serialized_workspace .as_ref() - .and_then(|workspace| Some((workspace.display?, workspace.window_bounds?))) - .or_else(|| { - let (display, window_bounds) = DB.last_window().log_err()?; - Some((display?, window_bounds?)) - }); + .and_then(|workspace| { + Some((workspace.display?, workspace.window_bounds.map(|b| b.0)?)) + }) + .or_else(|| persistence::read_default_window_bounds()); - if let Some((serialized_display, serialized_status)) = restorable_bounds { - (Some(serialized_status.0), Some(serialized_display)) + if let Some((serialized_display, serialized_bounds)) = restorable_bounds { + (Some(serialized_bounds), Some(serialized_display)) } else { (None, None) } @@ -9004,7 +9427,7 @@ mod tests { let right_pane = right_pane.await.unwrap(); cx.focus(&right_pane); - let mut close = right_pane.update_in(cx, |pane, window, cx| { + let close = right_pane.update_in(cx, |pane, window, cx| { pane.close_all_items(&CloseAllItems::default(), window, cx) .unwrap() }); @@ -9016,9 +9439,16 @@ mod tests { assert!(!msg.contains("3.txt")); assert!(!msg.contains("4.txt")); + // With best-effort close, cancelling item 1 keeps it open but items 4 + // and (3,4) still close since their entries exist in left pane. cx.simulate_prompt_answer("Cancel"); close.await; + right_pane.read_with(cx, |pane, _| { + assert_eq!(pane.items_len(), 1); + }); + + // Remove item 3 from left pane, making (2,3) the only item with entry 3. left_pane .update_in(cx, |left_pane, window, cx| { left_pane.close_item_by_id( @@ -9031,26 +9461,25 @@ mod tests { .await .unwrap(); - close = right_pane.update_in(cx, |pane, window, cx| { + let close = left_pane.update_in(cx, |pane, window, cx| { pane.close_all_items(&CloseAllItems::default(), window, cx) .unwrap() }); cx.executor().run_until_parked(); let details = cx.pending_prompt().unwrap().1; - assert!(details.contains("1.txt")); - assert!(!details.contains("2.txt")); + assert!(details.contains("0.txt")); assert!(details.contains("3.txt")); - // ideally this assertion could be made, but today we can only - // save whole items not project items, so the orphaned item 3 causes - // 4 to be saved too. - // assert!(!details.contains("4.txt")); + assert!(details.contains("4.txt")); + // Ideally 2.txt wouldn't appear since entry 2 still exists in item 2. + // But we can only save whole items, so saving (2,3) for entry 3 includes 2. + // assert!(!details.contains("2.txt")); cx.simulate_prompt_answer("Save all"); - cx.executor().run_until_parked(); close.await; - right_pane.read_with(cx, |pane, _| { + + left_pane.read_with(cx, |pane, _| { assert_eq!(pane.items_len(), 0); }); } @@ -9417,6 +9846,105 @@ mod tests { }); } + #[gpui::test] + async fn test_pane_zoom_in_out(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + + let pane = workspace.update_in(cx, |workspace, _window, _cx| { + workspace.active_pane().clone() + }); + + // Add an item to the pane so it can be zoomed + workspace.update_in(cx, |workspace, window, cx| { + let item = cx.new(TestItem::new); + workspace.add_item(pane.clone(), Box::new(item), None, true, true, window, cx); + }); + + // Initially not zoomed + workspace.update_in(cx, |workspace, _window, cx| { + assert!(!pane.read(cx).is_zoomed(), "Pane starts unzoomed"); + assert!( + workspace.zoomed.is_none(), + "Workspace should track no zoomed pane" + ); + assert!(pane.read(cx).items_len() > 0, "Pane should have items"); + }); + + // Zoom In + pane.update_in(cx, |pane, window, cx| { + pane.zoom_in(&crate::ZoomIn, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!( + pane.read(cx).is_zoomed(), + "Pane should be zoomed after ZoomIn" + ); + assert!( + workspace.zoomed.is_some(), + "Workspace should track the zoomed pane" + ); + assert!( + pane.read(cx).focus_handle(cx).contains_focused(window, cx), + "ZoomIn should focus the pane" + ); + }); + + // Zoom In again is a no-op + pane.update_in(cx, |pane, window, cx| { + pane.zoom_in(&crate::ZoomIn, window, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + assert!(pane.read(cx).is_zoomed(), "Second ZoomIn keeps pane zoomed"); + assert!( + workspace.zoomed.is_some(), + "Workspace still tracks zoomed pane" + ); + assert!( + pane.read(cx).focus_handle(cx).contains_focused(window, cx), + "Pane remains focused after repeated ZoomIn" + ); + }); + + // Zoom Out + pane.update_in(cx, |pane, window, cx| { + pane.zoom_out(&crate::ZoomOut, window, cx); + }); + + workspace.update_in(cx, |workspace, _window, cx| { + assert!( + !pane.read(cx).is_zoomed(), + "Pane should unzoom after ZoomOut" + ); + assert!( + workspace.zoomed.is_none(), + "Workspace clears zoom tracking after ZoomOut" + ); + }); + + // Zoom Out again is a no-op + pane.update_in(cx, |pane, window, cx| { + pane.zoom_out(&crate::ZoomOut, window, cx); + }); + + workspace.update_in(cx, |workspace, _window, cx| { + assert!( + !pane.read(cx).is_zoomed(), + "Second ZoomOut keeps pane unzoomed" + ); + assert!( + workspace.zoomed.is_none(), + "Workspace remains without zoomed pane" + ); + }); + } + #[gpui::test] async fn test_toggle_all_docks(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index 6d132fbd2cb8c7a1282bffcea6577260a15c4572..e7d3ac34e1886bd76e0a0f5d23ea981b6626909a 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -25,8 +25,10 @@ test-support = [ [dependencies] anyhow.workspace = true async-lock.workspace = true +chardetng.workspace = true clock.workspace = true collections.workspace = true +encoding_rs.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true diff --git a/crates/worktree/src/ignore.rs b/crates/worktree/src/ignore.rs index 17c362e2d7f78384fe3b9b444353d302c4dac4c5..87487c36df6dc4eca3da43eaab95f83847ba5d1f 100644 --- a/crates/worktree/src/ignore.rs +++ b/crates/worktree/src/ignore.rs @@ -13,6 +13,10 @@ pub enum IgnoreStackEntry { Global { ignore: Arc, }, + RepoExclude { + ignore: Arc, + parent: Arc, + }, Some { abs_base_path: Arc, ignore: Arc, @@ -21,6 +25,12 @@ pub enum IgnoreStackEntry { All, } +#[derive(Debug)] +pub enum IgnoreKind { + Gitignore(Arc), + RepoExclude, +} + impl IgnoreStack { pub fn none() -> Self { Self { @@ -43,13 +53,19 @@ impl IgnoreStack { } } - pub fn append(self, abs_base_path: Arc, ignore: Arc) -> Self { + pub fn append(self, kind: IgnoreKind, ignore: Arc) -> Self { let top = match self.top.as_ref() { IgnoreStackEntry::All => self.top.clone(), - _ => Arc::new(IgnoreStackEntry::Some { - abs_base_path, - ignore, - parent: self.top.clone(), + _ => Arc::new(match kind { + IgnoreKind::Gitignore(abs_base_path) => IgnoreStackEntry::Some { + abs_base_path, + ignore, + parent: self.top.clone(), + }, + IgnoreKind::RepoExclude => IgnoreStackEntry::RepoExclude { + ignore, + parent: self.top.clone(), + }, }), }; Self { @@ -84,6 +100,17 @@ impl IgnoreStack { ignore::Match::Whitelist(_) => false, } } + IgnoreStackEntry::RepoExclude { ignore, parent } => { + match ignore.matched(abs_path, is_dir) { + ignore::Match::None => IgnoreStack { + repo_root: self.repo_root.clone(), + top: parent.clone(), + } + .is_abs_path_ignored(abs_path, is_dir), + ignore::Match::Ignore(_) => true, + ignore::Match::Whitelist(_) => false, + } + } IgnoreStackEntry::Some { abs_base_path, ignore, diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 152277af2e3f626aa0da608af275505b04d0af32..ca5500249e95f0b9d3fb25f75393ab5c9ca5be9b 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -5,8 +5,10 @@ mod worktree_tests; use ::ignore::gitignore::{Gitignore, GitignoreBuilder}; use anyhow::{Context as _, Result, anyhow}; +use chardetng::EncodingDetector; use clock::ReplicaId; use collections::{HashMap, HashSet, VecDeque}; +use encoding_rs::Encoding; use fs::{Fs, MTime, PathEvent, RemoveOptions, Watcher, copy_recursive, read_dir_items}; use futures::{ FutureExt as _, Stream, StreamExt, @@ -14,15 +16,17 @@ use futures::{ mpsc::{self, UnboundedSender}, oneshot, }, - select_biased, + select_biased, stream, task::Poll, }; use fuzzy::CharBag; use git::{ - COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, status::GitSummary, + COMMIT_MESSAGE, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, INDEX_LOCK, LFS_DIR, REPO_EXCLUDE, + status::GitSummary, }; use gpui::{ - App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Task, + App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Priority, + Task, }; use ignore::IgnoreStack; use language::DiskState; @@ -52,7 +56,7 @@ use std::{ fmt, future::Future, mem::{self}, - ops::{Deref, DerefMut}, + ops::{Deref, DerefMut, Range}, path::{Path, PathBuf}, pin::Pin, sync::{ @@ -70,6 +74,8 @@ use util::{ }; pub use worktree_settings::WorktreeSettings; +use crate::ignore::IgnoreKind; + pub const FS_WATCH_LATENCY: Duration = Duration::from_millis(100); /// A set of local or remote files that are being opened as part of a project. @@ -97,9 +103,12 @@ pub enum CreatedEntry { Excluded { abs_path: PathBuf }, } +#[derive(Debug)] pub struct LoadedFile { pub file: Arc, pub text: String, + pub encoding: &'static Encoding, + pub has_bom: bool, } pub struct LoadedBinaryFile { @@ -129,6 +138,7 @@ pub struct LocalWorktree { next_entry_id: Arc, settings: WorktreeSettings, share_private_files: bool, + scanning_enabled: bool, } pub struct PathPrefixScanRequest { @@ -230,6 +240,9 @@ impl Default for WorkDirectory { pub struct LocalSnapshot { snapshot: Snapshot, global_gitignore: Option>, + /// Exclude files for all git repositories in the worktree, indexed by their absolute path. + /// The boolean indicates whether the gitignore needs to be updated. + repo_exclude_by_work_dir_abs_path: HashMap, (Arc, bool)>, /// All of the gitignore files in the worktree, indexed by their absolute path. /// The boolean indicates whether the gitignore needs to be updated. ignores_by_parent_abs_path: HashMap, (Arc, bool)>, @@ -356,6 +369,7 @@ impl Worktree { visible: bool, fs: Arc, next_entry_id: Arc, + scanning_enabled: bool, cx: &mut AsyncApp, ) -> Result> { let abs_path = path.into(); @@ -389,6 +403,7 @@ impl Worktree { let mut snapshot = LocalSnapshot { ignores_by_parent_abs_path: Default::default(), global_gitignore: Default::default(), + repo_exclude_by_work_dir_abs_path: Default::default(), git_repositories: Default::default(), snapshot: Snapshot::new( cx.entity_id().as_u64(), @@ -428,7 +443,7 @@ impl Worktree { let mut entry = Entry::new( RelPath::empty().into(), &metadata, - &next_entry_id, + ProjectEntryId::new(&next_entry_id), snapshot.root_char_bag, None, ); @@ -459,6 +474,7 @@ impl Worktree { fs_case_sensitive, visible, settings, + scanning_enabled, }; worktree.start_background_scanner(scan_requests_rx, path_prefixes_to_scan_rx, cx); Worktree::Local(worktree) @@ -729,10 +745,14 @@ impl Worktree { path: Arc, text: Rope, line_ending: LineEnding, + encoding: &'static Encoding, + has_bom: bool, cx: &Context, ) -> Task>> { match self { - Worktree::Local(this) => this.write_file(path, text, line_ending, cx), + Worktree::Local(this) => { + this.write_file(path, text, line_ending, encoding, has_bom, cx) + } Worktree::Remote(_) => { Task::ready(Err(anyhow!("remote worktree can't yet write files"))) } @@ -1049,13 +1069,18 @@ impl LocalWorktree { let share_private_files = self.share_private_files; let next_entry_id = self.next_entry_id.clone(); let fs = self.fs.clone(); + let scanning_enabled = self.scanning_enabled; let settings = self.settings.clone(); let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); let background_scanner = cx.background_spawn({ let abs_path = snapshot.abs_path.as_path().to_path_buf(); let background = cx.background_executor().clone(); async move { - let (events, watcher) = fs.watch(&abs_path, FS_WATCH_LATENCY).await; + let (events, watcher) = if scanning_enabled { + fs.watch(&abs_path, FS_WATCH_LATENCY).await + } else { + (Box::pin(stream::pending()) as _, Arc::new(NullWatcher) as _) + }; let fs_case_sensitive = fs.is_case_sensitive().await.unwrap_or_else(|e| { log::error!("Failed to determine whether filesystem is case sensitive: {e:#}"); true @@ -1080,6 +1105,7 @@ impl LocalWorktree { }), phase: BackgroundScannerPhase::InitialScan, share_private_files, + scanning_enabled, settings, watcher, }; @@ -1333,7 +1359,9 @@ impl LocalWorktree { anyhow::bail!("File is too large to load"); } } - let text = fs.load(&abs_path).await?; + + let content = fs.load_bytes(&abs_path).await?; + let (text, encoding, has_bom) = decode_byte(content)?; let worktree = this.upgrade().context("worktree was dropped")?; let file = match entry.await? { @@ -1361,7 +1389,12 @@ impl LocalWorktree { } }; - Ok(LoadedFile { file, text }) + Ok(LoadedFile { + file, + text, + encoding, + has_bom, + }) }) } @@ -1444,6 +1477,8 @@ impl LocalWorktree { path: Arc, text: Rope, line_ending: LineEnding, + encoding: &'static Encoding, + has_bom: bool, cx: &Context, ) -> Task>> { let fs = self.fs.clone(); @@ -1453,7 +1488,68 @@ impl LocalWorktree { let write = cx.background_spawn({ let fs = fs.clone(); let abs_path = abs_path.clone(); - async move { fs.save(&abs_path, &text, line_ending).await } + async move { + // For UTF-8, use the optimized `fs.save` which writes Rope chunks directly to disk + // without allocating a contiguous string. + if encoding == encoding_rs::UTF_8 && !has_bom { + return fs.save(&abs_path, &text, line_ending).await; + } + + // For legacy encodings (e.g. Shift-JIS), we fall back to converting the entire Rope + // to a String/Bytes in memory before writing. + // + // Note: This is inefficient for very large files compared to the streaming approach above, + // but supporting streaming writes for arbitrary encodings would require a significant + // refactor of the `fs` crate to expose a Writer interface. + let text_string = text.to_string(); + let normalized_text = match line_ending { + LineEnding::Unix => text_string, + LineEnding::Windows => text_string.replace('\n', "\r\n"), + }; + + // Create the byte vector manually for UTF-16 encodings because encoding_rs encodes to UTF-8 by default (per WHATWG standards), + // which is not what we want for saving files. + let bytes = if encoding == encoding_rs::UTF_16BE { + let mut data = Vec::with_capacity(normalized_text.len() * 2 + 2); + if has_bom { + data.extend_from_slice(&[0xFE, 0xFF]); // BOM + } + let utf16be_bytes = + normalized_text.encode_utf16().flat_map(|u| u.to_be_bytes()); + data.extend(utf16be_bytes); + data.into() + } else if encoding == encoding_rs::UTF_16LE { + let mut data = Vec::with_capacity(normalized_text.len() * 2 + 2); + if has_bom { + data.extend_from_slice(&[0xFF, 0xFE]); // BOM + } + let utf16le_bytes = + normalized_text.encode_utf16().flat_map(|u| u.to_le_bytes()); + data.extend(utf16le_bytes); + data.into() + } else { + // For other encodings (Shift-JIS, UTF-8 with BOM, etc.), delegate to encoding_rs. + let bom_bytes = if has_bom { + if encoding == encoding_rs::UTF_8 { + vec![0xEF, 0xBB, 0xBF] + } else { + vec![] + } + } else { + vec![] + }; + let (cow, _, _) = encoding.encode(&normalized_text); + if !bom_bytes.is_empty() { + let mut bytes = bom_bytes; + bytes.extend_from_slice(&cow); + bytes.into() + } else { + cow + } + }; + + fs.write(&abs_path, &bytes).await + } }); cx.spawn(async move |this, cx| { @@ -2554,13 +2650,21 @@ impl LocalSnapshot { } else { IgnoreStack::none() }; + + if let Some((repo_exclude, _)) = repo_root + .as_ref() + .and_then(|abs_path| self.repo_exclude_by_work_dir_abs_path.get(abs_path)) + { + ignore_stack = ignore_stack.append(IgnoreKind::RepoExclude, repo_exclude.clone()); + } ignore_stack.repo_root = repo_root; for (parent_abs_path, ignore) in new_ignores.into_iter().rev() { if ignore_stack.is_abs_path_ignored(parent_abs_path, true) { ignore_stack = IgnoreStack::all(); break; } else if let Some(ignore) = ignore { - ignore_stack = ignore_stack.append(parent_abs_path.into(), ignore); + ignore_stack = + ignore_stack.append(IgnoreKind::Gitignore(parent_abs_path.into()), ignore); } } @@ -2736,13 +2840,30 @@ impl BackgroundScannerState { } } - async fn insert_entry( + fn entry_id_for( &mut self, - mut entry: Entry, - fs: &dyn Fs, - watcher: &dyn Watcher, - ) -> Entry { - self.reuse_entry_id(&mut entry); + next_entry_id: &AtomicUsize, + path: &RelPath, + metadata: &fs::Metadata, + ) -> ProjectEntryId { + // If an entry with the same inode was removed from the worktree during this scan, + // then it *might* represent the same file or directory. But the OS might also have + // re-used the inode for a completely different file or directory. + // + // Conditionally reuse the old entry's id: + // * if the mtime is the same, the file was probably been renamed. + // * if the path is the same, the file may just have been updated + if let Some(removed_entry) = self.removed_entries.remove(&metadata.inode) { + if removed_entry.mtime == Some(metadata.mtime) || *removed_entry.path == *path { + return removed_entry.id; + } + } else if let Some(existing_entry) = self.snapshot.entry_for_path(path) { + return existing_entry.id; + } + ProjectEntryId::new(next_entry_id) + } + + async fn insert_entry(&mut self, entry: Entry, fs: &dyn Fs, watcher: &dyn Watcher) -> Entry { let entry = self.snapshot.insert_entry(entry, fs); if entry.path.file_name() == Some(&DOT_GIT) { self.insert_git_repository(entry.path.clone(), fs, watcher) @@ -3114,7 +3235,8 @@ impl language::File for File { entry_id: self.entry_id.map(|id| id.to_proto()), path: self.path.as_ref().to_proto(), mtime: self.disk_state.mtime().map(|time| time.into()), - is_deleted: self.disk_state == DiskState::Deleted, + is_deleted: self.disk_state.is_deleted(), + is_historic: matches!(self.disk_state, DiskState::Historic { .. }), } } @@ -3175,7 +3297,11 @@ impl File { "worktree id does not match file" ); - let disk_state = if proto.is_deleted { + let disk_state = if proto.is_historic { + DiskState::Historic { + was_deleted: proto.is_deleted, + } + } else if proto.is_deleted { DiskState::Deleted } else if let Some(mtime) = proto.mtime.map(&Into::into) { DiskState::Present { mtime } @@ -3389,13 +3515,13 @@ impl Entry { fn new( path: Arc, metadata: &fs::Metadata, - next_entry_id: &AtomicUsize, + id: ProjectEntryId, root_char_bag: CharBag, canonical_path: Option>, ) -> Self { let char_bag = char_bag_for_path(root_char_bag, &path); Self { - id: ProjectEntryId::new(next_entry_id), + id, kind: if metadata.is_dir { EntryKind::PendingDir } else { @@ -3600,6 +3726,7 @@ struct BackgroundScanner { watcher: Arc, settings: WorktreeSettings, share_private_files: bool, + scanning_enabled: bool, } #[derive(Copy, Clone, PartialEq)] @@ -3615,14 +3742,33 @@ impl BackgroundScanner { // the git repository in an ancestor directory. Find any gitignore files // in ancestor directories. let root_abs_path = self.state.lock().await.snapshot.abs_path.clone(); - let (ignores, repo) = discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await; - self.state - .lock() - .await - .snapshot - .ignores_by_parent_abs_path - .extend(ignores); - let containing_git_repository = if let Some((ancestor_dot_git, work_directory)) = repo { + + let repo = if self.scanning_enabled { + let (ignores, exclude, repo) = + discover_ancestor_git_repo(self.fs.clone(), &root_abs_path).await; + self.state + .lock() + .await + .snapshot + .ignores_by_parent_abs_path + .extend(ignores); + if let Some(exclude) = exclude { + self.state + .lock() + .await + .snapshot + .repo_exclude_by_work_dir_abs_path + .insert(root_abs_path.as_path().into(), (exclude, false)); + } + + repo + } else { + None + }; + + let containing_git_repository = if let Some((ancestor_dot_git, work_directory)) = repo + && self.scanning_enabled + { maybe!(async { self.state .lock() @@ -3646,6 +3792,7 @@ impl BackgroundScanner { let mut global_gitignore_events = if let Some(global_gitignore_path) = &paths::global_gitignore_path() + && self.scanning_enabled { let is_file = self.fs.is_file(&global_gitignore_path).await; self.state.lock().await.snapshot.global_gitignore = if is_file { @@ -3682,11 +3829,13 @@ impl BackgroundScanner { .await; if ignore_stack.is_abs_path_ignored(root_abs_path.as_path(), true) { root_entry.is_ignored = true; + let mut root_entry = root_entry.clone(); + state.reuse_entry_id(&mut root_entry); state - .insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref()) + .insert_entry(root_entry, self.fs.as_ref(), self.watcher.as_ref()) .await; } - if root_entry.is_dir() { + if root_entry.is_dir() && self.scanning_enabled { state .enqueue_scan_dir( root_abs_path.as_path().into(), @@ -3795,7 +3944,7 @@ impl BackgroundScanner { let root_canonical_path = match &root_canonical_path { Ok(path) => SanitizedPath::new(path), Err(err) => { - log::error!("failed to canonicalize root path {root_path:?}: {err}"); + log::error!("failed to canonicalize root path {root_path:?}: {err:#}"); return true; } }; @@ -3873,33 +4022,40 @@ impl BackgroundScanner { let mut relative_paths = Vec::with_capacity(abs_paths.len()); let mut dot_git_abs_paths = Vec::new(); + let mut work_dirs_needing_exclude_update = Vec::new(); abs_paths.sort_unstable(); abs_paths.dedup_by(|a, b| a.starts_with(b)); { let snapshot = &self.state.lock().await.snapshot; - abs_paths.retain(|abs_path| { - let abs_path = &SanitizedPath::new(abs_path); + let mut ranges_to_drop = SmallVec::<[Range; 4]>::new(); - { - let mut is_git_related = false; + fn skip_ix(ranges: &mut SmallVec<[Range; 4]>, ix: usize) { + if let Some(last_range) = ranges.last_mut() + && last_range.end == ix + { + last_range.end += 1; + } else { + ranges.push(ix..ix + 1); + } + } + + for (ix, abs_path) in abs_paths.iter().enumerate() { + let abs_path = &SanitizedPath::new(&abs_path); - let dot_git_paths = self.executor.block(maybe!(async { - let mut path = None; - for ancestor in abs_path.as_path().ancestors() { + let mut is_git_related = false; + let mut dot_git_paths = None; + for ancestor in abs_path.as_path().ancestors() { if is_git_dir(ancestor, self.fs.as_ref()).await { let path_in_git_dir = abs_path .as_path() .strip_prefix(ancestor) .expect("stripping off the ancestor"); - path = Some((ancestor.to_owned(), path_in_git_dir.to_owned())); - break; - } + dot_git_paths = Some((ancestor.to_owned(), path_in_git_dir.to_owned())); + break; } - path - - })); + } if let Some((dot_git_abs_path, path_in_git_dir)) = dot_git_paths { if skipped_files_in_dot_git @@ -3909,8 +4065,11 @@ impl BackgroundScanner { path_in_git_dir.starts_with(skipped_git_subdir) }) { - log::debug!("ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories"); - return false; + log::debug!( + "ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories" + ); + skip_ix(&mut ranges_to_drop, ix); + continue; } is_git_related = true; @@ -3919,8 +4078,7 @@ impl BackgroundScanner { } } - let relative_path = if let Ok(path) = - abs_path.strip_prefix(&root_canonical_path) + let relative_path = if let Ok(path) = abs_path.strip_prefix(&root_canonical_path) && let Ok(path) = RelPath::new(path, PathStyle::local()) { path @@ -3931,12 +4089,25 @@ impl BackgroundScanner { ); } else { log::error!( - "ignoring event {abs_path:?} outside of root path {root_canonical_path:?}", + "ignoring event {abs_path:?} outside of root path {root_canonical_path:?}", ); } - return false; + skip_ix(&mut ranges_to_drop, ix); + continue; }; + let absolute_path = abs_path.to_path_buf(); + if absolute_path.ends_with(Path::new(DOT_GIT).join(REPO_EXCLUDE)) { + if let Some(repository) = snapshot + .git_repositories + .values() + .find(|repo| repo.common_dir_abs_path.join(REPO_EXCLUDE) == absolute_path) + { + work_dirs_needing_exclude_update + .push(repository.work_directory_abs_path.clone()); + } + } + if abs_path.file_name() == Some(OsStr::new(GITIGNORE)) { for (_, repo) in snapshot .git_repositories @@ -3958,25 +4129,43 @@ impl BackgroundScanner { }); if !parent_dir_is_loaded { log::debug!("ignoring event {relative_path:?} within unloaded directory"); - return false; + skip_ix(&mut ranges_to_drop, ix); + continue; } if self.settings.is_path_excluded(&relative_path) { if !is_git_related { log::debug!("ignoring FS event for excluded path {relative_path:?}"); } - return false; + skip_ix(&mut ranges_to_drop, ix); + continue; } relative_paths.push(relative_path.into_arc()); - true } - }); + + for range_to_drop in ranges_to_drop.into_iter().rev() { + abs_paths.drain(range_to_drop); + } } + if relative_paths.is_empty() && dot_git_abs_paths.is_empty() { return; } + if !work_dirs_needing_exclude_update.is_empty() { + let mut state = self.state.lock().await; + for work_dir_abs_path in work_dirs_needing_exclude_update { + if let Some((_, needs_update)) = state + .snapshot + .repo_exclude_by_work_dir_abs_path + .get_mut(&work_dir_abs_path) + { + *needs_update = true; + } + } + } + self.state.lock().await.snapshot.scan_id += 1; let (scan_job_tx, scan_job_rx) = channel::unbounded(); @@ -4090,7 +4279,7 @@ impl BackgroundScanner { let progress_update_count = AtomicUsize::new(0); self.executor - .scoped(|scope| { + .scoped_priority(Priority::Low, |scope| { for _ in 0..self.executor.num_cpus() { scope.spawn(async { let mut last_progress_update_count = 0; @@ -4244,7 +4433,8 @@ impl BackgroundScanner { match build_gitignore(&child_abs_path, self.fs.as_ref()).await { Ok(ignore) => { let ignore = Arc::new(ignore); - ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone()); + ignore_stack = ignore_stack + .append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone()); new_ignore = Some(ignore); } Err(error) => { @@ -4275,7 +4465,7 @@ impl BackgroundScanner { let mut child_entry = Entry::new( child_path.clone(), &child_metadata, - &next_entry_id, + ProjectEntryId::new(&next_entry_id), root_char_bag, None, ); @@ -4462,10 +4652,11 @@ impl BackgroundScanner { .ignore_stack_for_abs_path(&abs_path, metadata.is_dir, self.fs.as_ref()) .await; let is_external = !canonical_path.starts_with(&root_canonical_path); + let entry_id = state.entry_id_for(self.next_entry_id.as_ref(), path, &metadata); let mut fs_entry = Entry::new( path.clone(), &metadata, - self.next_entry_id.as_ref(), + entry_id, state.snapshot.root_char_bag, if metadata.is_symlink { Some(canonical_path.as_path().to_path_buf().into()) @@ -4505,11 +4696,24 @@ impl BackgroundScanner { .await; if path.is_empty() - && let Some((ignores, repo)) = new_ancestor_repo.take() + && let Some((ignores, exclude, repo)) = new_ancestor_repo.take() { log::trace!("updating ancestor git repository"); state.snapshot.ignores_by_parent_abs_path.extend(ignores); if let Some((ancestor_dot_git, work_directory)) = repo { + if let Some(exclude) = exclude { + let work_directory_abs_path = self + .state + .lock() + .await + .snapshot + .work_directory_abs_path(&work_directory); + + state + .snapshot + .repo_exclude_by_work_dir_abs_path + .insert(work_directory_abs_path.into(), (exclude, false)); + } state .insert_git_repository_for_path( work_directory, @@ -4607,6 +4811,36 @@ impl BackgroundScanner { { let snapshot = &mut self.state.lock().await.snapshot; let abs_path = snapshot.abs_path.clone(); + + snapshot.repo_exclude_by_work_dir_abs_path.retain( + |work_dir_abs_path, (exclude, needs_update)| { + if *needs_update { + *needs_update = false; + ignores_to_update.push(work_dir_abs_path.clone()); + + if let Some((_, repository)) = snapshot + .git_repositories + .iter() + .find(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path) + { + let exclude_abs_path = + repository.common_dir_abs_path.join(REPO_EXCLUDE); + if let Ok(current_exclude) = self + .executor + .block(build_gitignore(&exclude_abs_path, self.fs.as_ref())) + { + *exclude = Arc::new(current_exclude); + } + } + } + + snapshot + .git_repositories + .iter() + .any(|(_, repo)| &repo.work_directory_abs_path == work_dir_abs_path) + }, + ); + snapshot .ignores_by_parent_abs_path .retain(|parent_abs_path, (_, needs_update)| { @@ -4661,7 +4895,8 @@ impl BackgroundScanner { let mut ignore_stack = job.ignore_stack; if let Some((ignore, _)) = snapshot.ignores_by_parent_abs_path.get(&job.abs_path) { - ignore_stack = ignore_stack.append(job.abs_path.clone(), ignore.clone()); + ignore_stack = + ignore_stack.append(IgnoreKind::Gitignore(job.abs_path.clone()), ignore.clone()); } let mut entries_by_id_edits = Vec::new(); @@ -4836,6 +5071,9 @@ impl BackgroundScanner { let preserve = ids_to_preserve.contains(work_directory_id); if !preserve { affected_repo_roots.push(entry.dot_git_abs_path.parent().unwrap().into()); + snapshot + .repo_exclude_by_work_dir_abs_path + .remove(&entry.work_directory_abs_path); } preserve }); @@ -4875,8 +5113,10 @@ async fn discover_ancestor_git_repo( root_abs_path: &SanitizedPath, ) -> ( HashMap, (Arc, bool)>, + Option>, Option<(PathBuf, WorkDirectory)>, ) { + let mut exclude = None; let mut ignores = HashMap::default(); for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() { if index != 0 { @@ -4912,6 +5152,7 @@ async fn discover_ancestor_git_repo( // also mark where in the git repo the root folder is located. return ( ignores, + exclude, Some(( ancestor_dot_git, WorkDirectory::AboveProject { @@ -4923,12 +5164,17 @@ async fn discover_ancestor_git_repo( }; } + let repo_exclude_abs_path = ancestor_dot_git.join(REPO_EXCLUDE); + if let Ok(repo_exclude) = build_gitignore(&repo_exclude_abs_path, fs.as_ref()).await { + exclude = Some(Arc::new(repo_exclude)); + } + // Reached root of git repository. break; } } - (ignores, None) + (ignores, exclude, None) } fn build_diff( @@ -5607,3 +5853,121 @@ async fn discover_git_paths(dot_git_abs_path: &Arc, fs: &dyn Fs) -> (Arc

Result<()> { + Ok(()) + } + + fn remove(&self, _path: &Path) -> Result<()> { + Ok(()) + } +} + +fn decode_byte(bytes: Vec) -> anyhow::Result<(String, &'static Encoding, bool)> { + // check BOM + if let Some((encoding, _bom_len)) = Encoding::for_bom(&bytes) { + let (cow, _) = encoding.decode_with_bom_removal(&bytes); + return Ok((cow.into_owned(), encoding, true)); + } + + match analyze_byte_content(&bytes) { + ByteContent::Utf16Le => { + let encoding = encoding_rs::UTF_16LE; + let (cow, _, _) = encoding.decode(&bytes); + return Ok((cow.into_owned(), encoding, false)); + } + ByteContent::Utf16Be => { + let encoding = encoding_rs::UTF_16BE; + let (cow, _, _) = encoding.decode(&bytes); + return Ok((cow.into_owned(), encoding, false)); + } + ByteContent::Binary => { + anyhow::bail!("Binary files are not supported"); + } + ByteContent::Unknown => {} + } + + fn detect_encoding(bytes: Vec) -> (String, &'static Encoding) { + let mut detector = EncodingDetector::new(); + detector.feed(&bytes, true); + + let encoding = detector.guess(None, true); // Use None for TLD hint to ensure neutral detection logic. + + let (cow, _, _) = encoding.decode(&bytes); + (cow.into_owned(), encoding) + } + + match String::from_utf8(bytes) { + Ok(text) => { + // ISO-2022-JP (and other ISO-2022 variants) consists entirely of 7-bit ASCII bytes, + // so it is valid UTF-8. However, it contains escape sequences starting with '\x1b'. + // If we find an escape character, we double-check the encoding to prevent + // displaying raw escape sequences instead of the correct characters. + if text.contains('\x1b') { + let (s, enc) = detect_encoding(text.into_bytes()); + Ok((s, enc, false)) + } else { + Ok((text, encoding_rs::UTF_8, false)) + } + } + Err(e) => { + let (s, enc) = detect_encoding(e.into_bytes()); + Ok((s, enc, false)) + } + } +} + +#[derive(PartialEq)] +enum ByteContent { + Utf16Le, + Utf16Be, + Binary, + Unknown, +} +// Heuristic check using null byte distribution. +// NOTE: This relies on the presence of ASCII characters (which become `0x00` in UTF-16). +// Files consisting purely of non-ASCII characters (like Japanese) may not be detected here +// and will result in `Unknown`. +fn analyze_byte_content(bytes: &[u8]) -> ByteContent { + if bytes.len() < 2 { + return ByteContent::Unknown; + } + + let check_len = bytes.len().min(1024); + let sample = &bytes[..check_len]; + + if !sample.contains(&0) { + return ByteContent::Unknown; + } + + let mut even_nulls = 0; + let mut odd_nulls = 0; + + for (i, &byte) in sample.iter().enumerate() { + if byte == 0 { + if i % 2 == 0 { + even_nulls += 1; + } else { + odd_nulls += 1; + } + } + } + + let total_nulls = even_nulls + odd_nulls; + if total_nulls < check_len / 10 { + return ByteContent::Unknown; + } + + if even_nulls > odd_nulls * 4 { + return ByteContent::Utf16Be; + } + + if odd_nulls > even_nulls * 4 { + return ByteContent::Utf16Le; + } + + ByteContent::Binary +} diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index 50e2c6acae0013a75e346ba754f9c9f861196b58..45d39710c6ea825aded4d29f447124ee4c2ecb33 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -1,7 +1,8 @@ use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle}; use anyhow::Result; +use encoding_rs; use fs::{FakeFs, Fs, RealFs, RemoveOptions}; -use git::GITIGNORE; +use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE}; use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext}; use parking_lot::Mutex; use postage::stream::Stream; @@ -19,6 +20,7 @@ use std::{ }; use util::{ ResultExt, path, + paths::PathStyle, rel_path::{RelPath, rel_path}, test::TempTree, }; @@ -44,6 +46,7 @@ async fn test_traversal(cx: &mut TestAppContext) { true, fs, Default::default(), + true, &mut cx.to_async(), ) .await @@ -108,6 +111,7 @@ async fn test_circular_symlinks(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -207,6 +211,7 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -357,6 +362,7 @@ async fn test_renaming_case_only(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -434,6 +440,7 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -598,6 +605,7 @@ async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -698,6 +706,7 @@ async fn test_write_file(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -716,6 +725,8 @@ async fn test_write_file(cx: &mut TestAppContext) { rel_path("tracked-dir/file.txt").into(), "hello".into(), Default::default(), + encoding_rs::UTF_8, + false, cx, ) }) @@ -727,6 +738,8 @@ async fn test_write_file(cx: &mut TestAppContext) { rel_path("ignored-dir/file.txt").into(), "world".into(), Default::default(), + encoding_rs::UTF_8, + false, cx, ) }) @@ -791,6 +804,7 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -856,6 +870,7 @@ async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -914,6 +929,7 @@ async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppC true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -999,6 +1015,7 @@ async fn test_file_scan_exclusions(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1080,6 +1097,7 @@ async fn test_hidden_files(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1190,6 +1208,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1301,6 +1320,7 @@ async fn test_fs_events_in_dot_git_worktree(cx: &mut TestAppContext) { true, Arc::new(RealFs::new(None, cx.executor())), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1339,6 +1359,7 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { true, fs, Default::default(), + true, &mut cx.to_async(), ) .await @@ -1407,6 +1428,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { true, fs_fake, Default::default(), + true, &mut cx.to_async(), ) .await @@ -1448,6 +1470,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { true, fs_real, Default::default(), + true, &mut cx.to_async(), ) .await @@ -1533,6 +1556,177 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_create_file_in_expanded_gitignored_dir(cx: &mut TestAppContext) { + // Tests the behavior of our worktree refresh when a file in a gitignored directory + // is created. + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "ignored_dir\n", + "ignored_dir": { + "existing_file.txt": "existing content", + "another_file.txt": "another content", + }, + }), + ) + .await; + + let tree = Worktree::local( + Path::new("/root"), + true, + fs.clone(), + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert!(ignored_dir.is_ignored); + assert_eq!(ignored_dir.kind, EntryKind::UnloadedDir); + }); + + tree.update(cx, |tree, cx| { + tree.load_file(rel_path("ignored_dir/existing_file.txt"), cx) + }) + .await + .unwrap(); + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert!(ignored_dir.is_ignored); + assert_eq!(ignored_dir.kind, EntryKind::Dir); + + assert!( + tree.entry_for_path(rel_path("ignored_dir/existing_file.txt")) + .is_some() + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/another_file.txt")) + .is_some() + ); + }); + + let entry = tree + .update(cx, |tree, cx| { + tree.create_entry(rel_path("ignored_dir/new_file.txt").into(), false, None, cx) + }) + .await + .unwrap(); + assert!(entry.into_included().is_some()); + + cx.executor().run_until_parked(); + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert!(ignored_dir.is_ignored); + assert_eq!( + ignored_dir.kind, + EntryKind::Dir, + "ignored_dir should still be loaded, not UnloadedDir" + ); + + assert!( + tree.entry_for_path(rel_path("ignored_dir/existing_file.txt")) + .is_some(), + "existing_file.txt should still be visible" + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/another_file.txt")) + .is_some(), + "another_file.txt should still be visible" + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/new_file.txt")) + .is_some(), + "new_file.txt should be visible" + ); + }); +} + +#[gpui::test] +async fn test_fs_event_for_gitignored_dir_does_not_lose_contents(cx: &mut TestAppContext) { + // Tests the behavior of our worktree refresh when a directory modification for a gitignored directory + // is triggered. + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "ignored_dir\n", + "ignored_dir": { + "file1.txt": "content1", + "file2.txt": "content2", + }, + }), + ) + .await; + + let tree = Worktree::local( + Path::new("/root"), + true, + fs.clone(), + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + // Load a file to expand the ignored directory + tree.update(cx, |tree, cx| { + tree.load_file(rel_path("ignored_dir/file1.txt"), cx) + }) + .await + .unwrap(); + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert_eq!(ignored_dir.kind, EntryKind::Dir); + assert!( + tree.entry_for_path(rel_path("ignored_dir/file1.txt")) + .is_some() + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/file2.txt")) + .is_some() + ); + }); + + fs.emit_fs_event("/root/ignored_dir", Some(fs::PathEventKind::Changed)); + tree.flush_fs_events(cx).await; + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert_eq!( + ignored_dir.kind, + EntryKind::Dir, + "ignored_dir should still be loaded (Dir), not UnloadedDir" + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/file1.txt")) + .is_some(), + "file1.txt should still be visible after directory fs event" + ); + assert!( + tree.entry_for_path(rel_path("ignored_dir/file2.txt")) + .is_some(), + "file2.txt should still be visible after directory fs event" + ); + }); +} + #[gpui::test(iterations = 100)] async fn test_random_worktree_operations_during_initial_scan( cx: &mut TestAppContext, @@ -1559,6 +1753,7 @@ async fn test_random_worktree_operations_during_initial_scan( true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1649,6 +1844,7 @@ async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1721,6 +1917,7 @@ async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -1844,8 +2041,14 @@ fn randomly_mutate_worktree( }) } else { log::info!("overwriting file {:?} ({})", &entry.path, entry.id.0); - let task = - worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx); + let task = worktree.write_file( + entry.path.clone(), + "".into(), + Default::default(), + encoding_rs::UTF_8, + false, + cx, + ); cx.background_spawn(async move { task.await?; Ok(()) @@ -2034,6 +2237,7 @@ async fn test_private_single_file_worktree(cx: &mut TestAppContext) { true, fs.clone(), Default::default(), + true, &mut cx.to_async(), ) .await @@ -2066,6 +2270,7 @@ async fn test_repository_above_root(executor: BackgroundExecutor, cx: &mut TestA true, fs.clone(), Arc::default(), + true, &mut cx.to_async(), ) .await @@ -2143,6 +2348,7 @@ async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppCon true, fs.clone(), Arc::default(), + true, &mut cx.to_async(), ) .await @@ -2218,6 +2424,94 @@ async fn test_global_gitignore(executor: BackgroundExecutor, cx: &mut TestAppCon }); } +#[gpui::test] +async fn test_repo_exclude(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor); + let project_dir = Path::new(path!("/project")); + fs.insert_tree( + project_dir, + json!({ + ".git": { + "info": { + "exclude": ".env.*" + } + }, + ".env.example": "secret=xxxx", + ".env.local": "secret=1234", + ".gitignore": "!.env.example", + "README.md": "# Repo Exclude", + "src": { + "main.rs": "fn main() {}", + }, + }), + ) + .await; + + let worktree = Worktree::local( + project_dir, + true, + fs.clone(), + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + worktree + .update(cx, |worktree, _| { + worktree.as_local().unwrap().scan_complete() + }) + .await; + cx.run_until_parked(); + + // .gitignore overrides .git/info/exclude + worktree.update(cx, |worktree, _cx| { + let expected_excluded_paths = []; + let expected_ignored_paths = [".env.local"]; + let expected_tracked_paths = [".env.example", "README.md", "src/main.rs"]; + let expected_included_paths = []; + + check_worktree_entries( + worktree, + &expected_excluded_paths, + &expected_ignored_paths, + &expected_tracked_paths, + &expected_included_paths, + ); + }); + + // Ignore statuses are updated when .git/info/exclude file changes + fs.write( + &project_dir.join(DOT_GIT).join(REPO_EXCLUDE), + ".env.example".as_bytes(), + ) + .await + .unwrap(); + worktree + .update(cx, |worktree, _| { + worktree.as_local().unwrap().scan_complete() + }) + .await; + cx.run_until_parked(); + + worktree.update(cx, |worktree, _cx| { + let expected_excluded_paths = []; + let expected_ignored_paths = []; + let expected_tracked_paths = [".env.example", ".env.local", "README.md", "src/main.rs"]; + let expected_included_paths = []; + + check_worktree_entries( + worktree, + &expected_excluded_paths, + &expected_ignored_paths, + &expected_tracked_paths, + &expected_included_paths, + ); + }); +} + #[track_caller] fn check_worktree_entries( tree: &Worktree, @@ -2270,3 +2564,282 @@ fn init_test(cx: &mut gpui::TestAppContext) { cx.set_global(settings_store); }); } + +#[gpui::test] +async fn test_load_file_encoding(cx: &mut TestAppContext) { + init_test(cx); + + struct TestCase { + name: &'static str, + bytes: Vec, + expected_text: &'static str, + } + + // --- Success Cases --- + let success_cases = vec![ + TestCase { + name: "utf8.txt", + bytes: "こんにちは".as_bytes().to_vec(), + expected_text: "こんにちは", + }, + TestCase { + name: "sjis.txt", + bytes: vec![0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd], + expected_text: "こんにちは", + }, + TestCase { + name: "eucjp.txt", + bytes: vec![0xa4, 0xb3, 0xa4, 0xf3, 0xa4, 0xcb, 0xa4, 0xc1, 0xa4, 0xcf], + expected_text: "こんにちは", + }, + TestCase { + name: "iso2022jp.txt", + bytes: vec![ + 0x1b, 0x24, 0x42, 0x24, 0x33, 0x24, 0x73, 0x24, 0x4b, 0x24, 0x41, 0x24, 0x4f, 0x1b, + 0x28, 0x42, + ], + expected_text: "こんにちは", + }, + TestCase { + name: "win1252.txt", + bytes: vec![0x43, 0x61, 0x66, 0xe9], + expected_text: "Café", + }, + TestCase { + name: "gbk.txt", + bytes: vec![ + 0xbd, 0xf1, 0xcc, 0xec, 0xcc, 0xec, 0xc6, 0xf8, 0xb2, 0xbb, 0xb4, 0xed, + ], + expected_text: "今天天气不错", + }, + // UTF-16LE with BOM + TestCase { + name: "utf16le_bom.txt", + bytes: vec![ + 0xFF, 0xFE, // BOM + 0x53, 0x30, 0x93, 0x30, 0x6B, 0x30, 0x61, 0x30, 0x6F, 0x30, + ], + expected_text: "こんにちは", + }, + // UTF-16BE with BOM + TestCase { + name: "utf16be_bom.txt", + bytes: vec![ + 0xFE, 0xFF, // BOM + 0x30, 0x53, 0x30, 0x93, 0x30, 0x6B, 0x30, 0x61, 0x30, 0x6F, + ], + expected_text: "こんにちは", + }, + // UTF-16LE without BOM (ASCII only) + // This relies on the "null byte heuristic" we implemented. + // "ABC" -> 41 00 42 00 43 00 + TestCase { + name: "utf16le_ascii_no_bom.txt", + bytes: vec![0x41, 0x00, 0x42, 0x00, 0x43, 0x00], + expected_text: "ABC", + }, + ]; + + // --- Failure Cases --- + let failure_cases = vec![ + // Binary File (Should be detected by heuristic and return Error) + // Contains random bytes and mixed nulls that don't match UTF-16 patterns + TestCase { + name: "binary.bin", + bytes: vec![0x00, 0xFF, 0x12, 0x00, 0x99, 0x88, 0x77, 0x66, 0x00], + expected_text: "", // Not used + }, + ]; + + let root_path = if cfg!(windows) { + Path::new("C:\\root") + } else { + Path::new("/root") + }; + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.create_dir(root_path).await.unwrap(); + + for case in success_cases.iter().chain(failure_cases.iter()) { + let path = root_path.join(case.name); + fs.write(&path, &case.bytes).await.unwrap(); + } + + let tree = Worktree::local( + root_path, + true, + fs, + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + let rel_path = |name: &str| { + RelPath::new(&Path::new(name), PathStyle::local()) + .unwrap() + .into_arc() + }; + + // Run Success Tests + for case in success_cases { + let loaded = tree + .update(cx, |tree, cx| tree.load_file(&rel_path(case.name), cx)) + .await; + if let Err(e) = &loaded { + panic!("Failed to load success case '{}': {:?}", case.name, e); + } + let loaded = loaded.unwrap(); + assert_eq!( + loaded.text, case.expected_text, + "Encoding mismatch for file: {}", + case.name + ); + } + + // Run Failure Tests + for case in failure_cases { + let loaded = tree + .update(cx, |tree, cx| tree.load_file(&rel_path(case.name), cx)) + .await; + assert!( + loaded.is_err(), + "Failure case '{}' unexpectedly succeeded! It should have been detected as binary.", + case.name + ); + let err_msg = loaded.unwrap_err().to_string(); + println!("Got expected error for {}: {}", case.name, err_msg); + } +} + +#[gpui::test] +async fn test_write_file_encoding(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let root_path = if cfg!(windows) { + Path::new("C:\\root") + } else { + Path::new("/root") + }; + fs.create_dir(root_path).await.unwrap(); + + let worktree = Worktree::local( + root_path, + true, + fs.clone(), + Default::default(), + true, + &mut cx.to_async(), + ) + .await + .unwrap(); + + // Define test case structure + struct TestCase { + name: &'static str, + text: &'static str, + encoding: &'static encoding_rs::Encoding, + has_bom: bool, + expected_bytes: Vec, + } + + let cases = vec![ + // Shift_JIS with Japanese + TestCase { + name: "Shift_JIS with Japanese", + text: "こんにちは", + encoding: encoding_rs::SHIFT_JIS, + has_bom: false, + expected_bytes: vec![0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd], + }, + // UTF-8 No BOM + TestCase { + name: "UTF-8 No BOM", + text: "AB", + encoding: encoding_rs::UTF_8, + has_bom: false, + expected_bytes: vec![0x41, 0x42], + }, + // UTF-8 with BOM + TestCase { + name: "UTF-8 with BOM", + text: "AB", + encoding: encoding_rs::UTF_8, + has_bom: true, + expected_bytes: vec![0xEF, 0xBB, 0xBF, 0x41, 0x42], + }, + // UTF-16LE No BOM with Japanese + // NOTE: This passes thanks to the manual encoding fix implemented in `write_file`. + TestCase { + name: "UTF-16LE No BOM with Japanese", + text: "こんにちは", + encoding: encoding_rs::UTF_16LE, + has_bom: false, + expected_bytes: vec![0x53, 0x30, 0x93, 0x30, 0x6b, 0x30, 0x61, 0x30, 0x6f, 0x30], + }, + // UTF-16LE with BOM + TestCase { + name: "UTF-16LE with BOM", + text: "A", + encoding: encoding_rs::UTF_16LE, + has_bom: true, + expected_bytes: vec![0xFF, 0xFE, 0x41, 0x00], + }, + // UTF-16BE No BOM with Japanese + // NOTE: This passes thanks to the manual encoding fix. + TestCase { + name: "UTF-16BE No BOM with Japanese", + text: "こんにちは", + encoding: encoding_rs::UTF_16BE, + has_bom: false, + expected_bytes: vec![0x30, 0x53, 0x30, 0x93, 0x30, 0x6b, 0x30, 0x61, 0x30, 0x6f], + }, + // UTF-16BE with BOM + TestCase { + name: "UTF-16BE with BOM", + text: "A", + encoding: encoding_rs::UTF_16BE, + has_bom: true, + expected_bytes: vec![0xFE, 0xFF, 0x00, 0x41], + }, + ]; + + for (i, case) in cases.into_iter().enumerate() { + let file_name = format!("test_{}.txt", i); + let path: Arc = Path::new(&file_name).into(); + let file_path = root_path.join(&file_name); + + fs.insert_file(&file_path, "".into()).await; + + let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc(); + let text = text::Rope::from(case.text); + + let task = worktree.update(cx, |wt, cx| { + wt.write_file( + rel_path, + text, + text::LineEnding::Unix, + case.encoding, + case.has_bom, + cx, + ) + }); + + if let Err(e) = task.await { + panic!("Unexpected error in case '{}': {:?}", case.name, e); + } + + let bytes = fs.load_bytes(&file_path).await.unwrap(); + + assert_eq!( + bytes, case.expected_bytes, + "case '{}' mismatch. Expected {:?}, but got {:?}", + case.name, case.expected_bytes, bytes + ); + } +} diff --git a/crates/worktree_benchmarks/src/main.rs b/crates/worktree_benchmarks/src/main.rs index 00f268b75fc5f1e7d6033ec46f3718ea39cdccda..c1b76f9e3c483ec6c989cc255a11c5320d4b49f7 100644 --- a/crates/worktree_benchmarks/src/main.rs +++ b/crates/worktree_benchmarks/src/main.rs @@ -5,8 +5,7 @@ use std::{ use fs::RealFs; use gpui::Application; -use settings::Settings; -use worktree::{Worktree, WorktreeSettings}; +use worktree::Worktree; fn main() { let Some(worktree_root_path) = std::env::args().nth(1) else { @@ -27,6 +26,7 @@ fn main() { true, fs, Arc::new(AtomicUsize::new(0)), + true, cx, ) .await diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index aac231b511684db9e2bfc36c822a963e5f231161..072a893a6a8f4fc7fbc8a6f4f5ed43316915b974 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -30,6 +30,17 @@ pub enum Model { alias = "grok-4-fast-non-reasoning-latest" )] Grok4FastNonReasoning, + #[serde( + rename = "grok-4-1-fast-non-reasoning", + alias = "grok-4-1-fast-non-reasoning-latest" + )] + Grok41FastNonReasoning, + #[serde( + rename = "grok-4-1-fast-reasoning", + alias = "grok-4-1-fast-reasoning-latest", + alias = "grok-4-1-fast" + )] + Grok41FastReasoning, #[serde(rename = "grok-code-fast-1", alias = "grok-code-fast-1-0825")] GrokCodeFast1, #[serde(rename = "custom")] @@ -56,6 +67,9 @@ impl Model { "grok-4" => Ok(Self::Grok4), "grok-4-fast-reasoning" => Ok(Self::Grok4FastReasoning), "grok-4-fast-non-reasoning" => Ok(Self::Grok4FastNonReasoning), + "grok-4-1-fast-non-reasoning" => Ok(Self::Grok41FastNonReasoning), + "grok-4-1-fast-reasoning" => Ok(Self::Grok41FastReasoning), + "grok-4-1-fast" => Ok(Self::Grok41FastReasoning), "grok-2-vision" => Ok(Self::Grok2Vision), "grok-3" => Ok(Self::Grok3), "grok-3-mini" => Ok(Self::Grok3Mini), @@ -76,6 +90,8 @@ impl Model { Self::Grok4 => "grok-4", Self::Grok4FastReasoning => "grok-4-fast-reasoning", Self::Grok4FastNonReasoning => "grok-4-fast-non-reasoning", + Self::Grok41FastNonReasoning => "grok-4-1-fast-non-reasoning", + Self::Grok41FastReasoning => "grok-4-1-fast-reasoning", Self::GrokCodeFast1 => "grok-code-fast-1", Self::Custom { name, .. } => name, } @@ -91,6 +107,8 @@ impl Model { Self::Grok4 => "Grok 4", Self::Grok4FastReasoning => "Grok 4 Fast", Self::Grok4FastNonReasoning => "Grok 4 Fast (Non-Reasoning)", + Self::Grok41FastNonReasoning => "Grok 4.1 Fast (Non-Reasoning)", + Self::Grok41FastReasoning => "Grok 4.1 Fast", Self::GrokCodeFast1 => "Grok Code Fast 1", Self::Custom { name, display_name, .. @@ -102,7 +120,10 @@ impl Model { match self { Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => 131_072, Self::Grok4 | Self::GrokCodeFast1 => 256_000, - Self::Grok4FastReasoning | Self::Grok4FastNonReasoning => 128_000, + Self::Grok4FastReasoning + | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning => 2_000_000, Self::Grok2Vision => 8_192, Self::Custom { max_tokens, .. } => *max_tokens, } @@ -114,6 +135,8 @@ impl Model { Self::Grok4 | Self::Grok4FastReasoning | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning | Self::GrokCodeFast1 => Some(64_000), Self::Grok2Vision => Some(4_096), Self::Custom { @@ -131,7 +154,9 @@ impl Model { | Self::Grok3MiniFast | Self::Grok4 | Self::Grok4FastReasoning - | Self::Grok4FastNonReasoning => true, + | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning => true, Self::Custom { parallel_tool_calls: Some(support), .. @@ -154,6 +179,8 @@ impl Model { | Self::Grok4 | Self::Grok4FastReasoning | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning | Self::GrokCodeFast1 => true, Self::Custom { supports_tools: Some(support), @@ -165,7 +192,12 @@ impl Model { pub fn supports_images(&self) -> bool { match self { - Self::Grok2Vision => true, + Self::Grok2Vision + | Self::Grok4 + | Self::Grok4FastReasoning + | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning => true, Self::Custom { supports_images: Some(support), .. diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 9e6a6a0fbd10a7695270f2651418d9e2cdc31b4c..5c5d35944672a36733d6da50505f8d5bacad74f3 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.216.0" +version = "0.219.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -10,11 +10,10 @@ authors = ["Zed Team "] [lints] workspace = true -[[bin]] -name = "zed" -path = "src/zed-main.rs" +[features] +tracy = ["ztracing/tracy"] -[lib] +[[bin]] name = "zed" path = "src/main.rs" @@ -23,6 +22,7 @@ acp_tools.workspace = true activity_indicator.workspace = true agent_settings.workspace = true agent_ui.workspace = true +agent_ui_v2.workspace = true anyhow.workspace = true askpass.workspace = true assets.workspace = true @@ -41,6 +41,7 @@ collab_ui.workspace = true collections.workspace = true command_palette.workspace = true component.workspace = true +component_preview.workspace = true copilot.workspace = true crashes.workspace = true dap_adapters.workspace = true @@ -50,7 +51,6 @@ debugger_tools.workspace = true debugger_ui.workspace = true diagnostics.workspace = true editor.workspace = true -zeta2_tools.workspace = true env_logger.workspace = true extension.workspace = true extension_host.workspace = true @@ -74,7 +74,8 @@ gpui = { workspace = true, features = [ gpui_tokio.workspace = true rayon.workspace = true -edit_prediction_button.workspace = true +edit_prediction.workspace = true +edit_prediction_ui.workspace = true http_client.workspace = true image_viewer.workspace = true inspector_ui.workspace = true @@ -144,9 +145,10 @@ theme_extension.workspace = true theme_selector.workspace = true time.workspace = true title_bar.workspace = true +ztracing.workspace = true +tracing.workspace = true toolchain_selector.workspace = true ui.workspace = true -ui_input.workspace = true ui_prompt.workspace = true url.workspace = true urlencoding.workspace = true @@ -157,10 +159,10 @@ vim_mode_setting.workspace = true watch.workspace = true web_search.workspace = true web_search_providers.workspace = true +which_key.workspace = true workspace.workspace = true zed_actions.workspace = true zed_env_vars.workspace = true -zeta.workspace = true zlog.workspace = true zlog_settings.workspace = true chrono.workspace = true @@ -190,6 +192,10 @@ terminal_view = { workspace = true, features = ["test-support"] } tree-sitter-md.workspace = true tree-sitter-rust.workspace = true workspace = { workspace = true, features = ["test-support"] } +agent_ui = { workspace = true, features = ["test-support"] } +agent_ui_v2 = { workspace = true, features = ["test-support"] } +search = { workspace = true, features = ["test-support"] } + [package.metadata.bundle-dev] icon = ["resources/app-icon-dev@2x.png", "resources/app-icon-dev.png"] @@ -224,4 +230,4 @@ osx_info_plist_exts = ["resources/info/*"] osx_url_schemes = ["zed"] [package.metadata.cargo-machete] -ignored = ["profiling", "zstd"] +ignored = ["profiling", "zstd", "tracing"] diff --git a/crates/zed/resources/Document.icns b/crates/zed/resources/Document.icns new file mode 100644 index 0000000000000000000000000000000000000000..5d0185c81a32c214f213f12243aeab01e32830e1 Binary files /dev/null and b/crates/zed/resources/Document.icns differ diff --git a/crates/zed/resources/zed.entitlements b/crates/zed/resources/zed.entitlements index cb4cd3dc692160047ae5012489a350829c4a1ccf..2a16afe7551f433e3f835a2097df61a2e9e86ee1 100644 --- a/crates/zed/resources/zed.entitlements +++ b/crates/zed/resources/zed.entitlements @@ -22,5 +22,9 @@ com.apple.security.personal-information.photos-library + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f92c819dd22c69d95533d16249345e6128e9ded0..8c6575780bbc418b4717af8329c51a057eb608d3 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1,9 +1,12 @@ +// Disable command line from opening on release mode +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + mod reliability; mod zed; use agent_ui::AgentPanel; use anyhow::{Context as _, Error, Result}; -use clap::{Parser, command}; +use clap::Parser; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; use client::{Client, ProxySettings, UserStore, parse_zed_link}; use collab_ui::channel_view::ChannelView; @@ -15,11 +18,13 @@ use extension::ExtensionHostProxy; use fs::{Fs, RealFs}; use futures::{StreamExt, channel::oneshot, future}; use git::GitHostingProviderRegistry; +use git_ui::clone::clone_and_open; use gpui::{App, AppContext, Application, AsyncApp, Focusable as _, QuitMode, UpdateGlobal as _}; use gpui_tokio::Tokio; use language::LanguageRegistry; use onboarding::{FIRST_OPEN, show_onboarding_view}; +use project_panel::ProjectPanel; use prompt_store::PromptBuilder; use remote::RemoteConnectionOptions; use reqwest_client::ReqwestClient; @@ -27,16 +32,18 @@ use reqwest_client::ReqwestClient; use assets::Assets; use node_runtime::{NodeBinaryOptions, NodeRuntime}; use parking_lot::Mutex; -use project::project_settings::ProjectSettings; +use project::{project_settings::ProjectSettings, trusted_worktrees}; use recent_projects::{SshSettings, open_remote_project}; use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; use session::{AppSession, Session}; use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file}; use std::{ + cell::RefCell, env, io::{self, IsTerminal}, path::{Path, PathBuf}, process, + rc::Rc, sync::{Arc, OnceLock}, time::Instant, }; @@ -130,6 +137,7 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) { process::exit(1); } + // Maybe unify this with gpui::platform::linux::platform::ResultExt::notify_err(..)? #[cfg(any(target_os = "linux", target_os = "freebsd"))] { use ashpd::desktop::notification::{Notification, NotificationProxy, Priority}; @@ -162,10 +170,9 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) { .detach(); } } +static STARTUP_TIME: OnceLock = OnceLock::new(); -pub static STARTUP_TIME: OnceLock = OnceLock::new(); - -pub fn main() { +fn main() { STARTUP_TIME.get_or_init(|| Instant::now()); #[cfg(unix)] @@ -240,6 +247,7 @@ pub fn main() { } zlog::init(); + if stdout_is_a_pty() { zlog::init_output_stdout(); } else { @@ -249,6 +257,7 @@ pub fn main() { zlog::init_output_stdout(); }; } + ztracing::init(); let version = option_env!("ZED_BUILD_ID"); let app_commit_sha = @@ -404,6 +413,14 @@ pub fn main() { }); app.run(move |cx| { + let trusted_paths = match workspace::WORKSPACE_DB.fetch_trusted_worktrees(None, None, cx) { + Ok(trusted_paths) => trusted_paths, + Err(e) => { + log::error!("Failed to do initial trusted worktrees fetch: {e:#}"); + HashMap::default() + } + }; + trusted_worktrees::init(trusted_paths, None, None, cx); menu::init(); zed_actions::init(); @@ -472,6 +489,7 @@ pub fn main() { tx.send(Some(options)).log_err(); }) .detach(); + let node_runtime = NodeRuntime::new(client.http_client(), Some(shell_env_loaded_rx), rx); debug_adapter_extension::init(extension_host_proxy.clone(), cx); @@ -581,7 +599,7 @@ pub fn main() { language_model::init(app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); acp_tools::init(cx); - zeta2_tools::init(cx); + edit_prediction_ui::init(cx); web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx); snippet_provider::init(cx); @@ -595,6 +613,7 @@ pub fn main() { false, cx, ); + agent_ui_v2::agents_panel::init(cx); repl::init(app_state.fs.clone(), cx); recent_projects::init(cx); @@ -640,10 +659,11 @@ pub fn main() { settings_ui::init(cx); keymap_editor::init(cx); extensions_ui::init(cx); - zeta::init(cx); + edit_prediction::init(cx); inspector_ui::init(app_state.clone(), cx); json_schema_store::init(cx); miniprofiler_ui::init(*STARTUP_TIME.get().unwrap(), cx); + which_key::init(cx); cx.observe_global::({ let http = app_state.client.http_client(); @@ -754,7 +774,7 @@ pub fn main() { let app_state = app_state.clone(); - crate::zed::component_preview::init(app_state.clone(), cx); + component_preview::init(app_state.clone(), cx); cx.spawn(async move |cx| { while let Some(urls) = open_rx.next().await { @@ -799,7 +819,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut workspace::get_any_active_workspace(app_state, cx.clone()).await?; workspace.update(cx, |workspace, window, cx| { if let Some(panel) = workspace.panel::(cx) { - panel.focus_handle(cx).focus(window); + panel.focus_handle(cx).focus(window, cx); } }) }) @@ -813,12 +833,19 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut cx.spawn_in(window, async move |workspace, cx| { let res = async move { let json = app_state.languages.language_for_name("JSONC").await.ok(); + let lsp_store = workspace.update(cx, |workspace, cx| { + workspace + .project() + .update(cx, |project, _| project.lsp_store()) + })?; let json_schema_content = json_schema_store::resolve_schema_request_inner( &app_state.languages, + lsp_store, &schema_path, cx, - )?; + ) + .await?; let json_schema_content = serde_json::to_string_pretty(&json_schema_content) .context("Failed to serialize JSON Schema as JSON")?; @@ -880,6 +907,79 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut }) .detach_and_log_err(cx); } + OpenRequestKind::GitClone { repo_url } => { + workspace::with_active_or_new_workspace(cx, |_workspace, window, cx| { + if window.is_window_active() { + clone_and_open( + repo_url, + cx.weak_entity(), + window, + cx, + Arc::new(|workspace: &mut workspace::Workspace, window, cx| { + workspace.focus_panel::(window, cx); + }), + ); + return; + } + + let subscription = Rc::new(RefCell::new(None)); + subscription.replace(Some(cx.observe_in(&cx.entity(), window, { + let subscription = subscription.clone(); + let repo_url = repo_url; + move |_, workspace_entity, window, cx| { + if window.is_window_active() && subscription.take().is_some() { + clone_and_open( + repo_url.clone(), + workspace_entity.downgrade(), + window, + cx, + Arc::new(|workspace: &mut workspace::Workspace, window, cx| { + workspace.focus_panel::(window, cx); + }), + ); + } + } + }))); + }); + } + OpenRequestKind::GitCommit { sha } => { + cx.spawn(async move |cx| { + let paths_with_position = + derive_paths_with_position(app_state.fs.as_ref(), request.open_paths).await; + let (workspace, _results) = open_paths_with_positions( + &paths_with_position, + &[], + app_state, + workspace::OpenOptions::default(), + cx, + ) + .await?; + + workspace + .update(cx, |workspace, window, cx| { + let Some(repo) = workspace.project().read(cx).active_repository(cx) + else { + log::error!("no active repository found for commit view"); + return Err(anyhow::anyhow!("no active repository found")); + }; + + git_ui::commit_view::CommitView::open( + sha, + repo.downgrade(), + workspace.weak_handle(), + None, + None, + window, + cx, + ); + Ok(()) + }) + .log_err(); + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } } return; @@ -1154,7 +1254,13 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp app_state, cx, |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) + let restore_on_startup = WorkspaceSettings::get_global(cx).restore_on_startup; + match restore_on_startup { + workspace::RestoreOnStartupBehavior::Launchpad => {} + _ => { + Editor::new_file(workspace, &Default::default(), window, cx); + } + } }, ) })? @@ -1244,7 +1350,7 @@ fn init_paths() -> HashMap> { }) } -pub fn stdout_is_a_pty() -> bool { +fn stdout_is_a_pty() -> bool { std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_none() && io::stdout().is_terminal() } @@ -1490,14 +1596,14 @@ fn dump_all_gpui_actions() { struct ActionDef { name: &'static str, human_name: String, - aliases: &'static [&'static str], + deprecated_aliases: &'static [&'static str], documentation: Option<&'static str>, } let mut actions = gpui::generate_list_of_all_registered_actions() .map(|action| ActionDef { name: action.name, human_name: command_palette::humanize_action_name(action.name), - aliases: action.deprecated_aliases, + deprecated_aliases: action.deprecated_aliases, documentation: action.documentation, }) .collect::>(); diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index 9d2f7f5da021cda38cef5a205f2d2ec77eb2b386..da8dffa85d57162a62dd6ae0a698d975d22ee374 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -1,8 +1,8 @@ use anyhow::{Context as _, Result}; use client::{Client, telemetry::MINIDUMP_ENDPOINT}; -use futures::AsyncReadExt; +use futures::{AsyncReadExt, TryStreamExt}; use gpui::{App, AppContext as _, SerializedThreadTaskTimings}; -use http_client::{self, HttpClient}; +use http_client::{self, AsyncBody, HttpClient, Request}; use log::info; use project::Project; use proto::{CrashReport, GetCrashFilesResponse}; @@ -296,11 +296,14 @@ async fn upload_minidump( // TODO: feature-flag-context, and more of device-context like screen resolution, available ram, device model, etc + let stream = form + .into_stream() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) + .into_async_read(); + let body = AsyncBody::from_reader(stream); + let req = Request::builder().uri(endpoint).body(body)?; let mut response_text = String::new(); - let mut response = client - .http_client() - .send_multipart_form(endpoint, form) - .await?; + let mut response = client.http_client().send(req).await?; response .body_mut() .read_to_string(&mut response_text) diff --git a/crates/zed/src/zed-main.rs b/crates/zed/src/zed-main.rs deleted file mode 100644 index 6c49c197dda01e97828c3662aa09ecf57804dfbc..0000000000000000000000000000000000000000 --- a/crates/zed/src/zed-main.rs +++ /dev/null @@ -1,8 +0,0 @@ -// Disable command line from opening on release mode -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -pub fn main() { - // separated out so that the file containing the main function can be imported by other crates, - // while having all gpui resources that are registered in main (primarily actions) initialized - zed::main(); -} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 49a43eae47fe36c9cd93f3ce6371cf39c5f5e514..8c39b308f83a33a1d3408d5fb28c94658ce63c54 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1,5 +1,4 @@ mod app_menus; -pub mod component_preview; pub mod edit_prediction_registry; #[cfg(target_os = "macos")] pub(crate) mod mac_only_instance; @@ -10,6 +9,7 @@ mod quick_action_bar; pub(crate) mod windows_only_instance; use agent_ui::{AgentDiffToolbar, AgentPanelDelegate}; +use agent_ui_v2::agents_panel::AgentsPanel; use anyhow::Context as _; pub use app_menus::*; use assets::Assets; @@ -31,8 +31,8 @@ use git_ui::project_diff::ProjectDiffToolbar; use gpui::{ Action, App, AppContext as _, AsyncWindowContext, Context, DismissEvent, Element, Entity, Focusable, KeyBinding, ParentElement, PathPromptOptions, PromptLevel, ReadGlobal, SharedString, - Styled, Task, TitlebarOptions, UpdateGlobal, WeakEntity, Window, WindowKind, WindowOptions, - actions, image_cache, point, px, retain_all, + Task, TitlebarOptions, UpdateGlobal, WeakEntity, Window, WindowKind, WindowOptions, actions, + image_cache, point, px, retain_all, }; use image_viewer::ImageInfo; use language::Capability; @@ -81,8 +81,9 @@ use vim_mode_setting::VimModeSetting; use workspace::notifications::{ NotificationId, SuppressEvent, dismiss_app_notification, show_app_notification, }; +use workspace::utility_pane::utility_slot_for_dock_position; use workspace::{ - AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings, + AppState, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings, create_and_open_local_file, notifications::simple_message_notification::MessageNotification, open_new, }; @@ -159,15 +160,15 @@ pub fn init(cx: &mut App) { || flag.await { cx.update(|cx| { - cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")); - cx.on_action(|_: &TestCrash, _| { - unsafe extern "C" { - fn puts(s: *const i8); - } - unsafe { - puts(0xabad1d3a as *const i8); - } - }); + cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) + .on_action(|_: &TestCrash, _| { + unsafe extern "C" { + fn puts(s: *const i8); + } + unsafe { + puts(0xabad1d3a as *const i8); + } + }); }) .ok(); }; @@ -177,11 +178,11 @@ pub fn init(cx: &mut App) { with_active_or_new_workspace(cx, |workspace, window, cx| { open_log_file(workspace, window, cx); }); - }); - cx.on_action(|_: &workspace::RevealLogInFileManager, cx| { + }) + .on_action(|_: &workspace::RevealLogInFileManager, cx| { cx.reveal_path(paths::log_file().as_path()); - }); - cx.on_action(|_: &zed_actions::OpenLicenses, cx| { + }) + .on_action(|_: &zed_actions::OpenLicenses, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -192,13 +193,13 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::OpenTelemetryLog, cx| { + }) + .on_action(|_: &zed_actions::OpenTelemetryLog, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_telemetry_log_file(workspace, window, cx); }); - }); - cx.on_action(|&zed_actions::OpenKeymapFile, cx| { + }) + .on_action(|&zed_actions::OpenKeymapFile, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::keymap_file(), @@ -207,8 +208,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenSettingsFile, cx| { + }) + .on_action(|_: &OpenSettingsFile, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::settings_file(), @@ -217,13 +218,13 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenAccountSettings, cx| { + }) + .on_action(|_: &OpenAccountSettings, cx| { with_active_or_new_workspace(cx, |_, _, cx| { cx.open_url(&zed_urls::account_url(cx)); }); - }); - cx.on_action(|_: &OpenTasks, cx| { + }) + .on_action(|_: &OpenTasks, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::tasks_file(), @@ -232,8 +233,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenDebugTasks, cx| { + }) + .on_action(|_: &OpenDebugTasks, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::debug_scenarios_file(), @@ -242,8 +243,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenDefaultSettings, cx| { + }) + .on_action(|_: &OpenDefaultSettings, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -254,8 +255,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::OpenDefaultKeymap, cx| { + }) + .on_action(|_: &zed_actions::OpenDefaultKeymap, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -266,8 +267,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::About, cx| { + }) + .on_action(|_: &zed_actions::About, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { about(workspace, window, cx); }); @@ -351,6 +352,8 @@ pub fn initialize_workspace( ) { let mut _on_close_subscription = bind_on_window_closed(cx); cx.observe_global::(move |cx| { + // A 1.92 regression causes unused-assignment to trigger on this variable. + _ = _on_close_subscription.is_some(); _on_close_subscription = bind_on_window_closed(cx); }) .detach(); @@ -401,8 +404,8 @@ pub fn initialize_workspace( unstable_version_notification(cx); let edit_prediction_menu_handle = PopoverMenuHandle::default(); - let edit_prediction_button = cx.new(|cx| { - edit_prediction_button::EditPredictionButton::new( + let edit_prediction_ui = cx.new(|cx| { + edit_prediction_ui::EditPredictionButton::new( app_state.fs.clone(), app_state.user_store.clone(), edit_prediction_menu_handle.clone(), @@ -411,7 +414,7 @@ pub fn initialize_workspace( ) }); workspace.register_action({ - move |_, _: &edit_prediction_button::ToggleMenu, window, cx| { + move |_, _: &edit_prediction_ui::ToggleMenu, window, cx| { edit_prediction_menu_handle.toggle(window, cx); } }); @@ -450,7 +453,7 @@ pub fn initialize_workspace( status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); status_bar.add_left_item(activity_indicator, window, cx); - status_bar.add_right_item(edit_prediction_button, window, cx); + status_bar.add_right_item(edit_prediction_ui, window, cx); status_bar.add_right_item(active_buffer_language, window, cx); status_bar.add_right_item(active_toolchain_language, window, cx); status_bar.add_right_item(line_ending_indicator, window, cx); @@ -473,7 +476,7 @@ pub fn initialize_workspace( initialize_panels(prompt_builder.clone(), window, cx); register_actions(app_state.clone(), workspace, window, cx); - workspace.focus_handle(cx).focus(window); + workspace.focus_handle(cx).focus(window, cx); }) .detach(); } @@ -679,7 +682,8 @@ fn initialize_panels( add_panel_when_ready(channels_panel, workspace_handle.clone(), cx.clone()), add_panel_when_ready(notification_panel, workspace_handle.clone(), cx.clone()), add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()), - initialize_agent_panel(workspace_handle, prompt_builder, cx.clone()).map(|r| r.log_err()) + initialize_agent_panel(workspace_handle.clone(), prompt_builder, cx.clone()).map(|r| r.log_err()), + initialize_agents_panel(workspace_handle, cx.clone()).map(|r| r.log_err()) ); anyhow::Ok(()) @@ -687,58 +691,64 @@ fn initialize_panels( .detach(); } +fn setup_or_teardown_ai_panel( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + load_panel: impl FnOnce( + WeakEntity, + AsyncWindowContext, + ) -> Task>> + + 'static, +) -> Task> { + let disable_ai = SettingsStore::global(cx) + .get::(None) + .disable_ai + || cfg!(test); + let existing_panel = workspace.panel::

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

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

(&existing_panel, window, cx); + Task::ready(Ok(())) + } + _ => Task::ready(Ok(())), + } +} + async fn initialize_agent_panel( workspace_handle: WeakEntity, prompt_builder: Arc, mut cx: AsyncWindowContext, ) -> anyhow::Result<()> { - fn setup_or_teardown_agent_panel( - workspace: &mut Workspace, - prompt_builder: Arc, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let disable_ai = SettingsStore::global(cx) - .get::(None) - .disable_ai - || cfg!(test); - let existing_panel = workspace.panel::(cx); - match (disable_ai, existing_panel) { - (false, None) => cx.spawn_in(window, async move |workspace, cx| { - let panel = - agent_ui::AgentPanel::load(workspace.clone(), prompt_builder, cx.clone()) - .await?; - workspace.update_in(cx, |workspace, window, cx| { - let disable_ai = SettingsStore::global(cx) - .get::(None) - .disable_ai; - let have_panel = workspace.panel::(cx).is_some(); - if !disable_ai && !have_panel { - workspace.add_panel(panel, window, cx); - } - }) - }), - (true, Some(existing_panel)) => { - workspace.remove_panel::(&existing_panel, window, cx); - Task::ready(Ok(())) - } - _ => Task::ready(Ok(())), - } - } - workspace_handle .update_in(&mut cx, |workspace, window, cx| { - setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx) + let prompt_builder = prompt_builder.clone(); + setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, prompt_builder, cx) + }) })? .await?; workspace_handle.update_in(&mut cx, |workspace, window, cx| { - cx.observe_global_in::(window, { + let prompt_builder = prompt_builder.clone(); + cx.observe_global_in::(window, move |workspace, window, cx| { let prompt_builder = prompt_builder.clone(); - move |workspace, window, cx| { - setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx) - .detach_and_log_err(cx); - } + setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, prompt_builder, cx) + }) + .detach_and_log_err(cx); }) .detach(); @@ -763,6 +773,31 @@ async fn initialize_agent_panel( anyhow::Ok(()) } +async fn initialize_agents_panel( + workspace_handle: WeakEntity, + mut cx: AsyncWindowContext, +) -> anyhow::Result<()> { + workspace_handle + .update_in(&mut cx, |workspace, window, cx| { + setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { + AgentsPanel::load(workspace, cx) + }) + })? + .await?; + + workspace_handle.update_in(&mut cx, |_workspace, window, cx| { + cx.observe_global_in::(window, move |workspace, window, cx| { + setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { + AgentsPanel::load(workspace, cx) + }) + .detach_and_log_err(cx); + }) + .detach(); + })?; + + anyhow::Ok(()) +} + fn register_actions( app_state: Arc, workspace: &mut Workspace, @@ -1052,6 +1087,18 @@ fn register_actions( workspace.toggle_panel_focus::(window, cx); }, ) + .register_action( + |workspace: &mut Workspace, + _: &zed_actions::agent::ToggleAgentPane, + window: &mut Window, + cx: &mut Context| { + if let Some(panel) = workspace.panel::(cx) { + let position = panel.read(cx).position(window, cx); + let slot = utility_slot_for_dock_position(position); + workspace.toggle_utility_pane(slot, window, cx); + } + }, + ) .register_action({ let app_state = Arc::downgrade(&app_state); move |_, _: &NewWindow, _, cx| { @@ -1062,7 +1109,21 @@ fn register_actions( cx, |workspace, window, cx| { cx.activate(true); - Editor::new_file(workspace, &Default::default(), window, cx) + // Create buffer synchronously to avoid flicker + let project = workspace.project().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("", None, true, cx) + }); + let editor = cx.new(|cx| { + Editor::for_buffer(buffer, Some(project), window, cx) + }); + workspace.add_item_to_active_pane( + Box::new(editor), + None, + true, + window, + cx, + ); }, ) .detach(); @@ -1643,6 +1704,7 @@ fn show_keymap_file_json_error( cx.new(|cx| { MessageNotification::new(message.clone(), cx) .primary_message("Open Keymap File") + .primary_icon(IconName::Settings) .primary_on_click(|window, cx| { window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx); cx.emit(DismissEvent); @@ -1701,16 +1763,18 @@ fn show_markdown_app_notification( cx.new(move |cx| { MessageNotification::new_from_builder(cx, move |window, cx| { image_cache(retain_all("notification-cache")) - .text_xs() - .child(markdown_preview::markdown_renderer::render_parsed_markdown( - &parsed_markdown.clone(), - Some(workspace_handle.clone()), - window, - cx, + .child(div().text_ui(cx).child( + markdown_preview::markdown_renderer::render_parsed_markdown( + &parsed_markdown.clone(), + Some(workspace_handle.clone()), + window, + cx, + ), )) .into_any() }) .primary_message(primary_button_message) + .primary_icon(IconName::Settings) .primary_on_click_arc(primary_button_on_click) }) }) @@ -2255,12 +2319,13 @@ mod tests { Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle, actions, }; - use language::{LanguageMatcher, LanguageRegistry}; + use language::LanguageRegistry; + use languages::{markdown_lang, rust_lang}; use pretty_assertions::{assert_eq, assert_ne}; use project::{Project, ProjectPath}; use semver::Version; use serde_json::json; - use settings::{SettingsStore, watch_config_file}; + use settings::{SaturatingBool, SettingsStore, watch_config_file}; use std::{ path::{Path, PathBuf}, time::Duration, @@ -2895,9 +2960,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let workspace = window.root(cx).unwrap(); @@ -3327,9 +3390,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let workspace = window.root(cx).unwrap(); @@ -3421,9 +3482,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let workspace = window.root(cx).unwrap(); @@ -3494,7 +3553,7 @@ mod tests { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _| { - project.languages().add(markdown_language()); + project.languages().add(markdown_lang()); project.languages().add(rust_lang()); }); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); @@ -3647,8 +3706,8 @@ mod tests { let project = Project::test(app_state.fs.clone(), [], cx).await; project.update(cx, |project, _| { - project.languages().add(rust_lang()); - project.languages().add(markdown_language()); + project.languages().add(language::rust_lang()); + project.languages().add(language::markdown_lang()); }); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); @@ -3727,9 +3786,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let workspace = window.root(cx).unwrap(); @@ -3760,7 +3817,7 @@ mod tests { }) .unwrap(); - cx.dispatch_action(window.into(), pane::SplitRight); + cx.dispatch_action(window.into(), pane::SplitRight::default()); let editor_2 = cx.update(|cx| { let pane_2 = workspace.read(cx).active_pane().clone(); assert_ne!(pane_1, pane_2); @@ -3831,9 +3888,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let pane = workspace @@ -4225,9 +4280,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(markdown_language()) - }); + project.update(cx, |project, _cx| project.languages().add(markdown_lang())); let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); let pane = workspace .read_with(cx, |workspace, _| workspace.active_pane().clone()) @@ -4725,7 +4778,7 @@ mod tests { "action", "activity_indicator", "agent", - #[cfg(not(target_os = "macos"))] + "agents", "app_menu", "assistant", "assistant2", @@ -4756,10 +4809,12 @@ mod tests { "git_panel", "go_to_line", "icon_theme_selector", + "inline_assistant", "journal", "keymap_editor", "keystroke_input", "language_selector", + "welcome", "line_ending_selector", "lsp_tool", "markdown", @@ -4914,7 +4969,7 @@ mod tests { let state = Arc::get_mut(&mut app_state).unwrap(); state.build_window_options = build_window_options; - app_state.languages.add(markdown_language()); + app_state.languages.add(markdown_lang()); gpui_tokio::init(cx); theme::init(theme::LoadThemes::JustBase, cx); @@ -4951,6 +5006,7 @@ mod tests { false, cx, ); + agent_ui_v2::agents_panel::init(cx); repl::init(app_state.fs.clone(), cx); repl::notebook::init(cx); tasks_ui::init(cx); @@ -4965,34 +5021,6 @@ mod tests { }) } - fn rust_lang() -> Arc { - Arc::new(language::Language::new( - language::LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )) - } - - fn markdown_language() -> Arc { - Arc::new(language::Language::new( - language::LanguageConfig { - name: "Markdown".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["md".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_md::LANGUAGE.into()), - )) - } - #[track_caller] fn assert_key_bindings_for( window: AnyWindowHandle, @@ -5140,6 +5168,28 @@ mod tests { ); } + #[gpui::test] + async fn test_disable_ai_crash(cx: &mut gpui::TestAppContext) { + let app_state = init_test(cx); + cx.update(init); + let project = Project::test(app_state.fs.clone(), [], cx).await; + let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + + cx.run_until_parked(); + + cx.update(|cx| { + SettingsStore::update_global(cx, |settings_store, cx| { + settings_store.update_user_settings(cx, |settings| { + settings.disable_ai = Some(SaturatingBool(true)); + }); + }); + }); + + cx.run_until_parked(); + + // If this panics, the test has failed + } + #[gpui::test] async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 9f91b237101044a870dda66d38edaeee08bc733d..7cdeddab790dcc2227d4d91956a6261c5add5259 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -32,10 +32,10 @@ pub fn app_menus(cx: &mut App) -> Vec

{ MenuItem::submenu(Menu { name: "Editor Layout".into(), items: vec![ - MenuItem::action("Split Up", workspace::SplitUp), - MenuItem::action("Split Down", workspace::SplitDown), - MenuItem::action("Split Left", workspace::SplitLeft), - MenuItem::action("Split Right", workspace::SplitRight), + MenuItem::action("Split Up", workspace::SplitUp::default()), + MenuItem::action("Split Down", workspace::SplitDown::default()), + MenuItem::action("Split Left", workspace::SplitLeft::default()), + MenuItem::action("Split Right", workspace::SplitRight::default()), ], }), MenuItem::separator(), @@ -169,7 +169,7 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::os_action("Paste", editor::actions::Paste, OsAction::Paste), MenuItem::separator(), MenuItem::action("Find", search::buffer_search::Deploy::find()), - MenuItem::action("Find In Project", workspace::DeploySearch::find()), + MenuItem::action("Find in Project", workspace::DeploySearch::find()), MenuItem::separator(), MenuItem::action( "Toggle Line Comment", @@ -247,7 +247,10 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::action("Go to Definition", editor::actions::GoToDefinition), MenuItem::action("Go to Declaration", editor::actions::GoToDeclaration), MenuItem::action("Go to Type Definition", editor::actions::GoToTypeDefinition), - MenuItem::action("Find All References", editor::actions::FindAllReferences), + MenuItem::action( + "Find All References", + editor::actions::FindAllReferences::default(), + ), MenuItem::separator(), MenuItem::action("Next Problem", editor::actions::GoToDiagnostic::default()), MenuItem::action( @@ -277,7 +280,7 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::separator(), MenuItem::action("Toggle Breakpoint", editor::actions::ToggleBreakpoint), MenuItem::action("Edit Breakpoint", editor::actions::EditLogBreakpoint), - MenuItem::action("Clear all Breakpoints", debugger_ui::ClearAllBreakpoints), + MenuItem::action("Clear All Breakpoints", debugger_ui::ClearAllBreakpoints), ], }, Menu { diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index f413fd94cb1a48adb213120364ed2f59c4cf58e0..51327bfc9ab715a1b11aa3c639ffd60b6b0a0ea8 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -1,20 +1,21 @@ use client::{Client, UserStore}; -use codestral::CodestralCompletionProvider; +use codestral::CodestralEditPredictionDelegate; use collections::HashMap; -use copilot::{Copilot, CopilotCompletionProvider}; +use copilot::{Copilot, CopilotEditPredictionDelegate}; +use edit_prediction::{SweepFeatureFlag, ZedEditPredictionDelegate, Zeta2FeatureFlag}; use editor::Editor; use feature_flags::FeatureFlagAppExt; use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity}; use language::language_settings::{EditPredictionProvider, all_language_settings}; use language_models::MistralLanguageModelProvider; use settings::{ + EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, SettingsStore, }; use std::{cell::RefCell, rc::Rc, sync::Arc}; -use supermaven::{Supermaven, SupermavenCompletionProvider}; +use supermaven::{Supermaven, SupermavenEditPredictionDelegate}; use ui::Window; -use zeta::{SweepFeatureFlag, Zeta2FeatureFlag, ZetaEditPredictionProvider}; pub fn init(client: Arc, user_store: Entity, cx: &mut App) { let editors: Rc, AnyWindowHandle>>> = Rc::default(); @@ -59,7 +60,7 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { }) .detach(); - cx.on_action(clear_zeta_edit_history); + cx.on_action(clear_edit_prediction_store_edit_history); let mut provider = all_language_settings(None, cx).edit_predictions.provider; cx.subscribe(&user_store, { @@ -100,9 +101,9 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { .detach(); } -fn clear_zeta_edit_history(_: &zeta::ClearHistory, cx: &mut App) { - if let Some(zeta) = zeta::Zeta::try_global(cx) { - zeta.update(cx, |zeta, _| zeta.clear_history()); +fn clear_edit_prediction_store_edit_history(_: &edit_prediction::ClearHistory, cx: &mut App) { + if let Some(ep_store) = edit_prediction::EditPredictionStore::try_global(cx) { + ep_store.update(cx, |ep_store, _| ep_store.clear_history()); } } @@ -144,23 +145,6 @@ fn register_backward_compatible_actions(editor: &mut Editor, cx: &mut Context| { - editor.next_edit_prediction(&Default::default(), window, cx); - }, - )) - .detach(); - editor - .register_action(cx.listener( - |editor, - _: &copilot::PreviousSuggestion, - window: &mut Window, - cx: &mut Context| { - editor.previous_edit_prediction(&Default::default(), window, cx); - }, - )) - .detach(); } fn assign_edit_prediction_provider( @@ -176,7 +160,7 @@ fn assign_edit_prediction_provider( match provider { EditPredictionProvider::None => { - editor.set_edit_prediction_provider::(None, window, cx); + editor.set_edit_prediction_provider::(None, window, cx); } EditPredictionProvider::Copilot => { if let Some(copilot) = Copilot::global(cx) { @@ -187,55 +171,65 @@ fn assign_edit_prediction_provider( copilot.register_buffer(&buffer, cx); }); } - let provider = cx.new(|_| CopilotCompletionProvider::new(copilot)); + let provider = cx.new(|_| CopilotEditPredictionDelegate::new(copilot)); editor.set_edit_prediction_provider(Some(provider), window, cx); } } EditPredictionProvider::Supermaven => { if let Some(supermaven) = Supermaven::global(cx) { - let provider = cx.new(|_| SupermavenCompletionProvider::new(supermaven)); + let provider = cx.new(|_| SupermavenEditPredictionDelegate::new(supermaven)); editor.set_edit_prediction_provider(Some(provider), window, cx); } } EditPredictionProvider::Codestral => { let http_client = client.http_client(); - let provider = cx.new(|_| CodestralCompletionProvider::new(http_client)); + let provider = cx.new(|_| CodestralEditPredictionDelegate::new(http_client)); editor.set_edit_prediction_provider(Some(provider), window, cx); } value @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed) => { - let zeta = zeta::Zeta::global(client, &user_store, cx); + let ep_store = edit_prediction::EditPredictionStore::global(client, &user_store, cx); if let Some(project) = editor.project() && let Some(buffer) = &singleton_buffer && buffer.read(cx).file().is_some() { - let has_model = zeta.update(cx, |zeta, cx| { + let has_model = ep_store.update(cx, |ep_store, cx| { let model = if let EditPredictionProvider::Experimental(name) = value { if name == EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME && cx.has_flag::() { - zeta::ZetaEditPredictionModel::Sweep + edit_prediction::EditPredictionModel::Sweep } else if name == EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME && cx.has_flag::() { - zeta::ZetaEditPredictionModel::Zeta2 + edit_prediction::EditPredictionModel::Zeta2 + } else if name == EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME + && cx.has_flag::() + { + edit_prediction::EditPredictionModel::Mercury } else { return false; } } else if user_store.read(cx).current_user().is_some() { - zeta::ZetaEditPredictionModel::Zeta1 + edit_prediction::EditPredictionModel::Zeta1 } else { return false; }; - zeta.set_edit_prediction_model(model); - zeta.register_buffer(buffer, project, cx); + ep_store.set_edit_prediction_model(model); + ep_store.register_buffer(buffer, project, cx); true }); if has_model { let provider = cx.new(|cx| { - ZetaEditPredictionProvider::new(project.clone(), &client, &user_store, cx) + ZedEditPredictionDelegate::new( + project.clone(), + singleton_buffer, + &client, + &user_store, + cx, + ) }); editor.set_edit_prediction_provider(Some(provider), window, cx); } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 13b636731798ebe13bb7c9ae8d97bf52356ea0b2..842f98520133c70f711d84d3f490bec1ec59e16f 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -3,13 +3,14 @@ use crate::restorable_workspace_locations; use anyhow::{Context as _, Result, anyhow}; use cli::{CliRequest, CliResponse, ipc::IpcSender}; use cli::{IpcHandshake, ipc}; -use client::parse_zed_link; +use client::{ZedLink, parse_zed_link}; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use fs::Fs; use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures::channel::{mpsc, oneshot}; +use futures::future; use futures::future::join_all; use futures::{FutureExt, SinkExt, StreamExt}; use git_ui::file_diff_view::FileDiffView; @@ -24,6 +25,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::thread; use std::time::Duration; +use ui::SharedString; use util::ResultExt; use util::paths::PathWithPosition; use workspace::PathList; @@ -54,9 +56,15 @@ pub enum OpenRequestKind { schema_path: String, }, Setting { - // None just opens settings without navigating to a specific path + /// `None` opens settings without navigating to a specific path. setting_path: Option, }, + GitClone { + repo_url: SharedString, + }, + GitCommit { + sha: String, + }, } impl OpenRequest { @@ -109,10 +117,24 @@ impl OpenRequest { this.kind = Some(OpenRequestKind::Setting { setting_path: Some(setting_path.to_string()), }); + } else if let Some(clone_path) = url.strip_prefix("zed://git/clone") { + this.parse_git_clone_url(clone_path)? + } else if let Some(commit_path) = url.strip_prefix("zed://git/commit/") { + this.parse_git_commit_url(commit_path)? } else if url.starts_with("ssh://") { this.parse_ssh_file_path(&url, cx)? - } else if let Some(request_path) = parse_zed_link(&url, cx) { - this.parse_request_path(request_path).log_err(); + } else if let Some(zed_link) = parse_zed_link(&url, cx) { + match zed_link { + ZedLink::Channel { channel_id } => { + this.join_channel = Some(channel_id); + } + ZedLink::ChannelNotes { + channel_id, + heading, + } => { + this.open_channel_notes.push((channel_id, heading)); + } + } } else { log::error!("unhandled url: {}", url); } @@ -127,6 +149,48 @@ impl OpenRequest { } } + fn parse_git_clone_url(&mut self, clone_path: &str) -> Result<()> { + // Format: /?repo= or ?repo= + let clone_path = clone_path.strip_prefix('/').unwrap_or(clone_path); + + let query = clone_path + .strip_prefix('?') + .context("invalid git clone url: missing query string")?; + + let repo_url = url::form_urlencoded::parse(query.as_bytes()) + .find_map(|(key, value)| (key == "repo").then_some(value)) + .filter(|s| !s.is_empty()) + .context("invalid git clone url: missing repo query parameter")? + .to_string() + .into(); + + self.kind = Some(OpenRequestKind::GitClone { repo_url }); + + Ok(()) + } + + fn parse_git_commit_url(&mut self, commit_path: &str) -> Result<()> { + // Format: ?repo= + let (sha, query) = commit_path + .split_once('?') + .context("invalid git commit url: missing query string")?; + anyhow::ensure!(!sha.is_empty(), "invalid git commit url: missing sha"); + + let repo = url::form_urlencoded::parse(query.as_bytes()) + .find_map(|(key, value)| (key == "repo").then_some(value)) + .filter(|s| !s.is_empty()) + .context("invalid git commit url: missing repo query parameter")? + .to_string(); + + self.open_paths.push(repo); + + self.kind = Some(OpenRequestKind::GitCommit { + sha: sha.to_string(), + }); + + Ok(()) + } + fn parse_ssh_file_path(&mut self, file: &str, cx: &App) -> Result<()> { let url = url::Url::parse(file)?; let host = url @@ -156,31 +220,6 @@ impl OpenRequest { self.parse_file_path(url.path()); Ok(()) } - - fn parse_request_path(&mut self, request_path: &str) -> Result<()> { - let mut parts = request_path.split('/'); - if parts.next() == Some("channel") - && let Some(slug) = parts.next() - && let Some(id_str) = slug.split('-').next_back() - && let Ok(channel_id) = id_str.parse::() - { - let Some(next) = parts.next() else { - self.join_channel = Some(channel_id); - return Ok(()); - }; - - if let Some(heading) = next.strip_prefix("notes#") { - self.open_channel_notes - .push((channel_id, Some(heading.to_string()))); - return Ok(()); - } - if next == "notes" { - self.open_channel_notes.push((channel_id, None)); - return Ok(()); - } - } - anyhow::bail!("invalid zed url: {request_path}") - } } #[derive(Clone)] @@ -514,33 +553,27 @@ async fn open_local_workspace( app_state: &Arc, cx: &mut AsyncApp, ) -> bool { - let mut errored = false; - let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), workspace_paths).await; - // Handle reuse flag by finding existing window to replace - let replace_window = if reuse { - cx.update(|cx| workspace::local_workspace_windows(cx).into_iter().next()) - .ok() - .flatten() - } else { - None - }; - - // For reuse, force new workspace creation but with replace_window set - let effective_open_new_workspace = if reuse { - Some(true) + // If reuse flag is passed, open a new workspace in an existing window. + let (open_new_workspace, replace_window) = if reuse { + ( + Some(true), + cx.update(|cx| workspace::local_workspace_windows(cx).into_iter().next()) + .ok() + .flatten(), + ) } else { - open_new_workspace + (open_new_workspace, None) }; - match open_paths_with_positions( + let (workspace, items) = match open_paths_with_positions( &paths_with_position, &diff_paths, app_state.clone(), workspace::OpenOptions { - open_new_workspace: effective_open_new_workspace, + open_new_workspace, replace_window, prefer_focused_window: wait, env: env.cloned(), @@ -550,80 +583,95 @@ async fn open_local_workspace( ) .await { - Ok((workspace, items)) => { - let mut item_release_futures = Vec::new(); + Ok(result) => result, + Err(error) => { + responses + .send(CliResponse::Stderr { + message: format!("error opening {paths_with_position:?}: {error}"), + }) + .log_err(); + return true; + } + }; - for item in items { - match item { - Some(Ok(item)) => { - cx.update(|cx| { - let released = oneshot::channel(); - item.on_release( - cx, - Box::new(move |_| { - let _ = released.0.send(()); - }), - ) - .detach(); - item_release_futures.push(released.1); - }) - .log_err(); - } - Some(Err(err)) => { - responses - .send(CliResponse::Stderr { - message: err.to_string(), - }) - .log_err(); - errored = true; - } - None => {} - } + let mut errored = false; + let mut item_release_futures = Vec::new(); + let mut subscriptions = Vec::new(); + + // If --wait flag is used with no paths, or a directory, then wait until + // the entire workspace is closed. + if wait { + let mut wait_for_window_close = paths_with_position.is_empty() && diff_paths.is_empty(); + for path_with_position in &paths_with_position { + if app_state.fs.is_dir(&path_with_position.path).await { + wait_for_window_close = true; + break; } + } + + if wait_for_window_close { + let (release_tx, release_rx) = oneshot::channel(); + item_release_futures.push(release_rx); + subscriptions.push(workspace.update(cx, |_, _, cx| { + cx.on_release(move |_, _| { + let _ = release_tx.send(()); + }) + })); + } + } - if wait { - let background = cx.background_executor().clone(); - let wait = async move { - if paths_with_position.is_empty() && diff_paths.is_empty() { - let (done_tx, done_rx) = oneshot::channel(); - let _subscription = workspace.update(cx, |_, _, cx| { - cx.on_release(move |_, _| { - let _ = done_tx.send(()); - }) - }); - let _ = done_rx.await; - } else { - let _ = futures::future::try_join_all(item_release_futures).await; - }; + for item in items { + match item { + Some(Ok(item)) => { + if wait { + let (release_tx, release_rx) = oneshot::channel(); + item_release_futures.push(release_rx); + subscriptions.push(cx.update(|cx| { + item.on_release( + cx, + Box::new(move |_| { + release_tx.send(()).ok(); + }), + ) + })); } - .fuse(); - - futures::pin_mut!(wait); - - loop { - // Repeatedly check if CLI is still open to avoid wasting resources - // waiting for files or workspaces to close. - let mut timer = background.timer(Duration::from_secs(1)).fuse(); - futures::select_biased! { - _ = wait => break, - _ = timer => { - if responses.send(CliResponse::Ping).is_err() { - break; - } - } + } + Some(Err(err)) => { + responses + .send(CliResponse::Stderr { + message: err.to_string(), + }) + .log_err(); + errored = true; + } + None => {} + } + } + + if wait { + let wait = async move { + let _subscriptions = subscriptions; + let _ = future::try_join_all(item_release_futures).await; + } + .fuse(); + futures::pin_mut!(wait); + + let background = cx.background_executor().clone(); + loop { + // Repeatedly check if CLI is still open to avoid wasting resources + // waiting for files or workspaces to close. + let mut timer = background.timer(Duration::from_secs(1)).fuse(); + futures::select_biased! { + _ = wait => break, + _ = timer => { + if responses.send(CliResponse::Ping).is_err() { + break; } } } } - Err(error) => { - errored = true; - responses - .send(CliResponse::Stderr { - message: format!("error opening {paths_with_position:?}: {error}"), - }) - .log_err(); - } } + errored } @@ -653,12 +701,13 @@ mod tests { ipc::{self}, }; use editor::Editor; - use gpui::TestAppContext; + use futures::poll; + use gpui::{AppContext as _, TestAppContext}; use language::LineEnding; use remote::SshConnectionOptions; use rope::Rope; use serde_json::json; - use std::sync::Arc; + use std::{sync::Arc, task::Poll}; use util::path; use workspace::{AppState, Workspace}; @@ -686,11 +735,92 @@ mod tests { port_forwards: None, nickname: None, upload_binary_over_ssh: false, + connection_timeout: None, }) ); assert_eq!(request.open_paths, vec!["/"]); } + #[gpui::test] + fn test_parse_git_commit_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + // Test basic git commit URL + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://git/commit/abc123?repo=path/to/repo".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind.unwrap() { + OpenRequestKind::GitCommit { sha } => { + assert_eq!(sha, "abc123"); + } + _ => panic!("expected GitCommit variant"), + } + // Verify path was added to open_paths for workspace routing + assert_eq!(request.open_paths, vec!["path/to/repo"]); + + // Test with URL encoded path + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://git/commit/def456?repo=path%20with%20spaces".into()], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind.unwrap() { + OpenRequestKind::GitCommit { sha } => { + assert_eq!(sha, "def456"); + } + _ => panic!("expected GitCommit variant"), + } + assert_eq!(request.open_paths, vec!["path with spaces"]); + + // Test with empty path + cx.update(|cx| { + assert!( + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://git/commit/abc123?repo=".into()], + ..Default::default() + }, + cx, + ) + .unwrap_err() + .to_string() + .contains("missing repo") + ); + }); + + // Test error case: missing SHA + let result = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec!["zed://git/commit/abc123?foo=bar".into()], + ..Default::default() + }, + cx, + ) + }); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("missing repo query parameter") + ); + } + #[gpui::test] async fn test_open_workspace_with_directory(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -753,6 +883,60 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_wait_with_directory_waits_for_window_close(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "dir1": { + "file1.txt": "content1", + }, + }), + ) + .await; + + let (response_tx, _) = ipc::channel::().unwrap(); + let workspace_paths = vec![path!("/root/dir1").to_owned()]; + + let (done_tx, mut done_rx) = futures::channel::oneshot::channel(); + cx.spawn({ + let app_state = app_state.clone(); + move |mut cx| async move { + let errored = open_local_workspace( + workspace_paths, + vec![], + None, + false, + true, + &response_tx, + None, + &app_state, + &mut cx, + ) + .await; + let _ = done_tx.send(errored); + } + }) + .detach(); + + cx.background_executor.run_until_parked(); + assert_eq!(cx.windows().len(), 1); + assert!(matches!(poll!(&mut done_rx), Poll::Pending)); + + let window = cx.windows()[0]; + cx.update_window(window, |_, window, _| window.remove_window()) + .unwrap(); + cx.background_executor.run_until_parked(); + + let errored = done_rx.await.unwrap(); + assert!(!errored); + } + #[gpui::test] async fn test_open_workspace_with_nonexistent_files(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -929,4 +1113,80 @@ mod tests { assert!(!errored_reuse); } + + #[gpui::test] + fn test_parse_git_clone_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec![ + "zed://git/clone/?repo=https://github.com/zed-industries/zed.git".into(), + ], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::GitClone { repo_url }) => { + assert_eq!(repo_url, "https://github.com/zed-industries/zed.git"); + } + _ => panic!("Expected GitClone kind"), + } + } + + #[gpui::test] + fn test_parse_git_clone_url_without_slash(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec![ + "zed://git/clone?repo=https://github.com/zed-industries/zed.git".into(), + ], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::GitClone { repo_url }) => { + assert_eq!(repo_url, "https://github.com/zed-industries/zed.git"); + } + _ => panic!("Expected GitClone kind"), + } + } + + #[gpui::test] + fn test_parse_git_clone_url_with_encoding(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + + let request = cx.update(|cx| { + OpenRequest::parse( + RawOpenRequest { + urls: vec![ + "zed://git/clone/?repo=https%3A%2F%2Fgithub.com%2Fzed-industries%2Fzed.git" + .into(), + ], + ..Default::default() + }, + cx, + ) + .unwrap() + }); + + match request.kind { + Some(OpenRequestKind::GitClone { repo_url }) => { + assert_eq!(repo_url, "https://github.com/zed-industries/zed.git"); + } + _ => panic!("Expected GitClone kind"), + } + } } diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index 402881680232ea636f7cb105db759f417a435145..2a52cc697249cb1f8eb280a48c89ff5aadf6fd85 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -174,17 +174,13 @@ impl Render for QuickActionBar { .as_ref() .is_some_and(|menu| matches!(menu.origin(), ContextMenuOrigin::QuickActionBar)) }; - let code_action_element = if is_deployed { - editor.update(cx, |editor, cx| { - if let Some(style) = editor.style() { - editor.render_context_menu(style, MAX_CODE_ACTION_MENU_LINES, window, cx) - } else { - None - } + let code_action_element = is_deployed + .then(|| { + editor.update(cx, |editor, cx| { + editor.render_context_menu(MAX_CODE_ACTION_MENU_LINES, window, cx) + }) }) - } else { - None - }; + .flatten(); v_flex() .child( IconButton::new("toggle_code_actions_icon", IconName::BoltOutlined) diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 803fde3f8787b4f6489bd6390d289c35b1c96199..85b6d4d37d06d5f1c229fc852dd5bad117bbd9d7 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -70,6 +70,8 @@ actions!( OpenTelemetryLog, /// Opens the performance profiler. OpenPerformanceProfiler, + /// Opens the onboarding view. + OpenOnboarding, ] ); @@ -215,6 +217,10 @@ pub mod git { Switch, /// Selects a different repository. SelectRepo, + /// Filter remotes. + FilterRemotes, + /// Create a git remote. + CreateRemote, /// Opens the git branch selector. #[action(deprecated_aliases = ["branches::OpenRecent"])] Branch, @@ -346,6 +352,10 @@ pub mod agent { AddSelectionToThread, /// Resets the agent panel zoom levels (agent UI and buffer font sizes). ResetAgentZoom, + /// Toggles the utility/agent pane open/closed state. + ToggleAgentPane, + /// Pastes clipboard content without any formatting. + PasteRaw, ] ); } @@ -424,6 +434,12 @@ pub struct OpenRemote { pub create_new_window: bool, } +/// Opens the dev container connection modal. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = projects)] +#[serde(deny_unknown_fields)] +pub struct OpenDevContainer; + /// Where to spawn the task in the UI. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] diff --git a/crates/zed_env_vars/src/zed_env_vars.rs b/crates/zed_env_vars/src/zed_env_vars.rs index 53b9c22bb207e81831d1d9ae6087d1a297331d3f..e601cc9536602ac943bd76bf1bfd8b8ac8979dd9 100644 --- a/crates/zed_env_vars/src/zed_env_vars.rs +++ b/crates/zed_env_vars/src/zed_env_vars.rs @@ -5,6 +5,7 @@ use std::sync::LazyLock; /// When true, Zed will use in-memory databases instead of persistent storage. pub static ZED_STATELESS: LazyLock = bool_env_var!("ZED_STATELESS"); +#[derive(Clone)] pub struct EnvVar { pub name: SharedString, /// Value of the environment variable. Also `None` when set to an empty string. @@ -30,7 +31,7 @@ impl EnvVar { #[macro_export] macro_rules! env_var { ($name:expr) => { - LazyLock::new(|| $crate::EnvVar::new(($name).into())) + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into())) }; } @@ -39,6 +40,6 @@ macro_rules! env_var { #[macro_export] macro_rules! bool_env_var { ($name:expr) => { - LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) }; } diff --git a/crates/zeta/Cargo.toml b/crates/zeta/Cargo.toml deleted file mode 100644 index 7429fcb8e8d5e4b485f69ea87c37d7d670c3b199..0000000000000000000000000000000000000000 --- a/crates/zeta/Cargo.toml +++ /dev/null @@ -1,84 +0,0 @@ -[package] -name = "zeta" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/zeta.rs" - -[features] -eval-support = [] - -[dependencies] -ai_onboarding.workspace = true -anyhow.workspace = true -arrayvec.workspace = true -brotli.workspace = true -buffer_diff.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -cloud_zeta2_prompt.workspace = true -collections.workspace = true -command_palette_hooks.workspace = true -copilot.workspace = true -credentials_provider.workspace = true -db.workspace = true -edit_prediction.workspace = true -edit_prediction_context.workspace = true -editor.workspace = true -feature_flags.workspace = true -fs.workspace = true -futures.workspace = true -gpui.workspace = true -indoc.workspace = true -itertools.workspace = true -language.workspace = true -language_model.workspace = true -log.workspace = true -lsp.workspace = true -markdown.workspace = true -menu.workspace = true -open_ai.workspace = true -postage.workspace = true -pretty_assertions.workspace = true -project.workspace = true -rand.workspace = true -regex.workspace = true -release_channel.workspace = true -semver.workspace = true -serde.workspace = true -serde_json.workspace = true -settings.workspace = true -smol.workspace = true -strsim.workspace = true -strum.workspace = true -telemetry.workspace = true -telemetry_events.workspace = true -theme.workspace = true -thiserror.workspace = true -ui.workspace = true -util.workspace = true -uuid.workspace = true -workspace.workspace = true -worktree.workspace = true -zed_actions.workspace = true - -[dev-dependencies] -clock = { workspace = true, features = ["test-support"] } -cloud_api_types.workspace = true -cloud_llm_client = { workspace = true, features = ["test-support"] } -ctor.workspace = true -gpui = { workspace = true, features = ["test-support"] } -indoc.workspace = true -language = { workspace = true, features = ["test-support"] } -language_model = { workspace = true, features = ["test-support"] } -lsp.workspace = true -parking_lot.workspace = true -project = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } -zlog.workspace = true diff --git a/crates/zeta/src/assemble_excerpts.rs b/crates/zeta/src/assemble_excerpts.rs deleted file mode 100644 index f2a5b5adb1fcffab945cd9bdb88153bc5e494138..0000000000000000000000000000000000000000 --- a/crates/zeta/src/assemble_excerpts.rs +++ /dev/null @@ -1,173 +0,0 @@ -use cloud_llm_client::predict_edits_v3::Excerpt; -use edit_prediction_context::Line; -use language::{BufferSnapshot, Point}; -use std::ops::Range; - -pub fn assemble_excerpts( - buffer: &BufferSnapshot, - merged_line_ranges: impl IntoIterator>, -) -> Vec { - let mut output = Vec::new(); - - let outline_items = buffer.outline_items_as_points_containing(0..buffer.len(), false, None); - let mut outline_items = outline_items.into_iter().peekable(); - - for range in merged_line_ranges { - let point_range = Point::new(range.start.0, 0)..Point::new(range.end.0, 0); - - while let Some(outline_item) = outline_items.peek() { - if outline_item.range.start >= point_range.start { - break; - } - if outline_item.range.end > point_range.start { - let mut point_range = outline_item.source_range_for_text.clone(); - point_range.start.column = 0; - point_range.end.column = buffer.line_len(point_range.end.row); - - output.push(Excerpt { - start_line: Line(point_range.start.row), - text: buffer - .text_for_range(point_range.clone()) - .collect::() - .into(), - }) - } - outline_items.next(); - } - - output.push(Excerpt { - start_line: Line(point_range.start.row), - text: buffer - .text_for_range(point_range.clone()) - .collect::() - .into(), - }) - } - - output -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::*; - use cloud_llm_client::predict_edits_v3; - use gpui::{TestAppContext, prelude::*}; - use indoc::indoc; - use language::{Buffer, Language, LanguageConfig, LanguageMatcher, OffsetRangeExt}; - use pretty_assertions::assert_eq; - use util::test::marked_text_ranges; - - #[gpui::test] - fn test_rust(cx: &mut TestAppContext) { - let table = [ - ( - indoc! {r#" - struct User { - first_name: String, - « last_name: String, - ageˇ: u32, - » email: String, - create_at: Instant, - } - - impl User { - pub fn first_name(&self) -> String { - self.first_name.clone() - } - - pub fn full_name(&self) -> String { - « format!("{} {}", self.first_name, self.last_name) - » } - } - "#}, - indoc! {r#" - 1|struct User { - … - 3| last_name: String, - 4| age<|cursor|>: u32, - … - 9|impl User { - … - 14| pub fn full_name(&self) -> String { - 15| format!("{} {}", self.first_name, self.last_name) - … - "#}, - ), - ( - indoc! {r#" - struct User { - first_name: String, - « last_name: String, - age: u32, - } - »"# - }, - indoc! {r#" - 1|struct User { - … - 3| last_name: String, - 4| age: u32, - 5|} - "#}, - ), - ]; - - for (input, expected_output) in table { - let input_without_ranges = input.replace(['«', '»'], ""); - let input_without_caret = input.replace('ˇ', ""); - let cursor_offset = input_without_ranges.find('ˇ'); - let (input, ranges) = marked_text_ranges(&input_without_caret, false); - let buffer = - cx.new(|cx| Buffer::local(input, cx).with_language(Arc::new(rust_lang()), cx)); - buffer.read_with(cx, |buffer, _cx| { - let insertions = cursor_offset - .map(|offset| { - let point = buffer.offset_to_point(offset); - vec![( - predict_edits_v3::Point { - line: Line(point.row), - column: point.column, - }, - "<|cursor|>", - )] - }) - .unwrap_or_default(); - let ranges: Vec> = ranges - .into_iter() - .map(|range| { - let point_range = range.to_point(&buffer); - Line(point_range.start.row)..Line(point_range.end.row) - }) - .collect(); - - let mut output = String::new(); - cloud_zeta2_prompt::write_excerpts( - assemble_excerpts(&buffer.snapshot(), ranges).iter(), - &insertions, - Line(buffer.max_point().row), - true, - &mut output, - ); - assert_eq!(output, expected_output); - }); - } - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(language::tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } -} diff --git a/crates/zeta/src/provider.rs b/crates/zeta/src/provider.rs deleted file mode 100644 index 019d780e579c079f745f56136bdbd3a4add76b50..0000000000000000000000000000000000000000 --- a/crates/zeta/src/provider.rs +++ /dev/null @@ -1,217 +0,0 @@ -use std::{cmp, sync::Arc, time::Duration}; - -use client::{Client, UserStore}; -use cloud_llm_client::EditPredictionRejectReason; -use edit_prediction::{DataCollectionState, Direction, EditPredictionProvider}; -use gpui::{App, Entity, prelude::*}; -use language::ToPoint as _; -use project::Project; - -use crate::{BufferEditPrediction, Zeta, ZetaEditPredictionModel}; - -pub struct ZetaEditPredictionProvider { - zeta: Entity, - project: Entity, -} - -impl ZetaEditPredictionProvider { - pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); - - pub fn new( - project: Entity, - client: &Arc, - user_store: &Entity, - cx: &mut Context, - ) -> Self { - let zeta = Zeta::global(client, user_store, cx); - zeta.update(cx, |zeta, cx| { - zeta.register_project(&project, cx); - }); - - cx.observe(&zeta, |_this, _zeta, cx| { - cx.notify(); - }) - .detach(); - - Self { - project: project, - zeta, - } - } -} - -impl EditPredictionProvider for ZetaEditPredictionProvider { - fn name() -> &'static str { - "zed-predict2" - } - - fn display_name() -> &'static str { - "Zed's Edit Predictions 2" - } - - fn show_completions_in_menu() -> bool { - true - } - - fn show_tab_accept_marker() -> bool { - true - } - - fn data_collection_state(&self, _cx: &App) -> DataCollectionState { - // TODO [zeta2] - DataCollectionState::Unsupported - } - - fn toggle_data_collection(&mut self, _cx: &mut App) { - // TODO [zeta2] - } - - fn usage(&self, cx: &App) -> Option { - self.zeta.read(cx).usage(cx) - } - - fn is_enabled( - &self, - _buffer: &Entity, - _cursor_position: language::Anchor, - cx: &App, - ) -> bool { - let zeta = self.zeta.read(cx); - if zeta.edit_prediction_model == ZetaEditPredictionModel::Sweep { - zeta.has_sweep_api_token() - } else { - true - } - } - - fn is_refreshing(&self, cx: &App) -> bool { - self.zeta.read(cx).is_refreshing(&self.project) - } - - fn refresh( - &mut self, - buffer: Entity, - cursor_position: language::Anchor, - _debounce: bool, - cx: &mut Context, - ) { - let zeta = self.zeta.read(cx); - - if zeta.user_store.read_with(cx, |user_store, _cx| { - user_store.account_too_young() || user_store.has_overdue_invoices() - }) { - return; - } - - if let Some(current) = zeta.current_prediction_for_buffer(&buffer, &self.project, cx) - && let BufferEditPrediction::Local { prediction } = current - && prediction.interpolate(buffer.read(cx)).is_some() - { - return; - } - - self.zeta.update(cx, |zeta, cx| { - zeta.refresh_context_if_needed(&self.project, &buffer, cursor_position, cx); - zeta.refresh_prediction_from_buffer(self.project.clone(), buffer, cursor_position, cx) - }); - } - - fn cycle( - &mut self, - _buffer: Entity, - _cursor_position: language::Anchor, - _direction: Direction, - _cx: &mut Context, - ) { - } - - fn accept(&mut self, cx: &mut Context) { - self.zeta.update(cx, |zeta, cx| { - zeta.accept_current_prediction(&self.project, cx); - }); - } - - fn discard(&mut self, cx: &mut Context) { - self.zeta.update(cx, |zeta, _cx| { - zeta.reject_current_prediction(EditPredictionRejectReason::Discarded, &self.project); - }); - } - - fn did_show(&mut self, cx: &mut Context) { - self.zeta.update(cx, |zeta, cx| { - zeta.did_show_current_prediction(&self.project, cx); - }); - } - - fn suggest( - &mut self, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut Context, - ) -> Option { - let prediction = - self.zeta - .read(cx) - .current_prediction_for_buffer(buffer, &self.project, cx)?; - - let prediction = match prediction { - BufferEditPrediction::Local { prediction } => prediction, - BufferEditPrediction::Jump { prediction } => { - return Some(edit_prediction::EditPrediction::Jump { - id: Some(prediction.id.to_string().into()), - snapshot: prediction.snapshot.clone(), - target: prediction.edits.first().unwrap().0.start, - }); - } - }; - - let buffer = buffer.read(cx); - let snapshot = buffer.snapshot(); - - let Some(edits) = prediction.interpolate(&snapshot) else { - self.zeta.update(cx, |zeta, _cx| { - zeta.reject_current_prediction( - EditPredictionRejectReason::InterpolatedEmpty, - &self.project, - ); - }); - return None; - }; - - let cursor_row = cursor_position.to_point(&snapshot).row; - let (closest_edit_ix, (closest_edit_range, _)) = - edits.iter().enumerate().min_by_key(|(_, (range, _))| { - let distance_from_start = cursor_row.abs_diff(range.start.to_point(&snapshot).row); - let distance_from_end = cursor_row.abs_diff(range.end.to_point(&snapshot).row); - cmp::min(distance_from_start, distance_from_end) - })?; - - let mut edit_start_ix = closest_edit_ix; - for (range, _) in edits[..edit_start_ix].iter().rev() { - let distance_from_closest_edit = closest_edit_range.start.to_point(&snapshot).row - - range.end.to_point(&snapshot).row; - if distance_from_closest_edit <= 1 { - edit_start_ix -= 1; - } else { - break; - } - } - - let mut edit_end_ix = closest_edit_ix + 1; - for (range, _) in &edits[edit_end_ix..] { - let distance_from_closest_edit = - range.start.to_point(buffer).row - closest_edit_range.end.to_point(&snapshot).row; - if distance_from_closest_edit <= 1 { - edit_end_ix += 1; - } else { - break; - } - } - - Some(edit_prediction::EditPrediction::Local { - id: Some(prediction.id.to_string().into()), - edits: edits[edit_start_ix..edit_end_ix].to_vec(), - edit_preview: Some(prediction.edit_preview.clone()), - }) - } -} diff --git a/crates/zeta/src/retrieval_search.rs b/crates/zeta/src/retrieval_search.rs deleted file mode 100644 index bcc0233ff7e872a151ecddf2cf55a3cb434f02b3..0000000000000000000000000000000000000000 --- a/crates/zeta/src/retrieval_search.rs +++ /dev/null @@ -1,642 +0,0 @@ -use anyhow::Result; -use cloud_zeta2_prompt::retrieval_prompt::SearchToolQuery; -use collections::HashMap; -use futures::{ - StreamExt, - channel::mpsc::{self, UnboundedSender}, -}; -use gpui::{AppContext, AsyncApp, Entity}; -use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt, Point, ToOffset, ToPoint}; -use project::{ - Project, WorktreeSettings, - search::{SearchQuery, SearchResult}, -}; -use smol::channel; -use std::ops::Range; -use util::{ - ResultExt as _, - paths::{PathMatcher, PathStyle}, -}; -use workspace::item::Settings as _; - -#[cfg(feature = "eval-support")] -type CachedSearchResults = std::collections::BTreeMap>>; - -pub async fn run_retrieval_searches( - queries: Vec, - project: Entity, - #[cfg(feature = "eval-support")] eval_cache: Option>, - cx: &mut AsyncApp, -) -> Result, Vec>>> { - #[cfg(feature = "eval-support")] - let cache = if let Some(eval_cache) = eval_cache { - use crate::EvalCacheEntryKind; - use anyhow::Context; - use collections::FxHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = FxHasher::default(); - project.read_with(cx, |project, cx| { - let mut worktrees = project.worktrees(cx); - let Some(worktree) = worktrees.next() else { - panic!("Expected a single worktree in eval project. Found none."); - }; - assert!( - worktrees.next().is_none(), - "Expected a single worktree in eval project. Found more than one." - ); - worktree.read(cx).abs_path().hash(&mut hasher); - })?; - - queries.hash(&mut hasher); - let key = (EvalCacheEntryKind::Search, hasher.finish()); - - if let Some(cached_results) = eval_cache.read(key) { - let file_results = serde_json::from_str::(&cached_results) - .context("Failed to deserialize cached search results")?; - let mut results = HashMap::default(); - - for (path, ranges) in file_results { - let buffer = project - .update(cx, |project, cx| { - let project_path = project.find_project_path(path, cx).unwrap(); - project.open_buffer(project_path, cx) - })? - .await?; - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?; - let mut ranges: Vec<_> = ranges - .into_iter() - .map(|range| { - snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end) - }) - .collect(); - merge_anchor_ranges(&mut ranges, &snapshot); - results.insert(buffer, ranges); - } - - return Ok(results); - } - - Some((eval_cache, serde_json::to_string_pretty(&queries)?, key)) - } else { - None - }; - - let (exclude_matcher, path_style) = project.update(cx, |project, cx| { - let global_settings = WorktreeSettings::get_global(cx); - let exclude_patterns = global_settings - .file_scan_exclusions - .sources() - .chain(global_settings.private_files.sources()); - let path_style = project.path_style(cx); - anyhow::Ok((PathMatcher::new(exclude_patterns, path_style)?, path_style)) - })??; - - let (results_tx, mut results_rx) = mpsc::unbounded(); - - for query in queries { - let exclude_matcher = exclude_matcher.clone(); - let results_tx = results_tx.clone(); - let project = project.clone(); - cx.spawn(async move |cx| { - run_query( - query, - results_tx.clone(), - path_style, - exclude_matcher, - &project, - cx, - ) - .await - .log_err(); - }) - .detach() - } - drop(results_tx); - - #[cfg(feature = "eval-support")] - let cache = cache.clone(); - cx.background_spawn(async move { - let mut results: HashMap, Vec>> = HashMap::default(); - let mut snapshots = HashMap::default(); - - let mut total_bytes = 0; - 'outer: while let Some((buffer, snapshot, excerpts)) = results_rx.next().await { - snapshots.insert(buffer.entity_id(), snapshot); - let existing = results.entry(buffer).or_default(); - existing.reserve(excerpts.len()); - - for (range, size) in excerpts { - // Blunt trimming of the results until we have a proper algorithmic filtering step - if (total_bytes + size) > MAX_RESULTS_LEN { - log::trace!("Combined results reached limit of {MAX_RESULTS_LEN}B"); - break 'outer; - } - total_bytes += size; - existing.push(range); - } - } - - #[cfg(feature = "eval-support")] - if let Some((cache, queries, key)) = cache { - let cached_results: CachedSearchResults = results - .iter() - .filter_map(|(buffer, ranges)| { - let snapshot = snapshots.get(&buffer.entity_id())?; - let path = snapshot.file().map(|f| f.path()); - let mut ranges = ranges - .iter() - .map(|range| range.to_offset(&snapshot)) - .collect::>(); - ranges.sort_unstable_by_key(|range| (range.start, range.end)); - - Some((path?.as_std_path().to_path_buf(), ranges)) - }) - .collect(); - cache.write( - key, - &queries, - &serde_json::to_string_pretty(&cached_results)?, - ); - } - - for (buffer, ranges) in results.iter_mut() { - if let Some(snapshot) = snapshots.get(&buffer.entity_id()) { - merge_anchor_ranges(ranges, snapshot); - } - } - - Ok(results) - }) - .await -} - -pub(crate) fn merge_anchor_ranges(ranges: &mut Vec>, snapshot: &BufferSnapshot) { - ranges.sort_unstable_by(|a, b| { - a.start - .cmp(&b.start, snapshot) - .then(b.end.cmp(&a.end, snapshot)) - }); - - let mut index = 1; - while index < ranges.len() { - if ranges[index - 1] - .end - .cmp(&ranges[index].start, snapshot) - .is_ge() - { - let removed = ranges.remove(index); - if removed.end.cmp(&ranges[index - 1].end, snapshot).is_gt() { - ranges[index - 1].end = removed.end; - } - } else { - index += 1; - } - } -} - -const MAX_EXCERPT_LEN: usize = 768; -const MAX_RESULTS_LEN: usize = MAX_EXCERPT_LEN * 5; - -struct SearchJob { - buffer: Entity, - snapshot: BufferSnapshot, - ranges: Vec>, - query_ix: usize, - jobs_tx: channel::Sender, -} - -async fn run_query( - input_query: SearchToolQuery, - results_tx: UnboundedSender<(Entity, BufferSnapshot, Vec<(Range, usize)>)>, - path_style: PathStyle, - exclude_matcher: PathMatcher, - project: &Entity, - cx: &mut AsyncApp, -) -> Result<()> { - let include_matcher = PathMatcher::new(vec![input_query.glob], path_style)?; - - let make_search = |regex: &str| -> Result { - SearchQuery::regex( - regex, - false, - true, - false, - true, - include_matcher.clone(), - exclude_matcher.clone(), - true, - None, - ) - }; - - if let Some(outer_syntax_regex) = input_query.syntax_node.first() { - let outer_syntax_query = make_search(outer_syntax_regex)?; - let nested_syntax_queries = input_query - .syntax_node - .into_iter() - .skip(1) - .map(|query| make_search(&query)) - .collect::>>()?; - let content_query = input_query - .content - .map(|regex| make_search(®ex)) - .transpose()?; - - let (jobs_tx, jobs_rx) = channel::unbounded(); - - let outer_search_results_rx = - project.update(cx, |project, cx| project.search(outer_syntax_query, cx))?; - - let outer_search_task = cx.spawn(async move |cx| { - futures::pin_mut!(outer_search_results_rx); - while let Some(SearchResult::Buffer { buffer, ranges }) = - outer_search_results_rx.next().await - { - buffer - .read_with(cx, |buffer, _| buffer.parsing_idle())? - .await; - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let expanded_ranges: Vec<_> = ranges - .into_iter() - .filter_map(|range| expand_to_parent_range(&range, &snapshot)) - .collect(); - jobs_tx - .send(SearchJob { - buffer, - snapshot, - ranges: expanded_ranges, - query_ix: 0, - jobs_tx: jobs_tx.clone(), - }) - .await?; - } - anyhow::Ok(()) - }); - - let n_workers = cx.background_executor().num_cpus(); - let search_job_task = cx.background_executor().scoped(|scope| { - for _ in 0..n_workers { - scope.spawn(async { - while let Ok(job) = jobs_rx.recv().await { - process_nested_search_job( - &results_tx, - &nested_syntax_queries, - &content_query, - job, - ) - .await; - } - }); - } - }); - - search_job_task.await; - outer_search_task.await?; - } else if let Some(content_regex) = &input_query.content { - let search_query = make_search(&content_regex)?; - - let results_rx = project.update(cx, |project, cx| project.search(search_query, cx))?; - futures::pin_mut!(results_rx); - - while let Some(SearchResult::Buffer { buffer, ranges }) = results_rx.next().await { - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - - let ranges = ranges - .into_iter() - .map(|range| { - let range = range.to_offset(&snapshot); - let range = expand_to_entire_lines(range, &snapshot); - let size = range.len(); - let range = - snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end); - (range, size) - }) - .collect(); - - let send_result = results_tx.unbounded_send((buffer.clone(), snapshot.clone(), ranges)); - - if let Err(err) = send_result - && !err.is_disconnected() - { - log::error!("{err}"); - } - } - } else { - log::warn!("Context gathering model produced a glob-only search"); - } - - anyhow::Ok(()) -} - -async fn process_nested_search_job( - results_tx: &UnboundedSender<(Entity, BufferSnapshot, Vec<(Range, usize)>)>, - queries: &Vec, - content_query: &Option, - job: SearchJob, -) { - if let Some(search_query) = queries.get(job.query_ix) { - let mut subranges = Vec::new(); - for range in job.ranges { - let start = range.start; - let search_results = search_query.search(&job.snapshot, Some(range)).await; - for subrange in search_results { - let subrange = start + subrange.start..start + subrange.end; - subranges.extend(expand_to_parent_range(&subrange, &job.snapshot)); - } - } - job.jobs_tx - .send(SearchJob { - buffer: job.buffer, - snapshot: job.snapshot, - ranges: subranges, - query_ix: job.query_ix + 1, - jobs_tx: job.jobs_tx.clone(), - }) - .await - .ok(); - } else { - let ranges = if let Some(content_query) = content_query { - let mut subranges = Vec::new(); - for range in job.ranges { - let start = range.start; - let search_results = content_query.search(&job.snapshot, Some(range)).await; - for subrange in search_results { - let subrange = start + subrange.start..start + subrange.end; - subranges.push(subrange); - } - } - subranges - } else { - job.ranges - }; - - let matches = ranges - .into_iter() - .map(|range| { - let snapshot = &job.snapshot; - let range = expand_to_entire_lines(range, snapshot); - let size = range.len(); - let range = snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end); - (range, size) - }) - .collect(); - - let send_result = results_tx.unbounded_send((job.buffer, job.snapshot, matches)); - - if let Err(err) = send_result - && !err.is_disconnected() - { - log::error!("{err}"); - } - } -} - -fn expand_to_entire_lines(range: Range, snapshot: &BufferSnapshot) -> Range { - let mut point_range = range.to_point(snapshot); - point_range.start.column = 0; - if point_range.end.column > 0 { - point_range.end = snapshot.max_point().min(point_range.end + Point::new(1, 0)); - } - point_range.to_offset(snapshot) -} - -fn expand_to_parent_range( - range: &Range, - snapshot: &BufferSnapshot, -) -> Option> { - let mut line_range = range.to_point(&snapshot); - line_range.start.column = snapshot.indent_size_for_line(line_range.start.row).len; - line_range.end.column = snapshot.line_len(line_range.end.row); - // TODO skip result if matched line isn't the first node line? - - let node = snapshot.syntax_ancestor(line_range)?; - Some(node.byte_range()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::assemble_excerpts::assemble_excerpts; - use cloud_zeta2_prompt::write_codeblock; - use edit_prediction_context::Line; - use gpui::TestAppContext; - use indoc::indoc; - use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; - use pretty_assertions::assert_eq; - use project::FakeFs; - use serde_json::json; - use settings::SettingsStore; - use std::path::Path; - use util::path; - - #[gpui::test] - async fn test_retrieval(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/root"), - json!({ - "user.rs": indoc!{" - pub struct Organization { - owner: Arc, - } - - pub struct User { - first_name: String, - last_name: String, - } - - impl Organization { - pub fn owner(&self) -> Arc { - self.owner.clone() - } - } - - impl User { - pub fn new(first_name: String, last_name: String) -> Self { - Self { - first_name, - last_name - } - } - - pub fn first_name(&self) -> String { - self.first_name.clone() - } - - pub fn last_name(&self) -> String { - self.last_name.clone() - } - } - "}, - "main.rs": indoc!{r#" - fn main() { - let user = User::new(FIRST_NAME.clone(), "doe".into()); - println!("user {:?}", user); - } - "#}, - }), - ) - .await; - - let project = Project::test(fs, vec![Path::new(path!("/root"))], cx).await; - project.update(cx, |project, _cx| { - project.languages().add(rust_lang().into()) - }); - - assert_results( - &project, - SearchToolQuery { - glob: "user.rs".into(), - syntax_node: vec!["impl\\s+User".into(), "pub\\s+fn\\s+first_name".into()], - content: None, - }, - indoc! {r#" - `````root/user.rs - … - impl User { - … - pub fn first_name(&self) -> String { - self.first_name.clone() - } - … - ````` - "#}, - cx, - ) - .await; - - assert_results( - &project, - SearchToolQuery { - glob: "user.rs".into(), - syntax_node: vec!["impl\\s+User".into()], - content: Some("\\.clone".into()), - }, - indoc! {r#" - `````root/user.rs - … - impl User { - … - pub fn first_name(&self) -> String { - self.first_name.clone() - … - pub fn last_name(&self) -> String { - self.last_name.clone() - … - ````` - "#}, - cx, - ) - .await; - - assert_results( - &project, - SearchToolQuery { - glob: "*.rs".into(), - syntax_node: vec![], - content: Some("\\.clone".into()), - }, - indoc! {r#" - `````root/main.rs - fn main() { - let user = User::new(FIRST_NAME.clone(), "doe".into()); - … - ````` - - `````root/user.rs - … - impl Organization { - pub fn owner(&self) -> Arc { - self.owner.clone() - … - impl User { - … - pub fn first_name(&self) -> String { - self.first_name.clone() - … - pub fn last_name(&self) -> String { - self.last_name.clone() - … - ````` - "#}, - cx, - ) - .await; - } - - async fn assert_results( - project: &Entity, - query: SearchToolQuery, - expected_output: &str, - cx: &mut TestAppContext, - ) { - let results = run_retrieval_searches( - vec![query], - project.clone(), - #[cfg(feature = "eval-support")] - None, - &mut cx.to_async(), - ) - .await - .unwrap(); - - let mut results = results.into_iter().collect::>(); - results.sort_by_key(|results| { - results - .0 - .read_with(cx, |buffer, _| buffer.file().unwrap().path().clone()) - }); - - let mut output = String::new(); - for (buffer, ranges) in results { - buffer.read_with(cx, |buffer, cx| { - let excerpts = ranges.into_iter().map(|range| { - let point_range = range.to_point(buffer); - if point_range.end.column > 0 { - Line(point_range.start.row)..Line(point_range.end.row + 1) - } else { - Line(point_range.start.row)..Line(point_range.end.row) - } - }); - - write_codeblock( - &buffer.file().unwrap().full_path(cx), - assemble_excerpts(&buffer.snapshot(), excerpts).iter(), - &[], - Line(buffer.max_point().row), - false, - &mut output, - ); - }); - } - output.pop(); - - assert_eq!(output, expected_output); - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_outline_query(include_str!("../../languages/src/rust/outline.scm")) - .unwrap() - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(move |cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - zlog::init_test(); - }); - } -} diff --git a/crates/zeta/src/xml_edits.rs b/crates/zeta/src/xml_edits.rs deleted file mode 100644 index ee8dd47cb25ad3dcd2c3d7d172b62e724b41c22d..0000000000000000000000000000000000000000 --- a/crates/zeta/src/xml_edits.rs +++ /dev/null @@ -1,637 +0,0 @@ -use anyhow::{Context as _, Result}; -use language::{Anchor, BufferSnapshot, OffsetRangeExt as _, Point}; -use std::{cmp, ops::Range, path::Path, sync::Arc}; - -const EDITS_TAG_NAME: &'static str = "edits"; -const OLD_TEXT_TAG_NAME: &'static str = "old_text"; -const NEW_TEXT_TAG_NAME: &'static str = "new_text"; -const XML_TAGS: &[&str] = &[EDITS_TAG_NAME, OLD_TEXT_TAG_NAME, NEW_TEXT_TAG_NAME]; - -pub async fn parse_xml_edits<'a>( - input: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - parse_xml_edits_inner(input, get_buffer) - .await - .with_context(|| format!("Failed to parse XML edits:\n{input}")) -} - -async fn parse_xml_edits_inner<'a>( - input: &'a str, - get_buffer: impl Fn(&Path) -> Option<(&'a BufferSnapshot, &'a [Range])> + Send, -) -> Result<(&'a BufferSnapshot, Vec<(Range, Arc)>)> { - let xml_edits = extract_xml_replacements(input)?; - - let (buffer, context_ranges) = get_buffer(xml_edits.file_path.as_ref()) - .with_context(|| format!("no buffer for file {}", xml_edits.file_path))?; - - let mut all_edits = vec![]; - for (old_text, new_text) in xml_edits.replacements { - let match_range = fuzzy_match_in_ranges(old_text, buffer, context_ranges)?; - let matched_old_text = buffer - .text_for_range(match_range.clone()) - .collect::(); - let edits_within_hunk = language::text_diff(&matched_old_text, new_text); - all_edits.extend( - edits_within_hunk - .into_iter() - .map(move |(inner_range, inner_text)| { - ( - buffer.anchor_after(match_range.start + inner_range.start) - ..buffer.anchor_before(match_range.start + inner_range.end), - inner_text, - ) - }), - ); - } - - Ok((buffer, all_edits)) -} - -fn fuzzy_match_in_ranges( - old_text: &str, - buffer: &BufferSnapshot, - context_ranges: &[Range], -) -> Result> { - let mut state = FuzzyMatcher::new(buffer, old_text); - let mut best_match = None; - let mut tie_match_range = None; - - for range in context_ranges { - let best_match_cost = best_match.as_ref().map(|(score, _)| *score); - match (best_match_cost, state.match_range(range.to_offset(buffer))) { - (Some(lowest_cost), Some((new_cost, new_range))) => { - if new_cost == lowest_cost { - tie_match_range = Some(new_range); - } else if new_cost < lowest_cost { - tie_match_range.take(); - best_match = Some((new_cost, new_range)); - } - } - (None, Some(new_match)) => { - best_match = Some(new_match); - } - (None, None) | (Some(_), None) => {} - }; - } - - if let Some((_, best_match_range)) = best_match { - if let Some(tie_match_range) = tie_match_range { - anyhow::bail!( - "Multiple ambiguous matches:\n{:?}:\n{}\n\n{:?}:\n{}", - best_match_range.clone(), - buffer.text_for_range(best_match_range).collect::(), - tie_match_range.clone(), - buffer.text_for_range(tie_match_range).collect::() - ); - } - return Ok(best_match_range); - } - - anyhow::bail!( - "Failed to fuzzy match `old_text`:\n{}\nin:\n```\n{}\n```", - old_text, - context_ranges - .iter() - .map(|range| buffer.text_for_range(range.clone()).collect::()) - .collect::>() - .join("```\n```") - ); -} - -#[derive(Debug)] -struct XmlEdits<'a> { - file_path: &'a str, - /// Vec of (old_text, new_text) pairs - replacements: Vec<(&'a str, &'a str)>, -} - -fn extract_xml_replacements(input: &str) -> Result> { - let mut cursor = 0; - - let (edits_body_start, edits_attrs) = - find_tag_open(input, &mut cursor, EDITS_TAG_NAME)?.context("No edits tag found")?; - - let file_path = edits_attrs - .trim_start() - .strip_prefix("path") - .context("no path attribute on edits tag")? - .trim_end() - .strip_prefix('=') - .context("no value for path attribute")? - .trim() - .trim_start_matches('"') - .trim_end_matches('"'); - - cursor = edits_body_start; - let mut edits_list = Vec::new(); - - while let Some((old_body_start, _)) = find_tag_open(input, &mut cursor, OLD_TEXT_TAG_NAME)? { - let old_body_end = find_tag_close(input, &mut cursor)?; - let old_text = trim_surrounding_newlines(&input[old_body_start..old_body_end]); - - let (new_body_start, _) = find_tag_open(input, &mut cursor, NEW_TEXT_TAG_NAME)? - .context("no new_text tag following old_text")?; - let new_body_end = find_tag_close(input, &mut cursor)?; - let new_text = trim_surrounding_newlines(&input[new_body_start..new_body_end]); - - edits_list.push((old_text, new_text)); - } - - Ok(XmlEdits { - file_path, - replacements: edits_list, - }) -} - -/// Trims a single leading and trailing newline -fn trim_surrounding_newlines(input: &str) -> &str { - let start = input.strip_prefix('\n').unwrap_or(input); - let end = start.strip_suffix('\n').unwrap_or(start); - end -} - -fn find_tag_open<'a>( - input: &'a str, - cursor: &mut usize, - expected_tag: &str, -) -> Result> { - let mut search_pos = *cursor; - - while search_pos < input.len() { - let Some(tag_start) = input[search_pos..].find("<") else { - break; - }; - let tag_start = search_pos + tag_start; - if !input[tag_start + 1..].starts_with(expected_tag) { - search_pos = search_pos + tag_start + 1; - continue; - }; - - let after_tag_name = tag_start + expected_tag.len() + 1; - let close_bracket = input[after_tag_name..] - .find('>') - .with_context(|| format!("missing > after <{}", expected_tag))?; - let attrs_end = after_tag_name + close_bracket; - let body_start = attrs_end + 1; - - let attributes = input[after_tag_name..attrs_end].trim(); - *cursor = body_start; - - return Ok(Some((body_start, attributes))); - } - - Ok(None) -} - -fn find_tag_close(input: &str, cursor: &mut usize) -> Result { - let mut depth = 1; - let mut search_pos = *cursor; - - while search_pos < input.len() && depth > 0 { - let Some(bracket_offset) = input[search_pos..].find('<') else { - break; - }; - let bracket_pos = search_pos + bracket_offset; - - if input[bracket_pos..].starts_with("') - { - let close_start = bracket_pos + 2; - let tag_name = input[close_start..close_start + close_end].trim(); - - if XML_TAGS.contains(&tag_name) { - depth -= 1; - if depth == 0 { - *cursor = close_start + close_end + 1; - return Ok(bracket_pos); - } - } - search_pos = close_start + close_end + 1; - continue; - } else if let Some(close_bracket_offset) = input[bracket_pos..].find('>') { - let close_bracket_pos = bracket_pos + close_bracket_offset; - let tag_name = &input[bracket_pos + 1..close_bracket_pos].trim(); - if XML_TAGS.contains(&tag_name) { - depth += 1; - } - } - - search_pos = bracket_pos + 1; - } - - anyhow::bail!("no closing tag found") -} - -const REPLACEMENT_COST: u32 = 1; -const INSERTION_COST: u32 = 3; -const DELETION_COST: u32 = 10; - -/// A fuzzy matcher that can process text chunks incrementally -/// and return the best match found so far at each step. -struct FuzzyMatcher<'a> { - snapshot: &'a BufferSnapshot, - query_lines: Vec<&'a str>, - matrix: SearchMatrix, -} - -impl<'a> FuzzyMatcher<'a> { - fn new(snapshot: &'a BufferSnapshot, old_text: &'a str) -> Self { - let query_lines = old_text.lines().collect(); - Self { - snapshot, - query_lines, - matrix: SearchMatrix::new(0), - } - } - - fn match_range(&mut self, range: Range) -> Option<(u32, Range)> { - let point_range = range.to_point(&self.snapshot); - let buffer_line_count = (point_range.end.row - point_range.start.row + 1) as usize; - - self.matrix - .reset(self.query_lines.len() + 1, buffer_line_count + 1); - let query_line_count = self.query_lines.len(); - - for row in 0..query_line_count { - let query_line = self.query_lines[row].trim(); - let leading_deletion_cost = (row + 1) as u32 * DELETION_COST; - - self.matrix.set( - row + 1, - 0, - SearchState::new(leading_deletion_cost, SearchDirection::Up), - ); - - let mut buffer_lines = self.snapshot.text_for_range(range.clone()).lines(); - - let mut col = 0; - while let Some(buffer_line) = buffer_lines.next() { - let buffer_line = buffer_line.trim(); - let up = SearchState::new( - self.matrix - .get(row, col + 1) - .cost - .saturating_add(DELETION_COST), - SearchDirection::Up, - ); - let left = SearchState::new( - self.matrix - .get(row + 1, col) - .cost - .saturating_add(INSERTION_COST), - SearchDirection::Left, - ); - let diagonal = SearchState::new( - if query_line == buffer_line { - self.matrix.get(row, col).cost - } else if fuzzy_eq(query_line, buffer_line) { - self.matrix.get(row, col).cost + REPLACEMENT_COST - } else { - self.matrix - .get(row, col) - .cost - .saturating_add(DELETION_COST + INSERTION_COST) - }, - SearchDirection::Diagonal, - ); - self.matrix - .set(row + 1, col + 1, up.min(left).min(diagonal)); - col += 1; - } - } - - // Find all matches with the best cost - let mut best_cost = u32::MAX; - let mut matches_with_best_cost = Vec::new(); - - for col in 1..=buffer_line_count { - let cost = self.matrix.get(query_line_count, col).cost; - if cost < best_cost { - best_cost = cost; - matches_with_best_cost.clear(); - matches_with_best_cost.push(col as u32); - } else if cost == best_cost { - matches_with_best_cost.push(col as u32); - } - } - - // Find ranges for the matches - for &match_end_col in &matches_with_best_cost { - let mut matched_lines = 0; - let mut query_row = query_line_count; - let mut match_start_col = match_end_col; - while query_row > 0 && match_start_col > 0 { - let current = self.matrix.get(query_row, match_start_col as usize); - match current.direction { - SearchDirection::Diagonal => { - query_row -= 1; - match_start_col -= 1; - matched_lines += 1; - } - SearchDirection::Up => { - query_row -= 1; - } - SearchDirection::Left => { - match_start_col -= 1; - } - } - } - - let buffer_row_start = match_start_col + point_range.start.row; - let buffer_row_end = match_end_col + point_range.start.row; - - let matched_buffer_row_count = buffer_row_end - buffer_row_start; - let matched_ratio = matched_lines as f32 - / (matched_buffer_row_count as f32).max(query_line_count as f32); - if matched_ratio >= 0.8 { - let buffer_start_ix = self - .snapshot - .point_to_offset(Point::new(buffer_row_start, 0)); - let buffer_end_ix = self.snapshot.point_to_offset(Point::new( - buffer_row_end - 1, - self.snapshot.line_len(buffer_row_end - 1), - )); - return Some((best_cost, buffer_start_ix..buffer_end_ix)); - } - } - - None - } -} - -fn fuzzy_eq(left: &str, right: &str) -> bool { - const THRESHOLD: f64 = 0.8; - - let min_levenshtein = left.len().abs_diff(right.len()); - let min_normalized_levenshtein = - 1. - (min_levenshtein as f64 / cmp::max(left.len(), right.len()) as f64); - if min_normalized_levenshtein < THRESHOLD { - return false; - } - - strsim::normalized_levenshtein(left, right) >= THRESHOLD -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum SearchDirection { - Up, - Left, - Diagonal, -} - -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct SearchState { - cost: u32, - direction: SearchDirection, -} - -impl SearchState { - fn new(cost: u32, direction: SearchDirection) -> Self { - Self { cost, direction } - } -} - -struct SearchMatrix { - cols: usize, - rows: usize, - data: Vec, -} - -impl SearchMatrix { - fn new(cols: usize) -> Self { - SearchMatrix { - cols, - rows: 0, - data: Vec::new(), - } - } - - fn reset(&mut self, rows: usize, cols: usize) { - self.rows = rows; - self.cols = cols; - self.data - .fill(SearchState::new(0, SearchDirection::Diagonal)); - self.data.resize( - self.rows * self.cols, - SearchState::new(0, SearchDirection::Diagonal), - ); - } - - fn get(&self, row: usize, col: usize) -> SearchState { - debug_assert!(row < self.rows); - debug_assert!(col < self.cols); - self.data[row * self.cols + col] - } - - fn set(&mut self, row: usize, col: usize, state: SearchState) { - debug_assert!(row < self.rows && col < self.cols); - self.data[row * self.cols + col] = state; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::TestAppContext; - use indoc::indoc; - use language::Point; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - - #[test] - fn test_extract_xml_edits() { - let input = indoc! {r#" - - - old content - - - new content - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "test.rs"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, "old content"); - assert_eq!(result.replacements[0].1, "new content"); - } - - #[test] - fn test_extract_xml_edits_with_wrong_closing_tags() { - let input = indoc! {r#" - - - old content - - - new content - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "test.rs"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, "old content"); - assert_eq!(result.replacements[0].1, "new content"); - } - - #[test] - fn test_extract_xml_edits_with_xml_like_content() { - let input = indoc! {r#" - - - - - - - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "component.tsx"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, ""); - assert_eq!( - result.replacements[0].1, - "" - ); - } - - #[test] - fn test_extract_xml_edits_with_conflicting_content() { - let input = indoc! {r#" - - - - - - - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "component.tsx"); - assert_eq!(result.replacements.len(), 1); - assert_eq!(result.replacements[0].0, ""); - assert_eq!(result.replacements[0].1, ""); - } - - #[test] - fn test_extract_xml_edits_multiple_pairs() { - let input = indoc! {r#" - Some reasoning before edits. Lots of thinking going on here - - - - first old - - - first new - - - second old - - - second new - - - "#}; - - let result = extract_xml_replacements(input).unwrap(); - assert_eq!(result.file_path, "test.rs"); - assert_eq!(result.replacements.len(), 2); - assert_eq!(result.replacements[0].0, "first old"); - assert_eq!(result.replacements[0].1, "first new"); - assert_eq!(result.replacements[1].0, "second old"); - assert_eq!(result.replacements[1].1, "second new"); - } - - #[test] - fn test_extract_xml_edits_unexpected_eof() { - let input = indoc! {r#" - - - first old - - - nine ten eleven twelve - - - nine TEN eleven twelve! - - - "#}; - - let included_ranges = [(buffer_snapshot.anchor_before(Point::new(1, 0))..Anchor::MAX)]; - let (buffer, edits) = parse_xml_edits(edits, |_path| { - Some((&buffer_snapshot, included_ranges.as_slice())) - }) - .await - .unwrap(); - - let edits = edits - .into_iter() - .map(|(range, text)| (range.to_point(&buffer), text)) - .collect::>(); - assert_eq!( - edits, - &[ - (Point::new(2, 5)..Point::new(2, 8), "TEN".into()), - (Point::new(2, 22)..Point::new(2, 22), "!".into()) - ] - ); - } - - fn init_test(cx: &mut TestAppContext) -> Arc { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); - - FakeFs::new(cx.background_executor.clone()) - } -} diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs deleted file mode 100644 index dba90abbc839566781d18308e53c4b0faa96e1d7..0000000000000000000000000000000000000000 --- a/crates/zeta/src/zeta.rs +++ /dev/null @@ -1,4057 +0,0 @@ -use anyhow::{Context as _, Result, anyhow, bail}; -use arrayvec::ArrayVec; -use client::{Client, EditPredictionUsage, UserStore}; -use cloud_llm_client::predict_edits_v3::{self, Event, PromptFormat, Signature}; -use cloud_llm_client::{ - AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, EditPredictionRejectReason, - EditPredictionRejection, MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST, - MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsRequestTrigger, RejectEditPredictionsBodyRef, - ZED_VERSION_HEADER_NAME, -}; -use cloud_zeta2_prompt::retrieval_prompt::{SearchToolInput, SearchToolQuery}; -use cloud_zeta2_prompt::{CURSOR_MARKER, DEFAULT_MAX_PROMPT_BYTES}; -use collections::{HashMap, HashSet}; -use command_palette_hooks::CommandPaletteFilter; -use db::kvp::{Dismissable, KEY_VALUE_STORE}; -use edit_prediction_context::{ - DeclarationId, DeclarationStyle, EditPredictionContext, EditPredictionContextOptions, - EditPredictionExcerpt, EditPredictionExcerptOptions, EditPredictionScoreOptions, Line, - SyntaxIndex, SyntaxIndexState, -}; -use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PredictEditsRateCompletionsFeatureFlag}; -use futures::channel::mpsc::UnboundedReceiver; -use futures::channel::{mpsc, oneshot}; -use futures::{AsyncReadExt as _, FutureExt as _, StreamExt as _, select_biased}; -use gpui::BackgroundExecutor; -use gpui::{ - App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, - http_client::{self, AsyncBody, Method}, - prelude::*, -}; -use language::{ - Anchor, Buffer, DiagnosticSet, File, LanguageServerId, Point, ToOffset as _, ToPoint, -}; -use language::{BufferSnapshot, OffsetRangeExt}; -use language_model::{LlmApiToken, RefreshLlmTokenListener}; -use open_ai::FunctionDefinition; -use project::{DisableAiSettings, Project, ProjectPath, WorktreeId}; -use release_channel::AppVersion; -use semver::Version; -use serde::de::DeserializeOwned; -use settings::{EditPredictionProvider, Settings as _, SettingsStore, update_settings_file}; -use std::any::{Any as _, TypeId}; -use std::collections::{VecDeque, hash_map}; -use telemetry_events::EditPredictionRating; -use workspace::Workspace; - -use std::ops::Range; -use std::path::Path; -use std::rc::Rc; -use std::str::FromStr as _; -use std::sync::{Arc, LazyLock}; -use std::time::{Duration, Instant}; -use std::{env, mem}; -use thiserror::Error; -use util::rel_path::RelPathBuf; -use util::{LogErrorFuture, RangeExt as _, ResultExt as _, TryFutureExt}; -use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; - -pub mod assemble_excerpts; -mod license_detection; -mod onboarding_modal; -mod prediction; -mod provider; -mod rate_prediction_modal; -pub mod retrieval_search; -pub mod sweep_ai; -pub mod udiff; -mod xml_edits; -pub mod zeta1; - -#[cfg(test)] -mod zeta_tests; - -use crate::assemble_excerpts::assemble_excerpts; -use crate::license_detection::LicenseDetectionWatcher; -use crate::onboarding_modal::ZedPredictModal; -pub use crate::prediction::EditPrediction; -pub use crate::prediction::EditPredictionId; -pub use crate::prediction::EditPredictionInputs; -use crate::prediction::EditPredictionResult; -use crate::rate_prediction_modal::{ - NextEdit, PreviousEdit, RatePredictionsModal, ThumbsDownActivePrediction, - ThumbsUpActivePrediction, -}; -pub use crate::sweep_ai::SweepAi; -use crate::zeta1::request_prediction_with_zeta1; -pub use provider::ZetaEditPredictionProvider; - -actions!( - edit_prediction, - [ - /// Resets the edit prediction onboarding state. - ResetOnboarding, - /// Opens the rate completions modal. - RateCompletions, - /// Clears the edit prediction history. - ClearHistory, - ] -); - -/// Maximum number of events to track. -const EVENT_COUNT_MAX: usize = 6; -const CHANGE_GROUPING_LINE_SPAN: u32 = 8; -const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; -const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); - -pub struct SweepFeatureFlag; - -impl FeatureFlag for SweepFeatureFlag { - const NAME: &str = "sweep-ai"; -} -pub const DEFAULT_EXCERPT_OPTIONS: EditPredictionExcerptOptions = EditPredictionExcerptOptions { - max_bytes: 512, - min_bytes: 128, - target_before_cursor_over_total_bytes: 0.5, -}; - -pub const DEFAULT_CONTEXT_OPTIONS: ContextMode = - ContextMode::Agentic(DEFAULT_AGENTIC_CONTEXT_OPTIONS); - -pub const DEFAULT_AGENTIC_CONTEXT_OPTIONS: AgenticContextOptions = AgenticContextOptions { - excerpt: DEFAULT_EXCERPT_OPTIONS, -}; - -pub const DEFAULT_SYNTAX_CONTEXT_OPTIONS: EditPredictionContextOptions = - EditPredictionContextOptions { - use_imports: true, - max_retrieved_declarations: 0, - excerpt: DEFAULT_EXCERPT_OPTIONS, - score: EditPredictionScoreOptions { - omit_excerpt_overlaps: true, - }, - }; - -pub const DEFAULT_OPTIONS: ZetaOptions = ZetaOptions { - context: DEFAULT_CONTEXT_OPTIONS, - max_prompt_bytes: DEFAULT_MAX_PROMPT_BYTES, - max_diagnostic_bytes: 2048, - prompt_format: PromptFormat::DEFAULT, - file_indexing_parallelism: 1, - buffer_change_grouping_interval: Duration::from_secs(1), -}; - -static USE_OLLAMA: LazyLock = - LazyLock::new(|| env::var("ZED_ZETA2_OLLAMA").is_ok_and(|var| !var.is_empty())); -static CONTEXT_RETRIEVAL_MODEL_ID: LazyLock = LazyLock::new(|| { - env::var("ZED_ZETA2_CONTEXT_MODEL").unwrap_or(if *USE_OLLAMA { - "qwen3-coder:30b".to_string() - } else { - "yqvev8r3".to_string() - }) -}); -static EDIT_PREDICTIONS_MODEL_ID: LazyLock = LazyLock::new(|| { - match env::var("ZED_ZETA2_MODEL").as_deref() { - Ok("zeta2-exp") => "4w5n28vw", // Fine-tuned model @ Baseten - Ok(model) => model, - Err(_) if *USE_OLLAMA => "qwen3-coder:30b", - Err(_) => "yqvev8r3", // Vanilla qwen3-coder @ Baseten - } - .to_string() -}); -static PREDICT_EDITS_URL: LazyLock> = LazyLock::new(|| { - env::var("ZED_PREDICT_EDITS_URL").ok().or_else(|| { - if *USE_OLLAMA { - Some("http://localhost:11434/v1/chat/completions".into()) - } else { - None - } - }) -}); - -pub struct Zeta2FeatureFlag; - -impl FeatureFlag for Zeta2FeatureFlag { - const NAME: &'static str = "zeta2"; - - fn enabled_for_staff() -> bool { - true - } -} - -#[derive(Clone)] -struct ZetaGlobal(Entity); - -impl Global for ZetaGlobal {} - -pub struct Zeta { - client: Arc, - user_store: Entity, - llm_token: LlmApiToken, - _llm_token_subscription: Subscription, - projects: HashMap, - options: ZetaOptions, - update_required: bool, - debug_tx: Option>, - #[cfg(feature = "eval-support")] - eval_cache: Option>, - edit_prediction_model: ZetaEditPredictionModel, - pub sweep_ai: SweepAi, - data_collection_choice: DataCollectionChoice, - reject_predictions_tx: mpsc::UnboundedSender, - shown_predictions: VecDeque, - rated_predictions: HashSet, -} - -#[derive(Copy, Clone, Default, PartialEq, Eq)] -pub enum ZetaEditPredictionModel { - #[default] - Zeta1, - Zeta2, - Sweep, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ZetaOptions { - pub context: ContextMode, - pub max_prompt_bytes: usize, - pub max_diagnostic_bytes: usize, - pub prompt_format: predict_edits_v3::PromptFormat, - pub file_indexing_parallelism: usize, - pub buffer_change_grouping_interval: Duration, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ContextMode { - Agentic(AgenticContextOptions), - Syntax(EditPredictionContextOptions), -} - -#[derive(Debug, Clone, PartialEq)] -pub struct AgenticContextOptions { - pub excerpt: EditPredictionExcerptOptions, -} - -impl ContextMode { - pub fn excerpt(&self) -> &EditPredictionExcerptOptions { - match self { - ContextMode::Agentic(options) => &options.excerpt, - ContextMode::Syntax(options) => &options.excerpt, - } - } -} - -#[derive(Debug)] -pub enum ZetaDebugInfo { - ContextRetrievalStarted(ZetaContextRetrievalStartedDebugInfo), - SearchQueriesGenerated(ZetaSearchQueryDebugInfo), - SearchQueriesExecuted(ZetaContextRetrievalDebugInfo), - ContextRetrievalFinished(ZetaContextRetrievalDebugInfo), - EditPredictionRequested(ZetaEditPredictionDebugInfo), -} - -#[derive(Debug)] -pub struct ZetaContextRetrievalStartedDebugInfo { - pub project: Entity, - pub timestamp: Instant, - pub search_prompt: String, -} - -#[derive(Debug)] -pub struct ZetaContextRetrievalDebugInfo { - pub project: Entity, - pub timestamp: Instant, -} - -#[derive(Debug)] -pub struct ZetaEditPredictionDebugInfo { - pub inputs: EditPredictionInputs, - pub retrieval_time: Duration, - pub buffer: WeakEntity, - pub position: language::Anchor, - pub local_prompt: Result, - pub response_rx: oneshot::Receiver<(Result, Duration)>, -} - -#[derive(Debug)] -pub struct ZetaSearchQueryDebugInfo { - pub project: Entity, - pub timestamp: Instant, - pub search_queries: Vec, -} - -pub type RequestDebugInfo = predict_edits_v3::DebugInfo; - -struct ZetaProject { - syntax_index: Option>, - events: VecDeque>, - last_event: Option, - recent_paths: VecDeque, - registered_buffers: HashMap, - current_prediction: Option, - next_pending_prediction_id: usize, - pending_predictions: ArrayVec, - last_prediction_refresh: Option<(EntityId, Instant)>, - cancelled_predictions: HashSet, - context: Option, Vec>>>, - refresh_context_task: Option>>>, - refresh_context_debounce_task: Option>>, - refresh_context_timestamp: Option, - license_detection_watchers: HashMap>, - _subscription: gpui::Subscription, -} - -impl ZetaProject { - pub fn events(&self, cx: &App) -> Vec> { - self.events - .iter() - .cloned() - .chain( - self.last_event - .as_ref() - .and_then(|event| event.finalize(&self.license_detection_watchers, cx)), - ) - .collect() - } - - fn cancel_pending_prediction( - &mut self, - pending_prediction: PendingPrediction, - cx: &mut Context, - ) { - self.cancelled_predictions.insert(pending_prediction.id); - - cx.spawn(async move |this, cx| { - let Some(prediction_id) = pending_prediction.task.await else { - return; - }; - - this.update(cx, |this, _cx| { - this.reject_prediction(prediction_id, EditPredictionRejectReason::Canceled, false); - }) - .ok(); - }) - .detach() - } -} - -#[derive(Debug, Clone)] -struct CurrentEditPrediction { - pub requested_by: PredictionRequestedBy, - pub prediction: EditPrediction, - pub was_shown: bool, -} - -impl CurrentEditPrediction { - fn should_replace_prediction(&self, old_prediction: &Self, cx: &App) -> bool { - let Some(new_edits) = self - .prediction - .interpolate(&self.prediction.buffer.read(cx)) - else { - return false; - }; - - if self.prediction.buffer != old_prediction.prediction.buffer { - return true; - } - - let Some(old_edits) = old_prediction - .prediction - .interpolate(&old_prediction.prediction.buffer.read(cx)) - else { - return true; - }; - - let requested_by_buffer_id = self.requested_by.buffer_id(); - - // This reduces the occurrence of UI thrash from replacing edits - // - // TODO: This is fairly arbitrary - should have a more general heuristic that handles multiple edits. - if requested_by_buffer_id == Some(self.prediction.buffer.entity_id()) - && requested_by_buffer_id == Some(old_prediction.prediction.buffer.entity_id()) - && old_edits.len() == 1 - && new_edits.len() == 1 - { - let (old_range, old_text) = &old_edits[0]; - let (new_range, new_text) = &new_edits[0]; - new_range == old_range && new_text.starts_with(old_text.as_ref()) - } else { - true - } - } -} - -#[derive(Debug, Clone)] -enum PredictionRequestedBy { - DiagnosticsUpdate, - Buffer(EntityId), -} - -impl PredictionRequestedBy { - pub fn buffer_id(&self) -> Option { - match self { - PredictionRequestedBy::DiagnosticsUpdate => None, - PredictionRequestedBy::Buffer(buffer_id) => Some(*buffer_id), - } - } -} - -#[derive(Debug)] -struct PendingPrediction { - id: usize, - task: Task>, -} - -/// A prediction from the perspective of a buffer. -#[derive(Debug)] -enum BufferEditPrediction<'a> { - Local { prediction: &'a EditPrediction }, - Jump { prediction: &'a EditPrediction }, -} - -#[cfg(test)] -impl std::ops::Deref for BufferEditPrediction<'_> { - type Target = EditPrediction; - - fn deref(&self) -> &Self::Target { - match self { - BufferEditPrediction::Local { prediction } => prediction, - BufferEditPrediction::Jump { prediction } => prediction, - } - } -} - -struct RegisteredBuffer { - snapshot: BufferSnapshot, - _subscriptions: [gpui::Subscription; 2], -} - -struct LastEvent { - old_snapshot: BufferSnapshot, - new_snapshot: BufferSnapshot, - end_edit_anchor: Option, -} - -impl LastEvent { - pub fn finalize( - &self, - license_detection_watchers: &HashMap>, - cx: &App, - ) -> Option> { - let path = buffer_path_with_id_fallback(&self.new_snapshot, cx); - let old_path = buffer_path_with_id_fallback(&self.old_snapshot, cx); - - let file = self.new_snapshot.file(); - let old_file = self.old_snapshot.file(); - - let in_open_source_repo = [file, old_file].iter().all(|file| { - file.is_some_and(|file| { - license_detection_watchers - .get(&file.worktree_id(cx)) - .is_some_and(|watcher| watcher.is_project_open_source()) - }) - }); - - let diff = language::unified_diff(&self.old_snapshot.text(), &self.new_snapshot.text()); - - if path == old_path && diff.is_empty() { - None - } else { - Some(Arc::new(predict_edits_v3::Event::BufferChange { - old_path, - path, - diff, - in_open_source_repo, - // TODO: Actually detect if this edit was predicted or not - predicted: false, - })) - } - } -} - -fn buffer_path_with_id_fallback(snapshot: &BufferSnapshot, cx: &App) -> Arc { - if let Some(file) = snapshot.file() { - file.full_path(cx).into() - } else { - Path::new(&format!("untitled-{}", snapshot.remote_id())).into() - } -} - -impl Zeta { - pub fn try_global(cx: &App) -> Option> { - cx.try_global::().map(|global| global.0.clone()) - } - - pub fn global( - client: &Arc, - user_store: &Entity, - cx: &mut App, - ) -> Entity { - cx.try_global::() - .map(|global| global.0.clone()) - .unwrap_or_else(|| { - let zeta = cx.new(|cx| Self::new(client.clone(), user_store.clone(), cx)); - cx.set_global(ZetaGlobal(zeta.clone())); - zeta - }) - } - - pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); - let data_collection_choice = Self::load_data_collection_choice(); - - let llm_token = LlmApiToken::default(); - - let (reject_tx, reject_rx) = mpsc::unbounded(); - cx.background_spawn({ - let client = client.clone(); - let llm_token = llm_token.clone(); - let app_version = AppVersion::global(cx); - let background_executor = cx.background_executor().clone(); - async move { - Self::handle_rejected_predictions( - reject_rx, - client, - llm_token, - app_version, - background_executor, - ) - .await - } - }) - .detach(); - - Self { - projects: HashMap::default(), - client, - user_store, - options: DEFAULT_OPTIONS, - llm_token, - _llm_token_subscription: cx.subscribe( - &refresh_llm_token_listener, - |this, _listener, _event, cx| { - let client = this.client.clone(); - let llm_token = this.llm_token.clone(); - cx.spawn(async move |_this, _cx| { - llm_token.refresh(&client).await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }, - ), - update_required: false, - debug_tx: None, - #[cfg(feature = "eval-support")] - eval_cache: None, - edit_prediction_model: ZetaEditPredictionModel::Zeta2, - sweep_ai: SweepAi::new(cx), - data_collection_choice, - reject_predictions_tx: reject_tx, - rated_predictions: Default::default(), - shown_predictions: Default::default(), - } - } - - pub fn set_edit_prediction_model(&mut self, model: ZetaEditPredictionModel) { - self.edit_prediction_model = model; - } - - pub fn has_sweep_api_token(&self) -> bool { - self.sweep_ai - .api_token - .clone() - .now_or_never() - .flatten() - .is_some() - } - - #[cfg(feature = "eval-support")] - pub fn with_eval_cache(&mut self, cache: Arc) { - self.eval_cache = Some(cache); - } - - pub fn debug_info(&mut self) -> mpsc::UnboundedReceiver { - let (debug_watch_tx, debug_watch_rx) = mpsc::unbounded(); - self.debug_tx = Some(debug_watch_tx); - debug_watch_rx - } - - pub fn options(&self) -> &ZetaOptions { - &self.options - } - - pub fn set_options(&mut self, options: ZetaOptions) { - self.options = options; - } - - pub fn clear_history(&mut self) { - for zeta_project in self.projects.values_mut() { - zeta_project.events.clear(); - } - } - - pub fn context_for_project( - &self, - project: &Entity, - ) -> impl Iterator, &[Range])> { - self.projects - .get(&project.entity_id()) - .and_then(|project| { - Some( - project - .context - .as_ref()? - .iter() - .map(|(buffer, ranges)| (buffer.clone(), ranges.as_slice())), - ) - }) - .into_iter() - .flatten() - } - - pub fn usage(&self, cx: &App) -> Option { - if self.edit_prediction_model == ZetaEditPredictionModel::Zeta2 { - self.user_store.read(cx).edit_prediction_usage() - } else { - None - } - } - - pub fn register_project(&mut self, project: &Entity, cx: &mut Context) { - self.get_or_init_zeta_project(project, cx); - } - - pub fn register_buffer( - &mut self, - buffer: &Entity, - project: &Entity, - cx: &mut Context, - ) { - let zeta_project = self.get_or_init_zeta_project(project, cx); - Self::register_buffer_impl(zeta_project, buffer, project, cx); - } - - fn get_or_init_zeta_project( - &mut self, - project: &Entity, - cx: &mut Context, - ) -> &mut ZetaProject { - self.projects - .entry(project.entity_id()) - .or_insert_with(|| ZetaProject { - syntax_index: if let ContextMode::Syntax(_) = &self.options.context { - Some(cx.new(|cx| { - SyntaxIndex::new(project, self.options.file_indexing_parallelism, cx) - })) - } else { - None - }, - events: VecDeque::new(), - last_event: None, - recent_paths: VecDeque::new(), - registered_buffers: HashMap::default(), - current_prediction: None, - cancelled_predictions: HashSet::default(), - pending_predictions: ArrayVec::new(), - next_pending_prediction_id: 0, - last_prediction_refresh: None, - context: None, - refresh_context_task: None, - refresh_context_debounce_task: None, - refresh_context_timestamp: None, - license_detection_watchers: HashMap::default(), - _subscription: cx.subscribe(&project, Self::handle_project_event), - }) - } - - fn handle_project_event( - &mut self, - project: Entity, - event: &project::Event, - cx: &mut Context, - ) { - // TODO [zeta2] init with recent paths - match event { - project::Event::ActiveEntryChanged(Some(active_entry_id)) => { - let Some(zeta_project) = self.projects.get_mut(&project.entity_id()) else { - return; - }; - let path = project.read(cx).path_for_entry(*active_entry_id, cx); - if let Some(path) = path { - if let Some(ix) = zeta_project - .recent_paths - .iter() - .position(|probe| probe == &path) - { - zeta_project.recent_paths.remove(ix); - } - zeta_project.recent_paths.push_front(path); - } - } - project::Event::DiagnosticsUpdated { .. } => { - if cx.has_flag::() { - self.refresh_prediction_from_diagnostics(project, cx); - } - } - _ => (), - } - } - - fn register_buffer_impl<'a>( - zeta_project: &'a mut ZetaProject, - buffer: &Entity, - project: &Entity, - cx: &mut Context, - ) -> &'a mut RegisteredBuffer { - let buffer_id = buffer.entity_id(); - - if let Some(file) = buffer.read(cx).file() { - let worktree_id = file.worktree_id(cx); - if let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) { - zeta_project - .license_detection_watchers - .entry(worktree_id) - .or_insert_with(|| { - let project_entity_id = project.entity_id(); - cx.observe_release(&worktree, move |this, _worktree, _cx| { - let Some(zeta_project) = this.projects.get_mut(&project_entity_id) - else { - return; - }; - zeta_project.license_detection_watchers.remove(&worktree_id); - }) - .detach(); - Rc::new(LicenseDetectionWatcher::new(&worktree, cx)) - }); - } - } - - match zeta_project.registered_buffers.entry(buffer_id) { - hash_map::Entry::Occupied(entry) => entry.into_mut(), - hash_map::Entry::Vacant(entry) => { - let snapshot = buffer.read(cx).snapshot(); - let project_entity_id = project.entity_id(); - entry.insert(RegisteredBuffer { - snapshot, - _subscriptions: [ - cx.subscribe(buffer, { - let project = project.downgrade(); - move |this, buffer, event, cx| { - if let language::BufferEvent::Edited = event - && let Some(project) = project.upgrade() - { - this.report_changes_for_buffer(&buffer, &project, cx); - } - } - }), - cx.observe_release(buffer, move |this, _buffer, _cx| { - let Some(zeta_project) = this.projects.get_mut(&project_entity_id) - else { - return; - }; - zeta_project.registered_buffers.remove(&buffer_id); - }), - ], - }) - } - } - } - - fn report_changes_for_buffer( - &mut self, - buffer: &Entity, - project: &Entity, - cx: &mut Context, - ) { - let project_state = self.get_or_init_zeta_project(project, cx); - let registered_buffer = Self::register_buffer_impl(project_state, buffer, project, cx); - - let new_snapshot = buffer.read(cx).snapshot(); - if new_snapshot.version == registered_buffer.snapshot.version { - return; - } - - let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); - let end_edit_anchor = new_snapshot - .anchored_edits_since::(&old_snapshot.version) - .last() - .map(|(_, range)| range.end); - let events = &mut project_state.events; - - if let Some(LastEvent { - new_snapshot: last_new_snapshot, - end_edit_anchor: last_end_edit_anchor, - .. - }) = project_state.last_event.as_mut() - { - let is_next_snapshot_of_same_buffer = old_snapshot.remote_id() - == last_new_snapshot.remote_id() - && old_snapshot.version == last_new_snapshot.version; - - let should_coalesce = is_next_snapshot_of_same_buffer - && end_edit_anchor - .as_ref() - .zip(last_end_edit_anchor.as_ref()) - .is_some_and(|(a, b)| { - let a = a.to_point(&new_snapshot); - let b = b.to_point(&new_snapshot); - a.row.abs_diff(b.row) <= CHANGE_GROUPING_LINE_SPAN - }); - - if should_coalesce { - *last_end_edit_anchor = end_edit_anchor; - *last_new_snapshot = new_snapshot; - return; - } - } - - if events.len() + 1 >= EVENT_COUNT_MAX { - events.pop_front(); - } - - if let Some(event) = project_state.last_event.take() { - events.extend(event.finalize(&project_state.license_detection_watchers, cx)); - } - - project_state.last_event = Some(LastEvent { - old_snapshot, - new_snapshot, - end_edit_anchor, - }); - } - - fn current_prediction_for_buffer( - &self, - buffer: &Entity, - project: &Entity, - cx: &App, - ) -> Option> { - let project_state = self.projects.get(&project.entity_id())?; - - let CurrentEditPrediction { - requested_by, - prediction, - .. - } = project_state.current_prediction.as_ref()?; - - if prediction.targets_buffer(buffer.read(cx)) { - Some(BufferEditPrediction::Local { prediction }) - } else { - let show_jump = match requested_by { - PredictionRequestedBy::Buffer(requested_by_buffer_id) => { - requested_by_buffer_id == &buffer.entity_id() - } - PredictionRequestedBy::DiagnosticsUpdate => true, - }; - - if show_jump { - Some(BufferEditPrediction::Jump { prediction }) - } else { - None - } - } - } - - fn accept_current_prediction(&mut self, project: &Entity, cx: &mut Context) { - match self.edit_prediction_model { - ZetaEditPredictionModel::Zeta1 | ZetaEditPredictionModel::Zeta2 => {} - ZetaEditPredictionModel::Sweep => return, - } - - let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { - return; - }; - - let Some(prediction) = project_state.current_prediction.take() else { - return; - }; - let request_id = prediction.prediction.id.to_string(); - for pending_prediction in mem::take(&mut project_state.pending_predictions) { - project_state.cancel_pending_prediction(pending_prediction, cx); - } - - let client = self.client.clone(); - let llm_token = self.llm_token.clone(); - let app_version = AppVersion::global(cx); - cx.spawn(async move |this, cx| { - let url = if let Ok(predict_edits_url) = env::var("ZED_ACCEPT_PREDICTION_URL") { - http_client::Url::parse(&predict_edits_url)? - } else { - client - .http_client() - .build_zed_llm_url("/predict_edits/accept", &[])? - }; - - let response = cx - .background_spawn(Self::send_api_request::<()>( - move |builder| { - let req = builder.uri(url.as_ref()).body( - serde_json::to_string(&AcceptEditPredictionBody { - request_id: request_id.clone(), - })? - .into(), - ); - Ok(req?) - }, - client, - llm_token, - app_version, - )) - .await; - - Self::handle_api_response(&this, response, cx)?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - async fn handle_rejected_predictions( - rx: UnboundedReceiver, - client: Arc, - llm_token: LlmApiToken, - app_version: Version, - background_executor: BackgroundExecutor, - ) { - let mut rx = std::pin::pin!(rx.peekable()); - let mut batched = Vec::new(); - - while let Some(rejection) = rx.next().await { - batched.push(rejection); - - if batched.len() < MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST / 2 { - select_biased! { - next = rx.as_mut().peek().fuse() => { - if next.is_some() { - continue; - } - } - () = background_executor.timer(REJECT_REQUEST_DEBOUNCE).fuse() => {}, - } - } - - let url = client - .http_client() - .build_zed_llm_url("/predict_edits/reject", &[]) - .unwrap(); - - let flush_count = batched - .len() - // in case items have accumulated after failure - .min(MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST); - let start = batched.len() - flush_count; - - let body = RejectEditPredictionsBodyRef { - rejections: &batched[start..], - }; - - let result = Self::send_api_request::<()>( - |builder| { - let req = builder - .uri(url.as_ref()) - .body(serde_json::to_string(&body)?.into()); - anyhow::Ok(req?) - }, - client.clone(), - llm_token.clone(), - app_version.clone(), - ) - .await; - - if result.log_err().is_some() { - batched.drain(start..); - } - } - } - - fn reject_current_prediction( - &mut self, - reason: EditPredictionRejectReason, - project: &Entity, - ) { - if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { - project_state.pending_predictions.clear(); - if let Some(prediction) = project_state.current_prediction.take() { - self.reject_prediction(prediction.prediction.id, reason, prediction.was_shown); - } - }; - } - - fn did_show_current_prediction(&mut self, project: &Entity, _cx: &mut Context) { - if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { - if let Some(current_prediction) = project_state.current_prediction.as_mut() { - if !current_prediction.was_shown { - current_prediction.was_shown = true; - self.shown_predictions - .push_front(current_prediction.prediction.clone()); - if self.shown_predictions.len() > 50 { - let completion = self.shown_predictions.pop_back().unwrap(); - self.rated_predictions.remove(&completion.id); - } - } - } - } - } - - fn reject_prediction( - &mut self, - prediction_id: EditPredictionId, - reason: EditPredictionRejectReason, - was_shown: bool, - ) { - self.reject_predictions_tx - .unbounded_send(EditPredictionRejection { - request_id: prediction_id.to_string(), - reason, - was_shown, - }) - .log_err(); - } - - fn is_refreshing(&self, project: &Entity) -> bool { - self.projects - .get(&project.entity_id()) - .is_some_and(|project_state| !project_state.pending_predictions.is_empty()) - } - - pub fn refresh_prediction_from_buffer( - &mut self, - project: Entity, - buffer: Entity, - position: language::Anchor, - cx: &mut Context, - ) { - self.queue_prediction_refresh(project.clone(), buffer.entity_id(), cx, move |this, cx| { - let Some(request_task) = this - .update(cx, |this, cx| { - this.request_prediction( - &project, - &buffer, - position, - PredictEditsRequestTrigger::Other, - cx, - ) - }) - .log_err() - else { - return Task::ready(anyhow::Ok(None)); - }; - - cx.spawn(async move |_cx| { - request_task.await.map(|prediction_result| { - prediction_result.map(|prediction_result| { - ( - prediction_result, - PredictionRequestedBy::Buffer(buffer.entity_id()), - ) - }) - }) - }) - }) - } - - pub fn refresh_prediction_from_diagnostics( - &mut self, - project: Entity, - cx: &mut Context, - ) { - let Some(zeta_project) = self.projects.get_mut(&project.entity_id()) else { - return; - }; - - // Prefer predictions from buffer - if zeta_project.current_prediction.is_some() { - return; - }; - - self.queue_prediction_refresh(project.clone(), project.entity_id(), cx, move |this, cx| { - let Some(open_buffer_task) = project - .update(cx, |project, cx| { - project - .active_entry() - .and_then(|entry| project.path_for_entry(entry, cx)) - .map(|path| project.open_buffer(path, cx)) - }) - .log_err() - .flatten() - else { - return Task::ready(anyhow::Ok(None)); - }; - - cx.spawn(async move |cx| { - let active_buffer = open_buffer_task.await?; - let snapshot = active_buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - - let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( - active_buffer, - &snapshot, - Default::default(), - Default::default(), - &project, - cx, - ) - .await? - else { - return anyhow::Ok(None); - }; - - let Some(prediction_result) = this - .update(cx, |this, cx| { - this.request_prediction( - &project, - &jump_buffer, - jump_position, - PredictEditsRequestTrigger::Diagnostics, - cx, - ) - })? - .await? - else { - return anyhow::Ok(None); - }; - - this.update(cx, |this, cx| { - Some(( - if this - .get_or_init_zeta_project(&project, cx) - .current_prediction - .is_none() - { - prediction_result - } else { - EditPredictionResult { - id: prediction_result.id, - prediction: Err(EditPredictionRejectReason::CurrentPreferred), - } - }, - PredictionRequestedBy::DiagnosticsUpdate, - )) - }) - }) - }); - } - - #[cfg(not(test))] - pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); - #[cfg(test)] - pub const THROTTLE_TIMEOUT: Duration = Duration::ZERO; - - fn queue_prediction_refresh( - &mut self, - project: Entity, - throttle_entity: EntityId, - cx: &mut Context, - do_refresh: impl FnOnce( - WeakEntity, - &mut AsyncApp, - ) - -> Task>> - + 'static, - ) { - let zeta_project = self.get_or_init_zeta_project(&project, cx); - let pending_prediction_id = zeta_project.next_pending_prediction_id; - zeta_project.next_pending_prediction_id += 1; - let last_request = zeta_project.last_prediction_refresh; - - let task = cx.spawn(async move |this, cx| { - if let Some((last_entity, last_timestamp)) = last_request - && throttle_entity == last_entity - && let Some(timeout) = - (last_timestamp + Self::THROTTLE_TIMEOUT).checked_duration_since(Instant::now()) - { - cx.background_executor().timer(timeout).await; - } - - // If this task was cancelled before the throttle timeout expired, - // do not perform a request. - let mut is_cancelled = true; - this.update(cx, |this, cx| { - let project_state = this.get_or_init_zeta_project(&project, cx); - if !project_state - .cancelled_predictions - .remove(&pending_prediction_id) - { - project_state.last_prediction_refresh = Some((throttle_entity, Instant::now())); - is_cancelled = false; - } - }) - .ok(); - if is_cancelled { - return None; - } - - let new_prediction_result = do_refresh(this.clone(), cx).await.log_err().flatten(); - let new_prediction_id = new_prediction_result - .as_ref() - .map(|(prediction, _)| prediction.id.clone()); - - // When a prediction completes, remove it from the pending list, and cancel - // any pending predictions that were enqueued before it. - this.update(cx, |this, cx| { - let zeta_project = this.get_or_init_zeta_project(&project, cx); - - let is_cancelled = zeta_project - .cancelled_predictions - .remove(&pending_prediction_id); - - let new_current_prediction = if !is_cancelled - && let Some((prediction_result, requested_by)) = new_prediction_result - { - match prediction_result.prediction { - Ok(prediction) => { - let new_prediction = CurrentEditPrediction { - requested_by, - prediction, - was_shown: false, - }; - - if let Some(current_prediction) = - zeta_project.current_prediction.as_ref() - { - if new_prediction.should_replace_prediction(¤t_prediction, cx) - { - this.reject_current_prediction( - EditPredictionRejectReason::Replaced, - &project, - ); - - Some(new_prediction) - } else { - this.reject_prediction( - new_prediction.prediction.id, - EditPredictionRejectReason::CurrentPreferred, - false, - ); - None - } - } else { - Some(new_prediction) - } - } - Err(reject_reason) => { - this.reject_prediction(prediction_result.id, reject_reason, false); - None - } - } - } else { - None - }; - - let zeta_project = this.get_or_init_zeta_project(&project, cx); - - if let Some(new_prediction) = new_current_prediction { - zeta_project.current_prediction = Some(new_prediction); - } - - let mut pending_predictions = mem::take(&mut zeta_project.pending_predictions); - for (ix, pending_prediction) in pending_predictions.iter().enumerate() { - if pending_prediction.id == pending_prediction_id { - pending_predictions.remove(ix); - for pending_prediction in pending_predictions.drain(0..ix) { - zeta_project.cancel_pending_prediction(pending_prediction, cx) - } - break; - } - } - this.get_or_init_zeta_project(&project, cx) - .pending_predictions = pending_predictions; - cx.notify(); - }) - .ok(); - - new_prediction_id - }); - - if zeta_project.pending_predictions.len() <= 1 { - zeta_project.pending_predictions.push(PendingPrediction { - id: pending_prediction_id, - task, - }); - } else if zeta_project.pending_predictions.len() == 2 { - let pending_prediction = zeta_project.pending_predictions.pop().unwrap(); - zeta_project.pending_predictions.push(PendingPrediction { - id: pending_prediction_id, - task, - }); - zeta_project.cancel_pending_prediction(pending_prediction, cx); - } - } - - pub fn request_prediction( - &mut self, - project: &Entity, - active_buffer: &Entity, - position: language::Anchor, - trigger: PredictEditsRequestTrigger, - cx: &mut Context, - ) -> Task>> { - self.request_prediction_internal( - project.clone(), - active_buffer.clone(), - position, - trigger, - cx.has_flag::(), - cx, - ) - } - - fn request_prediction_internal( - &mut self, - project: Entity, - active_buffer: Entity, - position: language::Anchor, - trigger: PredictEditsRequestTrigger, - allow_jump: bool, - cx: &mut Context, - ) -> Task>> { - const DIAGNOSTIC_LINES_RANGE: u32 = 20; - - self.get_or_init_zeta_project(&project, cx); - let zeta_project = self.projects.get(&project.entity_id()).unwrap(); - let events = zeta_project.events(cx); - let has_events = !events.is_empty(); - - let snapshot = active_buffer.read(cx).snapshot(); - let cursor_point = position.to_point(&snapshot); - let diagnostic_search_start = cursor_point.row.saturating_sub(DIAGNOSTIC_LINES_RANGE); - let diagnostic_search_end = cursor_point.row + DIAGNOSTIC_LINES_RANGE; - let diagnostic_search_range = - Point::new(diagnostic_search_start, 0)..Point::new(diagnostic_search_end, 0); - - let task = match self.edit_prediction_model { - ZetaEditPredictionModel::Zeta1 => request_prediction_with_zeta1( - self, - &project, - &active_buffer, - snapshot.clone(), - position, - events, - trigger, - cx, - ), - ZetaEditPredictionModel::Zeta2 => self.request_prediction_with_zeta2( - &project, - &active_buffer, - snapshot.clone(), - position, - events, - trigger, - cx, - ), - ZetaEditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep( - &project, - &active_buffer, - snapshot.clone(), - position, - events, - &zeta_project.recent_paths, - diagnostic_search_range.clone(), - cx, - ), - }; - - cx.spawn(async move |this, cx| { - let prediction = task.await?; - - if prediction.is_none() && allow_jump { - let cursor_point = position.to_point(&snapshot); - if has_events - && let Some((jump_buffer, jump_position)) = Self::next_diagnostic_location( - active_buffer.clone(), - &snapshot, - diagnostic_search_range, - cursor_point, - &project, - cx, - ) - .await? - { - return this - .update(cx, |this, cx| { - this.request_prediction_internal( - project, - jump_buffer, - jump_position, - trigger, - false, - cx, - ) - })? - .await; - } - - return anyhow::Ok(None); - } - - Ok(prediction) - }) - } - - async fn next_diagnostic_location( - active_buffer: Entity, - active_buffer_snapshot: &BufferSnapshot, - active_buffer_diagnostic_search_range: Range, - active_buffer_cursor_point: Point, - project: &Entity, - cx: &mut AsyncApp, - ) -> Result, language::Anchor)>> { - // find the closest diagnostic to the cursor that wasn't close enough to be included in the last request - let mut jump_location = active_buffer_snapshot - .diagnostic_groups(None) - .into_iter() - .filter_map(|(_, group)| { - let range = &group.entries[group.primary_ix] - .range - .to_point(&active_buffer_snapshot); - if range.overlaps(&active_buffer_diagnostic_search_range) { - None - } else { - Some(range.start) - } - }) - .min_by_key(|probe| probe.row.abs_diff(active_buffer_cursor_point.row)) - .map(|position| { - ( - active_buffer.clone(), - active_buffer_snapshot.anchor_before(position), - ) - }); - - if jump_location.is_none() { - let active_buffer_path = active_buffer.read_with(cx, |buffer, cx| { - let file = buffer.file()?; - - Some(ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path().clone(), - }) - })?; - - let buffer_task = project.update(cx, |project, cx| { - let (path, _, _) = project - .diagnostic_summaries(false, cx) - .filter(|(path, _, _)| Some(path) != active_buffer_path.as_ref()) - .max_by_key(|(path, _, _)| { - // find the buffer with errors that shares most parent directories - path.path - .components() - .zip( - active_buffer_path - .as_ref() - .map(|p| p.path.components()) - .unwrap_or_default(), - ) - .take_while(|(a, b)| a == b) - .count() - })?; - - Some(project.open_buffer(path, cx)) - })?; - - if let Some(buffer_task) = buffer_task { - let closest_buffer = buffer_task.await?; - - jump_location = closest_buffer - .read_with(cx, |buffer, _cx| { - buffer - .buffer_diagnostics(None) - .into_iter() - .min_by_key(|entry| entry.diagnostic.severity) - .map(|entry| entry.range.start) - })? - .map(|position| (closest_buffer, position)); - } - } - - anyhow::Ok(jump_location) - } - - fn request_prediction_with_zeta2( - &mut self, - project: &Entity, - active_buffer: &Entity, - active_snapshot: BufferSnapshot, - position: language::Anchor, - events: Vec>, - trigger: PredictEditsRequestTrigger, - cx: &mut Context, - ) -> Task>> { - let project_state = self.projects.get(&project.entity_id()); - - let index_state = project_state.and_then(|state| { - state - .syntax_index - .as_ref() - .map(|syntax_index| syntax_index.read_with(cx, |index, _cx| index.state().clone())) - }); - let options = self.options.clone(); - let buffer_snapshotted_at = Instant::now(); - let Some(excerpt_path) = active_snapshot - .file() - .map(|path| -> Arc { path.full_path(cx).into() }) - else { - return Task::ready(Err(anyhow!("No file path for excerpt"))); - }; - let client = self.client.clone(); - let llm_token = self.llm_token.clone(); - let app_version = AppVersion::global(cx); - let worktree_snapshots = project - .read(cx) - .worktrees(cx) - .map(|worktree| worktree.read(cx).snapshot()) - .collect::>(); - let debug_tx = self.debug_tx.clone(); - - let diagnostics = active_snapshot.diagnostic_sets().clone(); - - let file = active_buffer.read(cx).file(); - let parent_abs_path = project::File::from_dyn(file).and_then(|f| { - let mut path = f.worktree.read(cx).absolutize(&f.path); - if path.pop() { Some(path) } else { None } - }); - - // TODO data collection - let can_collect_data = file - .as_ref() - .map_or(false, |file| self.can_collect_file(project, file, cx)); - - let empty_context_files = HashMap::default(); - let context_files = project_state - .and_then(|project_state| project_state.context.as_ref()) - .unwrap_or(&empty_context_files); - - #[cfg(feature = "eval-support")] - let parsed_fut = futures::future::join_all( - context_files - .keys() - .map(|buffer| buffer.read(cx).parsing_idle()), - ); - - let mut included_files = context_files - .iter() - .filter_map(|(buffer_entity, ranges)| { - let buffer = buffer_entity.read(cx); - Some(( - buffer_entity.clone(), - buffer.snapshot(), - buffer.file()?.full_path(cx).into(), - ranges.clone(), - )) - }) - .collect::>(); - - included_files.sort_by(|(_, _, path_a, ranges_a), (_, _, path_b, ranges_b)| { - (path_a, ranges_a.len()).cmp(&(path_b, ranges_b.len())) - }); - - #[cfg(feature = "eval-support")] - let eval_cache = self.eval_cache.clone(); - - let request_task = cx.background_spawn({ - let active_buffer = active_buffer.clone(); - async move { - #[cfg(feature = "eval-support")] - parsed_fut.await; - - let index_state = if let Some(index_state) = index_state { - Some(index_state.lock_owned().await) - } else { - None - }; - - let cursor_offset = position.to_offset(&active_snapshot); - let cursor_point = cursor_offset.to_point(&active_snapshot); - - let before_retrieval = Instant::now(); - - let (diagnostic_groups, diagnostic_groups_truncated) = - Self::gather_nearby_diagnostics( - cursor_offset, - &diagnostics, - &active_snapshot, - options.max_diagnostic_bytes, - ); - - let cloud_request = match options.context { - ContextMode::Agentic(context_options) => { - let Some(excerpt) = EditPredictionExcerpt::select_from_buffer( - cursor_point, - &active_snapshot, - &context_options.excerpt, - index_state.as_deref(), - ) else { - return Ok((None, None)); - }; - - let excerpt_anchor_range = active_snapshot.anchor_after(excerpt.range.start) - ..active_snapshot.anchor_before(excerpt.range.end); - - if let Some(buffer_ix) = - included_files.iter().position(|(_, snapshot, _, _)| { - snapshot.remote_id() == active_snapshot.remote_id() - }) - { - let (_, buffer, _, ranges) = &mut included_files[buffer_ix]; - ranges.push(excerpt_anchor_range); - retrieval_search::merge_anchor_ranges(ranges, buffer); - let last_ix = included_files.len() - 1; - included_files.swap(buffer_ix, last_ix); - } else { - included_files.push(( - active_buffer.clone(), - active_snapshot.clone(), - excerpt_path.clone(), - vec![excerpt_anchor_range], - )); - } - - let included_files = included_files - .iter() - .map(|(_, snapshot, path, ranges)| { - let ranges = ranges - .iter() - .map(|range| { - let point_range = range.to_point(&snapshot); - Line(point_range.start.row)..Line(point_range.end.row) - }) - .collect::>(); - let excerpts = assemble_excerpts(&snapshot, ranges); - predict_edits_v3::IncludedFile { - path: path.clone(), - max_row: Line(snapshot.max_point().row), - excerpts, - } - }) - .collect::>(); - - predict_edits_v3::PredictEditsRequest { - excerpt_path, - excerpt: String::new(), - excerpt_line_range: Line(0)..Line(0), - excerpt_range: 0..0, - cursor_point: predict_edits_v3::Point { - line: predict_edits_v3::Line(cursor_point.row), - column: cursor_point.column, - }, - included_files, - referenced_declarations: vec![], - events, - can_collect_data, - diagnostic_groups, - diagnostic_groups_truncated, - debug_info: debug_tx.is_some(), - prompt_max_bytes: Some(options.max_prompt_bytes), - prompt_format: options.prompt_format, - // TODO [zeta2] - signatures: vec![], - excerpt_parent: None, - git_info: None, - trigger, - } - } - ContextMode::Syntax(context_options) => { - let Some(context) = EditPredictionContext::gather_context( - cursor_point, - &active_snapshot, - parent_abs_path.as_deref(), - &context_options, - index_state.as_deref(), - ) else { - return Ok((None, None)); - }; - - make_syntax_context_cloud_request( - excerpt_path, - context, - events, - can_collect_data, - diagnostic_groups, - diagnostic_groups_truncated, - None, - debug_tx.is_some(), - &worktree_snapshots, - index_state.as_deref(), - Some(options.max_prompt_bytes), - options.prompt_format, - trigger, - ) - } - }; - - let prompt_result = cloud_zeta2_prompt::build_prompt(&cloud_request); - - let inputs = EditPredictionInputs { - included_files: cloud_request.included_files, - events: cloud_request.events, - cursor_point: cloud_request.cursor_point, - cursor_path: cloud_request.excerpt_path, - }; - - let retrieval_time = Instant::now() - before_retrieval; - - let debug_response_tx = if let Some(debug_tx) = &debug_tx { - let (response_tx, response_rx) = oneshot::channel(); - - debug_tx - .unbounded_send(ZetaDebugInfo::EditPredictionRequested( - ZetaEditPredictionDebugInfo { - inputs: inputs.clone(), - retrieval_time, - buffer: active_buffer.downgrade(), - local_prompt: match prompt_result.as_ref() { - Ok((prompt, _)) => Ok(prompt.clone()), - Err(err) => Err(err.to_string()), - }, - position, - response_rx, - }, - )) - .ok(); - Some(response_tx) - } else { - None - }; - - if cfg!(debug_assertions) && env::var("ZED_ZETA2_SKIP_REQUEST").is_ok() { - if let Some(debug_response_tx) = debug_response_tx { - debug_response_tx - .send((Err("Request skipped".to_string()), Duration::ZERO)) - .ok(); - } - anyhow::bail!("Skipping request because ZED_ZETA2_SKIP_REQUEST is set") - } - - let (prompt, _) = prompt_result?; - let generation_params = - cloud_zeta2_prompt::generation_params(cloud_request.prompt_format); - let request = open_ai::Request { - model: EDIT_PREDICTIONS_MODEL_ID.clone(), - messages: vec![open_ai::RequestMessage::User { - content: open_ai::MessageContent::Plain(prompt), - }], - stream: false, - max_completion_tokens: None, - stop: generation_params.stop.unwrap_or_default(), - temperature: generation_params.temperature.unwrap_or(0.7), - tool_choice: None, - parallel_tool_calls: None, - tools: vec![], - prompt_cache_key: None, - reasoning_effort: None, - }; - - log::trace!("Sending edit prediction request"); - - let before_request = Instant::now(); - let response = Self::send_raw_llm_request( - request, - client, - llm_token, - app_version, - #[cfg(feature = "eval-support")] - eval_cache, - #[cfg(feature = "eval-support")] - EvalCacheEntryKind::Prediction, - ) - .await; - let received_response_at = Instant::now(); - let request_time = received_response_at - before_request; - - log::trace!("Got edit prediction response"); - - if let Some(debug_response_tx) = debug_response_tx { - debug_response_tx - .send(( - response - .as_ref() - .map_err(|err| err.to_string()) - .map(|response| response.0.clone()), - request_time, - )) - .ok(); - } - - let (res, usage) = response?; - let request_id = EditPredictionId(res.id.clone().into()); - let Some(mut output_text) = text_from_response(res) else { - return Ok((Some((request_id, None)), usage)); - }; - - if output_text.contains(CURSOR_MARKER) { - log::trace!("Stripping out {CURSOR_MARKER} from response"); - output_text = output_text.replace(CURSOR_MARKER, ""); - } - - let get_buffer_from_context = |path: &Path| { - included_files - .iter() - .find_map(|(_, buffer, probe_path, ranges)| { - if probe_path.as_ref() == path { - Some((buffer, ranges.as_slice())) - } else { - None - } - }) - }; - - let (edited_buffer_snapshot, edits) = match options.prompt_format { - PromptFormat::NumLinesUniDiff => { - // TODO: Implement parsing of multi-file diffs - crate::udiff::parse_diff(&output_text, get_buffer_from_context).await? - } - PromptFormat::Minimal - | PromptFormat::MinimalQwen - | PromptFormat::SeedCoder1120 => { - if output_text.contains("--- a/\n+++ b/\nNo edits") { - let edits = vec![]; - (&active_snapshot, edits) - } else { - crate::udiff::parse_diff(&output_text, get_buffer_from_context).await? - } - } - PromptFormat::OldTextNewText => { - crate::xml_edits::parse_xml_edits(&output_text, get_buffer_from_context) - .await? - } - _ => { - bail!("unsupported prompt format {}", options.prompt_format) - } - }; - - let edited_buffer = included_files - .iter() - .find_map(|(buffer, snapshot, _, _)| { - if snapshot.remote_id() == edited_buffer_snapshot.remote_id() { - Some(buffer.clone()) - } else { - None - } - }) - .context("Failed to find buffer in included_buffers")?; - - anyhow::Ok(( - Some(( - request_id, - Some(( - inputs, - edited_buffer, - edited_buffer_snapshot.clone(), - edits, - received_response_at, - )), - )), - usage, - )) - } - }); - - cx.spawn({ - async move |this, cx| { - let Some((id, prediction)) = - Self::handle_api_response(&this, request_task.await, cx)? - else { - return Ok(None); - }; - - let Some(( - inputs, - edited_buffer, - edited_buffer_snapshot, - edits, - received_response_at, - )) = prediction - else { - return Ok(Some(EditPredictionResult { - id, - prediction: Err(EditPredictionRejectReason::Empty), - })); - }; - - // TODO telemetry: duration, etc - Ok(Some( - EditPredictionResult::new( - id, - &edited_buffer, - &edited_buffer_snapshot, - edits.into(), - buffer_snapshotted_at, - received_response_at, - inputs, - cx, - ) - .await, - )) - } - }) - } - - async fn send_raw_llm_request( - request: open_ai::Request, - client: Arc, - llm_token: LlmApiToken, - app_version: Version, - #[cfg(feature = "eval-support")] eval_cache: Option>, - #[cfg(feature = "eval-support")] eval_cache_kind: EvalCacheEntryKind, - ) -> Result<(open_ai::Response, Option)> { - let url = if let Some(predict_edits_url) = PREDICT_EDITS_URL.as_ref() { - http_client::Url::parse(&predict_edits_url)? - } else { - client - .http_client() - .build_zed_llm_url("/predict_edits/raw", &[])? - }; - - #[cfg(feature = "eval-support")] - let cache_key = if let Some(cache) = eval_cache { - use collections::FxHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = FxHasher::default(); - url.hash(&mut hasher); - let request_str = serde_json::to_string_pretty(&request)?; - request_str.hash(&mut hasher); - let hash = hasher.finish(); - - let key = (eval_cache_kind, hash); - if let Some(response_str) = cache.read(key) { - return Ok((serde_json::from_str(&response_str)?, None)); - } - - Some((cache, request_str, key)) - } else { - None - }; - - let (response, usage) = Self::send_api_request( - |builder| { - let req = builder - .uri(url.as_ref()) - .body(serde_json::to_string(&request)?.into()); - Ok(req?) - }, - client, - llm_token, - app_version, - ) - .await?; - - #[cfg(feature = "eval-support")] - if let Some((cache, request, key)) = cache_key { - cache.write(key, &request, &serde_json::to_string_pretty(&response)?); - } - - Ok((response, usage)) - } - - fn handle_api_response( - this: &WeakEntity, - response: Result<(T, Option)>, - cx: &mut gpui::AsyncApp, - ) -> Result { - match response { - Ok((data, usage)) => { - if let Some(usage) = usage { - this.update(cx, |this, cx| { - this.user_store.update(cx, |user_store, cx| { - user_store.update_edit_prediction_usage(usage, cx); - }); - }) - .ok(); - } - Ok(data) - } - Err(err) => { - if err.is::() { - cx.update(|cx| { - this.update(cx, |this, _cx| { - this.update_required = true; - }) - .ok(); - - let error_message: SharedString = err.to_string().into(); - show_app_notification( - NotificationId::unique::(), - cx, - move |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new(error_message.clone(), cx) - .with_link_button("Update Zed", "https://zed.dev/releases") - }) - }, - ); - }) - .ok(); - } - Err(err) - } - } - } - - async fn send_api_request( - build: impl Fn(http_client::http::request::Builder) -> Result>, - client: Arc, - llm_token: LlmApiToken, - app_version: Version, - ) -> Result<(Res, Option)> - where - Res: DeserializeOwned, - { - let http_client = client.http_client(); - let mut token = llm_token.acquire(&client).await?; - let mut did_retry = false; - - loop { - let request_builder = http_client::Request::builder().method(Method::POST); - - let request = build( - request_builder - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", token)) - .header(ZED_VERSION_HEADER_NAME, app_version.to_string()), - )?; - - let mut response = http_client.send(request).await?; - - if let Some(minimum_required_version) = response - .headers() - .get(MINIMUM_REQUIRED_VERSION_HEADER_NAME) - .and_then(|version| Version::from_str(version.to_str().ok()?).ok()) - { - anyhow::ensure!( - app_version >= minimum_required_version, - ZedUpdateRequiredError { - minimum_version: minimum_required_version - } - ); - } - - if response.status().is_success() { - let usage = EditPredictionUsage::from_headers(response.headers()).ok(); - - let mut body = Vec::new(); - response.body_mut().read_to_end(&mut body).await?; - return Ok((serde_json::from_slice(&body)?, usage)); - } else if !did_retry - && response - .headers() - .get(EXPIRED_LLM_TOKEN_HEADER_NAME) - .is_some() - { - did_retry = true; - token = llm_token.refresh(&client).await?; - } else { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - anyhow::bail!( - "Request failed with status: {:?}\nBody: {}", - response.status(), - body - ); - } - } - } - - pub const CONTEXT_RETRIEVAL_IDLE_DURATION: Duration = Duration::from_secs(10); - pub const CONTEXT_RETRIEVAL_DEBOUNCE_DURATION: Duration = Duration::from_secs(3); - - // Refresh the related excerpts when the user just beguns editing after - // an idle period, and after they pause editing. - fn refresh_context_if_needed( - &mut self, - project: &Entity, - buffer: &Entity, - cursor_position: language::Anchor, - cx: &mut Context, - ) { - if !matches!(self.edit_prediction_model, ZetaEditPredictionModel::Zeta2) { - return; - } - - if !matches!(&self.options().context, ContextMode::Agentic { .. }) { - return; - } - - let Some(zeta_project) = self.projects.get_mut(&project.entity_id()) else { - return; - }; - - let now = Instant::now(); - let was_idle = zeta_project - .refresh_context_timestamp - .map_or(true, |timestamp| { - now - timestamp > Self::CONTEXT_RETRIEVAL_IDLE_DURATION - }); - zeta_project.refresh_context_timestamp = Some(now); - zeta_project.refresh_context_debounce_task = Some(cx.spawn({ - let buffer = buffer.clone(); - let project = project.clone(); - async move |this, cx| { - if was_idle { - log::debug!("refetching edit prediction context after idle"); - } else { - cx.background_executor() - .timer(Self::CONTEXT_RETRIEVAL_DEBOUNCE_DURATION) - .await; - log::debug!("refetching edit prediction context after pause"); - } - this.update(cx, |this, cx| { - let task = this.refresh_context(project.clone(), buffer, cursor_position, cx); - - if let Some(zeta_project) = this.projects.get_mut(&project.entity_id()) { - zeta_project.refresh_context_task = Some(task.log_err()); - }; - }) - .ok() - } - })); - } - - // Refresh the related excerpts asynchronously. Ensure the task runs to completion, - // and avoid spawning more than one concurrent task. - pub fn refresh_context( - &mut self, - project: Entity, - buffer: Entity, - cursor_position: language::Anchor, - cx: &mut Context, - ) -> Task> { - let Some(zeta_project) = self.projects.get(&project.entity_id()) else { - return Task::ready(anyhow::Ok(())); - }; - - let ContextMode::Agentic(options) = &self.options().context else { - return Task::ready(anyhow::Ok(())); - }; - - let snapshot = buffer.read(cx).snapshot(); - let cursor_point = cursor_position.to_point(&snapshot); - let Some(cursor_excerpt) = EditPredictionExcerpt::select_from_buffer( - cursor_point, - &snapshot, - &options.excerpt, - None, - ) else { - return Task::ready(Ok(())); - }; - - let app_version = AppVersion::global(cx); - let client = self.client.clone(); - let llm_token = self.llm_token.clone(); - let debug_tx = self.debug_tx.clone(); - let current_file_path: Arc = snapshot - .file() - .map(|f| f.full_path(cx).into()) - .unwrap_or_else(|| Path::new("untitled").into()); - - let prompt = match cloud_zeta2_prompt::retrieval_prompt::build_prompt( - predict_edits_v3::PlanContextRetrievalRequest { - excerpt: cursor_excerpt.text(&snapshot).body, - excerpt_path: current_file_path, - excerpt_line_range: cursor_excerpt.line_range, - cursor_file_max_row: Line(snapshot.max_point().row), - events: zeta_project.events(cx), - }, - ) { - Ok(prompt) => prompt, - Err(err) => { - return Task::ready(Err(err)); - } - }; - - if let Some(debug_tx) = &debug_tx { - debug_tx - .unbounded_send(ZetaDebugInfo::ContextRetrievalStarted( - ZetaContextRetrievalStartedDebugInfo { - project: project.clone(), - timestamp: Instant::now(), - search_prompt: prompt.clone(), - }, - )) - .ok(); - } - - pub static TOOL_SCHEMA: LazyLock<(serde_json::Value, String)> = LazyLock::new(|| { - let schema = language_model::tool_schema::root_schema_for::( - language_model::LanguageModelToolSchemaFormat::JsonSchemaSubset, - ); - - let description = schema - .get("description") - .and_then(|description| description.as_str()) - .unwrap() - .to_string(); - - (schema.into(), description) - }); - - let (tool_schema, tool_description) = TOOL_SCHEMA.clone(); - - let request = open_ai::Request { - model: CONTEXT_RETRIEVAL_MODEL_ID.clone(), - messages: vec![open_ai::RequestMessage::User { - content: open_ai::MessageContent::Plain(prompt), - }], - stream: false, - max_completion_tokens: None, - stop: Default::default(), - temperature: 0.7, - tool_choice: None, - parallel_tool_calls: None, - tools: vec![open_ai::ToolDefinition::Function { - function: FunctionDefinition { - name: cloud_zeta2_prompt::retrieval_prompt::TOOL_NAME.to_string(), - description: Some(tool_description), - parameters: Some(tool_schema), - }, - }], - prompt_cache_key: None, - reasoning_effort: None, - }; - - #[cfg(feature = "eval-support")] - let eval_cache = self.eval_cache.clone(); - - cx.spawn(async move |this, cx| { - log::trace!("Sending search planning request"); - let response = Self::send_raw_llm_request( - request, - client, - llm_token, - app_version, - #[cfg(feature = "eval-support")] - eval_cache.clone(), - #[cfg(feature = "eval-support")] - EvalCacheEntryKind::Context, - ) - .await; - let mut response = Self::handle_api_response(&this, response, cx)?; - log::trace!("Got search planning response"); - - let choice = response - .choices - .pop() - .context("No choices in retrieval response")?; - let open_ai::RequestMessage::Assistant { - content: _, - tool_calls, - } = choice.message - else { - anyhow::bail!("Retrieval response didn't include an assistant message"); - }; - - let mut queries: Vec = Vec::new(); - for tool_call in tool_calls { - let open_ai::ToolCallContent::Function { function } = tool_call.content; - if function.name != cloud_zeta2_prompt::retrieval_prompt::TOOL_NAME { - log::warn!( - "Context retrieval response tried to call an unknown tool: {}", - function.name - ); - - continue; - } - - let input: SearchToolInput = serde_json::from_str(&function.arguments) - .with_context(|| format!("invalid search json {}", &function.arguments))?; - queries.extend(input.queries); - } - - if let Some(debug_tx) = &debug_tx { - debug_tx - .unbounded_send(ZetaDebugInfo::SearchQueriesGenerated( - ZetaSearchQueryDebugInfo { - project: project.clone(), - timestamp: Instant::now(), - search_queries: queries.clone(), - }, - )) - .ok(); - } - - log::trace!("Running retrieval search: {queries:#?}"); - - let related_excerpts_result = retrieval_search::run_retrieval_searches( - queries, - project.clone(), - #[cfg(feature = "eval-support")] - eval_cache, - cx, - ) - .await; - - log::trace!("Search queries executed"); - - if let Some(debug_tx) = &debug_tx { - debug_tx - .unbounded_send(ZetaDebugInfo::SearchQueriesExecuted( - ZetaContextRetrievalDebugInfo { - project: project.clone(), - timestamp: Instant::now(), - }, - )) - .ok(); - } - - this.update(cx, |this, _cx| { - let Some(zeta_project) = this.projects.get_mut(&project.entity_id()) else { - return Ok(()); - }; - zeta_project.refresh_context_task.take(); - if let Some(debug_tx) = &this.debug_tx { - debug_tx - .unbounded_send(ZetaDebugInfo::ContextRetrievalFinished( - ZetaContextRetrievalDebugInfo { - project, - timestamp: Instant::now(), - }, - )) - .ok(); - } - match related_excerpts_result { - Ok(excerpts) => { - zeta_project.context = Some(excerpts); - Ok(()) - } - Err(error) => Err(error), - } - })? - }) - } - - pub fn set_context( - &mut self, - project: Entity, - context: HashMap, Vec>>, - ) { - if let Some(zeta_project) = self.projects.get_mut(&project.entity_id()) { - zeta_project.context = Some(context); - } - } - - fn gather_nearby_diagnostics( - cursor_offset: usize, - diagnostic_sets: &[(LanguageServerId, DiagnosticSet)], - snapshot: &BufferSnapshot, - max_diagnostics_bytes: usize, - ) -> (Vec, bool) { - // TODO: Could make this more efficient - let mut diagnostic_groups = Vec::new(); - for (language_server_id, diagnostics) in diagnostic_sets { - let mut groups = Vec::new(); - diagnostics.groups(*language_server_id, &mut groups, &snapshot); - diagnostic_groups.extend( - groups - .into_iter() - .map(|(_, group)| group.resolve::(&snapshot)), - ); - } - - // sort by proximity to cursor - diagnostic_groups.sort_by_key(|group| { - let range = &group.entries[group.primary_ix].range; - if range.start >= cursor_offset { - range.start - cursor_offset - } else if cursor_offset >= range.end { - cursor_offset - range.end - } else { - (cursor_offset - range.start).min(range.end - cursor_offset) - } - }); - - let mut results = Vec::new(); - let mut diagnostic_groups_truncated = false; - let mut diagnostics_byte_count = 0; - for group in diagnostic_groups { - let raw_value = serde_json::value::to_raw_value(&group).unwrap(); - diagnostics_byte_count += raw_value.get().len(); - if diagnostics_byte_count > max_diagnostics_bytes { - diagnostic_groups_truncated = true; - break; - } - results.push(predict_edits_v3::DiagnosticGroup(raw_value)); - } - - (results, diagnostic_groups_truncated) - } - - // TODO: Dedupe with similar code in request_prediction? - pub fn cloud_request_for_zeta_cli( - &mut self, - project: &Entity, - buffer: &Entity, - position: language::Anchor, - cx: &mut Context, - ) -> Task> { - let project_state = self.projects.get(&project.entity_id()); - - let index_state = project_state.and_then(|state| { - state - .syntax_index - .as_ref() - .map(|index| index.read_with(cx, |index, _cx| index.state().clone())) - }); - let options = self.options.clone(); - let snapshot = buffer.read(cx).snapshot(); - let Some(excerpt_path) = snapshot.file().map(|path| path.full_path(cx)) else { - return Task::ready(Err(anyhow!("No file path for excerpt"))); - }; - let worktree_snapshots = project - .read(cx) - .worktrees(cx) - .map(|worktree| worktree.read(cx).snapshot()) - .collect::>(); - - let parent_abs_path = project::File::from_dyn(buffer.read(cx).file()).and_then(|f| { - let mut path = f.worktree.read(cx).absolutize(&f.path); - if path.pop() { Some(path) } else { None } - }); - - cx.background_spawn(async move { - let index_state = if let Some(index_state) = index_state { - Some(index_state.lock_owned().await) - } else { - None - }; - - let cursor_point = position.to_point(&snapshot); - - let debug_info = true; - EditPredictionContext::gather_context( - cursor_point, - &snapshot, - parent_abs_path.as_deref(), - match &options.context { - ContextMode::Agentic(_) => { - // TODO - panic!("Llm mode not supported in zeta cli yet"); - } - ContextMode::Syntax(edit_prediction_context_options) => { - edit_prediction_context_options - } - }, - index_state.as_deref(), - ) - .context("Failed to select excerpt") - .map(|context| { - make_syntax_context_cloud_request( - excerpt_path.into(), - context, - // TODO pass everything - Vec::new(), - false, - Vec::new(), - false, - None, - debug_info, - &worktree_snapshots, - index_state.as_deref(), - Some(options.max_prompt_bytes), - options.prompt_format, - PredictEditsRequestTrigger::Other, - ) - }) - }) - } - - pub fn wait_for_initial_indexing( - &mut self, - project: &Entity, - cx: &mut Context, - ) -> Task> { - let zeta_project = self.get_or_init_zeta_project(project, cx); - if let Some(syntax_index) = &zeta_project.syntax_index { - syntax_index.read(cx).wait_for_initial_file_indexing(cx) - } else { - Task::ready(Ok(())) - } - } - - fn is_file_open_source( - &self, - project: &Entity, - file: &Arc, - cx: &App, - ) -> bool { - if !file.is_local() || file.is_private() { - return false; - } - let Some(zeta_project) = self.projects.get(&project.entity_id()) else { - return false; - }; - zeta_project - .license_detection_watchers - .get(&file.worktree_id(cx)) - .as_ref() - .is_some_and(|watcher| watcher.is_project_open_source()) - } - - fn can_collect_file(&self, project: &Entity, file: &Arc, cx: &App) -> bool { - self.data_collection_choice.is_enabled() && self.is_file_open_source(project, file, cx) - } - - fn can_collect_events(&self, events: &[Arc]) -> bool { - if !self.data_collection_choice.is_enabled() { - return false; - } - events.iter().all(|event| { - matches!( - event.as_ref(), - Event::BufferChange { - in_open_source_repo: true, - .. - } - ) - }) - } - - fn load_data_collection_choice() -> DataCollectionChoice { - let choice = KEY_VALUE_STORE - .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) - .log_err() - .flatten(); - - match choice.as_deref() { - Some("true") => DataCollectionChoice::Enabled, - Some("false") => DataCollectionChoice::Disabled, - Some(_) => { - log::error!("unknown value in '{ZED_PREDICT_DATA_COLLECTION_CHOICE}'"); - DataCollectionChoice::NotAnswered - } - None => DataCollectionChoice::NotAnswered, - } - } - - pub fn shown_predictions(&self) -> impl DoubleEndedIterator { - self.shown_predictions.iter() - } - - pub fn shown_completions_len(&self) -> usize { - self.shown_predictions.len() - } - - pub fn is_prediction_rated(&self, id: &EditPredictionId) -> bool { - self.rated_predictions.contains(id) - } - - pub fn rate_prediction( - &mut self, - prediction: &EditPrediction, - rating: EditPredictionRating, - feedback: String, - cx: &mut Context, - ) { - self.rated_predictions.insert(prediction.id.clone()); - telemetry::event!( - "Edit Prediction Rated", - rating, - inputs = prediction.inputs, - output = prediction.edit_preview.as_unified_diff(&prediction.edits), - feedback - ); - self.client.telemetry().flush_events().detach(); - cx.notify(); - } -} - -pub fn text_from_response(mut res: open_ai::Response) -> Option { - let choice = res.choices.pop()?; - let output_text = match choice.message { - open_ai::RequestMessage::Assistant { - content: Some(open_ai::MessageContent::Plain(content)), - .. - } => content, - open_ai::RequestMessage::Assistant { - content: Some(open_ai::MessageContent::Multipart(mut content)), - .. - } => { - if content.is_empty() { - log::error!("No output from Baseten completion response"); - return None; - } - - match content.remove(0) { - open_ai::MessagePart::Text { text } => text, - open_ai::MessagePart::Image { .. } => { - log::error!("Expected text, got an image"); - return None; - } - } - } - _ => { - log::error!("Invalid response message: {:?}", choice.message); - return None; - } - }; - Some(output_text) -} - -#[derive(Error, Debug)] -#[error( - "You must update to Zed version {minimum_version} or higher to continue using edit predictions." -)] -pub struct ZedUpdateRequiredError { - minimum_version: Version, -} - -fn make_syntax_context_cloud_request( - excerpt_path: Arc, - context: EditPredictionContext, - events: Vec>, - can_collect_data: bool, - diagnostic_groups: Vec, - diagnostic_groups_truncated: bool, - git_info: Option, - debug_info: bool, - worktrees: &Vec, - index_state: Option<&SyntaxIndexState>, - prompt_max_bytes: Option, - prompt_format: PromptFormat, - trigger: PredictEditsRequestTrigger, -) -> predict_edits_v3::PredictEditsRequest { - let mut signatures = Vec::new(); - let mut declaration_to_signature_index = HashMap::default(); - let mut referenced_declarations = Vec::new(); - - for snippet in context.declarations { - let project_entry_id = snippet.declaration.project_entry_id(); - let Some(path) = worktrees.iter().find_map(|worktree| { - worktree.entry_for_id(project_entry_id).map(|entry| { - let mut full_path = RelPathBuf::new(); - full_path.push(worktree.root_name()); - full_path.push(&entry.path); - full_path - }) - }) else { - continue; - }; - - let parent_index = index_state.and_then(|index_state| { - snippet.declaration.parent().and_then(|parent| { - add_signature( - parent, - &mut declaration_to_signature_index, - &mut signatures, - index_state, - ) - }) - }); - - let (text, text_is_truncated) = snippet.declaration.item_text(); - referenced_declarations.push(predict_edits_v3::ReferencedDeclaration { - path: path.as_std_path().into(), - text: text.into(), - range: snippet.declaration.item_line_range(), - text_is_truncated, - signature_range: snippet.declaration.signature_range_in_item_text(), - parent_index, - signature_score: snippet.score(DeclarationStyle::Signature), - declaration_score: snippet.score(DeclarationStyle::Declaration), - score_components: snippet.components, - }); - } - - let excerpt_parent = index_state.and_then(|index_state| { - context - .excerpt - .parent_declarations - .last() - .and_then(|(parent, _)| { - add_signature( - *parent, - &mut declaration_to_signature_index, - &mut signatures, - index_state, - ) - }) - }); - - predict_edits_v3::PredictEditsRequest { - excerpt_path, - excerpt: context.excerpt_text.body, - excerpt_line_range: context.excerpt.line_range, - excerpt_range: context.excerpt.range, - cursor_point: predict_edits_v3::Point { - line: predict_edits_v3::Line(context.cursor_point.row), - column: context.cursor_point.column, - }, - referenced_declarations, - included_files: vec![], - signatures, - excerpt_parent, - events, - can_collect_data, - diagnostic_groups, - diagnostic_groups_truncated, - git_info, - debug_info, - prompt_max_bytes, - prompt_format, - trigger, - } -} - -fn add_signature( - declaration_id: DeclarationId, - declaration_to_signature_index: &mut HashMap, - signatures: &mut Vec, - index: &SyntaxIndexState, -) -> Option { - if let Some(signature_index) = declaration_to_signature_index.get(&declaration_id) { - return Some(*signature_index); - } - let Some(parent_declaration) = index.declaration(declaration_id) else { - log::error!("bug: missing parent declaration"); - return None; - }; - let parent_index = parent_declaration.parent().and_then(|parent| { - add_signature(parent, declaration_to_signature_index, signatures, index) - }); - let (text, text_is_truncated) = parent_declaration.signature_text(); - let signature_index = signatures.len(); - signatures.push(Signature { - text: text.into(), - text_is_truncated, - parent_index, - range: parent_declaration.signature_line_range(), - }); - declaration_to_signature_index.insert(declaration_id, signature_index); - Some(signature_index) -} - -#[cfg(feature = "eval-support")] -pub type EvalCacheKey = (EvalCacheEntryKind, u64); - -#[cfg(feature = "eval-support")] -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum EvalCacheEntryKind { - Context, - Search, - Prediction, -} - -#[cfg(feature = "eval-support")] -impl std::fmt::Display for EvalCacheEntryKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EvalCacheEntryKind::Search => write!(f, "search"), - EvalCacheEntryKind::Context => write!(f, "context"), - EvalCacheEntryKind::Prediction => write!(f, "prediction"), - } - } -} - -#[cfg(feature = "eval-support")] -pub trait EvalCache: Send + Sync { - fn read(&self, key: EvalCacheKey) -> Option; - fn write(&self, key: EvalCacheKey, input: &str, value: &str); -} - -#[derive(Debug, Clone, Copy)] -pub enum DataCollectionChoice { - NotAnswered, - Enabled, - Disabled, -} - -impl DataCollectionChoice { - pub fn is_enabled(self) -> bool { - match self { - Self::Enabled => true, - Self::NotAnswered | Self::Disabled => false, - } - } - - pub fn is_answered(self) -> bool { - match self { - Self::Enabled | Self::Disabled => true, - Self::NotAnswered => false, - } - } - - #[must_use] - pub fn toggle(&self) -> DataCollectionChoice { - match self { - Self::Enabled => Self::Disabled, - Self::Disabled => Self::Enabled, - Self::NotAnswered => Self::Enabled, - } - } -} - -impl From for DataCollectionChoice { - fn from(value: bool) -> Self { - match value { - true => DataCollectionChoice::Enabled, - false => DataCollectionChoice::Disabled, - } - } -} - -struct ZedPredictUpsell; - -impl Dismissable for ZedPredictUpsell { - const KEY: &'static str = "dismissed-edit-predict-upsell"; - - fn dismissed() -> bool { - // To make this backwards compatible with older versions of Zed, we - // check if the user has seen the previous Edit Prediction Onboarding - // before, by checking the data collection choice which was written to - // the database once the user clicked on "Accept and Enable" - if KEY_VALUE_STORE - .read_kvp(ZED_PREDICT_DATA_COLLECTION_CHOICE) - .log_err() - .is_some_and(|s| s.is_some()) - { - return true; - } - - KEY_VALUE_STORE - .read_kvp(Self::KEY) - .log_err() - .is_some_and(|s| s.is_some()) - } -} - -pub fn should_show_upsell_modal() -> bool { - !ZedPredictUpsell::dismissed() -} - -pub fn init(cx: &mut App) { - feature_gate_predict_edits_actions(cx); - - cx.observe_new(move |workspace: &mut Workspace, _, _cx| { - workspace.register_action(|workspace, _: &RateCompletions, window, cx| { - if cx.has_flag::() { - RatePredictionsModal::toggle(workspace, window, cx); - } - }); - - workspace.register_action( - move |workspace, _: &zed_actions::OpenZedPredictOnboarding, window, cx| { - ZedPredictModal::toggle( - workspace, - workspace.user_store().clone(), - workspace.client().clone(), - window, - cx, - ) - }, - ); - - workspace.register_action(|workspace, _: &ResetOnboarding, _window, cx| { - update_settings_file(workspace.app_state().fs.clone(), cx, move |settings, _| { - settings - .project - .all_languages - .features - .get_or_insert_default() - .edit_prediction_provider = Some(EditPredictionProvider::None) - }); - }); - }) - .detach(); -} - -fn feature_gate_predict_edits_actions(cx: &mut App) { - let rate_completion_action_types = [TypeId::of::()]; - let reset_onboarding_action_types = [TypeId::of::()]; - let zeta_all_action_types = [ - TypeId::of::(), - TypeId::of::(), - zed_actions::OpenZedPredictOnboarding.type_id(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - TypeId::of::(), - ]; - - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.hide_action_types(&rate_completion_action_types); - filter.hide_action_types(&reset_onboarding_action_types); - filter.hide_action_types(&[zed_actions::OpenZedPredictOnboarding.type_id()]); - }); - - cx.observe_global::(move |cx| { - let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; - let has_feature_flag = cx.has_flag::(); - - CommandPaletteFilter::update_global(cx, |filter, _cx| { - if is_ai_disabled { - filter.hide_action_types(&zeta_all_action_types); - } else if has_feature_flag { - filter.show_action_types(&rate_completion_action_types); - } else { - filter.hide_action_types(&rate_completion_action_types); - } - }); - }) - .detach(); - - cx.observe_flag::(move |is_enabled, cx| { - if !DisableAiSettings::get_global(cx).disable_ai { - if is_enabled { - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.show_action_types(&rate_completion_action_types); - }); - } else { - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.hide_action_types(&rate_completion_action_types); - }); - } - } - }) - .detach(); -} - -#[cfg(test)] -mod tests { - use std::{path::Path, sync::Arc, time::Duration}; - - use client::UserStore; - use clock::FakeSystemClock; - use cloud_llm_client::{ - EditPredictionRejectReason, EditPredictionRejection, RejectEditPredictionsBody, - }; - use cloud_zeta2_prompt::retrieval_prompt::{SearchToolInput, SearchToolQuery}; - use futures::{ - AsyncReadExt, StreamExt, - channel::{mpsc, oneshot}, - }; - use gpui::{ - Entity, TestAppContext, - http_client::{FakeHttpClient, Response}, - prelude::*, - }; - use indoc::indoc; - use language::OffsetRangeExt as _; - use open_ai::Usage; - use pretty_assertions::{assert_eq, assert_matches}; - use project::{FakeFs, Project}; - use serde_json::json; - use settings::SettingsStore; - use util::path; - use uuid::Uuid; - - use crate::{BufferEditPrediction, EditPredictionId, REJECT_REQUEST_DEBOUNCE, Zeta}; - - #[gpui::test] - async fn test_current_state(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "1.txt": "Hello!\nHow\nBye\n", - "2.txt": "Hola!\nComo\nAdios\n" - }), - ) - .await; - let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - zeta.update(cx, |zeta, cx| { - zeta.register_project(&project, cx); - }); - - let buffer1 = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/1.txt"), cx).unwrap(); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - let snapshot1 = buffer1.read_with(cx, |buffer, _cx| buffer.snapshot()); - let position = snapshot1.anchor_before(language::Point::new(1, 3)); - - // Prediction for current file - - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx) - }); - let (_request, respond_tx) = requests.predict.next().await.unwrap(); - - respond_tx - .send(model_response(indoc! {r" - --- a/root/1.txt - +++ b/root/1.txt - @@ ... @@ - Hello! - -How - +How are you? - Bye - "})) - .unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - let prediction = zeta - .current_prediction_for_buffer(&buffer1, &project, cx) - .unwrap(); - assert_matches!(prediction, BufferEditPrediction::Local { .. }); - }); - - // Context refresh - let refresh_task = zeta.update(cx, |zeta, cx| { - zeta.refresh_context(project.clone(), buffer1.clone(), position, cx) - }); - let (_request, respond_tx) = requests.predict.next().await.unwrap(); - respond_tx - .send(open_ai::Response { - id: Uuid::new_v4().to_string(), - object: "response".into(), - created: 0, - model: "model".into(), - choices: vec![open_ai::Choice { - index: 0, - message: open_ai::RequestMessage::Assistant { - content: None, - tool_calls: vec![open_ai::ToolCall { - id: "search".into(), - content: open_ai::ToolCallContent::Function { - function: open_ai::FunctionContent { - name: cloud_zeta2_prompt::retrieval_prompt::TOOL_NAME - .to_string(), - arguments: serde_json::to_string(&SearchToolInput { - queries: Box::new([SearchToolQuery { - glob: "root/2.txt".to_string(), - syntax_node: vec![], - content: Some(".".into()), - }]), - }) - .unwrap(), - }, - }, - }], - }, - finish_reason: None, - }], - usage: Usage { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }) - .unwrap(); - refresh_task.await.unwrap(); - - zeta.update(cx, |zeta, _cx| { - zeta.reject_current_prediction(EditPredictionRejectReason::Discarded, &project); - }); - - // Prediction for another file - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx) - }); - let (_request, respond_tx) = requests.predict.next().await.unwrap(); - respond_tx - .send(model_response(indoc! {r#" - --- a/root/2.txt - +++ b/root/2.txt - Hola! - -Como - +Como estas? - Adios - "#})) - .unwrap(); - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - let prediction = zeta - .current_prediction_for_buffer(&buffer1, &project, cx) - .unwrap(); - assert_matches!( - prediction, - BufferEditPrediction::Jump { prediction } if prediction.snapshot.file().unwrap().full_path(cx) == Path::new(path!("root/2.txt")) - ); - }); - - let buffer2 = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/2.txt"), cx).unwrap(); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - - zeta.read_with(cx, |zeta, cx| { - let prediction = zeta - .current_prediction_for_buffer(&buffer2, &project, cx) - .unwrap(); - assert_matches!(prediction, BufferEditPrediction::Local { .. }); - }); - } - - #[gpui::test] - async fn test_simple_request(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(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)); - - let prediction_task = zeta.update(cx, |zeta, cx| { - zeta.request_prediction(&project, &buffer, position, Default::default(), cx) - }); - - let (_, respond_tx) = requests.predict.next().await.unwrap(); - - // TODO Put back when we have a structured request again - // assert_eq!( - // request.excerpt_path.as_ref(), - // Path::new(path!("root/foo.md")) - // ); - // assert_eq!( - // request.cursor_point, - // Point { - // line: Line(1), - // column: 3 - // } - // ); - - respond_tx - .send(model_response(indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are you? - Bye - "})) - .unwrap(); - - let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); - - assert_eq!(prediction.edits.len(), 1); - assert_eq!( - prediction.edits[0].0.to_point(&snapshot).start, - language::Point::new(1, 3) - ); - assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); - } - - #[gpui::test] - async fn test_request_events(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/root", - json!({ - "foo.md": "Hello!\n\nBye\n" - }), - ) - .await; - let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - let buffer = project - .update(cx, |project, cx| { - let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); - project.open_buffer(path, cx) - }) - .await - .unwrap(); - - zeta.update(cx, |zeta, cx| { - zeta.register_buffer(&buffer, &project, cx); - }); - - buffer.update(cx, |buffer, cx| { - buffer.edit(vec![(7..7, "How")], None, cx); - }); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let position = snapshot.anchor_before(language::Point::new(1, 3)); - - let prediction_task = zeta.update(cx, |zeta, cx| { - zeta.request_prediction(&project, &buffer, position, Default::default(), cx) - }); - - let (request, respond_tx) = requests.predict.next().await.unwrap(); - - let prompt = prompt_from_request(&request); - assert!( - prompt.contains(indoc! {" - --- a/root/foo.md - +++ b/root/foo.md - @@ -1,3 +1,3 @@ - Hello! - - - +How - Bye - "}), - "{prompt}" - ); - - respond_tx - .send(model_response(indoc! {r#" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are you? - Bye - "#})) - .unwrap(); - - let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); - - assert_eq!(prediction.edits.len(), 1); - assert_eq!( - prediction.edits[0].0.to_point(&snapshot).start, - language::Point::new(1, 3) - ); - assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); - } - - #[gpui::test] - async fn test_empty_prediction(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(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)); - - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - const NO_OP_DIFF: &str = indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How - Bye - "}; - - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let response = model_response(NO_OP_DIFF); - let id = response.id.clone(); - respond_tx.send(response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - assert!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .is_none() - ); - }); - - // prediction is reported as rejected - let (reject_request, _) = requests.reject.next().await.unwrap(); - - assert_eq!( - &reject_request.rejections, - &[EditPredictionRejection { - request_id: id, - reason: EditPredictionRejectReason::Empty, - was_shown: false - }] - ); - } - - #[gpui::test] - async fn test_interpolated_empty(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(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)); - - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - let (_, respond_tx) = requests.predict.next().await.unwrap(); - - buffer.update(cx, |buffer, cx| { - buffer.set_text("Hello!\nHow are you?\nBye", cx); - }); - - let response = model_response(SIMPLE_DIFF); - let id = response.id.clone(); - respond_tx.send(response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - assert!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .is_none() - ); - }); - - // prediction is reported as rejected - let (reject_request, _) = requests.reject.next().await.unwrap(); - - assert_eq!( - &reject_request.rejections, - &[EditPredictionRejection { - request_id: id, - reason: EditPredictionRejectReason::InterpolatedEmpty, - was_shown: false - }] - ); - } - - const SIMPLE_DIFF: &str = indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are you? - Bye - "}; - - #[gpui::test] - async fn test_replace_current(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(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)); - - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let first_response = model_response(SIMPLE_DIFF); - let first_id = first_response.id.clone(); - respond_tx.send(first_response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - assert_eq!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .unwrap() - .id - .0, - first_id - ); - }); - - // a second request is triggered - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let second_response = model_response(SIMPLE_DIFF); - let second_id = second_response.id.clone(); - respond_tx.send(second_response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - // second replaces first - assert_eq!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .unwrap() - .id - .0, - second_id - ); - }); - - // first is reported as replaced - let (reject_request, _) = requests.reject.next().await.unwrap(); - - assert_eq!( - &reject_request.rejections, - &[EditPredictionRejection { - request_id: first_id, - reason: EditPredictionRejectReason::Replaced, - was_shown: false - }] - ); - } - - #[gpui::test] - async fn test_current_preferred(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(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)); - - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - let (_, respond_tx) = requests.predict.next().await.unwrap(); - let first_response = model_response(SIMPLE_DIFF); - let first_id = first_response.id.clone(); - respond_tx.send(first_response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - assert_eq!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .unwrap() - .id - .0, - first_id - ); - }); - - // a second request is triggered - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - let (_, respond_tx) = requests.predict.next().await.unwrap(); - // worse than current prediction - let second_response = model_response(indoc! { r" - --- a/root/foo.md - +++ b/root/foo.md - @@ ... @@ - Hello! - -How - +How are - Bye - "}); - let second_id = second_response.id.clone(); - respond_tx.send(second_response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - // first is preferred over second - assert_eq!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .unwrap() - .id - .0, - first_id - ); - }); - - // second is reported as rejected - let (reject_request, _) = requests.reject.next().await.unwrap(); - - assert_eq!( - &reject_request.rejections, - &[EditPredictionRejection { - request_id: second_id, - reason: EditPredictionRejectReason::CurrentPreferred, - was_shown: false - }] - ); - } - - #[gpui::test] - async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(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)); - - // start two refresh tasks - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - let (_, respond_first) = requests.predict.next().await.unwrap(); - - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - let (_, respond_second) = requests.predict.next().await.unwrap(); - - // wait for throttle - cx.run_until_parked(); - - // second responds first - let second_response = model_response(SIMPLE_DIFF); - let second_id = second_response.id.clone(); - respond_second.send(second_response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - // current prediction is second - assert_eq!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .unwrap() - .id - .0, - second_id - ); - }); - - let first_response = model_response(SIMPLE_DIFF); - let first_id = first_response.id.clone(); - respond_first.send(first_response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - // current prediction is still second, since first was cancelled - assert_eq!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .unwrap() - .id - .0, - second_id - ); - }); - - // first is reported as rejected - let (reject_request, _) = requests.reject.next().await.unwrap(); - - cx.run_until_parked(); - - assert_eq!( - &reject_request.rejections, - &[EditPredictionRejection { - request_id: first_id, - reason: EditPredictionRejectReason::Canceled, - was_shown: false - }] - ); - } - - #[gpui::test] - async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(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)); - - // start two refresh tasks - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - let (_, respond_first) = requests.predict.next().await.unwrap(); - - zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - }); - - let (_, respond_second) = requests.predict.next().await.unwrap(); - - // wait for throttle, so requests are sent - cx.run_until_parked(); - - zeta.update(cx, |zeta, cx| { - // start a third request - zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - - // 2 are pending, so 2nd is cancelled - assert_eq!( - zeta.get_or_init_zeta_project(&project, cx) - .cancelled_predictions - .iter() - .copied() - .collect::>(), - [1] - ); - }); - - // wait for throttle - cx.run_until_parked(); - - let (_, respond_third) = requests.predict.next().await.unwrap(); - - let first_response = model_response(SIMPLE_DIFF); - let first_id = first_response.id.clone(); - respond_first.send(first_response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - // current prediction is first - assert_eq!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .unwrap() - .id - .0, - first_id - ); - }); - - let cancelled_response = model_response(SIMPLE_DIFF); - let cancelled_id = cancelled_response.id.clone(); - respond_second.send(cancelled_response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - // current prediction is still first, since second was cancelled - assert_eq!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .unwrap() - .id - .0, - first_id - ); - }); - - let third_response = model_response(SIMPLE_DIFF); - let third_response_id = third_response.id.clone(); - respond_third.send(third_response).unwrap(); - - cx.run_until_parked(); - - zeta.read_with(cx, |zeta, cx| { - // third completes and replaces first - assert_eq!( - zeta.current_prediction_for_buffer(&buffer, &project, cx) - .unwrap() - .id - .0, - third_response_id - ); - }); - - // second is reported as rejected - let (reject_request, _) = requests.reject.next().await.unwrap(); - - cx.run_until_parked(); - - assert_eq!( - &reject_request.rejections, - &[ - EditPredictionRejection { - request_id: cancelled_id, - reason: EditPredictionRejectReason::Canceled, - was_shown: false - }, - EditPredictionRejection { - request_id: first_id, - reason: EditPredictionRejectReason::Replaced, - was_shown: false - } - ] - ); - } - - #[gpui::test] - async fn test_rejections_flushing(cx: &mut TestAppContext) { - let (zeta, mut requests) = init_test(cx); - - zeta.update(cx, |zeta, _cx| { - zeta.reject_prediction( - EditPredictionId("test-1".into()), - EditPredictionRejectReason::Discarded, - false, - ); - zeta.reject_prediction( - EditPredictionId("test-2".into()), - EditPredictionRejectReason::Canceled, - true, - ); - }); - - cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); - cx.run_until_parked(); - - let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); - respond_tx.send(()).unwrap(); - - // batched - assert_eq!(reject_request.rejections.len(), 2); - assert_eq!( - reject_request.rejections[0], - EditPredictionRejection { - request_id: "test-1".to_string(), - reason: EditPredictionRejectReason::Discarded, - was_shown: false - } - ); - assert_eq!( - reject_request.rejections[1], - EditPredictionRejection { - request_id: "test-2".to_string(), - reason: EditPredictionRejectReason::Canceled, - was_shown: true - } - ); - - // Reaching batch size limit sends without debounce - zeta.update(cx, |zeta, _cx| { - for i in 0..70 { - zeta.reject_prediction( - EditPredictionId(format!("batch-{}", i).into()), - EditPredictionRejectReason::Discarded, - false, - ); - } - }); - - // First MAX/2 items are sent immediately - cx.run_until_parked(); - let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); - respond_tx.send(()).unwrap(); - - assert_eq!(reject_request.rejections.len(), 50); - assert_eq!(reject_request.rejections[0].request_id, "batch-0"); - assert_eq!(reject_request.rejections[49].request_id, "batch-49"); - - // Remaining items are debounced with the next batch - cx.executor().advance_clock(Duration::from_secs(15)); - cx.run_until_parked(); - - let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); - respond_tx.send(()).unwrap(); - - assert_eq!(reject_request.rejections.len(), 20); - assert_eq!(reject_request.rejections[0].request_id, "batch-50"); - assert_eq!(reject_request.rejections[19].request_id, "batch-69"); - - // Request failure - zeta.update(cx, |zeta, _cx| { - zeta.reject_prediction( - EditPredictionId("retry-1".into()), - EditPredictionRejectReason::Discarded, - false, - ); - }); - - cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); - cx.run_until_parked(); - - let (reject_request, _respond_tx) = requests.reject.next().await.unwrap(); - assert_eq!(reject_request.rejections.len(), 1); - assert_eq!(reject_request.rejections[0].request_id, "retry-1"); - // Simulate failure - drop(_respond_tx); - - // Add another rejection - zeta.update(cx, |zeta, _cx| { - zeta.reject_prediction( - EditPredictionId("retry-2".into()), - EditPredictionRejectReason::Discarded, - false, - ); - }); - - cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); - cx.run_until_parked(); - - // Retry should include both the failed item and the new one - let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); - respond_tx.send(()).unwrap(); - - assert_eq!(reject_request.rejections.len(), 2); - assert_eq!(reject_request.rejections[0].request_id, "retry-1"); - assert_eq!(reject_request.rejections[1].request_id, "retry-2"); - } - - // Skipped until we start including diagnostics in prompt - // #[gpui::test] - // async fn test_request_diagnostics(cx: &mut TestAppContext) { - // let (zeta, mut req_rx) = init_test(cx); - // let fs = FakeFs::new(cx.executor()); - // fs.insert_tree( - // "/root", - // json!({ - // "foo.md": "Hello!\nBye" - // }), - // ) - // .await; - // let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - - // let path_to_buffer_uri = lsp::Uri::from_file_path(path!("/root/foo.md")).unwrap(); - // let diagnostic = lsp::Diagnostic { - // range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), - // severity: Some(lsp::DiagnosticSeverity::ERROR), - // message: "\"Hello\" deprecated. Use \"Hi\" instead".to_string(), - // ..Default::default() - // }; - - // project.update(cx, |project, cx| { - // project.lsp_store().update(cx, |lsp_store, cx| { - // // Create some diagnostics - // lsp_store - // .update_diagnostics( - // LanguageServerId(0), - // lsp::PublishDiagnosticsParams { - // uri: path_to_buffer_uri.clone(), - // diagnostics: vec![diagnostic], - // version: None, - // }, - // None, - // language::DiagnosticSourceKind::Pushed, - // &[], - // cx, - // ) - // .unwrap(); - // }); - // }); - - // 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(0, 0)); - - // let _prediction_task = zeta.update(cx, |zeta, cx| { - // zeta.request_prediction(&project, &buffer, position, cx) - // }); - - // let (request, _respond_tx) = req_rx.next().await.unwrap(); - - // assert_eq!(request.diagnostic_groups.len(), 1); - // let value = serde_json::from_str::(request.diagnostic_groups[0].0.get()) - // .unwrap(); - // // We probably don't need all of this. TODO define a specific diagnostic type in predict_edits_v3 - // assert_eq!( - // value, - // json!({ - // "entries": [{ - // "range": { - // "start": 8, - // "end": 10 - // }, - // "diagnostic": { - // "source": null, - // "code": null, - // "code_description": null, - // "severity": 1, - // "message": "\"Hello\" deprecated. Use \"Hi\" instead", - // "markdown": null, - // "group_id": 0, - // "is_primary": true, - // "is_disk_based": false, - // "is_unnecessary": false, - // "source_kind": "Pushed", - // "data": null, - // "underline": true - // } - // }], - // "primary_ix": 0 - // }) - // ); - // } - - fn model_response(text: &str) -> open_ai::Response { - open_ai::Response { - id: Uuid::new_v4().to_string(), - object: "response".into(), - created: 0, - model: "model".into(), - choices: vec![open_ai::Choice { - index: 0, - message: open_ai::RequestMessage::Assistant { - content: Some(open_ai::MessageContent::Plain(text.to_string())), - tool_calls: vec![], - }, - finish_reason: None, - }], - usage: Usage { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - } - } - - fn prompt_from_request(request: &open_ai::Request) -> &str { - assert_eq!(request.messages.len(), 1); - let open_ai::RequestMessage::User { - content: open_ai::MessageContent::Plain(content), - .. - } = &request.messages[0] - else { - panic!( - "Request does not have single user message of type Plain. {:#?}", - request - ); - }; - content - } - - struct RequestChannels { - predict: mpsc::UnboundedReceiver<(open_ai::Request, oneshot::Sender)>, - reject: mpsc::UnboundedReceiver<(RejectEditPredictionsBody, oneshot::Sender<()>)>, - } - - fn init_test(cx: &mut TestAppContext) -> (Entity, RequestChannels) { - cx.update(move |cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - zlog::init_test(); - - let (predict_req_tx, predict_req_rx) = mpsc::unbounded(); - let (reject_req_tx, reject_req_rx) = mpsc::unbounded(); - - let http_client = FakeHttpClient::create({ - move |req| { - let uri = req.uri().path().to_string(); - let mut body = req.into_body(); - let predict_req_tx = predict_req_tx.clone(); - let reject_req_tx = reject_req_tx.clone(); - async move { - let resp = match uri.as_str() { - "/client/llm_tokens" => serde_json::to_string(&json!({ - "token": "test" - })) - .unwrap(), - "/predict_edits/raw" => { - let mut buf = Vec::new(); - body.read_to_end(&mut buf).await.ok(); - let req = serde_json::from_slice(&buf).unwrap(); - - let (res_tx, res_rx) = oneshot::channel(); - predict_req_tx.unbounded_send((req, res_tx)).unwrap(); - serde_json::to_string(&res_rx.await?).unwrap() - } - "/predict_edits/reject" => { - let mut buf = Vec::new(); - body.read_to_end(&mut buf).await.ok(); - let req = serde_json::from_slice(&buf).unwrap(); - - let (res_tx, res_rx) = oneshot::channel(); - reject_req_tx.unbounded_send((req, res_tx)).unwrap(); - serde_json::to_string(&res_rx.await?).unwrap() - } - _ => { - panic!("Unexpected path: {}", uri) - } - }; - - Ok(Response::builder().body(resp.into()).unwrap()) - } - } - }); - - let client = client::Client::new(Arc::new(FakeSystemClock::new()), http_client, cx); - client.cloud_client().set_credentials(1, "test".into()); - - language_model::init(client.clone(), cx); - - let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let zeta = Zeta::global(&client, &user_store, cx); - - ( - zeta, - RequestChannels { - predict: predict_req_rx, - reject: reject_req_rx, - }, - ) - }) - } -} diff --git a/crates/zeta/src/zeta1/input_excerpt.rs b/crates/zeta/src/zeta1/input_excerpt.rs deleted file mode 100644 index 853d74da463c19de4f1d3915cb703a53b6c43c61..0000000000000000000000000000000000000000 --- a/crates/zeta/src/zeta1/input_excerpt.rs +++ /dev/null @@ -1,231 +0,0 @@ -use super::{ - CURSOR_MARKER, EDITABLE_REGION_END_MARKER, EDITABLE_REGION_START_MARKER, START_OF_FILE_MARKER, - guess_token_count, -}; -use language::{BufferSnapshot, Point}; -use std::{fmt::Write, ops::Range}; - -#[derive(Debug)] -pub struct InputExcerpt { - pub context_range: Range, - pub editable_range: Range, - pub prompt: String, -} - -pub fn excerpt_for_cursor_position( - position: Point, - path: &str, - snapshot: &BufferSnapshot, - editable_region_token_limit: usize, - context_token_limit: usize, -) -> InputExcerpt { - let mut scope_range = position..position; - let mut remaining_edit_tokens = editable_region_token_limit; - - while let Some(parent) = snapshot.syntax_ancestor(scope_range.clone()) { - let parent_tokens = guess_token_count(parent.byte_range().len()); - let parent_point_range = Point::new( - parent.start_position().row as u32, - parent.start_position().column as u32, - ) - ..Point::new( - parent.end_position().row as u32, - parent.end_position().column as u32, - ); - if parent_point_range == scope_range { - break; - } else if parent_tokens <= editable_region_token_limit { - scope_range = parent_point_range; - remaining_edit_tokens = editable_region_token_limit - parent_tokens; - } else { - break; - } - } - - let editable_range = expand_range(snapshot, scope_range, remaining_edit_tokens); - let context_range = expand_range(snapshot, editable_range.clone(), context_token_limit); - - let mut prompt = String::new(); - - writeln!(&mut prompt, "```{path}").unwrap(); - if context_range.start == Point::zero() { - writeln!(&mut prompt, "{START_OF_FILE_MARKER}").unwrap(); - } - - for chunk in snapshot.chunks(context_range.start..editable_range.start, false) { - prompt.push_str(chunk.text); - } - - push_editable_range(position, snapshot, editable_range.clone(), &mut prompt); - - for chunk in snapshot.chunks(editable_range.end..context_range.end, false) { - prompt.push_str(chunk.text); - } - write!(prompt, "\n```").unwrap(); - - InputExcerpt { - context_range, - editable_range, - prompt, - } -} - -fn push_editable_range( - cursor_position: Point, - snapshot: &BufferSnapshot, - editable_range: Range, - prompt: &mut String, -) { - writeln!(prompt, "{EDITABLE_REGION_START_MARKER}").unwrap(); - for chunk in snapshot.chunks(editable_range.start..cursor_position, false) { - prompt.push_str(chunk.text); - } - prompt.push_str(CURSOR_MARKER); - for chunk in snapshot.chunks(cursor_position..editable_range.end, false) { - prompt.push_str(chunk.text); - } - write!(prompt, "\n{EDITABLE_REGION_END_MARKER}").unwrap(); -} - -fn expand_range( - snapshot: &BufferSnapshot, - range: Range, - mut remaining_tokens: usize, -) -> Range { - let mut expanded_range = range; - expanded_range.start.column = 0; - expanded_range.end.column = snapshot.line_len(expanded_range.end.row); - loop { - let mut expanded = false; - - if remaining_tokens > 0 && expanded_range.start.row > 0 { - expanded_range.start.row -= 1; - let line_tokens = - guess_token_count(snapshot.line_len(expanded_range.start.row) as usize); - remaining_tokens = remaining_tokens.saturating_sub(line_tokens); - expanded = true; - } - - if remaining_tokens > 0 && expanded_range.end.row < snapshot.max_point().row { - expanded_range.end.row += 1; - expanded_range.end.column = snapshot.line_len(expanded_range.end.row); - let line_tokens = guess_token_count(expanded_range.end.column as usize); - remaining_tokens = remaining_tokens.saturating_sub(line_tokens); - expanded = true; - } - - if !expanded { - break; - } - } - expanded_range -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::{App, AppContext}; - use indoc::indoc; - use language::{Buffer, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust}; - use std::sync::Arc; - - #[gpui::test] - fn test_excerpt_for_cursor_position(cx: &mut App) { - let text = indoc! {r#" - fn foo() { - let x = 42; - println!("Hello, world!"); - } - - fn bar() { - let x = 42; - let mut sum = 0; - for i in 0..x { - sum += i; - } - println!("Sum: {}", sum); - return sum; - } - - fn generate_random_numbers() -> Vec { - let mut rng = rand::thread_rng(); - let mut numbers = Vec::new(); - for _ in 0..5 { - numbers.push(rng.random_range(1..101)); - } - numbers - } - "#}; - let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(Arc::new(rust_lang()), cx)); - let snapshot = buffer.read(cx).snapshot(); - - // Ensure we try to fit the largest possible syntax scope, resorting to line-based expansion - // when a larger scope doesn't fit the editable region. - let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 50, 32); - assert_eq!( - excerpt.prompt, - indoc! {r#" - ```main.rs - let x = 42; - println!("Hello, world!"); - <|editable_region_start|> - } - - fn bar() { - let x = 42; - let mut sum = 0; - for i in 0..x { - sum += i; - } - println!("Sum: {}", sum); - r<|user_cursor_is_here|>eturn sum; - } - - fn generate_random_numbers() -> Vec { - <|editable_region_end|> - let mut rng = rand::thread_rng(); - let mut numbers = Vec::new(); - ```"#} - ); - - // The `bar` function won't fit within the editable region, so we resort to line-based expansion. - let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 40, 32); - assert_eq!( - excerpt.prompt, - indoc! {r#" - ```main.rs - fn bar() { - let x = 42; - let mut sum = 0; - <|editable_region_start|> - for i in 0..x { - sum += i; - } - println!("Sum: {}", sum); - r<|user_cursor_is_here|>eturn sum; - } - - fn generate_random_numbers() -> Vec { - let mut rng = rand::thread_rng(); - <|editable_region_end|> - let mut numbers = Vec::new(); - for _ in 0..5 { - numbers.push(rng.random_range(1..101)); - ```"#} - ); - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - } -} diff --git a/crates/zeta/src/zeta_tests.rs b/crates/zeta/src/zeta_tests.rs deleted file mode 100644 index 3549cda36d575a989f5bc4bd5bb8bea6810d3180..0000000000000000000000000000000000000000 --- a/crates/zeta/src/zeta_tests.rs +++ /dev/null @@ -1,671 +0,0 @@ -use client::test::FakeServer; -use clock::{FakeSystemClock, ReplicaId}; -use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; -use cloud_llm_client::{PredictEditsBody, PredictEditsResponse}; -use gpui::TestAppContext; -use http_client::FakeHttpClient; -use indoc::indoc; -use language::Point; -use parking_lot::Mutex; -use serde_json::json; -use settings::SettingsStore; -use util::{path, rel_path::rel_path}; - -use crate::zeta1::MAX_EVENT_TOKENS; - -use super::*; - -const BSD_0_TXT: &str = include_str!("../license_examples/0bsd.txt"); - -#[gpui::test] -async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { - let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); - let edits: Arc<[(Range, Arc)]> = cx.update(|cx| { - to_completion_edits([(2..5, "REM".into()), (9..11, "".into())], &buffer, cx).into() - }); - - let edit_preview = cx - .read(|cx| buffer.read(cx).preview_edits(edits.clone(), cx)) - .await; - - let completion = EditPrediction { - edits, - edit_preview, - buffer: buffer.clone(), - snapshot: cx.read(|cx| buffer.read(cx).snapshot()), - id: EditPredictionId("the-id".into()), - inputs: EditPredictionInputs { - events: Default::default(), - included_files: Default::default(), - cursor_point: cloud_llm_client::predict_edits_v3::Point { - line: Line(0), - column: 0, - }, - cursor_path: Path::new("").into(), - }, - buffer_snapshotted_at: Instant::now(), - response_received_at: Instant::now(), - }; - - cx.update(|cx| { - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(2..5, "REM".into()), (9..11, "".into())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(2..2, "REM".into()), (6..8, "".into())] - ); - - buffer.update(cx, |buffer, cx| buffer.undo(cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(2..5, "REM".into()), (9..11, "".into())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(3..3, "EM".into()), (7..9, "".into())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(4..4, "M".into()), (8..10, "".into())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(9..11, "".into())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(4..4, "M".into()), (8..10, "".into())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); - assert_eq!( - from_completion_edits( - &completion.interpolate(&buffer.read(cx).snapshot()).unwrap(), - &buffer, - cx - ), - vec![(4..4, "M".into())] - ); - - buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); - assert_eq!(completion.interpolate(&buffer.read(cx).snapshot()), None); - }) -} - -#[gpui::test] -async fn test_clean_up_diff(cx: &mut TestAppContext) { - init_test(cx); - - assert_eq!( - apply_edit_prediction( - indoc! {" - fn main() { - let word_1 = \"lorem\"; - let range = word.len()..word.len(); - } - "}, - indoc! {" - <|editable_region_start|> - fn main() { - let word_1 = \"lorem\"; - let range = word_1.len()..word_1.len(); - } - - <|editable_region_end|> - "}, - cx, - ) - .await, - indoc! {" - fn main() { - let word_1 = \"lorem\"; - let range = word_1.len()..word_1.len(); - } - "}, - ); - - assert_eq!( - apply_edit_prediction( - indoc! {" - fn main() { - let story = \"the quick\" - } - "}, - indoc! {" - <|editable_region_start|> - fn main() { - let story = \"the quick brown fox jumps over the lazy dog\"; - } - - <|editable_region_end|> - "}, - cx, - ) - .await, - indoc! {" - fn main() { - let story = \"the quick brown fox jumps over the lazy dog\"; - } - "}, - ); -} - -#[gpui::test] -async fn test_edit_prediction_end_of_buffer(cx: &mut TestAppContext) { - init_test(cx); - - let buffer_content = "lorem\n"; - let completion_response = indoc! {" - ```animals.js - <|start_of_file|> - <|editable_region_start|> - lorem - ipsum - <|editable_region_end|> - ```"}; - - assert_eq!( - apply_edit_prediction(buffer_content, completion_response, cx).await, - "lorem\nipsum" - ); -} - -#[gpui::test] -async fn test_can_collect_data(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree(path!("/project"), json!({ "LICENSE": BSD_0_TXT })) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/project/src/main.rs"), cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Disabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_for_remote_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [], cx).await; - - let buffer = cx.new(|_cx| { - Buffer::remote( - language::BufferId::new(1).unwrap(), - ReplicaId::new(1), - language::Capability::ReadWrite, - "fn main() {\n println!(\"Hello\");\n}", - ) - }); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_for_private_file(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - "LICENSE": BSD_0_TXT, - ".env": "SECRET_KEY=secret" - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/project/.env", cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_for_untitled_buffer(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [], cx).await; - let buffer = cx.new(|cx| Buffer::local("", cx)); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_when_closed_source(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree(path!("/project"), json!({ "main.rs": "fn main() {}" })) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/project/main.rs", cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_data_collection_status_changes_on_move(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/open_source_worktree"), - json!({ "LICENSE": BSD_0_TXT, "main.rs": "" }), - ) - .await; - fs.insert_tree(path!("/closed_source_worktree"), json!({ "main.rs": "" })) - .await; - - let project = Project::test( - fs.clone(), - [ - path!("/open_source_worktree").as_ref(), - path!("/closed_source_worktree").as_ref(), - ], - cx, - ) - .await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/open_source_worktree/main.rs"), cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - - let closed_source_file = project - .update(cx, |project, cx| { - let worktree2 = project - .worktree_for_root_name("closed_source_worktree", cx) - .unwrap(); - worktree2.update(cx, |worktree2, cx| { - worktree2.load_file(rel_path("main.rs"), cx) - }) - }) - .await - .unwrap() - .file; - - buffer.update(cx, |buffer, cx| { - buffer.file_updated(closed_source_file, cx); - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); -} - -#[gpui::test] -async fn test_no_data_collection_for_events_in_uncollectable_buffers(cx: &mut TestAppContext) { - init_test(cx); - - let fs = project::FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/worktree1"), - json!({ "LICENSE": BSD_0_TXT, "main.rs": "", "other.rs": "" }), - ) - .await; - fs.insert_tree(path!("/worktree2"), json!({ "private.rs": "" })) - .await; - - let project = Project::test( - fs.clone(), - [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()], - cx, - ) - .await; - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/worktree1/main.rs"), cx) - }) - .await - .unwrap(); - let private_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer(path!("/worktree2/file.rs"), cx) - }) - .await - .unwrap(); - - let (zeta, captured_request, _) = make_test_zeta(&project, cx).await; - zeta.update(cx, |zeta, _cx| { - zeta.data_collection_choice = DataCollectionChoice::Enabled - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); - - // this has a side effect of registering the buffer to watch for edits - run_edit_prediction(&private_buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - - private_buffer.update(cx, |private_buffer, cx| { - private_buffer.edit([(0..0, "An edit for the history!")], None, cx); - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - false - ); - - // make an edit that uses too many bytes, causing private_buffer edit to not be able to be - // included - buffer.update(cx, |buffer, cx| { - buffer.edit( - [( - 0..0, - " ".repeat(MAX_EVENT_TOKENS * zeta1::BYTES_PER_TOKEN_GUESS), - )], - None, - cx, - ); - }); - - run_edit_prediction(&buffer, &project, &zeta, cx).await; - assert_eq!( - captured_request.lock().clone().unwrap().can_collect_data, - true - ); -} - -fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - }); -} - -async fn apply_edit_prediction( - buffer_content: &str, - completion_response: &str, - cx: &mut TestAppContext, -) -> String { - let fs = project::FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); - let (zeta, _, response) = make_test_zeta(&project, cx).await; - *response.lock() = completion_response.to_string(); - let edit_prediction = run_edit_prediction(&buffer, &project, &zeta, cx).await; - buffer.update(cx, |buffer, cx| { - buffer.edit(edit_prediction.edits.iter().cloned(), None, cx) - }); - buffer.read_with(cx, |buffer, _| buffer.text()) -} - -async fn run_edit_prediction( - buffer: &Entity, - project: &Entity, - zeta: &Entity, - cx: &mut TestAppContext, -) -> EditPrediction { - let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); - zeta.update(cx, |zeta, cx| zeta.register_buffer(buffer, &project, cx)); - cx.background_executor.run_until_parked(); - let prediction_task = zeta.update(cx, |zeta, cx| { - zeta.request_prediction(&project, buffer, cursor, Default::default(), cx) - }); - prediction_task.await.unwrap().unwrap().prediction.unwrap() -} - -async fn make_test_zeta( - project: &Entity, - cx: &mut TestAppContext, -) -> ( - Entity, - Arc>>, - Arc>, -) { - let default_response = indoc! {" - ```main.rs - <|start_of_file|> - <|editable_region_start|> - hello world - <|editable_region_end|> - ```" - }; - let captured_request: Arc>> = Arc::new(Mutex::new(None)); - let completion_response: Arc> = - Arc::new(Mutex::new(default_response.to_string())); - let http_client = FakeHttpClient::create({ - let captured_request = captured_request.clone(); - let completion_response = completion_response.clone(); - let mut next_request_id = 0; - move |req| { - let captured_request = captured_request.clone(); - let completion_response = completion_response.clone(); - async move { - match (req.method(), req.uri().path()) { - (&Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() - .status(200) - .body( - serde_json::to_string(&CreateLlmTokenResponse { - token: LlmToken("the-llm-token".to_string()), - }) - .unwrap() - .into(), - ) - .unwrap()), - (&Method::POST, "/predict_edits/v2") => { - let mut request_body = String::new(); - req.into_body().read_to_string(&mut request_body).await?; - *captured_request.lock() = - Some(serde_json::from_str(&request_body).unwrap()); - next_request_id += 1; - Ok(http_client::Response::builder() - .status(200) - .body( - serde_json::to_string(&PredictEditsResponse { - request_id: format!("request-{next_request_id}"), - output_excerpt: completion_response.lock().clone(), - }) - .unwrap() - .into(), - ) - .unwrap()) - } - _ => Ok(http_client::Response::builder() - .status(404) - .body("Not Found".into()) - .unwrap()), - } - } - } - }); - - let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); - cx.update(|cx| { - RefreshLlmTokenListener::register(client.clone(), cx); - }); - let _server = FakeServer::for_client(42, &client, cx).await; - - let zeta = cx.new(|cx| { - let mut zeta = Zeta::new(client, project.read(cx).user_store(), cx); - zeta.set_edit_prediction_model(ZetaEditPredictionModel::Zeta1); - - let worktrees = project.read(cx).worktrees(cx).collect::>(); - for worktree in worktrees { - let worktree_id = worktree.read(cx).id(); - zeta.get_or_init_zeta_project(project, cx) - .license_detection_watchers - .entry(worktree_id) - .or_insert_with(|| Rc::new(LicenseDetectionWatcher::new(&worktree, cx))); - } - - zeta - }); - - (zeta, captured_request, completion_response) -} - -fn to_completion_edits( - iterator: impl IntoIterator, Arc)>, - buffer: &Entity, - cx: &App, -) -> Vec<(Range, Arc)> { - let buffer = buffer.read(cx); - iterator - .into_iter() - .map(|(range, text)| { - ( - buffer.anchor_after(range.start)..buffer.anchor_before(range.end), - text, - ) - }) - .collect() -} - -fn from_completion_edits( - editor_edits: &[(Range, Arc)], - buffer: &Entity, - cx: &App, -) -> Vec<(Range, Arc)> { - let buffer = buffer.read(cx); - editor_edits - .iter() - .map(|(range, text)| { - ( - range.start.to_offset(buffer)..range.end.to_offset(buffer), - text.clone(), - ) - }) - .collect() -} - -#[ctor::ctor] -fn init_logger() { - zlog::init_test(); -} diff --git a/crates/zeta2_tools/Cargo.toml b/crates/zeta2_tools/Cargo.toml deleted file mode 100644 index 607e24c895d96de1464ff1bfa2a4dfa01c5d9669..0000000000000000000000000000000000000000 --- a/crates/zeta2_tools/Cargo.toml +++ /dev/null @@ -1,49 +0,0 @@ -[package] -name = "zeta2_tools" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/zeta2_tools.rs" - -[dependencies] -anyhow.workspace = true -client.workspace = true -cloud_llm_client.workspace = true -cloud_zeta2_prompt.workspace = true -collections.workspace = true -edit_prediction_context.workspace = true -editor.workspace = true -feature_flags.workspace = true -futures.workspace = true -gpui.workspace = true -language.workspace = true -multi_buffer.workspace = true -project.workspace = true -serde.workspace = true -serde_json.workspace = true -telemetry.workspace = true -text.workspace = true -ui.workspace = true -ui_input.workspace = true -util.workspace = true -workspace.workspace = true -zeta.workspace = true - -[dev-dependencies] -clap.workspace = true -gpui = { workspace = true, features = ["test-support"] } -indoc.workspace = true -language = { workspace = true, features = ["test-support"] } -pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } -serde_json.workspace = true -settings = { workspace = true, features = ["test-support"] } -text = { workspace = true, features = ["test-support"] } -util = { workspace = true, features = ["test-support"] } -zlog.workspace = true diff --git a/crates/zeta2_tools/src/zeta2_context_view.rs b/crates/zeta2_tools/src/zeta2_context_view.rs deleted file mode 100644 index 54f1ea2d813f7c00d30b12e341fb3e5ac3f155dc..0000000000000000000000000000000000000000 --- a/crates/zeta2_tools/src/zeta2_context_view.rs +++ /dev/null @@ -1,438 +0,0 @@ -use std::{ - any::TypeId, - collections::VecDeque, - ops::Add, - sync::Arc, - time::{Duration, Instant}, -}; - -use anyhow::Result; -use client::{Client, UserStore}; -use cloud_zeta2_prompt::retrieval_prompt::SearchToolQuery; -use editor::{Editor, PathKey}; -use futures::StreamExt as _; -use gpui::{ - Animation, AnimationExt, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, - Focusable, ParentElement as _, SharedString, Styled as _, Task, TextAlign, Window, actions, - pulsating_between, -}; -use multi_buffer::MultiBuffer; -use project::Project; -use text::OffsetRangeExt; -use ui::{ - ButtonCommon, Clickable, Color, Disableable, FluentBuilder as _, Icon, IconButton, IconName, - IconSize, InteractiveElement, IntoElement, ListHeader, ListItem, StyledTypography, div, h_flex, - v_flex, -}; -use workspace::Item; -use zeta::{ - Zeta, ZetaContextRetrievalDebugInfo, ZetaContextRetrievalStartedDebugInfo, ZetaDebugInfo, - ZetaSearchQueryDebugInfo, -}; - -pub struct Zeta2ContextView { - empty_focus_handle: FocusHandle, - project: Entity, - zeta: Entity, - runs: VecDeque, - current_ix: usize, - _update_task: Task>, -} - -#[derive(Debug)] -struct RetrievalRun { - editor: Entity, - search_queries: Vec, - started_at: Instant, - search_results_generated_at: Option, - search_results_executed_at: Option, - finished_at: Option, -} - -actions!( - dev, - [ - /// Go to the previous context retrieval run - Zeta2ContextGoBack, - /// Go to the next context retrieval run - Zeta2ContextGoForward - ] -); - -impl Zeta2ContextView { - pub fn new( - project: Entity, - client: &Arc, - user_store: &Entity, - window: &mut gpui::Window, - cx: &mut Context, - ) -> Self { - let zeta = Zeta::global(client, user_store, cx); - - let mut debug_rx = zeta.update(cx, |zeta, _| zeta.debug_info()); - let _update_task = cx.spawn_in(window, async move |this, cx| { - while let Some(event) = debug_rx.next().await { - this.update_in(cx, |this, window, cx| { - this.handle_zeta_event(event, window, cx) - })?; - } - Ok(()) - }); - - Self { - empty_focus_handle: cx.focus_handle(), - project, - runs: VecDeque::new(), - current_ix: 0, - zeta, - _update_task, - } - } - - fn handle_zeta_event( - &mut self, - event: ZetaDebugInfo, - window: &mut gpui::Window, - cx: &mut Context, - ) { - match event { - ZetaDebugInfo::ContextRetrievalStarted(info) => { - if info.project == self.project { - self.handle_context_retrieval_started(info, window, cx); - } - } - ZetaDebugInfo::SearchQueriesGenerated(info) => { - if info.project == self.project { - self.handle_search_queries_generated(info, window, cx); - } - } - ZetaDebugInfo::SearchQueriesExecuted(info) => { - if info.project == self.project { - self.handle_search_queries_executed(info, window, cx); - } - } - ZetaDebugInfo::ContextRetrievalFinished(info) => { - if info.project == self.project { - self.handle_context_retrieval_finished(info, window, cx); - } - } - ZetaDebugInfo::EditPredictionRequested(_) => {} - } - } - - fn handle_context_retrieval_started( - &mut self, - info: ZetaContextRetrievalStartedDebugInfo, - window: &mut Window, - cx: &mut Context, - ) { - if self - .runs - .back() - .is_some_and(|run| run.search_results_executed_at.is_none()) - { - self.runs.pop_back(); - } - - let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadOnly)); - let editor = cx - .new(|cx| Editor::for_multibuffer(multibuffer, Some(self.project.clone()), window, cx)); - - if self.runs.len() == 32 { - self.runs.pop_front(); - } - - self.runs.push_back(RetrievalRun { - editor, - search_queries: Vec::new(), - started_at: info.timestamp, - search_results_generated_at: None, - search_results_executed_at: None, - finished_at: None, - }); - - cx.notify(); - } - - fn handle_context_retrieval_finished( - &mut self, - info: ZetaContextRetrievalDebugInfo, - window: &mut Window, - cx: &mut Context, - ) { - let Some(run) = self.runs.back_mut() else { - return; - }; - - run.finished_at = Some(info.timestamp); - - let multibuffer = run.editor.read(cx).buffer().clone(); - multibuffer.update(cx, |multibuffer, cx| { - multibuffer.clear(cx); - - let context = self.zeta.read(cx).context_for_project(&self.project); - let mut paths = Vec::new(); - for (buffer, ranges) in context { - let path = PathKey::for_buffer(&buffer, cx); - let snapshot = buffer.read(cx).snapshot(); - let ranges = ranges - .iter() - .map(|range| range.to_point(&snapshot)) - .collect::>(); - paths.push((path, buffer, ranges)); - } - - for (path, buffer, ranges) in paths { - multibuffer.set_excerpts_for_path(path, buffer, ranges, 0, cx); - } - }); - - run.editor.update(cx, |editor, cx| { - editor.move_to_beginning(&Default::default(), window, cx); - }); - - cx.notify(); - } - - fn handle_search_queries_generated( - &mut self, - info: ZetaSearchQueryDebugInfo, - _window: &mut Window, - cx: &mut Context, - ) { - let Some(run) = self.runs.back_mut() else { - return; - }; - - run.search_results_generated_at = Some(info.timestamp); - run.search_queries = info.search_queries; - cx.notify(); - } - - fn handle_search_queries_executed( - &mut self, - info: ZetaContextRetrievalDebugInfo, - _window: &mut Window, - cx: &mut Context, - ) { - if self.current_ix + 2 == self.runs.len() { - // Switch to latest when the queries are executed - self.current_ix += 1; - } - - let Some(run) = self.runs.back_mut() else { - return; - }; - - run.search_results_executed_at = Some(info.timestamp); - cx.notify(); - } - - fn handle_go_back( - &mut self, - _: &Zeta2ContextGoBack, - window: &mut Window, - cx: &mut Context, - ) { - self.current_ix = self.current_ix.saturating_sub(1); - cx.focus_self(window); - cx.notify(); - } - - fn handle_go_forward( - &mut self, - _: &Zeta2ContextGoForward, - window: &mut Window, - cx: &mut Context, - ) { - self.current_ix = self - .current_ix - .add(1) - .min(self.runs.len().saturating_sub(1)); - cx.focus_self(window); - cx.notify(); - } - - fn render_informational_footer(&self, cx: &mut Context<'_, Zeta2ContextView>) -> ui::Div { - let is_latest = self.runs.len() == self.current_ix + 1; - let run = &self.runs[self.current_ix]; - - h_flex() - .p_2() - .w_full() - .font_buffer(cx) - .text_xs() - .border_t_1() - .gap_2() - .child( - v_flex().h_full().flex_1().children( - run.search_queries - .iter() - .enumerate() - .flat_map(|(ix, query)| { - std::iter::once(ListHeader::new(query.glob.clone()).into_any_element()) - .chain(query.syntax_node.iter().enumerate().map( - move |(regex_ix, regex)| { - ListItem::new(ix * 100 + regex_ix) - .start_slot( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(regex.clone()) - .into_any_element() - }, - )) - .chain(query.content.as_ref().map(move |regex| { - ListItem::new(ix * 100 + query.syntax_node.len()) - .start_slot( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(regex.clone()) - .into_any_element() - })) - }), - ), - ) - .child( - v_flex() - .h_full() - .text_align(TextAlign::Right) - .child( - h_flex() - .justify_end() - .child( - IconButton::new("go-back", IconName::ChevronLeft) - .disabled(self.current_ix == 0 || self.runs.len() < 2) - .tooltip(ui::Tooltip::for_action_title( - "Go to previous run", - &Zeta2ContextGoBack, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_go_back(&Zeta2ContextGoBack, window, cx); - })), - ) - .child( - div() - .child(format!("{}/{}", self.current_ix + 1, self.runs.len())) - .map(|this| { - if self.runs.back().is_some_and(|back| { - back.search_results_executed_at.is_none() - }) { - this.with_animation( - "pulsating-count", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.opacity(delta), - ) - .into_any_element() - } else { - this.into_any_element() - } - }), - ) - .child( - IconButton::new("go-forward", IconName::ChevronRight) - .disabled(self.current_ix + 1 == self.runs.len()) - .tooltip(ui::Tooltip::for_action_title( - "Go to next run", - &Zeta2ContextGoBack, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_go_forward(&Zeta2ContextGoForward, window, cx); - })), - ), - ) - .map(|mut div| { - let pending_message = |div: ui::Div, msg: &'static str| { - if is_latest { - return div.child(msg); - } else { - return div.child("Canceled"); - } - }; - - let t0 = run.started_at; - let Some(t1) = run.search_results_generated_at else { - return pending_message(div, "Planning search..."); - }; - div = div.child(format!("Planned search: {:>5} ms", (t1 - t0).as_millis())); - - let Some(t2) = run.search_results_executed_at else { - return pending_message(div, "Running search..."); - }; - div = div.child(format!("Ran search: {:>5} ms", (t2 - t1).as_millis())); - - div.child(format!( - "Total: {:>5} ms", - (run.finished_at.unwrap_or(t0) - t0).as_millis() - )) - }), - ) - } -} - -impl Focusable for Zeta2ContextView { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.runs - .get(self.current_ix) - .map(|run| run.editor.read(cx).focus_handle(cx)) - .unwrap_or_else(|| self.empty_focus_handle.clone()) - } -} - -impl EventEmitter<()> for Zeta2ContextView {} - -impl Item for Zeta2ContextView { - type Event = (); - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Edit Prediction Context".into() - } - - fn buffer_kind(&self, _cx: &App) -> workspace::item::ItemBufferKind { - workspace::item::ItemBufferKind::Multibuffer - } - - fn act_as_type<'a>( - &'a self, - type_id: TypeId, - self_handle: &'a Entity, - _: &'a App, - ) -> Option { - if type_id == TypeId::of::() { - Some(self_handle.clone().into()) - } else if type_id == TypeId::of::() { - Some(self.runs.get(self.current_ix)?.editor.clone().into()) - } else { - None - } - } -} - -impl gpui::Render for Zeta2ContextView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { - v_flex() - .key_context("Zeta2Context") - .on_action(cx.listener(Self::handle_go_back)) - .on_action(cx.listener(Self::handle_go_forward)) - .size_full() - .map(|this| { - if self.runs.is_empty() { - this.child( - v_flex() - .size_full() - .justify_center() - .items_center() - .child("No retrieval runs yet"), - ) - } else { - this.child(self.runs[self.current_ix].editor.clone()) - .child(self.render_informational_footer(cx)) - } - }) - } -} diff --git a/crates/zeta2_tools/src/zeta2_tools.rs b/crates/zeta2_tools/src/zeta2_tools.rs deleted file mode 100644 index 4e650f2405d63feab010c5c9b73efc75bd576af6..0000000000000000000000000000000000000000 --- a/crates/zeta2_tools/src/zeta2_tools.rs +++ /dev/null @@ -1,1023 +0,0 @@ -mod zeta2_context_view; - -use std::{str::FromStr, sync::Arc, time::Duration}; - -use client::{Client, UserStore}; -use cloud_llm_client::predict_edits_v3::PromptFormat; -use collections::HashMap; -use editor::{Editor, EditorEvent, EditorMode, MultiBuffer}; -use feature_flags::FeatureFlagAppExt as _; -use futures::{FutureExt, StreamExt as _, channel::oneshot, future::Shared}; -use gpui::{ - Empty, Entity, EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity, actions, - prelude::*, -}; -use language::Buffer; -use project::{Project, telemetry_snapshot::TelemetrySnapshot}; -use ui::{ButtonLike, ContextMenu, ContextMenuEntry, DropdownMenu, KeyBinding, prelude::*}; -use ui_input::InputField; -use util::ResultExt; -use workspace::{Item, SplitDirection, Workspace}; -use zeta::{ - AgenticContextOptions, ContextMode, DEFAULT_SYNTAX_CONTEXT_OPTIONS, EditPredictionInputs, Zeta, - Zeta2FeatureFlag, ZetaDebugInfo, ZetaEditPredictionDebugInfo, ZetaOptions, -}; - -use edit_prediction_context::{EditPredictionContextOptions, EditPredictionExcerptOptions}; -use zeta2_context_view::Zeta2ContextView; - -actions!( - dev, - [ - /// Opens the edit prediction context view. - OpenZeta2ContextView, - /// Opens the edit prediction inspector. - OpenZeta2Inspector, - /// Rate prediction as positive. - Zeta2RatePredictionPositive, - /// Rate prediction as negative. - Zeta2RatePredictionNegative, - ] -); - -pub fn init(cx: &mut App) { - cx.observe_new(move |workspace: &mut Workspace, _, _cx| { - workspace.register_action_renderer(|div, _, _, cx| { - let has_flag = cx.has_flag::(); - div.when(has_flag, |div| { - div.on_action( - cx.listener(move |workspace, _: &OpenZeta2Inspector, window, cx| { - let project = workspace.project(); - workspace.split_item( - SplitDirection::Right, - Box::new(cx.new(|cx| { - Zeta2Inspector::new( - &project, - workspace.client(), - workspace.user_store(), - window, - cx, - ) - })), - window, - cx, - ) - }), - ) - .on_action(cx.listener( - move |workspace, _: &OpenZeta2ContextView, window, cx| { - let project = workspace.project(); - workspace.split_item( - SplitDirection::Right, - Box::new(cx.new(|cx| { - Zeta2ContextView::new( - project.clone(), - workspace.client(), - workspace.user_store(), - window, - cx, - ) - })), - window, - cx, - ); - }, - )) - }) - }); - }) - .detach(); -} - -// TODO show included diagnostics, and events - -pub struct Zeta2Inspector { - focus_handle: FocusHandle, - project: Entity, - last_prediction: Option, - max_excerpt_bytes_input: Entity, - min_excerpt_bytes_input: Entity, - cursor_context_ratio_input: Entity, - max_prompt_bytes_input: Entity, - context_mode: ContextModeState, - zeta: Entity, - _active_editor_subscription: Option, - _update_state_task: Task<()>, - _receive_task: Task<()>, -} - -pub enum ContextModeState { - Llm, - Syntax { - max_retrieved_declarations: Entity, - }, -} - -struct LastPrediction { - prompt_editor: Entity, - retrieval_time: Duration, - request_time: Option, - buffer: WeakEntity, - position: language::Anchor, - state: LastPredictionState, - inputs: EditPredictionInputs, - project_snapshot: Shared>>, - _task: Option>, -} - -#[derive(Clone, Copy, PartialEq)] -enum Feedback { - Positive, - Negative, -} - -enum LastPredictionState { - Requested, - Success { - model_response_editor: Entity, - feedback_editor: Entity, - feedback: Option, - request_id: String, - }, - Failed { - message: String, - }, -} - -impl Zeta2Inspector { - pub fn new( - project: &Entity, - client: &Arc, - user_store: &Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let zeta = Zeta::global(client, user_store, cx); - let mut request_rx = zeta.update(cx, |zeta, _cx| zeta.debug_info()); - - let receive_task = cx.spawn_in(window, async move |this, cx| { - while let Some(prediction) = request_rx.next().await { - this.update_in(cx, |this, window, cx| { - this.update_last_prediction(prediction, window, cx) - }) - .ok(); - } - }); - - let mut this = Self { - focus_handle: cx.focus_handle(), - project: project.clone(), - last_prediction: None, - max_excerpt_bytes_input: Self::number_input("Max Excerpt Bytes", window, cx), - min_excerpt_bytes_input: Self::number_input("Min Excerpt Bytes", window, cx), - cursor_context_ratio_input: Self::number_input("Cursor Context Ratio", window, cx), - max_prompt_bytes_input: Self::number_input("Max Prompt Bytes", window, cx), - context_mode: ContextModeState::Llm, - zeta: zeta.clone(), - _active_editor_subscription: None, - _update_state_task: Task::ready(()), - _receive_task: receive_task, - }; - this.set_options_state(&zeta.read(cx).options().clone(), window, cx); - this - } - - fn set_options_state( - &mut self, - options: &ZetaOptions, - window: &mut Window, - cx: &mut Context, - ) { - let excerpt_options = options.context.excerpt(); - self.max_excerpt_bytes_input.update(cx, |input, cx| { - input.set_text(excerpt_options.max_bytes.to_string(), window, cx); - }); - self.min_excerpt_bytes_input.update(cx, |input, cx| { - input.set_text(excerpt_options.min_bytes.to_string(), window, cx); - }); - self.cursor_context_ratio_input.update(cx, |input, cx| { - input.set_text( - format!( - "{:.2}", - excerpt_options.target_before_cursor_over_total_bytes - ), - window, - cx, - ); - }); - self.max_prompt_bytes_input.update(cx, |input, cx| { - input.set_text(options.max_prompt_bytes.to_string(), window, cx); - }); - - match &options.context { - ContextMode::Agentic(_) => { - self.context_mode = ContextModeState::Llm; - } - ContextMode::Syntax(_) => { - self.context_mode = ContextModeState::Syntax { - max_retrieved_declarations: Self::number_input( - "Max Retrieved Definitions", - window, - cx, - ), - }; - } - } - cx.notify(); - } - - fn set_zeta_options(&mut self, options: ZetaOptions, cx: &mut Context) { - self.zeta.update(cx, |this, _cx| this.set_options(options)); - - if let Some(prediction) = self.last_prediction.as_mut() { - if let Some(buffer) = prediction.buffer.upgrade() { - let position = prediction.position; - let project = self.project.clone(); - self.zeta.update(cx, |zeta, cx| { - zeta.refresh_prediction_from_buffer(project, buffer, position, cx) - }); - prediction.state = LastPredictionState::Requested; - } else { - self.last_prediction.take(); - } - } - - cx.notify(); - } - - fn number_input( - label: &'static str, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - let input = cx.new(|cx| { - InputField::new(window, cx, "") - .label(label) - .label_min_width(px(64.)) - }); - - cx.subscribe_in( - &input.read(cx).editor().clone(), - window, - |this, _, event, _window, cx| { - let EditorEvent::BufferEdited = event else { - return; - }; - - fn number_input_value( - input: &Entity, - cx: &App, - ) -> T { - input - .read(cx) - .editor() - .read(cx) - .text(cx) - .parse::() - .unwrap_or_default() - } - - let zeta_options = this.zeta.read(cx).options().clone(); - - let excerpt_options = EditPredictionExcerptOptions { - max_bytes: number_input_value(&this.max_excerpt_bytes_input, cx), - min_bytes: number_input_value(&this.min_excerpt_bytes_input, cx), - target_before_cursor_over_total_bytes: number_input_value( - &this.cursor_context_ratio_input, - cx, - ), - }; - - let context = match zeta_options.context { - ContextMode::Agentic(_context_options) => { - ContextMode::Agentic(AgenticContextOptions { - excerpt: excerpt_options, - }) - } - ContextMode::Syntax(context_options) => { - let max_retrieved_declarations = match &this.context_mode { - ContextModeState::Llm => { - zeta::DEFAULT_SYNTAX_CONTEXT_OPTIONS.max_retrieved_declarations - } - ContextModeState::Syntax { - max_retrieved_declarations, - } => number_input_value(max_retrieved_declarations, cx), - }; - - ContextMode::Syntax(EditPredictionContextOptions { - excerpt: excerpt_options, - max_retrieved_declarations, - ..context_options - }) - } - }; - - this.set_zeta_options( - ZetaOptions { - context, - max_prompt_bytes: number_input_value(&this.max_prompt_bytes_input, cx), - max_diagnostic_bytes: zeta_options.max_diagnostic_bytes, - prompt_format: zeta_options.prompt_format, - file_indexing_parallelism: zeta_options.file_indexing_parallelism, - buffer_change_grouping_interval: zeta_options - .buffer_change_grouping_interval, - }, - cx, - ); - }, - ) - .detach(); - input - } - - fn update_last_prediction( - &mut self, - prediction: zeta::ZetaDebugInfo, - window: &mut Window, - cx: &mut Context, - ) { - self._update_state_task = cx.spawn_in(window, { - let language_registry = self.project.read(cx).languages().clone(); - async move |this, cx| { - let mut languages = HashMap::default(); - let ZetaDebugInfo::EditPredictionRequested(prediction) = prediction else { - return; - }; - for ext in prediction - .inputs - .included_files - .iter() - .filter_map(|file| file.path.extension()) - { - if !languages.contains_key(ext) { - // Most snippets are gonna be the same language, - // so we think it's fine to do this sequentially for now - languages.insert( - ext.to_owned(), - language_registry - .language_for_name_or_extension(&ext.to_string_lossy()) - .await - .ok(), - ); - } - } - - let markdown_language = language_registry - .language_for_name("Markdown") - .await - .log_err(); - - let json_language = language_registry.language_for_name("Json").await.log_err(); - - this.update_in(cx, |this, window, cx| { - let ZetaEditPredictionDebugInfo { - response_rx, - position, - buffer, - retrieval_time, - local_prompt, - .. - } = prediction; - - let task = cx.spawn_in(window, { - let markdown_language = markdown_language.clone(); - let json_language = json_language.clone(); - async move |this, cx| { - let response = response_rx.await; - - this.update_in(cx, |this, window, cx| { - if let Some(prediction) = this.last_prediction.as_mut() { - prediction.state = match response { - Ok((Ok(response), request_time)) => { - prediction.request_time = Some(request_time); - - let feedback_editor = cx.new(|cx| { - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local("", cx); - buffer.set_language( - markdown_language.clone(), - cx, - ); - buffer - }); - let buffer = - cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - let mut editor = Editor::new( - EditorMode::AutoHeight { - min_lines: 3, - max_lines: None, - }, - buffer, - None, - window, - cx, - ); - editor.set_placeholder_text( - "Write feedback here", - window, - cx, - ); - editor.set_show_line_numbers(false, cx); - editor.set_show_gutter(false, cx); - editor.set_show_scrollbars(false, cx); - editor - }); - - cx.subscribe_in( - &feedback_editor, - window, - |this, editor, ev, window, cx| match ev { - EditorEvent::BufferEdited => { - if let Some(last_prediction) = - this.last_prediction.as_mut() - && let LastPredictionState::Success { - feedback: feedback_state, - .. - } = &mut last_prediction.state - { - if feedback_state.take().is_some() { - editor.update(cx, |editor, cx| { - editor.set_placeholder_text( - "Write feedback here", - window, - cx, - ); - }); - cx.notify(); - } - } - } - _ => {} - }, - ) - .detach(); - - LastPredictionState::Success { - model_response_editor: cx.new(|cx| { - let buffer = cx.new(|cx| { - let mut buffer = Buffer::local( - serde_json::to_string_pretty(&response) - .unwrap_or_default(), - cx, - ); - buffer.set_language(json_language, cx); - buffer - }); - let buffer = cx.new(|cx| { - MultiBuffer::singleton(buffer, cx) - }); - let mut editor = Editor::new( - EditorMode::full(), - buffer, - None, - window, - cx, - ); - editor.set_read_only(true); - editor.set_show_line_numbers(false, cx); - editor.set_show_gutter(false, cx); - editor.set_show_scrollbars(false, cx); - editor - }), - feedback_editor, - feedback: None, - request_id: response.id.clone(), - } - } - Ok((Err(err), request_time)) => { - prediction.request_time = Some(request_time); - LastPredictionState::Failed { message: err } - } - Err(oneshot::Canceled) => LastPredictionState::Failed { - message: "Canceled".to_string(), - }, - }; - } - }) - .ok(); - } - }); - - let project_snapshot_task = TelemetrySnapshot::new(&this.project, cx); - - this.last_prediction = Some(LastPrediction { - prompt_editor: cx.new(|cx| { - let buffer = cx.new(|cx| { - let mut buffer = - Buffer::local(local_prompt.unwrap_or_else(|err| err), cx); - buffer.set_language(markdown_language.clone(), cx); - buffer - }); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - let mut editor = - Editor::new(EditorMode::full(), buffer, None, window, cx); - editor.set_read_only(true); - editor.set_show_line_numbers(false, cx); - editor.set_show_gutter(false, cx); - editor.set_show_scrollbars(false, cx); - editor - }), - retrieval_time, - request_time: None, - buffer, - position, - state: LastPredictionState::Requested, - project_snapshot: cx - .foreground_executor() - .spawn(async move { Arc::new(project_snapshot_task.await) }) - .shared(), - inputs: prediction.inputs, - _task: Some(task), - }); - cx.notify(); - }) - .ok(); - } - }); - } - - fn handle_rate_positive( - &mut self, - _action: &Zeta2RatePredictionPositive, - window: &mut Window, - cx: &mut Context, - ) { - self.handle_rate(Feedback::Positive, window, cx); - } - - fn handle_rate_negative( - &mut self, - _action: &Zeta2RatePredictionNegative, - window: &mut Window, - cx: &mut Context, - ) { - self.handle_rate(Feedback::Negative, window, cx); - } - - fn handle_rate(&mut self, kind: Feedback, window: &mut Window, cx: &mut Context) { - let Some(last_prediction) = self.last_prediction.as_mut() else { - return; - }; - - let project_snapshot_task = last_prediction.project_snapshot.clone(); - - cx.spawn_in(window, async move |this, cx| { - let project_snapshot = project_snapshot_task.await; - this.update_in(cx, |this, window, cx| { - let Some(last_prediction) = this.last_prediction.as_mut() else { - return; - }; - - let LastPredictionState::Success { - feedback: feedback_state, - feedback_editor, - model_response_editor, - request_id, - .. - } = &mut last_prediction.state - else { - return; - }; - - *feedback_state = Some(kind); - let text = feedback_editor.update(cx, |feedback_editor, cx| { - feedback_editor.set_placeholder_text( - "Submitted. Edit or submit again to change.", - window, - cx, - ); - feedback_editor.text(cx) - }); - cx.notify(); - - cx.defer_in(window, { - let model_response_editor = model_response_editor.downgrade(); - move |_, window, cx| { - if let Some(model_response_editor) = model_response_editor.upgrade() { - model_response_editor.focus_handle(cx).focus(window); - } - } - }); - - let kind = match kind { - Feedback::Positive => "positive", - Feedback::Negative => "negative", - }; - - telemetry::event!( - "Zeta2 Prediction Rated", - id = request_id, - kind = kind, - text = text, - request = last_prediction.inputs, - project_snapshot = project_snapshot, - ); - }) - .log_err(); - }) - .detach(); - } - - fn render_options(&self, window: &mut Window, cx: &mut Context) -> Div { - v_flex() - .gap_2() - .child( - h_flex() - .child(Headline::new("Options").size(HeadlineSize::Small)) - .justify_between() - .child( - ui::Button::new("reset-options", "Reset") - .disabled(self.zeta.read(cx).options() == &zeta::DEFAULT_OPTIONS) - .style(ButtonStyle::Outlined) - .size(ButtonSize::Large) - .on_click(cx.listener(|this, _, window, cx| { - this.set_options_state(&zeta::DEFAULT_OPTIONS, window, cx); - })), - ), - ) - .child( - v_flex() - .gap_2() - .child( - h_flex() - .gap_2() - .items_end() - .child(self.max_excerpt_bytes_input.clone()) - .child(self.min_excerpt_bytes_input.clone()) - .child(self.cursor_context_ratio_input.clone()) - .child(self.render_context_mode_dropdown(window, cx)), - ) - .child( - h_flex() - .gap_2() - .items_end() - .children(match &self.context_mode { - ContextModeState::Llm => None, - ContextModeState::Syntax { - max_retrieved_declarations, - } => Some(max_retrieved_declarations.clone()), - }) - .child(self.max_prompt_bytes_input.clone()) - .child(self.render_prompt_format_dropdown(window, cx)), - ), - ) - } - - fn render_context_mode_dropdown(&self, window: &mut Window, cx: &mut Context) -> Div { - let this = cx.weak_entity(); - - v_flex() - .gap_1p5() - .child( - Label::new("Context Mode") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - DropdownMenu::new( - "ep-ctx-mode", - match &self.context_mode { - ContextModeState::Llm => "LLM-based", - ContextModeState::Syntax { .. } => "Syntax", - }, - ContextMenu::build(window, cx, move |menu, _window, _cx| { - menu.item( - ContextMenuEntry::new("LLM-based") - .toggleable( - IconPosition::End, - matches!(self.context_mode, ContextModeState::Llm), - ) - .handler({ - let this = this.clone(); - move |window, cx| { - this.update(cx, |this, cx| { - let current_options = - this.zeta.read(cx).options().clone(); - match current_options.context.clone() { - ContextMode::Agentic(_) => {} - ContextMode::Syntax(context_options) => { - let options = ZetaOptions { - context: ContextMode::Agentic( - AgenticContextOptions { - excerpt: context_options.excerpt, - }, - ), - ..current_options - }; - this.set_options_state(&options, window, cx); - this.set_zeta_options(options, cx); - } - } - }) - .ok(); - } - }), - ) - .item( - ContextMenuEntry::new("Syntax") - .toggleable( - IconPosition::End, - matches!(self.context_mode, ContextModeState::Syntax { .. }), - ) - .handler({ - move |window, cx| { - this.update(cx, |this, cx| { - let current_options = - this.zeta.read(cx).options().clone(); - match current_options.context.clone() { - ContextMode::Agentic(context_options) => { - let options = ZetaOptions { - context: ContextMode::Syntax( - EditPredictionContextOptions { - excerpt: context_options.excerpt, - ..DEFAULT_SYNTAX_CONTEXT_OPTIONS - }, - ), - ..current_options - }; - this.set_options_state(&options, window, cx); - this.set_zeta_options(options, cx); - } - ContextMode::Syntax(_) => {} - } - }) - .ok(); - } - }), - ) - }), - ) - .style(ui::DropdownStyle::Outlined), - ) - } - - fn render_prompt_format_dropdown(&self, window: &mut Window, cx: &mut Context) -> Div { - let active_format = self.zeta.read(cx).options().prompt_format; - let this = cx.weak_entity(); - - v_flex() - .gap_1p5() - .child( - Label::new("Prompt Format") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - DropdownMenu::new( - "ep-prompt-format", - active_format.to_string(), - ContextMenu::build(window, cx, move |mut menu, _window, _cx| { - for prompt_format in PromptFormat::iter() { - menu = menu.item( - ContextMenuEntry::new(prompt_format.to_string()) - .toggleable(IconPosition::End, active_format == prompt_format) - .handler({ - let this = this.clone(); - move |_window, cx| { - this.update(cx, |this, cx| { - let current_options = - this.zeta.read(cx).options().clone(); - let options = ZetaOptions { - prompt_format, - ..current_options - }; - this.set_zeta_options(options, cx); - }) - .ok(); - } - }), - ) - } - menu - }), - ) - .style(ui::DropdownStyle::Outlined), - ) - } - - fn render_stats(&self) -> Option
{ - let Some(prediction) = self.last_prediction.as_ref() else { - return None; - }; - - Some( - v_flex() - .p_4() - .gap_2() - .min_w(px(160.)) - .child(Headline::new("Stats").size(HeadlineSize::Small)) - .child(Self::render_duration( - "Context retrieval", - Some(prediction.retrieval_time), - )) - .child(Self::render_duration("Request", prediction.request_time)), - ) - } - - fn render_duration(name: &'static str, time: Option) -> Div { - h_flex() - .gap_1() - .child(Label::new(name).color(Color::Muted).size(LabelSize::Small)) - .child(match time { - Some(time) => Label::new(if time.as_micros() >= 1000 { - format!("{} ms", time.as_millis()) - } else { - format!("{} µs", time.as_micros()) - }) - .size(LabelSize::Small), - None => Label::new("...").size(LabelSize::Small), - }) - } - - fn render_content(&self, _: &mut Window, cx: &mut Context) -> AnyElement { - if !cx.has_flag::() { - return Self::render_message("`zeta2` feature flag is not enabled"); - } - - match self.last_prediction.as_ref() { - None => Self::render_message("No prediction"), - Some(prediction) => self.render_last_prediction(prediction, cx).into_any(), - } - } - - fn render_message(message: impl Into) -> AnyElement { - v_flex() - .size_full() - .justify_center() - .items_center() - .child(Label::new(message).size(LabelSize::Large)) - .into_any() - } - - fn render_last_prediction(&self, prediction: &LastPrediction, cx: &mut Context) -> Div { - h_flex() - .items_start() - .w_full() - .flex_1() - .border_t_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .child( - v_flex() - .flex_1() - .gap_2() - .p_4() - .h_full() - .child( - h_flex() - .justify_between() - .child(ui::Headline::new("Prompt").size(ui::HeadlineSize::XSmall)) - .child(match prediction.state { - LastPredictionState::Requested - | LastPredictionState::Failed { .. } => ui::Chip::new("Local") - .bg_color(cx.theme().status().warning_background) - .label_color(Color::Success), - LastPredictionState::Success { .. } => ui::Chip::new("Cloud") - .bg_color(cx.theme().status().success_background) - .label_color(Color::Success), - }), - ) - .child(prediction.prompt_editor.clone()), - ) - .child(ui::vertical_divider()) - .child( - v_flex() - .flex_1() - .gap_2() - .h_full() - .child( - v_flex() - .flex_1() - .gap_2() - .p_4() - .child( - ui::Headline::new("Model Response").size(ui::HeadlineSize::XSmall), - ) - .child(match &prediction.state { - LastPredictionState::Success { - model_response_editor, - .. - } => model_response_editor.clone().into_any_element(), - LastPredictionState::Requested => v_flex() - .gap_2() - .child(Label::new("Loading...").buffer_font(cx)) - .into_any_element(), - LastPredictionState::Failed { message } => v_flex() - .gap_2() - .max_w_96() - .child(Label::new(message.clone()).buffer_font(cx)) - .into_any_element(), - }), - ) - .child(ui::divider()) - .child( - if let LastPredictionState::Success { - feedback_editor, - feedback: feedback_state, - .. - } = &prediction.state - { - v_flex() - .key_context("Zeta2Feedback") - .on_action(cx.listener(Self::handle_rate_positive)) - .on_action(cx.listener(Self::handle_rate_negative)) - .gap_2() - .p_2() - .child(feedback_editor.clone()) - .child( - h_flex() - .justify_end() - .w_full() - .child( - ButtonLike::new("rate-positive") - .when( - *feedback_state == Some(Feedback::Positive), - |this| this.style(ButtonStyle::Filled), - ) - .child( - KeyBinding::for_action( - &Zeta2RatePredictionPositive, - cx, - ) - .size(TextSize::Small.rems(cx)), - ) - .child(ui::Icon::new(ui::IconName::ThumbsUp)) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_rate_positive( - &Zeta2RatePredictionPositive, - window, - cx, - ); - })), - ) - .child( - ButtonLike::new("rate-negative") - .when( - *feedback_state == Some(Feedback::Negative), - |this| this.style(ButtonStyle::Filled), - ) - .child( - KeyBinding::for_action( - &Zeta2RatePredictionNegative, - cx, - ) - .size(TextSize::Small.rems(cx)), - ) - .child(ui::Icon::new(ui::IconName::ThumbsDown)) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_rate_negative( - &Zeta2RatePredictionNegative, - window, - cx, - ); - })), - ), - ) - .into_any() - } else { - Empty.into_any_element() - }, - ), - ) - } -} - -impl Focusable for Zeta2Inspector { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Item for Zeta2Inspector { - type Event = (); - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Zeta2 Inspector".into() - } -} - -impl EventEmitter<()> for Zeta2Inspector {} - -impl Render for Zeta2Inspector { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .bg(cx.theme().colors().editor_background) - .child( - h_flex() - .w_full() - .child( - v_flex() - .flex_1() - .p_4() - .h_full() - .justify_between() - .child(self.render_options(window, cx)) - .gap_4(), - ) - .child(ui::vertical_divider()) - .children(self.render_stats()), - ) - .child(self.render_content(window, cx)) - } -} diff --git a/crates/zeta_cli/src/evaluate.rs b/crates/zeta_cli/src/evaluate.rs deleted file mode 100644 index 043844768557ad46f61d5fd0d809e1e85c62574f..0000000000000000000000000000000000000000 --- a/crates/zeta_cli/src/evaluate.rs +++ /dev/null @@ -1,641 +0,0 @@ -use crate::metrics::{self, Scores}; -use std::{ - collections::HashMap, - io::{IsTerminal, Write}, - sync::Arc, -}; - -use anyhow::Result; -use gpui::{AsyncApp, Entity}; -use project::Project; -use util::ResultExt as _; -use zeta::{Zeta, udiff::DiffLine}; - -use crate::{ - EvaluateArguments, PredictionOptions, - example::{Example, NamedExample}, - headless::ZetaCliAppState, - paths::print_run_data_dir, - predict::{PredictionDetails, perform_predict, setup_zeta}, -}; - -#[derive(Debug)] -pub(crate) struct ExecutionData { - execution_id: String, - diff: String, - reasoning: String, -} - -pub async fn run_evaluate( - args: EvaluateArguments, - app_state: &Arc, - cx: &mut AsyncApp, -) { - if args.example_paths.is_empty() { - eprintln!("No examples provided"); - return; - } - - let all_tasks = args.example_paths.into_iter().map(|path| { - let options = args.options.clone(); - let app_state = app_state.clone(); - let example = NamedExample::load(&path).expect("Failed to load example"); - - cx.spawn(async move |cx| { - let project = example.setup_project(&app_state, cx).await.unwrap(); - - let providers = (0..args.repetitions) - .map(|_| setup_zeta(args.options.provider, &project, &app_state, cx).unwrap()) - .collect::>(); - - let _edited_buffers = example.apply_edit_history(&project, cx).await.unwrap(); - - let tasks = providers - .into_iter() - .enumerate() - .map(move |(repetition_ix, zeta)| { - let repetition_ix = (args.repetitions > 1).then(|| repetition_ix as u16); - let example = example.clone(); - let project = project.clone(); - let options = options.clone(); - - cx.spawn(async move |cx| { - let name = example.name.clone(); - run_evaluate_one( - example, - repetition_ix, - project, - zeta, - options, - !args.skip_prediction, - cx, - ) - .await - .map_err(|err| (err, name, repetition_ix)) - }) - }); - futures::future::join_all(tasks).await - }) - }); - let all_results = futures::future::join_all(all_tasks).await; - - write_aggregated_scores(&mut std::io::stdout(), &all_results).unwrap(); - if let Some(mut output_file) = - std::fs::File::create(crate::paths::RUN_DIR.join("aggregated_results.md")).log_err() - { - write_aggregated_scores(&mut output_file, &all_results).log_err(); - }; - - if args.repetitions > 1 { - if let Err(e) = write_bucketed_analysis(&all_results) { - eprintln!("Failed to write bucketed analysis: {:?}", e); - } - } - - print_run_data_dir(args.repetitions == 1, std::io::stdout().is_terminal()); -} - -fn write_aggregated_scores( - w: &mut impl std::io::Write, - all_results: &Vec< - Vec)>>, - >, -) -> Result<()> { - let mut successful = Vec::new(); - let mut failed_count = 0; - - for result in all_results.iter().flatten() { - match result { - Ok((eval_result, _execution_data)) => successful.push(eval_result), - Err((err, name, repetition_ix)) => { - if failed_count == 0 { - writeln!(w, "## Errors\n")?; - } - - failed_count += 1; - writeln!(w, "{}", fmt_evaluation_error(err, name, repetition_ix))?; - } - } - } - - if successful.len() > 1 { - let edit_scores = successful - .iter() - .filter_map(|r| r.edit_scores.clone()) - .collect::>(); - let has_edit_predictions = edit_scores.len() > 0; - let aggregated_result = EvaluationResult { - context_scores: Scores::aggregate(successful.iter().map(|r| &r.context_scores)), - edit_scores: has_edit_predictions.then(|| EditScores::aggregate(&edit_scores)), - prompt_len: successful.iter().map(|r| r.prompt_len).sum::() / successful.len(), - generated_len: successful.iter().map(|r| r.generated_len).sum::() - / successful.len(), - }; - - writeln!(w, "\n{}", "-".repeat(80))?; - writeln!(w, "\n## TOTAL SCORES")?; - writeln!(w, "{:#}", aggregated_result)?; - } - - if successful.len() + failed_count > 1 { - writeln!( - w, - "\nCongratulations! {}/{} ({:.2}%) of runs weren't outright failures 🎉", - successful.len(), - successful.len() + failed_count, - (successful.len() as f64 / (successful.len() + failed_count) as f64) * 100.0 - )?; - } - - Ok(()) -} - -pub async fn run_evaluate_one( - example: NamedExample, - repetition_ix: Option, - project: Entity, - zeta: Entity, - prediction_options: PredictionOptions, - predict: bool, - cx: &mut AsyncApp, -) -> Result<(EvaluationResult, ExecutionData)> { - let predict_result = perform_predict( - example.clone(), - project, - zeta, - repetition_ix, - prediction_options, - cx, - ) - .await?; - - let evaluation_result = evaluate(&example.example, &predict_result, predict); - - if repetition_ix.is_none() { - write_eval_result( - &example, - &predict_result, - &evaluation_result, - &mut std::io::stdout(), - std::io::stdout().is_terminal(), - predict, - )?; - } - - if let Some(mut results_file) = - std::fs::File::create(predict_result.run_example_dir.join("results.md")).log_err() - { - write_eval_result( - &example, - &predict_result, - &evaluation_result, - &mut results_file, - false, - predict, - ) - .log_err(); - } - - let execution_data = ExecutionData { - execution_id: if let Some(rep_ix) = repetition_ix { - format!("{:03}", rep_ix) - } else { - example.name.clone() - }, - diff: predict_result.diff.clone(), - reasoning: std::fs::read_to_string( - predict_result - .run_example_dir - .join("prediction_response.md"), - ) - .unwrap_or_default(), - }; - - anyhow::Ok((evaluation_result, execution_data)) -} - -fn write_eval_result( - example: &NamedExample, - predictions: &PredictionDetails, - evaluation_result: &EvaluationResult, - out: &mut impl Write, - use_color: bool, - predict: bool, -) -> Result<()> { - if predict { - writeln!( - out, - "## Expected edit prediction:\n\n```diff\n{}\n```\n", - compare_diffs( - &example.example.expected_patch, - &predictions.diff, - use_color - ) - )?; - writeln!( - out, - "## Actual edit prediction:\n\n```diff\n{}\n```\n", - compare_diffs( - &predictions.diff, - &example.example.expected_patch, - use_color - ) - )?; - } - - writeln!(out, "{:#}", evaluation_result)?; - - anyhow::Ok(()) -} - -#[derive(Debug, Default, Clone)] -pub struct EditScores { - pub line_match: Scores, - pub chr_f: f64, -} - -impl EditScores { - pub fn aggregate(scores: &[EditScores]) -> EditScores { - let line_match = Scores::aggregate(scores.iter().map(|s| &s.line_match)); - let chr_f = scores.iter().map(|s| s.chr_f).sum::() / scores.len() as f64; - - EditScores { line_match, chr_f } - } -} - -#[derive(Debug, Default)] -pub struct EvaluationResult { - pub edit_scores: Option, - pub context_scores: Scores, - pub prompt_len: usize, - pub generated_len: usize, -} - -impl std::fmt::Display for EvaluationResult { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if f.alternate() { - self.fmt_table(f) - } else { - self.fmt_markdown(f) - } - } -} - -impl EvaluationResult { - fn fmt_markdown(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - r#" -### Context Scores -{} -"#, - self.context_scores.to_markdown(), - )?; - if let Some(scores) = &self.edit_scores { - write!( - f, - r#" - ### Edit Prediction Scores - {}"#, - scores.line_match.to_markdown() - )?; - } - Ok(()) - } - - fn fmt_table(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "#### Prompt Statistics")?; - writeln!(f, "─────────────────────────")?; - writeln!(f, "Prompt_len Generated_len")?; - writeln!(f, "─────────────────────────")?; - writeln!(f, "{:<11} {:<14}", self.prompt_len, self.generated_len,)?; - writeln!(f)?; - writeln!(f)?; - writeln!(f, "#### Performance Scores")?; - writeln!( - f, - "──────────────────────────────────────────────────────────────────" - )?; - writeln!( - f, - " TP FP FN Precision Recall F1" - )?; - writeln!( - f, - "──────────────────────────────────────────────────────────────────" - )?; - writeln!( - f, - "Context Retrieval {:<6} {:<6} {:<6} {:>8.2} {:>7.2} {:>6.2}", - self.context_scores.true_positives, - self.context_scores.false_positives, - self.context_scores.false_negatives, - self.context_scores.precision() * 100.0, - self.context_scores.recall() * 100.0, - self.context_scores.f1_score() * 100.0 - )?; - if let Some(edit_scores) = &self.edit_scores { - let line_match = &edit_scores.line_match; - writeln!(f, "Edit Prediction")?; - writeln!( - f, - " ├─ exact lines {:<6} {:<6} {:<6} {:>8.2} {:>7.2} {:>6.2}", - line_match.true_positives, - line_match.false_positives, - line_match.false_negatives, - line_match.precision() * 100.0, - line_match.recall() * 100.0, - line_match.f1_score() * 100.0 - )?; - writeln!( - f, - " └─ diff chrF {:<6} {:<6} {:<6} {:>8} {:>8} {:>6.2}", - "-", "-", "-", "-", "-", edit_scores.chr_f - )?; - } - Ok(()) - } -} - -fn evaluate(example: &Example, preds: &PredictionDetails, predict: bool) -> EvaluationResult { - let mut eval_result = EvaluationResult { - prompt_len: preds.prompt_len, - generated_len: preds.generated_len, - ..Default::default() - }; - - if predict { - // todo: alternatives for patches - let expected_patch = example - .expected_patch - .lines() - .map(DiffLine::parse) - .collect::>(); - let actual_patch = preds.diff.lines().map(DiffLine::parse).collect::>(); - - let line_match = metrics::line_match_score(&expected_patch, &actual_patch); - let chr_f = metrics::delta_chr_f(&expected_patch, &actual_patch); - - eval_result.edit_scores = Some(EditScores { line_match, chr_f }); - } - - eval_result -} - -/// Return annotated `patch_a` so that: -/// Additions and deletions that are not present in `patch_b` will be highlighted in red. -/// Additions and deletions that are present in `patch_b` will be highlighted in green. -pub fn compare_diffs(patch_a: &str, patch_b: &str, use_color: bool) -> String { - let green = if use_color { "\x1b[32m✓ " } else { "" }; - let red = if use_color { "\x1b[31m✗ " } else { "" }; - let neutral = if use_color { " " } else { "" }; - let reset = if use_color { "\x1b[0m" } else { "" }; - let lines_a = patch_a.lines().map(DiffLine::parse); - let lines_b: Vec<_> = patch_b.lines().map(DiffLine::parse).collect(); - - let annotated = lines_a - .map(|line| match line { - DiffLine::Addition(_) | DiffLine::Deletion(_) => { - if lines_b.contains(&line) { - format!("{green}{line}{reset}") - } else { - format!("{red}{line}{reset}") - } - } - _ => format!("{neutral}{line}{reset}"), - }) - .collect::>(); - - annotated.join("\n") -} - -fn write_bucketed_analysis( - all_results: &Vec< - Vec)>>, - >, -) -> Result<()> { - #[derive(Debug)] - struct EditBucket { - diff: String, - is_correct: bool, - execution_indices: Vec, - reasoning_samples: Vec, - } - - let mut total_executions = 0; - let mut empty_predictions = Vec::new(); - let mut errors = Vec::new(); - - let mut buckets: HashMap = HashMap::new(); - - for result in all_results.iter().flatten() { - total_executions += 1; - - let (evaluation_result, execution_data) = match result { - Ok((eval_result, execution_data)) => { - if execution_data.diff.is_empty() { - empty_predictions.push(execution_data); - continue; - } - (eval_result, execution_data) - } - Err(err) => { - errors.push(err); - continue; - } - }; - - buckets - .entry(execution_data.diff.clone()) - .and_modify(|bucket| { - bucket - .execution_indices - .push(execution_data.execution_id.clone()); - bucket - .reasoning_samples - .push(execution_data.reasoning.clone()); - }) - .or_insert_with(|| EditBucket { - diff: execution_data.diff.clone(), - is_correct: { - evaluation_result - .edit_scores - .as_ref() - .map_or(false, |edit_scores| { - edit_scores.line_match.false_positives == 0 - && edit_scores.line_match.false_negatives == 0 - && edit_scores.line_match.true_positives > 0 - }) - }, - execution_indices: vec![execution_data.execution_id.clone()], - reasoning_samples: vec![execution_data.reasoning.clone()], - }); - } - - let mut sorted_buckets = buckets.into_values().collect::>(); - sorted_buckets.sort_by(|a, b| match (a.is_correct, b.is_correct) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => b.execution_indices.len().cmp(&a.execution_indices.len()), - }); - - let output_path = crate::paths::RUN_DIR.join("bucketed_analysis.md"); - let mut output = std::fs::File::create(&output_path)?; - - writeln!(output, "# Bucketed Edit Analysis\n")?; - - writeln!(output, "## Summary\n")?; - writeln!(output, "- **Total executions**: {}", total_executions)?; - - let correct_count: usize = sorted_buckets - .iter() - .filter(|b| b.is_correct) - .map(|b| b.execution_indices.len()) - .sum(); - - let incorrect_count: usize = sorted_buckets - .iter() - .filter(|b| !b.is_correct) - .map(|b| b.execution_indices.len()) - .sum(); - - writeln!( - output, - "- **Correct predictions**: {} ({:.1}%)", - correct_count, - (correct_count as f64 / total_executions as f64) * 100.0 - )?; - - writeln!( - output, - "- **Incorrect predictions**: {} ({:.1}%)", - incorrect_count, - (incorrect_count as f64 / total_executions as f64) * 100.0 - )?; - - writeln!( - output, - "- **No Predictions**: {} ({:.1}%)", - empty_predictions.len(), - (empty_predictions.len() as f64 / total_executions as f64) * 100.0 - )?; - - let unique_incorrect = sorted_buckets.iter().filter(|b| !b.is_correct).count(); - writeln!( - output, - "- **Unique incorrect edit patterns**: {}\n", - unique_incorrect - )?; - - writeln!(output, "---\n")?; - - for (idx, bucket) in sorted_buckets.iter().filter(|b| b.is_correct).enumerate() { - if idx == 0 { - writeln!( - output, - "## Correct Predictions ({} occurrences)\n", - bucket.execution_indices.len() - )?; - } - - writeln!(output, "**Predicted Edit:**\n")?; - writeln!(output, "```diff")?; - writeln!(output, "{}", bucket.diff)?; - writeln!(output, "```\n")?; - - writeln!( - output, - "**Executions:** {}\n", - bucket.execution_indices.join(", ") - )?; - writeln!(output, "---\n")?; - } - - for (idx, bucket) in sorted_buckets.iter().filter(|b| !b.is_correct).enumerate() { - writeln!( - output, - "## Incorrect Prediction #{} ({} occurrences)\n", - idx + 1, - bucket.execution_indices.len() - )?; - - writeln!(output, "**Predicted Edit:**\n")?; - writeln!(output, "```diff")?; - writeln!(output, "{}", bucket.diff)?; - writeln!(output, "```\n")?; - - writeln!( - output, - "**Executions:** {}\n", - bucket.execution_indices.join(", ") - )?; - - for (exec_id, reasoning) in bucket - .execution_indices - .iter() - .zip(bucket.reasoning_samples.iter()) - { - writeln!(output, "{}", fmt_execution(exec_id, reasoning))?; - } - - writeln!(output, "\n---\n")?; - } - - if !empty_predictions.is_empty() { - writeln!( - output, - "## No Predictions ({} occurrences)\n", - empty_predictions.len() - )?; - - for execution_data in &empty_predictions { - writeln!( - output, - "{}", - fmt_execution(&execution_data.execution_id, &execution_data.reasoning) - )?; - } - writeln!(output, "\n---\n")?; - } - - if !errors.is_empty() { - writeln!(output, "## Errors ({} occurrences)\n", errors.len())?; - - for (err, name, repetition_ix) in &errors { - writeln!(output, "{}", fmt_evaluation_error(err, name, repetition_ix))?; - } - writeln!(output, "\n---\n")?; - } - - fn fmt_execution(exec_id: &str, reasoning: &str) -> String { - let exec_content = format!( - "\n### Execution {} `{}/{}/prediction_response.md`{}", - exec_id, - crate::paths::RUN_DIR.display(), - exec_id, - indent_text(&format!("\n\n```\n{}\n```\n", reasoning,), 2) - ); - indent_text(&exec_content, 2) - } - - fn indent_text(text: &str, spaces: usize) -> String { - let indent = " ".repeat(spaces); - text.lines() - .collect::>() - .join(&format!("\n{}", indent)) - } - - Ok(()) -} - -fn fmt_evaluation_error(err: &anyhow::Error, name: &str, repetition_ix: &Option) -> String { - let err = format!("{err:?}") - .replace("", "\n```"); - format!( - "### ERROR {name}{}\n\n{err}\n", - repetition_ix - .map(|ix| format!(" [RUN {ix:03}]")) - .unwrap_or_default() - ) -} diff --git a/crates/zeta_cli/src/example.rs b/crates/zeta_cli/src/example.rs deleted file mode 100644 index a9d4c4f47c5a05d4198b1cffaee51e14a122e88d..0000000000000000000000000000000000000000 --- a/crates/zeta_cli/src/example.rs +++ /dev/null @@ -1,561 +0,0 @@ -use std::{ - borrow::Cow, - cell::RefCell, - fmt::{self, Display}, - fs, - io::Write, - mem, - path::{Path, PathBuf}, - sync::{Arc, OnceLock}, -}; - -use crate::headless::ZetaCliAppState; -use anyhow::{Context as _, Result, anyhow}; -use clap::ValueEnum; -use cloud_zeta2_prompt::CURSOR_MARKER; -use collections::HashMap; -use futures::{ - AsyncWriteExt as _, - lock::{Mutex, OwnedMutexGuard}, -}; -use futures::{FutureExt as _, future::Shared}; -use gpui::{AsyncApp, Entity, Task, http_client::Url}; -use language::{Anchor, Buffer}; -use project::{Project, ProjectPath}; -use pulldown_cmark::CowStr; -use serde::{Deserialize, Serialize}; -use util::{paths::PathStyle, rel_path::RelPath}; -use zeta::udiff::OpenedBuffers; - -use crate::paths::{REPOS_DIR, WORKTREES_DIR}; - -const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; -const EDIT_HISTORY_HEADING: &str = "Edit History"; -const CURSOR_POSITION_HEADING: &str = "Cursor Position"; -const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; -const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; -const REPOSITORY_URL_FIELD: &str = "repository_url"; -const REVISION_FIELD: &str = "revision"; - -#[derive(Debug, Clone)] -pub struct NamedExample { - pub name: String, - pub example: Example, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Example { - pub repository_url: String, - pub revision: String, - pub uncommitted_diff: String, - pub cursor_path: PathBuf, - pub cursor_position: String, - pub edit_history: String, - pub expected_patch: String, -} - -pub type ActualExcerpt = Excerpt; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Excerpt { - pub path: PathBuf, - pub text: String, -} - -#[derive(ValueEnum, Debug, Clone)] -pub enum ExampleFormat { - Json, - Toml, - Md, -} - -impl NamedExample { - pub fn load(path: impl AsRef) -> Result { - let path = path.as_ref(); - let content = std::fs::read_to_string(path)?; - let ext = path.extension(); - - match ext.and_then(|s| s.to_str()) { - Some("json") => Ok(Self { - name: path.file_stem().unwrap_or_default().display().to_string(), - example: serde_json::from_str(&content)?, - }), - Some("toml") => Ok(Self { - name: path.file_stem().unwrap_or_default().display().to_string(), - example: toml::from_str(&content)?, - }), - Some("md") => Self::parse_md(&content), - Some(_) => { - anyhow::bail!("Unrecognized example extension: {}", ext.unwrap().display()); - } - None => { - anyhow::bail!( - "Failed to determine example type since the file does not have an extension." - ); - } - } - } - - pub fn parse_md(input: &str) -> Result { - use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd}; - - let parser = Parser::new(input); - - let mut named = NamedExample { - name: String::new(), - example: Example { - repository_url: String::new(), - revision: String::new(), - uncommitted_diff: String::new(), - cursor_path: PathBuf::new(), - cursor_position: String::new(), - edit_history: String::new(), - expected_patch: String::new(), - }, - }; - - let mut text = String::new(); - let mut block_info: CowStr = "".into(); - - #[derive(PartialEq)] - enum Section { - UncommittedDiff, - EditHistory, - CursorPosition, - ExpectedExcerpts, - ExpectedPatch, - Other, - } - - let mut current_section = Section::Other; - - for event in parser { - match event { - Event::Text(line) => { - text.push_str(&line); - - if !named.name.is_empty() - && current_section == Section::Other - // in h1 section - && let Some((field, value)) = line.split_once('=') - { - match field.trim() { - REPOSITORY_URL_FIELD => { - named.example.repository_url = value.trim().to_string(); - } - REVISION_FIELD => { - named.example.revision = value.trim().to_string(); - } - _ => {} - } - } - } - Event::End(TagEnd::Heading(HeadingLevel::H1)) => { - if !named.name.is_empty() { - anyhow::bail!( - "Found multiple H1 headings. There should only be one with the name of the example." - ); - } - named.name = mem::take(&mut text); - } - Event::End(TagEnd::Heading(HeadingLevel::H2)) => { - let title = mem::take(&mut text); - current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { - Section::UncommittedDiff - } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { - Section::EditHistory - } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { - Section::CursorPosition - } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { - Section::ExpectedPatch - } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { - Section::ExpectedExcerpts - } else { - Section::Other - }; - } - Event::End(TagEnd::Heading(HeadingLevel::H3)) => { - mem::take(&mut text); - } - Event::End(TagEnd::Heading(HeadingLevel::H4)) => { - mem::take(&mut text); - } - Event::End(TagEnd::Heading(level)) => { - anyhow::bail!("Unexpected heading level: {level}"); - } - Event::Start(Tag::CodeBlock(kind)) => { - match kind { - CodeBlockKind::Fenced(info) => { - block_info = info; - } - CodeBlockKind::Indented => { - anyhow::bail!("Unexpected indented codeblock"); - } - }; - } - Event::Start(_) => { - text.clear(); - block_info = "".into(); - } - Event::End(TagEnd::CodeBlock) => { - let block_info = block_info.trim(); - match current_section { - Section::UncommittedDiff => { - named.example.uncommitted_diff = mem::take(&mut text); - } - Section::EditHistory => { - named.example.edit_history.push_str(&mem::take(&mut text)); - } - Section::CursorPosition => { - named.example.cursor_path = block_info.into(); - named.example.cursor_position = mem::take(&mut text); - } - Section::ExpectedExcerpts => { - mem::take(&mut text); - } - Section::ExpectedPatch => { - named.example.expected_patch = mem::take(&mut text); - } - Section::Other => {} - } - } - _ => {} - } - } - - if named.example.cursor_path.as_path() == Path::new("") - || named.example.cursor_position.is_empty() - { - anyhow::bail!("Missing cursor position codeblock"); - } - - Ok(named) - } - - pub fn write(&self, format: ExampleFormat, mut out: impl Write) -> Result<()> { - match format { - ExampleFormat::Json => Ok(serde_json::to_writer(out, &self.example)?), - ExampleFormat::Toml => { - Ok(out.write_all(toml::to_string_pretty(&self.example)?.as_bytes())?) - } - ExampleFormat::Md => Ok(write!(out, "{}", self)?), - } - } - - pub async fn setup_project( - &self, - app_state: &Arc, - cx: &mut AsyncApp, - ) -> Result> { - let worktree_path = self.setup_worktree().await?; - - static AUTHENTICATED: OnceLock>> = OnceLock::new(); - - AUTHENTICATED - .get_or_init(|| { - let client = app_state.client.clone(); - cx.spawn(async move |cx| { - client - .sign_in_with_optional_connect(true, cx) - .await - .unwrap(); - }) - .shared() - }) - .clone() - .await; - - let project = cx.update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - })?; - - let worktree = project - .update(cx, |project, cx| { - project.create_worktree(&worktree_path, true, cx) - })? - .await?; - worktree - .read_with(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - })? - .await; - - anyhow::Ok(project) - } - - pub async fn setup_worktree(&self) -> Result { - let (repo_owner, repo_name) = self.repo_name()?; - let file_name = self.file_name(); - - let repo_dir = REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref()); - let repo_lock = lock_repo(&repo_dir).await; - - if !repo_dir.is_dir() { - fs::create_dir_all(&repo_dir)?; - run_git(&repo_dir, &["init"]).await?; - run_git( - &repo_dir, - &["remote", "add", "origin", &self.example.repository_url], - ) - .await?; - } - - // Resolve the example to a revision, fetching it if needed. - let revision = run_git( - &repo_dir, - &[ - "rev-parse", - &format!("{}^{{commit}}", self.example.revision), - ], - ) - .await; - let revision = if let Ok(revision) = revision { - revision - } else { - run_git( - &repo_dir, - &["fetch", "--depth", "1", "origin", &self.example.revision], - ) - .await?; - let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]).await?; - if revision != self.example.revision { - run_git(&repo_dir, &["tag", &self.example.revision, &revision]).await?; - } - revision - }; - - // Create the worktree for this example if needed. - let worktree_path = WORKTREES_DIR.join(&file_name).join(repo_name.as_ref()); - if worktree_path.is_dir() { - run_git(&worktree_path, &["clean", "--force", "-d"]).await?; - run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; - run_git(&worktree_path, &["checkout", revision.as_str()]).await?; - } else { - let worktree_path_string = worktree_path.to_string_lossy(); - run_git(&repo_dir, &["branch", "-f", &file_name, revision.as_str()]).await?; - run_git( - &repo_dir, - &["worktree", "add", "-f", &worktree_path_string, &file_name], - ) - .await?; - } - drop(repo_lock); - - // Apply the uncommitted diff for this example. - if !self.example.uncommitted_diff.is_empty() { - let mut apply_process = smol::process::Command::new("git") - .current_dir(&worktree_path) - .args(&["apply", "-"]) - .stdin(std::process::Stdio::piped()) - .spawn()?; - - let mut stdin = apply_process.stdin.take().unwrap(); - stdin - .write_all(self.example.uncommitted_diff.as_bytes()) - .await?; - stdin.close().await?; - drop(stdin); - - let apply_result = apply_process.output().await?; - if !apply_result.status.success() { - anyhow::bail!( - "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", - apply_result.status, - String::from_utf8_lossy(&apply_result.stderr), - String::from_utf8_lossy(&apply_result.stdout), - ); - } - } - - Ok(worktree_path) - } - - pub fn file_name(&self) -> String { - self.name - .chars() - .map(|c| { - if c.is_whitespace() { - '-' - } else { - c.to_ascii_lowercase() - } - }) - .collect() - } - - fn repo_name(&self) -> Result<(Cow<'_, str>, Cow<'_, str>)> { - // git@github.com:owner/repo.git - if self.example.repository_url.contains('@') { - let (owner, repo) = self - .example - .repository_url - .split_once(':') - .context("expected : in git url")? - .1 - .split_once('/') - .context("expected / in git url")?; - Ok(( - Cow::Borrowed(owner), - Cow::Borrowed(repo.trim_end_matches(".git")), - )) - // http://github.com/owner/repo.git - } else { - let url = Url::parse(&self.example.repository_url)?; - let mut segments = url.path_segments().context("empty http url")?; - let owner = segments - .next() - .context("expected owner path segment")? - .to_string(); - let repo = segments - .next() - .context("expected repo path segment")? - .trim_end_matches(".git") - .to_string(); - assert!(segments.next().is_none()); - - Ok((owner.into(), repo.into())) - } - } - - pub async fn cursor_position( - &self, - project: &Entity, - cx: &mut AsyncApp, - ) -> Result<(Entity, Anchor)> { - let worktree = project.read_with(cx, |project, cx| { - project.visible_worktrees(cx).next().unwrap() - })?; - let cursor_path = RelPath::new(&self.example.cursor_path, PathStyle::Posix)?.into_arc(); - let cursor_buffer = project - .update(cx, |project, cx| { - project.open_buffer( - ProjectPath { - worktree_id: worktree.read(cx).id(), - path: cursor_path, - }, - cx, - ) - })? - .await?; - let cursor_offset_within_excerpt = self - .example - .cursor_position - .find(CURSOR_MARKER) - .ok_or_else(|| anyhow!("missing cursor marker"))?; - let mut cursor_excerpt = self.example.cursor_position.clone(); - cursor_excerpt.replace_range( - cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), - "", - ); - let excerpt_offset = cursor_buffer.read_with(cx, |buffer, _cx| { - let text = buffer.text(); - - let mut matches = text.match_indices(&cursor_excerpt); - let Some((excerpt_offset, _)) = matches.next() else { - anyhow::bail!( - "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Cursor excerpt did not exist in buffer." - ); - }; - assert!(matches.next().is_none()); - - Ok(excerpt_offset) - })??; - - let cursor_offset = excerpt_offset + cursor_offset_within_excerpt; - let cursor_anchor = - cursor_buffer.read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset))?; - Ok((cursor_buffer, cursor_anchor)) - } - - #[must_use] - pub async fn apply_edit_history( - &self, - project: &Entity, - cx: &mut AsyncApp, - ) -> Result> { - zeta::udiff::apply_diff(&self.example.edit_history, project, cx).await - } -} - -async fn run_git(repo_path: &Path, args: &[&str]) -> Result { - let output = smol::process::Command::new("git") - .current_dir(repo_path) - .args(args) - .output() - .await?; - - anyhow::ensure!( - output.status.success(), - "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}", - args.join(" "), - repo_path.display(), - output.status, - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout), - ); - Ok(String::from_utf8(output.stdout)?.trim().to_string()) -} - -impl Display for NamedExample { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "# {}\n\n", self.name)?; - write!( - f, - "{REPOSITORY_URL_FIELD} = {}\n", - self.example.repository_url - )?; - write!(f, "{REVISION_FIELD} = {}\n\n", self.example.revision)?; - - write!(f, "## {UNCOMMITTED_DIFF_HEADING}\n\n")?; - write!(f, "`````diff\n")?; - write!(f, "{}", self.example.uncommitted_diff)?; - write!(f, "`````\n")?; - - if !self.example.edit_history.is_empty() { - write!(f, "`````diff\n{}`````\n", self.example.edit_history)?; - } - - write!( - f, - "## {CURSOR_POSITION_HEADING}\n\n`````{}\n{}`````\n", - self.example.cursor_path.display(), - self.example.cursor_position - )?; - write!(f, "## {EDIT_HISTORY_HEADING}\n\n")?; - - if !self.example.expected_patch.is_empty() { - write!( - f, - "\n## {EXPECTED_PATCH_HEADING}\n\n`````diff\n{}`````\n", - self.example.expected_patch - )?; - } - - Ok(()) - } -} - -thread_local! { - static REPO_LOCKS: RefCell>>> = RefCell::new(HashMap::default()); -} - -#[must_use] -pub async fn lock_repo(path: impl AsRef) -> OwnedMutexGuard<()> { - REPO_LOCKS - .with(|cell| { - cell.borrow_mut() - .entry(path.as_ref().to_path_buf()) - .or_default() - .clone() - }) - .lock_owned() - .await -} diff --git a/crates/zeta_cli/src/main.rs b/crates/zeta_cli/src/main.rs deleted file mode 100644 index a9c8b5998cdd32a94a71f1894dfbdc40c22abaed..0000000000000000000000000000000000000000 --- a/crates/zeta_cli/src/main.rs +++ /dev/null @@ -1,560 +0,0 @@ -mod evaluate; -mod example; -mod headless; -mod metrics; -mod paths; -mod predict; -mod source_location; -mod syntax_retrieval_stats; -mod util; - -use crate::{ - evaluate::run_evaluate, - example::{ExampleFormat, NamedExample}, - headless::ZetaCliAppState, - predict::run_predict, - source_location::SourceLocation, - syntax_retrieval_stats::retrieval_stats, - util::{open_buffer, open_buffer_with_language_server}, -}; -use ::util::paths::PathStyle; -use anyhow::{Result, anyhow}; -use clap::{Args, Parser, Subcommand, ValueEnum}; -use cloud_llm_client::predict_edits_v3; -use edit_prediction_context::{ - EditPredictionContextOptions, EditPredictionExcerptOptions, EditPredictionScoreOptions, -}; -use gpui::{Application, AsyncApp, Entity, prelude::*}; -use language::{Bias, Buffer, BufferSnapshot, Point}; -use metrics::delta_chr_f; -use project::{Project, Worktree}; -use reqwest_client::ReqwestClient; -use serde_json::json; -use std::io::{self}; -use std::time::Duration; -use std::{collections::HashSet, path::PathBuf, str::FromStr, sync::Arc}; -use zeta::ContextMode; -use zeta::udiff::DiffLine; - -#[derive(Parser, Debug)] -#[command(name = "zeta")] -struct ZetaCliArgs { - #[arg(long, default_value_t = false)] - printenv: bool, - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand, Debug)] -enum Command { - Context(ContextArgs), - ContextStats(ContextStatsArgs), - Predict(PredictArguments), - Eval(EvaluateArguments), - ConvertExample { - path: PathBuf, - #[arg(long, value_enum, default_value_t = ExampleFormat::Md)] - output_format: ExampleFormat, - }, - Score { - golden_patch: PathBuf, - actual_patch: PathBuf, - }, - Clean, -} - -#[derive(Debug, Args)] -struct ContextStatsArgs { - #[arg(long)] - worktree: PathBuf, - #[arg(long)] - extension: Option, - #[arg(long)] - limit: Option, - #[arg(long)] - skip: Option, - #[clap(flatten)] - zeta2_args: Zeta2Args, -} - -#[derive(Debug, Args)] -struct ContextArgs { - #[arg(long)] - provider: ContextProvider, - #[arg(long)] - worktree: PathBuf, - #[arg(long)] - cursor: SourceLocation, - #[arg(long)] - use_language_server: bool, - #[arg(long)] - edit_history: Option, - #[clap(flatten)] - zeta2_args: Zeta2Args, -} - -#[derive(clap::ValueEnum, Default, Debug, Clone, Copy)] -enum ContextProvider { - Zeta1, - #[default] - Syntax, -} - -#[derive(Clone, Debug, Args)] -struct Zeta2Args { - #[arg(long, default_value_t = 8192)] - max_prompt_bytes: usize, - #[arg(long, default_value_t = 2048)] - max_excerpt_bytes: usize, - #[arg(long, default_value_t = 1024)] - min_excerpt_bytes: usize, - #[arg(long, default_value_t = 0.66)] - target_before_cursor_over_total_bytes: f32, - #[arg(long, default_value_t = 1024)] - max_diagnostic_bytes: usize, - #[arg(long, value_enum, default_value_t = PromptFormat::default())] - prompt_format: PromptFormat, - #[arg(long, value_enum, default_value_t = Default::default())] - output_format: OutputFormat, - #[arg(long, default_value_t = 42)] - file_indexing_parallelism: usize, - #[arg(long, default_value_t = false)] - disable_imports_gathering: bool, - #[arg(long, default_value_t = u8::MAX)] - max_retrieved_definitions: u8, -} - -#[derive(Debug, Args)] -pub struct PredictArguments { - #[clap(long, short, value_enum, default_value_t = PredictionsOutputFormat::Md)] - format: PredictionsOutputFormat, - example_path: PathBuf, - #[clap(flatten)] - options: PredictionOptions, -} - -#[derive(Clone, Debug, Args)] -pub struct PredictionOptions { - #[clap(flatten)] - zeta2: Zeta2Args, - #[clap(long)] - provider: PredictionProvider, - #[clap(long, value_enum, default_value_t = CacheMode::default())] - cache: CacheMode, -} - -#[derive(Debug, ValueEnum, Default, Clone, Copy, PartialEq)] -pub enum CacheMode { - /// Use cached LLM requests and responses, except when multiple repetitions are requested - #[default] - Auto, - /// Use cached LLM requests and responses, based on the hash of the prompt and the endpoint. - #[value(alias = "request")] - Requests, - /// Ignore existing cache entries for both LLM and search. - Skip, - /// Use cached LLM responses AND search results for full determinism. Fails if they haven't been cached yet. - /// Useful for reproducing results and fixing bugs outside of search queries - Force, -} - -impl CacheMode { - fn use_cached_llm_responses(&self) -> bool { - self.assert_not_auto(); - matches!(self, CacheMode::Requests | CacheMode::Force) - } - - fn use_cached_search_results(&self) -> bool { - self.assert_not_auto(); - matches!(self, CacheMode::Force) - } - - fn assert_not_auto(&self) { - assert_ne!( - *self, - CacheMode::Auto, - "Cache mode should not be auto at this point!" - ); - } -} - -#[derive(clap::ValueEnum, Debug, Clone)] -pub enum PredictionsOutputFormat { - Json, - Md, - Diff, -} - -#[derive(Debug, Args)] -pub struct EvaluateArguments { - example_paths: Vec, - #[clap(flatten)] - options: PredictionOptions, - #[clap(short, long, default_value_t = 1, alias = "repeat")] - repetitions: u16, - #[arg(long)] - skip_prediction: bool, -} - -#[derive(clap::ValueEnum, Default, Debug, Clone, Copy, PartialEq)] -enum PredictionProvider { - Zeta1, - #[default] - Zeta2, - Sweep, -} - -fn zeta2_args_to_options(args: &Zeta2Args, omit_excerpt_overlaps: bool) -> zeta::ZetaOptions { - zeta::ZetaOptions { - context: ContextMode::Syntax(EditPredictionContextOptions { - max_retrieved_declarations: args.max_retrieved_definitions, - use_imports: !args.disable_imports_gathering, - excerpt: EditPredictionExcerptOptions { - max_bytes: args.max_excerpt_bytes, - min_bytes: args.min_excerpt_bytes, - target_before_cursor_over_total_bytes: args.target_before_cursor_over_total_bytes, - }, - score: EditPredictionScoreOptions { - omit_excerpt_overlaps, - }, - }), - max_diagnostic_bytes: args.max_diagnostic_bytes, - max_prompt_bytes: args.max_prompt_bytes, - prompt_format: args.prompt_format.into(), - file_indexing_parallelism: args.file_indexing_parallelism, - buffer_change_grouping_interval: Duration::ZERO, - } -} - -#[derive(clap::ValueEnum, Default, Debug, Clone, Copy)] -enum PromptFormat { - MarkedExcerpt, - LabeledSections, - OnlySnippets, - #[default] - NumberedLines, - OldTextNewText, - Minimal, - MinimalQwen, - SeedCoder1120, -} - -impl Into for PromptFormat { - fn into(self) -> predict_edits_v3::PromptFormat { - match self { - Self::MarkedExcerpt => predict_edits_v3::PromptFormat::MarkedExcerpt, - Self::LabeledSections => predict_edits_v3::PromptFormat::LabeledSections, - Self::OnlySnippets => predict_edits_v3::PromptFormat::OnlySnippets, - Self::NumberedLines => predict_edits_v3::PromptFormat::NumLinesUniDiff, - Self::OldTextNewText => predict_edits_v3::PromptFormat::OldTextNewText, - Self::Minimal => predict_edits_v3::PromptFormat::Minimal, - Self::MinimalQwen => predict_edits_v3::PromptFormat::MinimalQwen, - Self::SeedCoder1120 => predict_edits_v3::PromptFormat::SeedCoder1120, - } - } -} - -#[derive(clap::ValueEnum, Default, Debug, Clone)] -enum OutputFormat { - #[default] - Prompt, - Request, - Full, -} - -#[derive(Debug, Clone)] -enum FileOrStdin { - File(PathBuf), - Stdin, -} - -impl FileOrStdin { - async fn read_to_string(&self) -> Result { - match self { - FileOrStdin::File(path) => smol::fs::read_to_string(path).await, - FileOrStdin::Stdin => smol::unblock(|| std::io::read_to_string(std::io::stdin())).await, - } - } -} - -impl FromStr for FileOrStdin { - type Err = ::Err; - - fn from_str(s: &str) -> Result { - match s { - "-" => Ok(Self::Stdin), - _ => Ok(Self::File(PathBuf::from_str(s)?)), - } - } -} - -struct LoadedContext { - full_path_str: String, - snapshot: BufferSnapshot, - clipped_cursor: Point, - worktree: Entity, - project: Entity, - buffer: Entity, -} - -async fn load_context( - args: &ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let ContextArgs { - worktree: worktree_path, - cursor, - use_language_server, - .. - } = args; - - let worktree_path = worktree_path.canonicalize()?; - - let project = cx.update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - })?; - - let worktree = project - .update(cx, |project, cx| { - project.create_worktree(&worktree_path, true, cx) - })? - .await?; - - let mut ready_languages = HashSet::default(); - let (_lsp_open_handle, buffer) = if *use_language_server { - let (lsp_open_handle, _, buffer) = open_buffer_with_language_server( - project.clone(), - worktree.clone(), - cursor.path.clone(), - &mut ready_languages, - cx, - ) - .await?; - (Some(lsp_open_handle), buffer) - } else { - let buffer = - open_buffer(project.clone(), worktree.clone(), cursor.path.clone(), cx).await?; - (None, buffer) - }; - - let full_path_str = worktree - .read_with(cx, |worktree, _| worktree.root_name().join(&cursor.path))? - .display(PathStyle::local()) - .to_string(); - - let snapshot = cx.update(|cx| buffer.read(cx).snapshot())?; - let clipped_cursor = snapshot.clip_point(cursor.point, Bias::Left); - if clipped_cursor != cursor.point { - let max_row = snapshot.max_point().row; - if cursor.point.row < max_row { - return Err(anyhow!( - "Cursor position {:?} is out of bounds (line length is {})", - cursor.point, - snapshot.line_len(cursor.point.row) - )); - } else { - return Err(anyhow!( - "Cursor position {:?} is out of bounds (max row is {})", - cursor.point, - max_row - )); - } - } - - Ok(LoadedContext { - full_path_str, - snapshot, - clipped_cursor, - worktree, - project, - buffer, - }) -} - -async fn zeta2_syntax_context( - args: ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let LoadedContext { - worktree, - project, - buffer, - clipped_cursor, - .. - } = load_context(&args, app_state, cx).await?; - - // wait for worktree scan before starting zeta2 so that wait_for_initial_indexing waits for - // the whole worktree. - worktree - .read_with(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - })? - .await; - let output = cx - .update(|cx| { - let zeta = cx.new(|cx| { - zeta::Zeta::new(app_state.client.clone(), app_state.user_store.clone(), cx) - }); - let indexing_done_task = zeta.update(cx, |zeta, cx| { - zeta.set_options(zeta2_args_to_options(&args.zeta2_args, true)); - zeta.register_buffer(&buffer, &project, cx); - zeta.wait_for_initial_indexing(&project, cx) - }); - cx.spawn(async move |cx| { - indexing_done_task.await?; - let request = zeta - .update(cx, |zeta, cx| { - let cursor = buffer.read(cx).snapshot().anchor_before(clipped_cursor); - zeta.cloud_request_for_zeta_cli(&project, &buffer, cursor, cx) - })? - .await?; - - let (prompt_string, section_labels) = cloud_zeta2_prompt::build_prompt(&request)?; - - match args.zeta2_args.output_format { - OutputFormat::Prompt => anyhow::Ok(prompt_string), - OutputFormat::Request => anyhow::Ok(serde_json::to_string_pretty(&request)?), - OutputFormat::Full => anyhow::Ok(serde_json::to_string_pretty(&json!({ - "request": request, - "prompt": prompt_string, - "section_labels": section_labels, - }))?), - } - }) - })? - .await?; - - Ok(output) -} - -async fn zeta1_context( - args: ContextArgs, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result { - let LoadedContext { - full_path_str, - snapshot, - clipped_cursor, - .. - } = load_context(&args, app_state, cx).await?; - - let events = match args.edit_history { - Some(events) => events.read_to_string().await?, - None => String::new(), - }; - - let prompt_for_events = move || (events, 0); - cx.update(|cx| { - zeta::zeta1::gather_context( - full_path_str, - &snapshot, - clipped_cursor, - prompt_for_events, - cloud_llm_client::PredictEditsRequestTrigger::Cli, - cx, - ) - })? - .await -} - -fn main() { - zlog::init(); - zlog::init_output_stderr(); - let args = ZetaCliArgs::parse(); - let http_client = Arc::new(ReqwestClient::new()); - let app = Application::headless().with_http_client(http_client); - - app.run(move |cx| { - let app_state = Arc::new(headless::init(cx)); - cx.spawn(async move |cx| { - match args.command { - None => { - if args.printenv { - ::util::shell_env::print_env(); - return; - } else { - panic!("Expected a command"); - } - } - Some(Command::ContextStats(arguments)) => { - let result = retrieval_stats( - arguments.worktree, - app_state, - arguments.extension, - arguments.limit, - arguments.skip, - zeta2_args_to_options(&arguments.zeta2_args, false), - cx, - ) - .await; - println!("{}", result.unwrap()); - } - Some(Command::Context(context_args)) => { - let result = match context_args.provider { - ContextProvider::Zeta1 => { - let context = - zeta1_context(context_args, &app_state, cx).await.unwrap(); - serde_json::to_string_pretty(&context.body).unwrap() - } - ContextProvider::Syntax => { - zeta2_syntax_context(context_args, &app_state, cx) - .await - .unwrap() - } - }; - println!("{}", result); - } - Some(Command::Predict(arguments)) => { - run_predict(arguments, &app_state, cx).await; - } - Some(Command::Eval(arguments)) => { - run_evaluate(arguments, &app_state, cx).await; - } - Some(Command::ConvertExample { - path, - output_format, - }) => { - let example = NamedExample::load(path).unwrap(); - example.write(output_format, io::stdout()).unwrap(); - } - Some(Command::Score { - golden_patch, - actual_patch, - }) => { - let golden_content = std::fs::read_to_string(golden_patch).unwrap(); - let actual_content = std::fs::read_to_string(actual_patch).unwrap(); - - let golden_diff: Vec = golden_content - .lines() - .map(|line| DiffLine::parse(line)) - .collect(); - - let actual_diff: Vec = actual_content - .lines() - .map(|line| DiffLine::parse(line)) - .collect(); - - let score = delta_chr_f(&golden_diff, &actual_diff); - println!("{:.2}", score); - } - Some(Command::Clean) => { - std::fs::remove_dir_all(&*crate::paths::TARGET_ZETA_DIR).unwrap() - } - }; - - let _ = cx.update(|cx| cx.quit()); - }) - .detach(); - }); -} diff --git a/crates/zeta_cli/src/paths.rs b/crates/zeta_cli/src/paths.rs deleted file mode 100644 index 3cc2beec5bd50380b9eef8b502dcba0ccba32772..0000000000000000000000000000000000000000 --- a/crates/zeta_cli/src/paths.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::{env, path::PathBuf, sync::LazyLock}; - -pub static TARGET_ZETA_DIR: LazyLock = - LazyLock::new(|| env::current_dir().unwrap().join("target/zeta")); -pub static CACHE_DIR: LazyLock = LazyLock::new(|| TARGET_ZETA_DIR.join("cache")); -pub static REPOS_DIR: LazyLock = LazyLock::new(|| TARGET_ZETA_DIR.join("repos")); -pub static WORKTREES_DIR: LazyLock = LazyLock::new(|| TARGET_ZETA_DIR.join("worktrees")); -pub static RUN_DIR: LazyLock = LazyLock::new(|| { - TARGET_ZETA_DIR - .join("runs") - .join(chrono::Local::now().format("%d-%m-%y-%H_%M_%S").to_string()) -}); -pub static LATEST_EXAMPLE_RUN_DIR: LazyLock = - LazyLock::new(|| TARGET_ZETA_DIR.join("latest")); - -pub fn print_run_data_dir(deep: bool, use_color: bool) { - println!("\n## Run Data\n"); - let mut files = Vec::new(); - - let current_dir = std::env::current_dir().unwrap(); - for file in std::fs::read_dir(&*RUN_DIR).unwrap() { - let file = file.unwrap(); - if file.file_type().unwrap().is_dir() && deep { - for file in std::fs::read_dir(file.path()).unwrap() { - let path = file.unwrap().path(); - let path = path.strip_prefix(¤t_dir).unwrap_or(&path); - files.push(format!( - "- {}/{}{}{}", - path.parent().unwrap().display(), - if use_color { "\x1b[34m" } else { "" }, - path.file_name().unwrap().display(), - if use_color { "\x1b[0m" } else { "" }, - )); - } - } else { - let path = file.path(); - let path = path.strip_prefix(¤t_dir).unwrap_or(&path); - files.push(format!( - "- {}/{}{}{}", - path.parent().unwrap().display(), - if use_color { "\x1b[34m" } else { "" }, - path.file_name().unwrap().display(), - if use_color { "\x1b[0m" } else { "" } - )); - } - } - files.sort(); - - for file in files { - println!("{}", file); - } - - println!( - "\n💡 Tip of the day: {} always points to the latest run\n", - LATEST_EXAMPLE_RUN_DIR.display() - ); -} diff --git a/crates/zeta_cli/src/predict.rs b/crates/zeta_cli/src/predict.rs deleted file mode 100644 index 99fe65cfa3221a1deb18e767e8faa8e1a1fca0ac..0000000000000000000000000000000000000000 --- a/crates/zeta_cli/src/predict.rs +++ /dev/null @@ -1,384 +0,0 @@ -use crate::example::{ActualExcerpt, NamedExample}; -use crate::headless::ZetaCliAppState; -use crate::paths::{CACHE_DIR, LATEST_EXAMPLE_RUN_DIR, RUN_DIR, print_run_data_dir}; -use crate::{ - CacheMode, PredictArguments, PredictionOptions, PredictionProvider, PredictionsOutputFormat, -}; -use ::serde::Serialize; -use anyhow::{Context, Result, anyhow}; -use cloud_zeta2_prompt::{CURSOR_MARKER, write_codeblock}; -use futures::StreamExt as _; -use gpui::{AppContext, AsyncApp, Entity}; -use project::Project; -use project::buffer_store::BufferStoreEvent; -use serde::Deserialize; -use std::fs; -use std::io::{IsTerminal, Write}; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::time::{Duration, Instant}; -use zeta::{EvalCache, EvalCacheEntryKind, EvalCacheKey, Zeta}; - -pub async fn run_predict( - args: PredictArguments, - app_state: &Arc, - cx: &mut AsyncApp, -) { - let example = NamedExample::load(args.example_path).unwrap(); - let project = example.setup_project(app_state, cx).await.unwrap(); - let zeta = setup_zeta(args.options.provider, &project, app_state, cx).unwrap(); - let _edited_buffers = example.apply_edit_history(&project, cx).await.unwrap(); - let result = perform_predict(example, project, zeta, None, args.options, cx) - .await - .unwrap(); - result.write(args.format, std::io::stdout()).unwrap(); - - print_run_data_dir(true, std::io::stdout().is_terminal()); -} - -pub fn setup_zeta( - provider: PredictionProvider, - project: &Entity, - app_state: &Arc, - cx: &mut AsyncApp, -) -> Result> { - let zeta = - cx.new(|cx| zeta::Zeta::new(app_state.client.clone(), app_state.user_store.clone(), cx))?; - - zeta.update(cx, |zeta, _cx| { - let model = match provider { - PredictionProvider::Zeta1 => zeta::ZetaEditPredictionModel::Zeta1, - PredictionProvider::Zeta2 => zeta::ZetaEditPredictionModel::Zeta2, - PredictionProvider::Sweep => zeta::ZetaEditPredictionModel::Sweep, - }; - zeta.set_edit_prediction_model(model); - })?; - - let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; - - cx.subscribe(&buffer_store, { - let project = project.clone(); - let zeta = zeta.clone(); - move |_, event, cx| match event { - BufferStoreEvent::BufferAdded(buffer) => { - zeta.update(cx, |zeta, cx| zeta.register_buffer(&buffer, &project, cx)); - } - _ => {} - } - })? - .detach(); - - anyhow::Ok(zeta) -} - -pub async fn perform_predict( - example: NamedExample, - project: Entity, - zeta: Entity, - repetition_ix: Option, - options: PredictionOptions, - cx: &mut AsyncApp, -) -> Result { - let mut cache_mode = options.cache; - if repetition_ix.is_some() { - if cache_mode != CacheMode::Auto && cache_mode != CacheMode::Skip { - panic!("Repetitions are not supported in Auto cache mode"); - } else { - cache_mode = CacheMode::Skip; - } - } else if cache_mode == CacheMode::Auto { - cache_mode = CacheMode::Requests; - } - - let mut example_run_dir = RUN_DIR.join(&example.file_name()); - if let Some(repetition_ix) = repetition_ix { - example_run_dir = example_run_dir.join(format!("{:03}", repetition_ix)); - } - fs::create_dir_all(&example_run_dir)?; - if LATEST_EXAMPLE_RUN_DIR.is_symlink() { - fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR)?; - } - - #[cfg(unix)] - std::os::unix::fs::symlink(&example_run_dir, &*LATEST_EXAMPLE_RUN_DIR) - .context("creating latest link")?; - - #[cfg(windows)] - std::os::windows::fs::symlink_dir(&example_run_dir, &*LATEST_EXAMPLE_RUN_DIR) - .context("creating latest link")?; - - zeta.update(cx, |zeta, _cx| { - zeta.with_eval_cache(Arc::new(RunCache { - example_run_dir: example_run_dir.clone(), - cache_mode, - })); - })?; - - let (cursor_buffer, cursor_anchor) = example.cursor_position(&project, cx).await?; - - let result = Arc::new(Mutex::new(PredictionDetails::new(example_run_dir.clone()))); - - let prompt_format = options.zeta2.prompt_format; - - zeta.update(cx, |zeta, _cx| { - let mut options = zeta.options().clone(); - options.prompt_format = prompt_format.into(); - zeta.set_options(options); - })?; - - let mut debug_task = gpui::Task::ready(Ok(())); - - if options.provider == crate::PredictionProvider::Zeta2 { - let mut debug_rx = zeta.update(cx, |zeta, _| zeta.debug_info())?; - - debug_task = cx.background_spawn({ - let result = result.clone(); - async move { - let mut start_time = None; - let mut search_queries_generated_at = None; - let mut search_queries_executed_at = None; - while let Some(event) = debug_rx.next().await { - match event { - zeta::ZetaDebugInfo::ContextRetrievalStarted(info) => { - start_time = Some(info.timestamp); - fs::write( - example_run_dir.join("search_prompt.md"), - &info.search_prompt, - )?; - } - zeta::ZetaDebugInfo::SearchQueriesGenerated(info) => { - search_queries_generated_at = Some(info.timestamp); - fs::write( - example_run_dir.join("search_queries.json"), - serde_json::to_string_pretty(&info.search_queries).unwrap(), - )?; - } - zeta::ZetaDebugInfo::SearchQueriesExecuted(info) => { - search_queries_executed_at = Some(info.timestamp); - } - zeta::ZetaDebugInfo::ContextRetrievalFinished(_info) => {} - zeta::ZetaDebugInfo::EditPredictionRequested(request) => { - let prediction_started_at = Instant::now(); - start_time.get_or_insert(prediction_started_at); - let prompt = request.local_prompt.unwrap_or_default(); - fs::write(example_run_dir.join("prediction_prompt.md"), &prompt)?; - - { - let mut result = result.lock().unwrap(); - result.prompt_len = prompt.chars().count(); - - for included_file in request.inputs.included_files { - let insertions = - vec![(request.inputs.cursor_point, CURSOR_MARKER)]; - result.excerpts.extend(included_file.excerpts.iter().map( - |excerpt| ActualExcerpt { - path: included_file.path.components().skip(1).collect(), - text: String::from(excerpt.text.as_ref()), - }, - )); - write_codeblock( - &included_file.path, - included_file.excerpts.iter(), - if included_file.path == request.inputs.cursor_path { - &insertions - } else { - &[] - }, - included_file.max_row, - false, - &mut result.excerpts_text, - ); - } - } - - let response = - request.response_rx.await?.0.map_err(|err| anyhow!(err))?; - let response = zeta::text_from_response(response).unwrap_or_default(); - let prediction_finished_at = Instant::now(); - fs::write(example_run_dir.join("prediction_response.md"), &response)?; - - let mut result = result.lock().unwrap(); - result.generated_len = response.chars().count(); - - result.planning_search_time = - Some(search_queries_generated_at.unwrap() - start_time.unwrap()); - result.running_search_time = Some( - search_queries_executed_at.unwrap() - - search_queries_generated_at.unwrap(), - ); - result.prediction_time = prediction_finished_at - prediction_started_at; - result.total_time = prediction_finished_at - start_time.unwrap(); - - break; - } - } - } - anyhow::Ok(()) - } - }); - - zeta.update(cx, |zeta, cx| { - zeta.refresh_context(project.clone(), cursor_buffer.clone(), cursor_anchor, cx) - })? - .await?; - } - - let prediction = zeta - .update(cx, |zeta, cx| { - zeta.request_prediction( - &project, - &cursor_buffer, - cursor_anchor, - cloud_llm_client::PredictEditsRequestTrigger::Cli, - cx, - ) - })? - .await?; - - debug_task.await?; - - let mut result = Arc::into_inner(result).unwrap().into_inner().unwrap(); - - result.diff = prediction - .and_then(|prediction| { - let prediction = prediction.prediction.ok()?; - prediction.edit_preview.as_unified_diff(&prediction.edits) - }) - .unwrap_or_default(); - - anyhow::Ok(result) -} - -struct RunCache { - cache_mode: CacheMode, - example_run_dir: PathBuf, -} - -impl RunCache { - fn output_cache_path((kind, key): &EvalCacheKey) -> PathBuf { - CACHE_DIR.join(format!("{kind}_out_{key:x}.json",)) - } - - fn input_cache_path((kind, key): &EvalCacheKey) -> PathBuf { - CACHE_DIR.join(format!("{kind}_in_{key:x}.json",)) - } - - fn link_to_run(&self, key: &EvalCacheKey) { - let output_link_path = self.example_run_dir.join(format!("{}_out.json", key.0)); - fs::hard_link(Self::output_cache_path(key), &output_link_path).unwrap(); - - let input_link_path = self.example_run_dir.join(format!("{}_in.json", key.0)); - fs::hard_link(Self::input_cache_path(key), &input_link_path).unwrap(); - } -} - -impl EvalCache for RunCache { - fn read(&self, key: EvalCacheKey) -> Option { - let path = RunCache::output_cache_path(&key); - - if path.exists() { - let use_cache = match key.0 { - EvalCacheEntryKind::Search => self.cache_mode.use_cached_search_results(), - EvalCacheEntryKind::Context | EvalCacheEntryKind::Prediction => { - self.cache_mode.use_cached_llm_responses() - } - }; - if use_cache { - log::info!("Using cache entry: {}", path.display()); - self.link_to_run(&key); - Some(fs::read_to_string(path).unwrap()) - } else { - log::trace!("Skipping cached entry: {}", path.display()); - None - } - } else if matches!(self.cache_mode, CacheMode::Force) { - panic!( - "No cached entry found for {:?}. Run without `--cache force` at least once.", - key.0 - ); - } else { - None - } - } - - fn write(&self, key: EvalCacheKey, input: &str, output: &str) { - fs::create_dir_all(&*CACHE_DIR).unwrap(); - - let input_path = RunCache::input_cache_path(&key); - fs::write(&input_path, input).unwrap(); - - let output_path = RunCache::output_cache_path(&key); - log::trace!("Writing cache entry: {}", output_path.display()); - fs::write(&output_path, output).unwrap(); - - self.link_to_run(&key); - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PredictionDetails { - pub diff: String, - pub excerpts: Vec, - pub excerpts_text: String, // TODO: contains the worktree root path. Drop this field and compute it on the fly - pub planning_search_time: Option, - pub running_search_time: Option, - pub prediction_time: Duration, - pub total_time: Duration, - pub run_example_dir: PathBuf, - pub prompt_len: usize, - pub generated_len: usize, -} - -impl PredictionDetails { - pub fn new(run_example_dir: PathBuf) -> Self { - Self { - diff: Default::default(), - excerpts: Default::default(), - excerpts_text: Default::default(), - planning_search_time: Default::default(), - running_search_time: Default::default(), - prediction_time: Default::default(), - total_time: Default::default(), - run_example_dir, - prompt_len: 0, - generated_len: 0, - } - } - - pub fn write(&self, format: PredictionsOutputFormat, mut out: impl Write) -> Result<()> { - let formatted = match format { - PredictionsOutputFormat::Md => self.to_markdown(), - PredictionsOutputFormat::Json => serde_json::to_string_pretty(self)?, - PredictionsOutputFormat::Diff => self.diff.clone(), - }; - - Ok(out.write_all(formatted.as_bytes())?) - } - - pub fn to_markdown(&self) -> String { - let inference_time = self.planning_search_time.unwrap_or_default() + self.prediction_time; - - format!( - "## Excerpts\n\n\ - {}\n\n\ - ## Prediction\n\n\ - {}\n\n\ - ## Time\n\n\ - Planning searches: {}ms\n\ - Running searches: {}ms\n\ - Making Prediction: {}ms\n\n\ - -------------------\n\n\ - Total: {}ms\n\ - Inference: {}ms ({:.2}%)\n", - self.excerpts_text, - self.diff, - self.planning_search_time.unwrap_or_default().as_millis(), - self.running_search_time.unwrap_or_default().as_millis(), - self.prediction_time.as_millis(), - self.total_time.as_millis(), - inference_time.as_millis(), - (inference_time.as_millis() as f64 / self.total_time.as_millis() as f64) * 100. - ) - } -} diff --git a/crates/zeta_cli/src/source_location.rs b/crates/zeta_cli/src/source_location.rs deleted file mode 100644 index 3438675e78ac4d8bba6f58f7ce8a9016aed6c0c7..0000000000000000000000000000000000000000 --- a/crates/zeta_cli/src/source_location.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::{fmt, fmt::Display, path::Path, str::FromStr, sync::Arc}; - -use ::util::{paths::PathStyle, rel_path::RelPath}; -use anyhow::{Result, anyhow}; -use language::Point; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub struct SourceLocation { - pub path: Arc, - pub point: Point, -} - -impl Serialize for SourceLocation { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.to_string()) - } -} - -impl<'de> Deserialize<'de> for SourceLocation { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - s.parse().map_err(serde::de::Error::custom) - } -} - -impl Display for SourceLocation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}:{}:{}", - self.path.display(PathStyle::Posix), - self.point.row + 1, - self.point.column + 1 - ) - } -} - -impl FromStr for SourceLocation { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - let parts: Vec<&str> = s.split(':').collect(); - if parts.len() != 3 { - return Err(anyhow!( - "Invalid source location. Expected 'file.rs:line:column', got '{}'", - s - )); - } - - let path = RelPath::new(Path::new(&parts[0]), PathStyle::local())?.into_arc(); - let line: u32 = parts[1] - .parse() - .map_err(|_| anyhow!("Invalid line number: '{}'", parts[1]))?; - let column: u32 = parts[2] - .parse() - .map_err(|_| anyhow!("Invalid column number: '{}'", parts[2]))?; - - // Convert from 1-based to 0-based indexing - let point = Point::new(line.saturating_sub(1), column.saturating_sub(1)); - - Ok(SourceLocation { path, point }) - } -} diff --git a/crates/zeta_cli/src/syntax_retrieval_stats.rs b/crates/zeta_cli/src/syntax_retrieval_stats.rs deleted file mode 100644 index 4c7506ff78952da79acfeae751959bfe8182b9d4..0000000000000000000000000000000000000000 --- a/crates/zeta_cli/src/syntax_retrieval_stats.rs +++ /dev/null @@ -1,1260 +0,0 @@ -use ::util::rel_path::RelPath; -use ::util::{RangeExt, ResultExt as _}; -use anyhow::{Context as _, Result}; -use cloud_llm_client::predict_edits_v3::DeclarationScoreComponents; -use edit_prediction_context::{ - Declaration, DeclarationStyle, EditPredictionContext, EditPredictionContextOptions, Identifier, - Imports, Reference, ReferenceRegion, SyntaxIndex, SyntaxIndexState, references_in_range, -}; -use futures::StreamExt as _; -use futures::channel::mpsc; -use gpui::Entity; -use gpui::{AppContext, AsyncApp}; -use language::OffsetRangeExt; -use language::{BufferSnapshot, Point}; -use ordered_float::OrderedFloat; -use polars::prelude::*; -use project::{Project, ProjectEntryId, ProjectPath, Worktree}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::{ - cmp::Reverse, - collections::{HashMap, HashSet}, - fs::File, - hash::{Hash, Hasher}, - io::{BufRead, BufReader, BufWriter, Write as _}, - ops::Range, - path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{self, AtomicUsize}, - }, - time::Duration, -}; -use util::paths::PathStyle; -use zeta::ContextMode; - -use crate::headless::ZetaCliAppState; -use crate::source_location::SourceLocation; -use crate::util::{open_buffer, open_buffer_with_language_server}; - -pub async fn retrieval_stats( - worktree: PathBuf, - app_state: Arc, - only_extension: Option, - file_limit: Option, - skip_files: Option, - options: zeta::ZetaOptions, - cx: &mut AsyncApp, -) -> Result { - let ContextMode::Syntax(context_options) = options.context.clone() else { - anyhow::bail!("retrieval stats only works in ContextMode::Syntax"); - }; - - let options = Arc::new(options); - let worktree_path = worktree.canonicalize()?; - - let project = cx.update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - })?; - - let worktree = project - .update(cx, |project, cx| { - project.create_worktree(&worktree_path, true, cx) - })? - .await?; - - // wait for worktree scan so that wait_for_initial_file_indexing waits for the whole worktree. - worktree - .read_with(cx, |worktree, _cx| { - worktree.as_local().unwrap().scan_complete() - })? - .await; - - let index = cx.new(|cx| SyntaxIndex::new(&project, options.file_indexing_parallelism, cx))?; - index - .read_with(cx, |index, cx| index.wait_for_initial_file_indexing(cx))? - .await?; - let indexed_files = index - .read_with(cx, |index, cx| index.indexed_file_paths(cx))? - .await; - let mut filtered_files = indexed_files - .into_iter() - .filter(|project_path| { - let file_extension = project_path.path.extension(); - if let Some(only_extension) = only_extension.as_ref() { - file_extension.is_some_and(|extension| extension == only_extension) - } else { - file_extension - .is_some_and(|extension| !["md", "json", "sh", "diff"].contains(&extension)) - } - }) - .collect::>(); - filtered_files.sort_by(|a, b| a.path.cmp(&b.path)); - - let index_state = index.read_with(cx, |index, _cx| index.state().clone())?; - cx.update(|_| { - drop(index); - })?; - let index_state = Arc::new( - Arc::into_inner(index_state) - .context("Index state had more than 1 reference")? - .into_inner(), - ); - - struct FileSnapshot { - project_entry_id: ProjectEntryId, - snapshot: BufferSnapshot, - hash: u64, - parent_abs_path: Arc, - } - - let files: Vec = futures::future::try_join_all({ - filtered_files - .iter() - .map(|file| { - let buffer_task = - open_buffer(project.clone(), worktree.clone(), file.path.clone(), cx); - cx.spawn(async move |cx| { - let buffer = buffer_task.await?; - let (project_entry_id, parent_abs_path, snapshot) = - buffer.read_with(cx, |buffer, cx| { - let file = project::File::from_dyn(buffer.file()).unwrap(); - let project_entry_id = file.project_entry_id().unwrap(); - let mut parent_abs_path = file.worktree.read(cx).absolutize(&file.path); - if !parent_abs_path.pop() { - panic!("Invalid worktree path"); - } - - (project_entry_id, parent_abs_path, buffer.snapshot()) - })?; - - anyhow::Ok( - cx.background_spawn(async move { - let mut hasher = collections::FxHasher::default(); - snapshot.text().hash(&mut hasher); - FileSnapshot { - project_entry_id, - snapshot, - hash: hasher.finish(), - parent_abs_path: parent_abs_path.into(), - } - }) - .await, - ) - }) - }) - .collect::>() - }) - .await?; - - let mut file_snapshots = HashMap::default(); - let mut hasher = collections::FxHasher::default(); - for FileSnapshot { - project_entry_id, - snapshot, - hash, - .. - } in &files - { - file_snapshots.insert(*project_entry_id, snapshot.clone()); - hash.hash(&mut hasher); - } - let files_hash = hasher.finish(); - let file_snapshots = Arc::new(file_snapshots); - let target_cli_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/zeta_cli"); - fs::create_dir_all(&target_cli_dir).unwrap(); - let target_cli_dir = target_cli_dir.canonicalize().unwrap(); - - let lsp_cache_dir = target_cli_dir.join("cache"); - fs::create_dir_all(&lsp_cache_dir).unwrap(); - - let lsp_definitions_path = lsp_cache_dir.join(format!( - "{}-{:x}.jsonl", - worktree_path.file_stem().unwrap_or_default().display(), - files_hash - )); - - let mut lsp_definitions = HashMap::default(); - let mut lsp_files = 0; - - if fs::exists(&lsp_definitions_path)? { - log::info!( - "Using cached LSP definitions from {}", - lsp_definitions_path.display() - ); - - let file = File::options() - .read(true) - .write(true) - .open(&lsp_definitions_path)?; - let lines = BufReader::new(&file).lines(); - let mut valid_len: usize = 0; - - for (line, expected_file) in lines.zip(files.iter()) { - let line = line?; - let FileLspDefinitions { path, references } = match serde_json::from_str(&line) { - Ok(ok) => ok, - Err(_) => { - log::error!("Found invalid cache line. Truncating to #{lsp_files}.",); - file.set_len(valid_len as u64)?; - break; - } - }; - let expected_path = expected_file.snapshot.file().unwrap().path().as_unix_str(); - if expected_path != path.as_ref() { - log::error!( - "Expected file #{} to be {expected_path}, but found {path}. Truncating to #{lsp_files}.", - lsp_files + 1 - ); - file.set_len(valid_len as u64)?; - break; - } - for (point, ranges) in references { - let Ok(path) = RelPath::new(Path::new(path.as_ref()), PathStyle::Posix) else { - log::warn!("Invalid path: {}", path); - continue; - }; - lsp_definitions.insert( - SourceLocation { - path: path.into_arc(), - point: point.into(), - }, - ranges, - ); - } - lsp_files += 1; - valid_len += line.len() + 1 - } - } - - if lsp_files < files.len() { - if lsp_files == 0 { - log::warn!( - "No LSP definitions found, populating {}", - lsp_definitions_path.display() - ); - } else { - log::warn!("{} files missing from LSP cache", files.len() - lsp_files); - } - - gather_lsp_definitions( - &lsp_definitions_path, - lsp_files, - &filtered_files, - &worktree, - &project, - &mut lsp_definitions, - cx, - ) - .await?; - } - let files_len = files.len().min(file_limit.unwrap_or(usize::MAX)); - let done_count = Arc::new(AtomicUsize::new(0)); - - let (output_tx, output_rx) = mpsc::unbounded::(); - - let tasks = files - .into_iter() - .skip(skip_files.unwrap_or(0)) - .take(file_limit.unwrap_or(usize::MAX)) - .map(|project_file| { - let index_state = index_state.clone(); - let lsp_definitions = lsp_definitions.clone(); - let output_tx = output_tx.clone(); - let done_count = done_count.clone(); - let file_snapshots = file_snapshots.clone(); - let context_options = context_options.clone(); - cx.background_spawn(async move { - let snapshot = project_file.snapshot; - - let full_range = 0..snapshot.len(); - let references = references_in_range( - full_range, - &snapshot.text(), - ReferenceRegion::Nearby, - &snapshot, - ); - - let imports = if context_options.use_imports { - Imports::gather(&snapshot, Some(&project_file.parent_abs_path)) - } else { - Imports::default() - }; - - let path = snapshot.file().unwrap().path(); - - for reference in references { - let query_point = snapshot.offset_to_point(reference.range.start); - let source_location = SourceLocation { - path: path.clone(), - point: query_point, - }; - let lsp_definitions = lsp_definitions - .get(&source_location) - .cloned() - .unwrap_or_else(|| { - log::warn!( - "No definitions found for source location: {:?}", - source_location - ); - Vec::new() - }); - - let retrieve_result = retrieve_definitions( - &reference, - &imports, - query_point, - &snapshot, - &index_state, - &file_snapshots, - &context_options, - ) - .await?; - - let result = ReferenceRetrievalResult { - cursor_path: path.clone(), - identifier: reference.identifier, - cursor_point: query_point, - lsp_definitions, - retrieved_definitions: retrieve_result.definitions, - excerpt_range: retrieve_result.excerpt_range, - }; - - output_tx.unbounded_send(result).ok(); - } - - println!( - "{:02}/{:02} done", - done_count.fetch_add(1, atomic::Ordering::Relaxed) + 1, - files_len, - ); - - anyhow::Ok(()) - }) - }) - .collect::>(); - - drop(output_tx); - - let df_task = cx.background_spawn(build_dataframe(output_rx)); - - futures::future::try_join_all(tasks).await?; - let mut df = df_task.await?; - - let run_id = format!( - "{}-{}", - worktree_path.file_stem().unwrap_or_default().display(), - chrono::Local::now().format("%Y%m%d_%H%M%S") - ); - let run_dir = target_cli_dir.join(run_id); - fs::create_dir(&run_dir).unwrap(); - - let parquet_path = run_dir.join("stats.parquet"); - let mut parquet_file = fs::File::create(&parquet_path)?; - - ParquetWriter::new(&mut parquet_file) - .finish(&mut df) - .unwrap(); - - let stats = SummaryStats::from_dataframe(df)?; - - let stats_path = run_dir.join("stats.txt"); - fs::write(&stats_path, format!("{}", stats))?; - - println!("{}", stats); - println!("\nWrote:"); - println!("- {}", relativize_path(&parquet_path).display()); - println!("- {}", relativize_path(&stats_path).display()); - println!("- {}", relativize_path(&lsp_definitions_path).display()); - - Ok("".to_string()) -} - -async fn build_dataframe( - mut output_rx: mpsc::UnboundedReceiver, -) -> Result { - use soa_rs::{Soa, Soars}; - - #[derive(Default, Soars)] - struct Row { - ref_id: u32, - cursor_path: String, - cursor_row: u32, - cursor_column: u32, - cursor_identifier: String, - gold_in_excerpt: bool, - gold_path: String, - gold_row: u32, - gold_column: u32, - gold_is_external: bool, - candidate_count: u32, - candidate_path: Option, - candidate_row: Option, - candidate_column: Option, - candidate_is_gold: Option, - candidate_rank: Option, - candidate_is_same_file: Option, - candidate_is_referenced_nearby: Option, - candidate_is_referenced_in_breadcrumb: Option, - candidate_reference_count: Option, - candidate_same_file_declaration_count: Option, - candidate_declaration_count: Option, - candidate_reference_line_distance: Option, - candidate_declaration_line_distance: Option, - candidate_excerpt_vs_item_jaccard: Option, - candidate_excerpt_vs_signature_jaccard: Option, - candidate_adjacent_vs_item_jaccard: Option, - candidate_adjacent_vs_signature_jaccard: Option, - candidate_excerpt_vs_item_weighted_overlap: Option, - candidate_excerpt_vs_signature_weighted_overlap: Option, - candidate_adjacent_vs_item_weighted_overlap: Option, - candidate_adjacent_vs_signature_weighted_overlap: Option, - candidate_path_import_match_count: Option, - candidate_wildcard_path_import_match_count: Option, - candidate_import_similarity: Option, - candidate_max_import_similarity: Option, - candidate_normalized_import_similarity: Option, - candidate_wildcard_import_similarity: Option, - candidate_normalized_wildcard_import_similarity: Option, - candidate_included_by_others: Option, - candidate_includes_others: Option, - } - let mut rows = Soa::::new(); - let mut next_ref_id = 0; - - while let Some(result) = output_rx.next().await { - let mut gold_is_external = false; - let mut gold_in_excerpt = false; - let cursor_path = result.cursor_path.as_unix_str(); - let cursor_row = result.cursor_point.row + 1; - let cursor_column = result.cursor_point.column + 1; - let cursor_identifier = result.identifier.name.to_string(); - let ref_id = next_ref_id; - next_ref_id += 1; - - for lsp_definition in result.lsp_definitions { - let SourceRange { - path: gold_path, - point_range: gold_point_range, - offset_range: gold_offset_range, - } = lsp_definition; - let lsp_point_range = - SerializablePoint::into_language_point_range(gold_point_range.clone()); - - gold_is_external = gold_is_external - || gold_path.is_absolute() - || gold_path - .components() - .any(|component| component.as_os_str() == "node_modules"); - - gold_in_excerpt = gold_in_excerpt - || result.excerpt_range.as_ref().is_some_and(|excerpt_range| { - excerpt_range.contains_inclusive(&gold_offset_range) - }); - - let gold_row = gold_point_range.start.row; - let gold_column = gold_point_range.start.column; - let candidate_count = result.retrieved_definitions.len() as u32; - - for (candidate_rank, retrieved_definition) in - result.retrieved_definitions.iter().enumerate() - { - let candidate_is_gold = gold_path.as_path() - == retrieved_definition.path.as_std_path() - && retrieved_definition - .range - .contains_inclusive(&lsp_point_range); - - let candidate_row = retrieved_definition.range.start.row + 1; - let candidate_column = retrieved_definition.range.start.column + 1; - - let DeclarationScoreComponents { - is_same_file, - is_referenced_nearby, - is_referenced_in_breadcrumb, - reference_count, - same_file_declaration_count, - declaration_count, - reference_line_distance, - declaration_line_distance, - excerpt_vs_item_jaccard, - excerpt_vs_signature_jaccard, - adjacent_vs_item_jaccard, - adjacent_vs_signature_jaccard, - excerpt_vs_item_weighted_overlap, - excerpt_vs_signature_weighted_overlap, - adjacent_vs_item_weighted_overlap, - adjacent_vs_signature_weighted_overlap, - path_import_match_count, - wildcard_path_import_match_count, - import_similarity, - max_import_similarity, - normalized_import_similarity, - wildcard_import_similarity, - normalized_wildcard_import_similarity, - included_by_others, - includes_others, - } = retrieved_definition.components; - - rows.push(Row { - ref_id, - cursor_path: cursor_path.to_string(), - cursor_row, - cursor_column, - cursor_identifier: cursor_identifier.clone(), - gold_in_excerpt, - gold_path: gold_path.to_string_lossy().to_string(), - gold_row, - gold_column, - gold_is_external, - candidate_count, - candidate_path: Some(retrieved_definition.path.as_unix_str().to_string()), - candidate_row: Some(candidate_row), - candidate_column: Some(candidate_column), - candidate_is_gold: Some(candidate_is_gold), - candidate_rank: Some(candidate_rank as u32), - candidate_is_same_file: Some(is_same_file), - candidate_is_referenced_nearby: Some(is_referenced_nearby), - candidate_is_referenced_in_breadcrumb: Some(is_referenced_in_breadcrumb), - candidate_reference_count: Some(reference_count as u32), - candidate_same_file_declaration_count: Some(same_file_declaration_count as u32), - candidate_declaration_count: Some(declaration_count as u32), - candidate_reference_line_distance: Some(reference_line_distance), - candidate_declaration_line_distance: Some(declaration_line_distance), - candidate_excerpt_vs_item_jaccard: Some(excerpt_vs_item_jaccard), - candidate_excerpt_vs_signature_jaccard: Some(excerpt_vs_signature_jaccard), - candidate_adjacent_vs_item_jaccard: Some(adjacent_vs_item_jaccard), - candidate_adjacent_vs_signature_jaccard: Some(adjacent_vs_signature_jaccard), - candidate_excerpt_vs_item_weighted_overlap: Some( - excerpt_vs_item_weighted_overlap, - ), - candidate_excerpt_vs_signature_weighted_overlap: Some( - excerpt_vs_signature_weighted_overlap, - ), - candidate_adjacent_vs_item_weighted_overlap: Some( - adjacent_vs_item_weighted_overlap, - ), - candidate_adjacent_vs_signature_weighted_overlap: Some( - adjacent_vs_signature_weighted_overlap, - ), - candidate_path_import_match_count: Some(path_import_match_count as u32), - candidate_wildcard_path_import_match_count: Some( - wildcard_path_import_match_count as u32, - ), - candidate_import_similarity: Some(import_similarity), - candidate_max_import_similarity: Some(max_import_similarity), - candidate_normalized_import_similarity: Some(normalized_import_similarity), - candidate_wildcard_import_similarity: Some(wildcard_import_similarity), - candidate_normalized_wildcard_import_similarity: Some( - normalized_wildcard_import_similarity, - ), - candidate_included_by_others: Some(included_by_others as u32), - candidate_includes_others: Some(includes_others as u32), - }); - } - - if result.retrieved_definitions.is_empty() { - rows.push(Row { - ref_id, - cursor_path: cursor_path.to_string(), - cursor_row, - cursor_column, - cursor_identifier: cursor_identifier.clone(), - gold_in_excerpt, - gold_path: gold_path.to_string_lossy().to_string(), - gold_row, - gold_column, - gold_is_external, - candidate_count, - ..Default::default() - }); - } - } - } - let slices = rows.slices(); - - let RowSlices { - ref_id, - cursor_path, - cursor_row, - cursor_column, - cursor_identifier, - gold_in_excerpt, - gold_path, - gold_row, - gold_column, - gold_is_external, - candidate_path, - candidate_row, - candidate_column, - candidate_is_gold, - candidate_rank, - candidate_count, - candidate_is_same_file, - candidate_is_referenced_nearby, - candidate_is_referenced_in_breadcrumb, - candidate_reference_count, - candidate_same_file_declaration_count, - candidate_declaration_count, - candidate_reference_line_distance, - candidate_declaration_line_distance, - candidate_excerpt_vs_item_jaccard, - candidate_excerpt_vs_signature_jaccard, - candidate_adjacent_vs_item_jaccard, - candidate_adjacent_vs_signature_jaccard, - candidate_excerpt_vs_item_weighted_overlap, - candidate_excerpt_vs_signature_weighted_overlap, - candidate_adjacent_vs_item_weighted_overlap, - candidate_adjacent_vs_signature_weighted_overlap, - candidate_path_import_match_count, - candidate_wildcard_path_import_match_count, - candidate_import_similarity, - candidate_max_import_similarity, - candidate_normalized_import_similarity, - candidate_wildcard_import_similarity, - candidate_normalized_wildcard_import_similarity, - candidate_included_by_others, - candidate_includes_others, - } = slices; - - let df = DataFrame::new(vec![ - Series::new(PlSmallStr::from_str("ref_id"), ref_id).into(), - Series::new(PlSmallStr::from_str("cursor_path"), cursor_path).into(), - Series::new(PlSmallStr::from_str("cursor_row"), cursor_row).into(), - Series::new(PlSmallStr::from_str("cursor_column"), cursor_column).into(), - Series::new(PlSmallStr::from_str("cursor_identifier"), cursor_identifier).into(), - Series::new(PlSmallStr::from_str("gold_in_excerpt"), gold_in_excerpt).into(), - Series::new(PlSmallStr::from_str("gold_path"), gold_path).into(), - Series::new(PlSmallStr::from_str("gold_row"), gold_row).into(), - Series::new(PlSmallStr::from_str("gold_column"), gold_column).into(), - Series::new(PlSmallStr::from_str("gold_is_external"), gold_is_external).into(), - Series::new(PlSmallStr::from_str("candidate_count"), candidate_count).into(), - Series::new(PlSmallStr::from_str("candidate_path"), candidate_path).into(), - Series::new(PlSmallStr::from_str("candidate_row"), candidate_row).into(), - Series::new(PlSmallStr::from_str("candidate_column"), candidate_column).into(), - Series::new(PlSmallStr::from_str("candidate_is_gold"), candidate_is_gold).into(), - Series::new(PlSmallStr::from_str("candidate_rank"), candidate_rank).into(), - Series::new( - PlSmallStr::from_str("candidate_is_same_file"), - candidate_is_same_file, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_is_referenced_nearby"), - candidate_is_referenced_nearby, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_is_referenced_in_breadcrumb"), - candidate_is_referenced_in_breadcrumb, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_reference_count"), - candidate_reference_count, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_same_file_declaration_count"), - candidate_same_file_declaration_count, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_declaration_count"), - candidate_declaration_count, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_reference_line_distance"), - candidate_reference_line_distance, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_declaration_line_distance"), - candidate_declaration_line_distance, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_excerpt_vs_item_jaccard"), - candidate_excerpt_vs_item_jaccard, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_excerpt_vs_signature_jaccard"), - candidate_excerpt_vs_signature_jaccard, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_adjacent_vs_item_jaccard"), - candidate_adjacent_vs_item_jaccard, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_adjacent_vs_signature_jaccard"), - candidate_adjacent_vs_signature_jaccard, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_excerpt_vs_item_weighted_overlap"), - candidate_excerpt_vs_item_weighted_overlap, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_excerpt_vs_signature_weighted_overlap"), - candidate_excerpt_vs_signature_weighted_overlap, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_adjacent_vs_item_weighted_overlap"), - candidate_adjacent_vs_item_weighted_overlap, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_adjacent_vs_signature_weighted_overlap"), - candidate_adjacent_vs_signature_weighted_overlap, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_path_import_match_count"), - candidate_path_import_match_count, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_wildcard_path_import_match_count"), - candidate_wildcard_path_import_match_count, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_import_similarity"), - candidate_import_similarity, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_max_import_similarity"), - candidate_max_import_similarity, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_normalized_import_similarity"), - candidate_normalized_import_similarity, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_wildcard_import_similarity"), - candidate_wildcard_import_similarity, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_normalized_wildcard_import_similarity"), - candidate_normalized_wildcard_import_similarity, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_included_by_others"), - candidate_included_by_others, - ) - .into(), - Series::new( - PlSmallStr::from_str("candidate_includes_others"), - candidate_includes_others, - ) - .into(), - ])?; - - Ok(df) -} - -fn relativize_path(path: &Path) -> &Path { - path.strip_prefix(std::env::current_dir().unwrap()) - .unwrap_or(path) -} - -struct SummaryStats { - references_count: u32, - retrieved_count: u32, - top_match_count: u32, - non_top_match_count: u32, - ranking_involved_top_match_count: u32, - missing_none_retrieved: u32, - missing_wrong_retrieval: u32, - missing_external: u32, - in_excerpt_count: u32, -} - -impl SummaryStats { - fn from_dataframe(df: DataFrame) -> Result { - // TODO: use lazy more - let unique_refs = - df.unique::<(), ()>(Some(&["ref_id".into()]), UniqueKeepStrategy::Any, None)?; - let references_count = unique_refs.height() as u32; - - let gold_mask = df.column("candidate_is_gold")?.bool()?; - let gold_df = df.filter(&gold_mask)?; - let retrieved_count = gold_df.height() as u32; - - let top_match_mask = gold_df.column("candidate_rank")?.u32()?.equal(0); - let top_match_df = gold_df.filter(&top_match_mask)?; - let top_match_count = top_match_df.height() as u32; - - let ranking_involved_top_match_count = top_match_df - .column("candidate_count")? - .u32()? - .gt(1) - .sum() - .unwrap_or_default(); - - let non_top_match_count = (!top_match_mask).sum().unwrap_or(0); - - let not_retrieved_df = df - .lazy() - .group_by(&[col("ref_id"), col("candidate_count")]) - .agg(&[ - col("candidate_is_gold") - .fill_null(false) - .sum() - .alias("gold_count"), - col("gold_in_excerpt").sum().alias("gold_in_excerpt_count"), - col("gold_is_external") - .sum() - .alias("gold_is_external_count"), - ]) - .filter(col("gold_count").eq(lit(0))) - .collect()?; - - let in_excerpt_mask = not_retrieved_df - .column("gold_in_excerpt_count")? - .u32()? - .gt(0); - let in_excerpt_count = in_excerpt_mask.sum().unwrap_or(0); - - let missing_df = not_retrieved_df.filter(&!in_excerpt_mask)?; - - let missing_none_retrieved_mask = missing_df.column("candidate_count")?.u32()?.equal(0); - let missing_none_retrieved = missing_none_retrieved_mask.sum().unwrap_or(0); - let external_mask = missing_df.column("gold_is_external_count")?.u32()?.gt(0); - let missing_external = (missing_none_retrieved_mask & external_mask) - .sum() - .unwrap_or(0); - - let missing_wrong_retrieval = missing_df - .column("candidate_count")? - .u32()? - .gt(0) - .sum() - .unwrap_or(0); - - Ok(SummaryStats { - references_count, - retrieved_count, - top_match_count, - non_top_match_count, - ranking_involved_top_match_count, - missing_none_retrieved, - missing_wrong_retrieval, - missing_external, - in_excerpt_count, - }) - } - - fn count_and_percentage(part: u32, total: u32) -> String { - format!("{} ({:.2}%)", part, (part as f64 / total as f64) * 100.0) - } -} - -impl std::fmt::Display for SummaryStats { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let included = self.in_excerpt_count + self.retrieved_count; - let missing = self.references_count - included; - writeln!(f)?; - writeln!(f, "╮ references: {}", self.references_count)?; - writeln!( - f, - "├─╮ included: {}", - Self::count_and_percentage(included, self.references_count), - )?; - writeln!( - f, - "│ ├─╮ retrieved: {}", - Self::count_and_percentage(self.retrieved_count, self.references_count) - )?; - writeln!( - f, - "│ │ ├─╮ top match : {}", - Self::count_and_percentage(self.top_match_count, self.retrieved_count) - )?; - writeln!( - f, - "│ │ │ ╰─╴ involving ranking: {}", - Self::count_and_percentage(self.ranking_involved_top_match_count, self.top_match_count) - )?; - writeln!( - f, - "│ │ ╰─╴ non-top match: {}", - Self::count_and_percentage(self.non_top_match_count, self.retrieved_count) - )?; - writeln!( - f, - "│ ╰─╴ in excerpt: {}", - Self::count_and_percentage(self.in_excerpt_count, included) - )?; - writeln!( - f, - "╰─╮ missing: {}", - Self::count_and_percentage(missing, self.references_count) - )?; - writeln!( - f, - " ├─╮ none retrieved: {}", - Self::count_and_percentage(self.missing_none_retrieved, missing) - )?; - writeln!( - f, - " │ ╰─╴ external (expected): {}", - Self::count_and_percentage(self.missing_external, missing) - )?; - writeln!( - f, - " ╰─╴ wrong retrieval: {}", - Self::count_and_percentage(self.missing_wrong_retrieval, missing) - )?; - Ok(()) - } -} - -#[derive(Debug)] -struct ReferenceRetrievalResult { - cursor_path: Arc, - cursor_point: Point, - identifier: Identifier, - excerpt_range: Option>, - lsp_definitions: Vec, - retrieved_definitions: Vec, -} - -#[derive(Debug)] -struct RetrievedDefinition { - path: Arc, - range: Range, - score: f32, - #[allow(dead_code)] - retrieval_score: f32, - #[allow(dead_code)] - components: DeclarationScoreComponents, -} - -struct RetrieveResult { - definitions: Vec, - excerpt_range: Option>, -} - -async fn retrieve_definitions( - reference: &Reference, - imports: &Imports, - query_point: Point, - snapshot: &BufferSnapshot, - index: &Arc, - file_snapshots: &Arc>, - context_options: &EditPredictionContextOptions, -) -> Result { - let mut single_reference_map = HashMap::default(); - single_reference_map.insert(reference.identifier.clone(), vec![reference.clone()]); - let edit_prediction_context = EditPredictionContext::gather_context_with_references_fn( - query_point, - snapshot, - imports, - &context_options, - Some(&index), - |_, _, _| single_reference_map, - ); - - let Some(edit_prediction_context) = edit_prediction_context else { - return Ok(RetrieveResult { - definitions: Vec::new(), - excerpt_range: None, - }); - }; - - let mut retrieved_definitions = Vec::new(); - for scored_declaration in edit_prediction_context.declarations { - match &scored_declaration.declaration { - Declaration::File { - project_entry_id, - declaration, - .. - } => { - let Some(snapshot) = file_snapshots.get(&project_entry_id) else { - log::error!("bug: file project entry not found"); - continue; - }; - let path = snapshot.file().unwrap().path().clone(); - retrieved_definitions.push(RetrievedDefinition { - path, - range: snapshot.offset_to_point(declaration.item_range.start) - ..snapshot.offset_to_point(declaration.item_range.end), - score: scored_declaration.score(DeclarationStyle::Declaration), - retrieval_score: scored_declaration.retrieval_score(), - components: scored_declaration.components, - }); - } - Declaration::Buffer { - project_entry_id, - rope, - declaration, - .. - } => { - let Some(snapshot) = file_snapshots.get(&project_entry_id) else { - // This case happens when dependency buffers have been opened by - // go-to-definition, resulting in single-file worktrees. - continue; - }; - let path = snapshot.file().unwrap().path().clone(); - retrieved_definitions.push(RetrievedDefinition { - path, - range: rope.offset_to_point(declaration.item_range.start) - ..rope.offset_to_point(declaration.item_range.end), - score: scored_declaration.score(DeclarationStyle::Declaration), - retrieval_score: scored_declaration.retrieval_score(), - components: scored_declaration.components, - }); - } - } - } - retrieved_definitions.sort_by_key(|definition| Reverse(OrderedFloat(definition.score))); - - Ok(RetrieveResult { - definitions: retrieved_definitions, - excerpt_range: Some(edit_prediction_context.excerpt.range), - }) -} - -async fn gather_lsp_definitions( - lsp_definitions_path: &Path, - start_index: usize, - files: &[ProjectPath], - worktree: &Entity, - project: &Entity, - definitions: &mut HashMap>, - cx: &mut AsyncApp, -) -> Result<()> { - let worktree_id = worktree.read_with(cx, |worktree, _cx| worktree.id())?; - - let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store())?; - cx.subscribe(&lsp_store, { - move |_, event, _| { - if let project::LspStoreEvent::LanguageServerUpdate { - message: - client::proto::update_language_server::Variant::WorkProgress( - client::proto::LspWorkProgress { - message: Some(message), - .. - }, - ), - .. - } = event - { - println!("⟲ {message}") - } - } - })? - .detach(); - - let (cache_line_tx, mut cache_line_rx) = mpsc::unbounded::(); - - let cache_file = File::options() - .append(true) - .create(true) - .open(lsp_definitions_path) - .unwrap(); - - let cache_task = cx.background_spawn(async move { - let mut writer = BufWriter::new(cache_file); - while let Some(line) = cache_line_rx.next().await { - serde_json::to_writer(&mut writer, &line).unwrap(); - writer.write_all(&[b'\n']).unwrap(); - } - writer.flush().unwrap(); - }); - - let mut error_count = 0; - let mut lsp_open_handles = Vec::new(); - let mut ready_languages = HashSet::default(); - for (file_index, project_path) in files[start_index..].iter().enumerate() { - println!( - "Processing file {} of {}: {}", - start_index + file_index + 1, - files.len(), - project_path.path.display(PathStyle::Posix) - ); - - let Some((lsp_open_handle, language_server_id, buffer)) = open_buffer_with_language_server( - project.clone(), - worktree.clone(), - project_path.path.clone(), - &mut ready_languages, - cx, - ) - .await - .log_err() else { - continue; - }; - lsp_open_handles.push(lsp_open_handle); - - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - let full_range = 0..snapshot.len(); - let references = references_in_range( - full_range, - &snapshot.text(), - ReferenceRegion::Nearby, - &snapshot, - ); - - loop { - let is_ready = lsp_store - .read_with(cx, |lsp_store, _cx| { - lsp_store - .language_server_statuses - .get(&language_server_id) - .is_some_and(|status| status.pending_work.is_empty()) - }) - .unwrap(); - if is_ready { - break; - } - cx.background_executor() - .timer(Duration::from_millis(10)) - .await; - } - - let mut cache_line_references = Vec::with_capacity(references.len()); - - for reference in references { - // TODO: Rename declaration to definition in edit_prediction_context? - let lsp_result = project - .update(cx, |project, cx| { - project.definitions(&buffer, reference.range.start, cx) - })? - .await; - - match lsp_result { - Ok(lsp_definitions) => { - let mut targets = Vec::new(); - for target in lsp_definitions.unwrap_or_default() { - let buffer = target.target.buffer; - let anchor_range = target.target.range; - buffer.read_with(cx, |buffer, cx| { - let Some(file) = project::File::from_dyn(buffer.file()) else { - return; - }; - let file_worktree = file.worktree.read(cx); - let file_worktree_id = file_worktree.id(); - // Relative paths for worktree files, absolute for all others - let path = if worktree_id != file_worktree_id { - file.worktree.read(cx).absolutize(&file.path) - } else { - file.path.as_std_path().to_path_buf() - }; - let offset_range = anchor_range.to_offset(&buffer); - let point_range = SerializablePoint::from_language_point_range( - offset_range.to_point(&buffer), - ); - targets.push(SourceRange { - path, - offset_range, - point_range, - }); - })?; - } - - let point = snapshot.offset_to_point(reference.range.start); - - cache_line_references.push((point.into(), targets.clone())); - definitions.insert( - SourceLocation { - path: project_path.path.clone(), - point, - }, - targets, - ); - } - Err(err) => { - log::error!("Language server error: {err}"); - error_count += 1; - } - } - } - - cache_line_tx - .unbounded_send(FileLspDefinitions { - path: project_path.path.as_unix_str().into(), - references: cache_line_references, - }) - .log_err(); - } - - drop(cache_line_tx); - - if error_count > 0 { - log::error!("Encountered {} language server errors", error_count); - } - - cache_task.await; - - Ok(()) -} - -#[derive(Serialize, Deserialize)] -struct FileLspDefinitions { - path: Arc, - references: Vec<(SerializablePoint, Vec)>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct SourceRange { - path: PathBuf, - point_range: Range, - offset_range: Range, -} - -/// Serializes to 1-based row and column indices. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SerializablePoint { - pub row: u32, - pub column: u32, -} - -impl SerializablePoint { - pub fn into_language_point_range(range: Range) -> Range { - range.start.into()..range.end.into() - } - - pub fn from_language_point_range(range: Range) -> Range { - range.start.into()..range.end.into() - } -} - -impl From for SerializablePoint { - fn from(point: Point) -> Self { - SerializablePoint { - row: point.row + 1, - column: point.column + 1, - } - } -} - -impl From for Point { - fn from(serializable: SerializablePoint) -> Self { - Point { - row: serializable.row.saturating_sub(1), - column: serializable.column.saturating_sub(1), - } - } -} diff --git a/crates/zeta_cli/src/util.rs b/crates/zeta_cli/src/util.rs deleted file mode 100644 index 699c1c743f67e09ef5ca7211c385114802d4ab32..0000000000000000000000000000000000000000 --- a/crates/zeta_cli/src/util.rs +++ /dev/null @@ -1,186 +0,0 @@ -use anyhow::{Result, anyhow}; -use futures::channel::mpsc; -use futures::{FutureExt as _, StreamExt as _}; -use gpui::{AsyncApp, Entity, Task}; -use language::{Buffer, LanguageId, LanguageServerId, ParseStatus}; -use project::{Project, ProjectPath, Worktree}; -use std::collections::HashSet; -use std::sync::Arc; -use std::time::Duration; -use util::rel_path::RelPath; - -pub fn open_buffer( - project: Entity, - worktree: Entity, - path: Arc, - cx: &AsyncApp, -) -> Task>> { - cx.spawn(async move |cx| { - let project_path = worktree.read_with(cx, |worktree, _cx| ProjectPath { - worktree_id: worktree.id(), - path, - })?; - - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx))? - .await?; - - let mut parse_status = buffer.read_with(cx, |buffer, _cx| buffer.parse_status())?; - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; - } - - Ok(buffer) - }) -} - -pub async fn open_buffer_with_language_server( - project: Entity, - worktree: Entity, - path: Arc, - ready_languages: &mut HashSet, - cx: &mut AsyncApp, -) -> Result<(Entity>, LanguageServerId, Entity)> { - let buffer = open_buffer(project.clone(), worktree, path.clone(), cx).await?; - - let (lsp_open_handle, path_style) = project.update(cx, |project, cx| { - ( - project.register_buffer_with_language_servers(&buffer, cx), - project.path_style(cx), - ) - })?; - - let Some(language_id) = buffer.read_with(cx, |buffer, _cx| { - buffer.language().map(|language| language.id()) - })? - else { - return Err(anyhow!("No language for {}", path.display(path_style))); - }; - - let log_prefix = path.display(path_style); - if !ready_languages.contains(&language_id) { - wait_for_lang_server(&project, &buffer, log_prefix.into_owned(), cx).await?; - ready_languages.insert(language_id); - } - - let lsp_store = project.read_with(cx, |project, _cx| project.lsp_store())?; - - // hacky wait for buffer to be registered with the language server - for _ in 0..100 { - let Some(language_server_id) = lsp_store.update(cx, |lsp_store, cx| { - buffer.update(cx, |buffer, cx| { - lsp_store - .language_servers_for_local_buffer(&buffer, cx) - .next() - .map(|(_, language_server)| language_server.server_id()) - }) - })? - else { - cx.background_executor() - .timer(Duration::from_millis(10)) - .await; - continue; - }; - - return Ok((lsp_open_handle, language_server_id, buffer)); - } - - return Err(anyhow!("No language server found for buffer")); -} - -// TODO: Dedupe with similar function in crates/eval/src/instance.rs -pub fn wait_for_lang_server( - project: &Entity, - buffer: &Entity, - log_prefix: String, - cx: &mut AsyncApp, -) -> Task> { - println!("{}⏵ Waiting for language server", log_prefix); - - let (mut tx, mut rx) = mpsc::channel(1); - - let lsp_store = project - .read_with(cx, |project, _| project.lsp_store()) - .unwrap(); - - let has_lang_server = buffer - .update(cx, |buffer, cx| { - lsp_store.update(cx, |lsp_store, cx| { - lsp_store - .language_servers_for_local_buffer(buffer, cx) - .next() - .is_some() - }) - }) - .unwrap_or(false); - - if has_lang_server { - project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .unwrap() - .detach(); - } - let (mut added_tx, mut added_rx) = mpsc::channel(1); - - let subscriptions = [ - cx.subscribe(&lsp_store, { - let log_prefix = log_prefix.clone(); - move |_, event, _| { - if let project::LspStoreEvent::LanguageServerUpdate { - message: - client::proto::update_language_server::Variant::WorkProgress( - client::proto::LspWorkProgress { - message: Some(message), - .. - }, - ), - .. - } = event - { - println!("{}⟲ {message}", log_prefix) - } - } - }), - cx.subscribe(project, { - let buffer = buffer.clone(); - move |project, event, cx| match event { - project::Event::LanguageServerAdded(_, _, _) => { - let buffer = buffer.clone(); - project - .update(cx, |project, cx| project.save_buffer(buffer, cx)) - .detach(); - added_tx.try_send(()).ok(); - } - project::Event::DiskBasedDiagnosticsFinished { .. } => { - tx.try_send(()).ok(); - } - _ => {} - } - }), - ]; - - cx.spawn(async move |cx| { - if !has_lang_server { - // some buffers never have a language server, so this aborts quickly in that case. - let timeout = cx.background_executor().timer(Duration::from_secs(5)); - futures::select! { - _ = added_rx.next() => {}, - _ = timeout.fuse() => { - anyhow::bail!("Waiting for language server add timed out after 5 seconds"); - } - }; - } - let timeout = cx.background_executor().timer(Duration::from_secs(60 * 5)); - let result = futures::select! { - _ = rx.next() => { - println!("{}⚑ Language server idle", log_prefix); - anyhow::Ok(()) - }, - _ = timeout.fuse() => { - anyhow::bail!("LSP wait timed out after 5 minutes"); - } - }; - drop(subscriptions); - result - }) -} diff --git a/crates/zeta_prompt/Cargo.toml b/crates/zeta_prompt/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..c9b1e2d784d10ea2fd278f70ffdae2ef0981fce0 --- /dev/null +++ b/crates/zeta_prompt/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zeta_prompt" +version = "0.1.0" +publish.workspace = true +edition.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/zeta_prompt.rs" + +[dependencies] +serde.workspace = true \ No newline at end of file diff --git a/crates/zeta_prompt/LICENSE-GPL b/crates/zeta_prompt/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/zeta_prompt/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs new file mode 100644 index 0000000000000000000000000000000000000000..21fbca1ae10b715d0c11a31dc9390aada03fa157 --- /dev/null +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; +use std::fmt::Write; +use std::ops::Range; +use std::path::Path; +use std::sync::Arc; + +pub const CURSOR_MARKER: &str = "<|user_cursor|>"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ZetaPromptInput { + pub cursor_path: Arc, + pub cursor_excerpt: Arc, + pub editable_range_in_excerpt: Range, + pub cursor_offset_in_excerpt: usize, + pub events: Vec>, + pub related_files: Arc<[RelatedFile]>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "event")] +pub enum Event { + BufferChange { + path: Arc, + old_path: Arc, + diff: String, + predicted: bool, + in_open_source_repo: bool, + }, +} + +pub fn write_event(prompt: &mut String, event: &Event) { + fn write_path_as_unix_str(prompt: &mut String, path: &Path) { + for component in path.components() { + prompt.push('/'); + write!(prompt, "{}", component.as_os_str().display()).ok(); + } + } + match event { + Event::BufferChange { + path, + old_path, + diff, + predicted, + in_open_source_repo: _, + } => { + if *predicted { + prompt.push_str("// User accepted prediction:\n"); + } + prompt.push_str("--- a"); + write_path_as_unix_str(prompt, old_path.as_ref()); + prompt.push_str("\n+++ b"); + write_path_as_unix_str(prompt, path.as_ref()); + prompt.push('\n'); + prompt.push_str(diff); + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RelatedFile { + pub path: Arc, + pub max_row: u32, + pub excerpts: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RelatedExcerpt { + pub row_range: Range, + pub text: String, +} + +pub fn format_zeta_prompt(input: &ZetaPromptInput) -> String { + let mut prompt = String::new(); + write_related_files(&mut prompt, &input.related_files); + write_edit_history_section(&mut prompt, input); + write_cursor_excerpt_section(&mut prompt, input); + prompt +} + +pub fn write_related_files(prompt: &mut String, related_files: &[RelatedFile]) { + push_delimited(prompt, "related_files", &[], |prompt| { + for file in related_files { + let path_str = file.path.to_string_lossy(); + push_delimited(prompt, "related_file", &[("path", &path_str)], |prompt| { + for excerpt in &file.excerpts { + push_delimited( + prompt, + "related_excerpt", + &[( + "lines", + &format!( + "{}-{}", + excerpt.row_range.start + 1, + excerpt.row_range.end + 1 + ), + )], + |prompt| { + prompt.push_str(&excerpt.text); + prompt.push('\n'); + }, + ); + } + }); + } + }); +} + +fn write_edit_history_section(prompt: &mut String, input: &ZetaPromptInput) { + push_delimited(prompt, "edit_history", &[], |prompt| { + if input.events.is_empty() { + prompt.push_str("(No edit history)"); + } else { + for event in &input.events { + write_event(prompt, event); + } + } + }); +} + +fn write_cursor_excerpt_section(prompt: &mut String, input: &ZetaPromptInput) { + push_delimited(prompt, "cursor_excerpt", &[], |prompt| { + let path_str = input.cursor_path.to_string_lossy(); + push_delimited(prompt, "file", &[("path", &path_str)], |prompt| { + prompt.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + push_delimited(prompt, "editable_region", &[], |prompt| { + prompt.push_str( + &input.cursor_excerpt + [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], + ); + prompt.push_str(CURSOR_MARKER); + prompt.push_str( + &input.cursor_excerpt + [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], + ); + }); + prompt.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + }); + }); +} + +fn push_delimited( + prompt: &mut String, + tag: &'static str, + arguments: &[(&str, &str)], + cb: impl FnOnce(&mut String), +) { + if !prompt.ends_with("\n") { + prompt.push('\n'); + } + prompt.push('<'); + prompt.push_str(tag); + for (arg_name, arg_value) in arguments { + write!(prompt, " {}=\"{}\"", arg_name, arg_value).ok(); + } + prompt.push_str(">\n"); + + cb(prompt); + + if !prompt.ends_with('\n') { + prompt.push('\n'); + } + prompt.push_str("\n"); +} diff --git a/crates/zlog/src/filter.rs b/crates/zlog/src/filter.rs index e2ca04be60f4fe7eba7cdb2fc9eb983092d2331a..0be6f4ead5bf64aa47f7a60391bf377c9998cfb4 100644 --- a/crates/zlog/src/filter.rs +++ b/crates/zlog/src/filter.rs @@ -5,12 +5,12 @@ use std::sync::{ atomic::{AtomicU8, Ordering}, }; -use crate::{SCOPE_DEPTH_MAX, SCOPE_STRING_SEP_STR, Scope, ScopeAlloc, env_config, private}; +use crate::{SCOPE_DEPTH_MAX, SCOPE_STRING_SEP_STR, ScopeAlloc, ScopeRef, env_config, private}; use log; static ENV_FILTER: OnceLock = OnceLock::new(); -static SCOPE_MAP: RwLock> = RwLock::new(None); +static SCOPE_MAP: RwLock = RwLock::new(ScopeMap::empty()); pub const LEVEL_ENABLED_MAX_DEFAULT: log::LevelFilter = log::LevelFilter::Info; /// The maximum log level of verbosity that is enabled by default. @@ -59,7 +59,11 @@ pub fn is_possibly_enabled_level(level: log::Level) -> bool { level as u8 <= LEVEL_ENABLED_MAX_CONFIG.load(Ordering::Acquire) } -pub fn is_scope_enabled(scope: &Scope, module_path: Option<&str>, level: log::Level) -> bool { +pub fn is_scope_enabled( + scope: &ScopeRef<'_>, + module_path: Option<&str>, + level: log::Level, +) -> bool { // TODO: is_always_allowed_level that checks against LEVEL_ENABLED_MIN_CONFIG if !is_possibly_enabled_level(level) { // [FAST PATH] @@ -74,16 +78,11 @@ pub fn is_scope_enabled(scope: &Scope, module_path: Option<&str>, level: log::Le err.into_inner() }); - let Some(map) = global_scope_map.as_ref() else { - // on failure, return false because it's not <= LEVEL_ENABLED_MAX_STATIC - return is_enabled_by_default; - }; - - if map.is_empty() { + if global_scope_map.is_empty() { // if no scopes are enabled, return false because it's not <= LEVEL_ENABLED_MAX_STATIC return is_enabled_by_default; } - let enabled_status = map.is_enabled(scope, module_path, level); + let enabled_status = global_scope_map.is_enabled(scope, module_path, level); match enabled_status { EnabledStatus::NotConfigured => is_enabled_by_default, EnabledStatus::Enabled => true, @@ -107,7 +106,7 @@ pub fn refresh_from_settings(settings: &HashMap) { SCOPE_MAP.clear_poison(); err.into_inner() }); - global_map.replace(map_new); + *global_map = map_new; } log::trace!("Log configuration updated"); } @@ -395,12 +394,21 @@ impl ScopeMap { } EnabledStatus::NotConfigured } + + const fn empty() -> ScopeMap { + ScopeMap { + entries: vec![], + modules: vec![], + root_count: 0, + } + } } #[cfg(test)] mod tests { use log::LevelFilter; + use crate::Scope; use crate::private::scope_new; use super::*; diff --git a/crates/zlog/src/sink.rs b/crates/zlog/src/sink.rs index 303e3139bc7cdb95ae01c7e87fff8f9bc6d100c2..07e87be1b071f2538e716bb8fd2b692527363fc4 100644 --- a/crates/zlog/src/sink.rs +++ b/crates/zlog/src/sink.rs @@ -8,7 +8,7 @@ use std::{ }, }; -use crate::{SCOPE_STRING_SEP_CHAR, Scope}; +use crate::{SCOPE_STRING_SEP_CHAR, ScopeRef}; // ANSI color escape codes for log levels const ANSI_RESET: &str = "\x1b[0m"; @@ -35,7 +35,7 @@ static SINK_FILE_SIZE_BYTES: AtomicU64 = AtomicU64::new(0); const SINK_FILE_SIZE_BYTES_MAX: u64 = 1024 * 1024; // 1 MB pub struct Record<'a> { - pub scope: Scope, + pub scope: ScopeRef<'a>, pub level: log::Level, pub message: &'a std::fmt::Arguments<'a>, pub module_path: Option<&'a str>, @@ -208,7 +208,7 @@ pub fn flush() { } struct SourceFmt<'a> { - scope: Scope, + scope: ScopeRef<'a>, module_path: Option<&'a str>, line: Option, ansi: bool, diff --git a/crates/zlog/src/zlog.rs b/crates/zlog/src/zlog.rs index bcd13216252e0b45f6dc553160e17c7216a87f27..3c154f790845da74dcf3a4f9bfdd830d2d32c9ec 100644 --- a/crates/zlog/src/zlog.rs +++ b/crates/zlog/src/zlog.rs @@ -70,15 +70,18 @@ impl log::Log for Zlog { if !self.enabled(record.metadata()) { return; } - let (crate_name_scope, module_scope) = match record.module_path_static() { + let module_path = record.module_path().or(record.file()); + let (crate_name_scope, module_scope) = match module_path { Some(module_path) => { let crate_name = private::extract_crate_name_from_module_path(module_path); - let crate_name_scope = private::scope_new(&[crate_name]); - let module_scope = private::scope_new(&[module_path]); + let crate_name_scope = private::scope_ref_new(&[crate_name]); + let module_scope = private::scope_ref_new(&[module_path]); (crate_name_scope, module_scope) } - // TODO: when do we hit this - None => (private::scope_new(&[]), private::scope_new(&["*unknown*"])), + None => { + // TODO: when do we hit this + (private::scope_new(&[]), private::scope_new(&["*unknown*"])) + } }; let level = record.metadata().level(); if !filter::is_scope_enabled(&crate_name_scope, Some(record.target()), level) { @@ -89,7 +92,7 @@ impl log::Log for Zlog { level, message: record.args(), // PERF(batching): store non-static paths in a cache + leak them and pass static str here - module_path: record.module_path().or(record.file()), + module_path, line: record.line(), }); } @@ -252,6 +255,10 @@ pub mod private { } pub const fn scope_new(scopes: &[&'static str]) -> Scope { + scope_ref_new(scopes) + } + + pub const fn scope_ref_new<'a>(scopes: &[&'a str]) -> ScopeRef<'a> { assert!(scopes.len() <= SCOPE_DEPTH_MAX); let mut scope = [""; SCOPE_DEPTH_MAX]; let mut i = 0; @@ -275,6 +282,7 @@ pub mod private { } pub type Scope = [&'static str; SCOPE_DEPTH_MAX]; +pub type ScopeRef<'a> = [&'a str; SCOPE_DEPTH_MAX]; pub type ScopeAlloc = [String; SCOPE_DEPTH_MAX]; const SCOPE_STRING_SEP_STR: &str = "."; const SCOPE_STRING_SEP_CHAR: char = '.'; diff --git a/crates/ztracing/Cargo.toml b/crates/ztracing/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..0d9f15b9afccca4a1a05036c013562c8ad1ae8f4 --- /dev/null +++ b/crates/ztracing/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ztracing" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[features] +tracy = ["tracing-tracy"] + +[dependencies] +zlog.workspace = true +tracing.workspace = true + +tracing-subscriber = "0.3.22" +tracing-tracy = { version = "0.11.4", optional = true, features = ["enable", "ondemand"] } + +ztracing_macro.workspace = true diff --git a/crates/ztracing/LICENSE-AGPL b/crates/ztracing/LICENSE-AGPL new file mode 120000 index 0000000000000000000000000000000000000000..5f5cf25dc458e75f4050c7378c186fca9b68fd19 --- /dev/null +++ b/crates/ztracing/LICENSE-AGPL @@ -0,0 +1 @@ +../../LICENSE-AGPL \ No newline at end of file diff --git a/crates/ztracing/LICENSE-APACHE b/crates/ztracing/LICENSE-APACHE new file mode 120000 index 0000000000000000000000000000000000000000..1cd601d0a3affae83854be02a0afdec3b7a9ec4d --- /dev/null +++ b/crates/ztracing/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/ztracing/LICENSE-GPL b/crates/ztracing/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/ztracing/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/ztracing/build.rs b/crates/ztracing/build.rs new file mode 100644 index 0000000000000000000000000000000000000000..dc0d0ad704d49c4c0ab639d769024330e10d2481 --- /dev/null +++ b/crates/ztracing/build.rs @@ -0,0 +1,9 @@ +use std::env; + +fn main() { + if env::var_os("ZTRACING").is_some() { + println!(r"cargo::rustc-cfg=ztracing"); + } + println!("cargo::rerun-if-changed=build.rs"); + println!("cargo::rerun-if-env-changed=ZTRACING"); +} diff --git a/crates/ztracing/src/lib.rs b/crates/ztracing/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..c9007be1ed43150ef877d51c882aee77845e5bd6 --- /dev/null +++ b/crates/ztracing/src/lib.rs @@ -0,0 +1,58 @@ +pub use tracing::{Level, field}; + +#[cfg(ztracing)] +pub use tracing::{ + Span, debug_span, error_span, event, info_span, instrument, span, trace_span, warn_span, +}; +#[cfg(not(ztracing))] +pub use ztracing_macro::instrument; + +#[cfg(not(ztracing))] +pub use __consume_all_tokens as trace_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as info_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as debug_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as warn_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as error_span; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as event; +#[cfg(not(ztracing))] +pub use __consume_all_tokens as span; + +#[cfg(not(ztracing))] +#[macro_export] +macro_rules! __consume_all_tokens { + ($($t:tt)*) => { + $crate::Span + }; +} + +#[cfg(not(ztracing))] +pub struct Span; + +#[cfg(not(ztracing))] +impl Span { + pub fn current() -> Self { + Self + } + + pub fn enter(&self) {} + + pub fn record(&self, _t: T, _s: S) {} +} + +#[cfg(ztracing)] +pub fn init() { + zlog::info!("Starting tracy subscriber, you can now connect the profiler"); + use tracing_subscriber::prelude::*; + tracing::subscriber::set_global_default( + tracing_subscriber::registry().with(tracing_tracy::TracyLayer::default()), + ) + .expect("setup tracy layer"); +} + +#[cfg(not(ztracing))] +pub fn init() {} diff --git a/crates/ztracing_macro/Cargo.toml b/crates/ztracing_macro/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..dbd7adce5fccd054c3dc87acaf1283e9e7c36889 --- /dev/null +++ b/crates/ztracing_macro/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ztracing_macro" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lib] +proc-macro = true + +[dependencies] diff --git a/crates/ztracing_macro/LICENSE-AGPL b/crates/ztracing_macro/LICENSE-AGPL new file mode 120000 index 0000000000000000000000000000000000000000..5f5cf25dc458e75f4050c7378c186fca9b68fd19 --- /dev/null +++ b/crates/ztracing_macro/LICENSE-AGPL @@ -0,0 +1 @@ +../../LICENSE-AGPL \ No newline at end of file diff --git a/crates/ztracing_macro/LICENSE-APACHE b/crates/ztracing_macro/LICENSE-APACHE new file mode 120000 index 0000000000000000000000000000000000000000..1cd601d0a3affae83854be02a0afdec3b7a9ec4d --- /dev/null +++ b/crates/ztracing_macro/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/ztracing_macro/LICENSE-GPL b/crates/ztracing_macro/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/ztracing_macro/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/ztracing_macro/src/lib.rs b/crates/ztracing_macro/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..d9b073ed130bdc829e4d5d943b6d4b6a6d802888 --- /dev/null +++ b/crates/ztracing_macro/src/lib.rs @@ -0,0 +1,7 @@ +#[proc_macro_attribute] +pub fn instrument( + _attr: proc_macro::TokenStream, + item: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + item +} diff --git a/docs/.rules b/docs/.rules new file mode 100644 index 0000000000000000000000000000000000000000..4e6ca312f13b12a54a73d736ffeed8a8e09061ef --- /dev/null +++ b/docs/.rules @@ -0,0 +1,158 @@ +# Zed Documentation Guidelines + +## Voice and Tone + +### Core Principles + +- **Practical over promotional**: Focus on what users can do, not on selling Zed. Avoid marketing language like "powerful," "revolutionary," or "best-in-class." +- **Honest about limitations**: When Zed lacks a feature or doesn't match another tool's depth, say so directly. Pair limitations with workarounds or alternative workflows. +- **Direct and concise**: Use short sentences. Get to the point. Developers are scanning, not reading novels. +- **Second person**: Address the reader as "you." Avoid "the user" or "one." +- **Present tense**: "Zed opens the file" not "Zed will open the file." + +### What to Avoid + +- Superlatives without substance ("incredibly fast," "seamlessly integrated") +- Hedging language ("simply," "just," "easily")—if something is simple, the instructions will show it +- Apologetic tone for missing features—state the limitation and move on +- Comparisons that disparage other tools—be factual, not competitive +- Meta-commentary about honesty ("the honest take is...", "to be frank...", "honestly...")—let honesty show through frank assessments, not announcements +- LLM-isms and filler words ("entirely," "certainly,", "deeply," "definitely," "actually")—these add nothing + +## Content Structure + +### Page Organization + +1. **Start with the goal**: Open with what the reader will accomplish, not background +2. **Front-load the action**: Put the most common task first, edge cases later +3. **Use headers liberally**: Readers scan; headers help them find what they need +4. **End with "what's next"**: Link to related docs or logical next steps + +### Section Patterns + +For how-to content: +1. Brief context (1-2 sentences max) +2. Steps or instructions +3. Example (code block or screenshot reference) +4. Tips or gotchas (if any) + +For reference content: +1. What it is (definition) +2. How to access/configure it +3. Options/parameters table +4. Examples + +## Formatting Conventions + +### Keybindings + +- Use backticks for key combinations: `Cmd+Shift+P` +- Show both macOS and Linux/Windows when they differ: `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +- Use `+` to join simultaneous keys, space for sequences: `Cmd+K Cmd+C` + +### Code and Settings + +- Inline code for setting names, file paths, commands: `format_on_save`, `.zed/settings.json`, `zed .` +- Code blocks for JSON config, multi-line commands, or file contents +- Always show complete, working examples—not fragments + +### Terminal Commands + +Use `sh` code blocks for terminal commands, not plain backticks: + +```sh +brew install zed-editor/zed/zed +``` + +Not: +``` +brew install zed-editor/zed/zed +``` + +For single inline commands in prose, backticks are fine: `zed .` + +### Tables + +Use tables for: +- Keybinding comparisons between editors +- Settings mappings (e.g., VS Code → Zed) +- Feature comparisons with clear columns + +Format: +``` +| Action | Shortcut | Notes | +| --- | --- | --- | +| Open File | `Cmd+O` | Works from any context | +``` + +### Tips and Notes + +Use blockquote format with bold label: +``` +> **Tip:** Practical advice that helps bridge gaps or saves time. +``` + +Reserve tips for genuinely useful information, not padding. + +## Writing Guidelines + +### Settings Documentation + +- **Settings Editor first**: Show how to find and change settings in the UI before showing JSON +- **JSON as secondary**: Present JSON examples as "Or add this to your settings.json" for users who prefer direct editing +- **Complete examples**: Include the full JSON structure, not just the value + +### Migration Guides + +- **Jobs to be done**: Frame around tasks ("How do I search files?") not features ("File Search Feature") +- **Acknowledge the source**: Respect that users have muscle memory and preferences from their previous editor +- **Keybindings tables**: Essential for migration docs—show what maps, what's different, what's missing +- **Trade-offs section**: Be explicit about what the user gains and loses in the switch + +### Feature Documentation + +- **Start with the default**: Document the out-of-box experience first +- **Configuration options**: Group related settings together +- **Cross-link generously**: Link to related features, settings reference, and relevant guides + +## Terminology + +| Use | Instead of | +| --- | --- | +| folder | directory (in user-facing text) | +| project | workspace (Zed doesn't have workspaces) | +| Settings Editor | settings UI, preferences | +| command palette | command bar, action search | +| language server | LSP (spell out first use, then LSP is fine) | +| panel | tool window, sidebar (be specific: "Project Panel," "Terminal Panel") | + +## Examples + +### Good: Direct and actionable +``` +To format on save, open the Settings Editor (`Cmd+,`) and search for `format_on_save`. Set it to `on`. + +Or add this to your settings.json: +{ + "format_on_save": "on" +} +``` + +### Bad: Wordy and promotional +``` +Zed provides a powerful and seamless formatting experience. Simply navigate to the settings and you'll find the format_on_save option which enables Zed's incredible auto-formatting capabilities. +``` + +### Good: Honest about limitations +``` +Zed doesn't index your project like IntelliJ does. You open a folder and start working immediately—no waiting. The trade-off: cross-project analysis relies on language servers, which may not go as deep. + +**How to adapt:** +- Use `Cmd+Shift+F` for project-wide text search +- Use `Cmd+O` for symbol search (powered by your language server) +``` + +### Bad: Defensive or dismissive +``` +While some users might miss indexing, Zed's approach is actually better because it's faster. +``` diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..fdd61ff6aeaf8cd09ae0b017c5199e7033fba964 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,353 @@ +# Documentation Automation Agent Guidelines + +This file governs automated documentation updates triggered by code changes. All automation phases must comply with these rules. + +## Documentation System + +This documentation uses **mdBook** (https://rust-lang.github.io/mdBook/). + +### Key Files + +- **`docs/src/SUMMARY.md`**: Table of contents following mdBook format (https://rust-lang.github.io/mdBook/format/summary.html) +- **`docs/book.toml`**: mdBook configuration +- **`docs/.prettierrc`**: Prettier config (80 char line width) + +### SUMMARY.md Format + +The `SUMMARY.md` file defines the book structure. Format rules: + +- Chapter titles are links: `[Title](./path/to/file.md)` +- Nesting via indentation (2 spaces per level) +- Separators: `---` for horizontal rules between sections +- Draft chapters: `[Title]()` (empty parens, not yet written) + +Example: + +```markdown +# Section Title + +- [Chapter](./chapter.md) + - [Nested Chapter](./nested.md) + +--- + +# Another Section +``` + +### Custom Preprocessor + +The docs use a custom preprocessor (`docs_preprocessor`) that expands special commands: + +| Syntax | Purpose | Example | +| ----------------------------- | ------------------------------------- | ------------------------------- | +| `{#kb action::ActionName}` | Keybinding for action | `{#kb agent::ToggleFocus}` | +| `{#action agent::ActionName}` | Action reference (renders as command) | `{#action agent::OpenSettings}` | + +**Rules:** + +- Always use preprocessor syntax for keybindings instead of hardcoding +- Action names use `snake_case` in the namespace, `PascalCase` for the action +- Common namespaces: `agent::`, `editor::`, `assistant::`, `vim::` + +### Formatting Requirements + +All documentation must pass **Prettier** formatting: + +```sh +cd docs && npx prettier --check src/ +``` + +Before any documentation change is considered complete: + +1. Run Prettier to format: `cd docs && npx prettier --write src/` +2. Verify it passes: `cd docs && npx prettier --check src/` + +Prettier config: 80 character line width (`docs/.prettierrc`) + +### Section Anchors + +Use `{#anchor-id}` syntax for linkable section headers: + +```markdown +## Getting Started {#getting-started} + +### Custom Models {#anthropic-custom-models} +``` + +Anchor IDs should be: + +- Lowercase with hyphens +- Unique within the page +- Descriptive (can include parent context like `anthropic-custom-models`) + +### Code Block Annotations + +Use annotations after the language identifier to indicate file context: + +```markdown +\`\`\`json [settings] +{ +"agent": { ... } +} +\`\`\` + +\`\`\`json [keymap] +[ +{ "bindings": { ... } } +] +\`\`\` +``` + +Valid annotations: `[settings]` (for settings.json), `[keymap]` (for keymap.json) + +### Blockquote Formatting + +Use bold labels for callouts: + +```markdown +> **Note:** Important information the user should know. + +> **Tip:** Helpful advice that saves time or improves workflow. + +> **Warn:** Caution about potential issues or gotchas. +``` + +### Image References + +Images are hosted externally. Reference format: + +```markdown +![Alt text description](https://zed.dev/img/path/to/image.webp) +``` + +### Cross-Linking + +- Relative links for same-directory: `[Agent Panel](./agent-panel.md)` +- With anchors: `[Custom Models](./llm-providers.md#anthropic-custom-models)` +- Parent directory: `[Telemetry](../telemetry.md)` + +## Scope + +### In-Scope Documentation + +- All Markdown files in `docs/src/` +- `docs/src/SUMMARY.md` (mdBook table of contents) +- Language-specific docs in `docs/src/languages/` +- Feature docs (AI, extensions, configuration, etc.) + +### Out-of-Scope (Do Not Modify) + +- `CHANGELOG.md`, `CONTRIBUTING.md`, `README.md` at repo root +- Inline code comments and rustdoc +- `CLAUDE.md`, `GEMINI.md`, or other AI instruction files +- Build configuration (`book.toml`, theme files, `docs_preprocessor`) +- Any file outside `docs/src/` + +## Page Structure Patterns + +### Standard Page Layout + +Most documentation pages follow this structure: + +1. **Title** (H1) - Single sentence or phrase +2. **Overview/Introduction** - 1-3 paragraphs explaining what this is +3. **Getting Started** `{#getting-started}` - Prerequisites and first steps +4. **Main Content** - Feature details, organized by topic +5. **Advanced/Configuration** - Power user options +6. **See Also** (optional) - Related documentation links + +### Settings Documentation Pattern + +When documenting settings: + +1. Show the Settings Editor (UI) approach first +2. Then show JSON as "Or add this to your settings.json:" +3. Always show complete, valid JSON with surrounding structure: + +```json [settings] +{ + "agent": { + "default_model": { + "provider": "anthropic", + "model": "claude-sonnet-4" + } + } +} +``` + +### Provider/Feature Documentation Pattern + +For each provider or distinct feature: + +1. H3 heading with anchor: `### Provider Name {#provider-name}` +2. Brief description (1-2 sentences) +3. Setup steps (numbered list) +4. Configuration example (JSON code block) +5. Custom models section if applicable: `#### Custom Models {#provider-custom-models}` + +## Style Rules + +Inherit all conventions from `docs/.rules`. Key points: + +### Voice + +- Second person ("you"), present tense +- Direct and concise—no hedging ("simply", "just", "easily") +- Honest about limitations; no promotional language + +### Formatting + +- Keybindings: backticks with `+` for simultaneous keys (`Cmd+Shift+P`) +- Show both macOS and Linux/Windows variants when they differ +- Use `sh` code blocks for terminal commands +- Settings: show Settings Editor UI first, JSON as secondary + +### Terminology + +| Use | Instead of | +| --------------- | -------------------------------------- | +| folder | directory | +| project | workspace | +| Settings Editor | settings UI | +| command palette | command bar | +| panel | sidebar (be specific: "Project Panel") | + +## Zed-Specific Conventions + +### Recognized Rules Files + +When documenting rules/instructions for AI, note that Zed recognizes these files (in priority order): + +- `.rules` +- `.cursorrules` +- `.windsurfrules` +- `.clinerules` +- `.github/copilot-instructions.md` +- `AGENT.md` +- `AGENTS.md` +- `CLAUDE.md` +- `GEMINI.md` + +### Settings File Locations + +- macOS: `~/.config/zed/settings.json` +- Linux: `~/.config/zed/settings.json` +- Windows: `%AppData%\Zed\settings.json` + +### Keymap File Locations + +- macOS: `~/.config/zed/keymap.json` +- Linux: `~/.config/zed/keymap.json` +- Windows: `%AppData%\Zed\keymap.json` + +## Safety Constraints + +### Must Not + +- Delete existing documentation files +- Remove sections documenting existing functionality +- Change URLs or anchor links without verifying references +- Modify `SUMMARY.md` structure without corresponding content +- Add speculative documentation for unreleased features +- Include internal implementation details not relevant to users + +### Must + +- Preserve existing structure when updating content +- Maintain backward compatibility of documented settings/commands +- Flag uncertainty explicitly rather than guessing +- Link to related documentation when adding new sections + +## Change Classification + +### Requires Documentation Update + +- New user-facing features or commands +- Changed keybindings or default behaviors +- Modified settings schema or options +- Deprecated or removed functionality +- API changes affecting extensions + +### Does Not Require Documentation Update + +- Internal refactoring without behavioral changes +- Performance optimizations (unless user-visible) +- Bug fixes that restore documented behavior +- Test changes +- CI/CD changes + +## Output Format + +### Phase 4 Documentation Plan + +When generating a documentation plan, use this structure: + +```markdown +## Documentation Impact Assessment + +### Summary + +Brief description of code changes analyzed. + +### Documentation Updates Required: [Yes/No] + +### Planned Changes + +#### 1. [File Path] + +- **Section**: [Section name or "New section"] +- **Change Type**: [Update/Add/Deprecate] +- **Reason**: Why this change is needed +- **Description**: What will be added/modified + +#### 2. [File Path] + +... + +### Uncertainty Flags + +- [ ] [Description of any assumptions or areas needing confirmation] + +### No Changes Needed + +- [List files reviewed but not requiring updates, with brief reason] +``` + +### Phase 6 Summary Format + +```markdown +## Documentation Update Summary + +### Changes Made + +| File | Change | Related Code | +| -------------- | ----------------- | ----------------- | +| path/to/doc.md | Brief description | link to PR/commit | + +### Rationale + +Brief explanation of why these updates were made. + +### Review Notes + +Any items reviewers should pay special attention to. +``` + +## Behavioral Guidelines + +### Conservative by Default + +- When uncertain whether to document something, flag it for human review +- Prefer smaller, focused updates over broad rewrites +- Do not "improve" documentation unrelated to the triggering code change + +### Traceability + +- Every documentation change should trace to a specific code change +- Include references to relevant commits, PRs, or issues in summaries + +### Incremental Updates + +- Update existing sections rather than creating parallel documentation +- Maintain consistency with surrounding content +- Follow the established patterns in each documentation area diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 0d0cd35f43610d206749dea7a87af553620633f0..a82ddac990c4379df03db2b4bdcd8272eb8715e9 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -23,6 +23,9 @@ - [Visual Customization](./visual-customization.md) - [Vim Mode](./vim.md) - [Helix Mode](./helix.md) +- [Privacy and Security](./ai/privacy-and-security.md) + - [Worktree Trust](./worktree-trust.md) + - [AI Improvement](./ai/ai-improvement.md) @@ -41,7 +44,9 @@ - [Debugger](./debugger.md) - [Diagnostics](./diagnostics.md) - [Tasks](./tasks.md) +- [Tab Switcher](./tab-switcher.md) - [Remote Development](./remote-development.md) +- [Dev Containers](./dev-containers.md) - [Environment Variables](./environment.md) - [REPL](./repl.md) @@ -68,8 +73,6 @@ - [Models](./ai/models.md) - [Plans and Usage](./ai/plans-and-usage.md) - [Billing](./ai/billing.md) -- [Privacy and Security](./ai/privacy-and-security.md) - - [AI Improvement](./ai/ai-improvement.md) # Extensions @@ -85,9 +88,13 @@ - [Agent Server Extensions](./extensions/agent-servers.md) - [MCP Server Extensions](./extensions/mcp-extensions.md) -# Migrate +# Coming From... - [VS Code](./migrate/vs-code.md) +- [IntelliJ IDEA](./migrate/intellij.md) +- [PyCharm](./migrate/pycharm.md) +- [WebStorm](./migrate/webstorm.md) +- [RustRover](./migrate/rustrover.md) # Language Support @@ -170,7 +177,6 @@ - [Linux](./development/linux.md) - [Windows](./development/windows.md) - [FreeBSD](./development/freebsd.md) - - [Local Collaboration](./development/local-collaboration.md) - [Using Debuggers](./development/debuggers.md) - [Performance](./performance.md) - [Glossary](./development/glossary.md) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index c383862ed631cbfdc42f591d63be09fb1209c8b8..0197bfb3ecc7b4ea245c6ce6963c3b592558a90f 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -85,6 +85,8 @@ You can type `@` to mention files, directories, symbols, previous threads, and r Copying images and pasting them in the panel's message editor is also supported. +When you paste multi-line code selections copied from an editor buffer, Zed automatically formats them as @mentions with the file context. To paste content without this automatic formatting, use {#kb agent::PasteRaw} to paste raw text directly. + ### Selection as Context Additionally, you can also select text in a buffer and add it as context by using the {#kb agent::AddSelectionToThread} keybinding, running the {#action agent::AddSelectionToThread} action, or choosing the "Selection" item in the `@` menu. @@ -100,6 +102,8 @@ You can also do this at any time with an ongoing thread via the "Agent Options" After you've configured your LLM providers—either via [a custom API key](./llm-providers.md) or through [Zed's hosted models](./models.md)—you can switch between them by clicking on the model selector on the message editor or by using the {#kb agent::ToggleModelSelector} keybinding. +If you have favorited models configured, you can cycle through them with {#kb agent::CycleFavoriteModels} without opening the model selector. + > The same model can be offered via multiple providers - for example, Claude Sonnet 4 is available via Zed Pro, OpenRouter, Anthropic directly, and more. > Make sure you've selected the correct model **_provider_** for the model you'd like to use, delineated by the logo to the left of the model in the model selector. diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index 64ff871ce1b629fad72d4ddd6f9c8f42f2bf92da..788c0c1cf7cb0bfd64bdd83812e1e62bf51abf88 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -5,7 +5,7 @@ For invoice-based billing, a Business plan is required. Contact [sales@zed.dev]( ## Billing Information {#settings} -You can access billing information and settings at [zed.dev/account](https://zed.dev/account). +You can access billing information and settings at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Most of the page embeds information from our invoicing/metering partner, Orb (we're planning on a more native experience soon!). ## Billing Cycles {#billing-cycles} @@ -28,7 +28,7 @@ If payment of an invoice fails, Zed will block usage of our hosted models until ## Invoice History {#invoice-history} -You can access your invoice history by navigating to [zed.dev/account](https://zed.dev/account) and clicking `Invoice history` within the embedded Orb portal. +You can access your invoice history by navigating to [dashboard.zed.dev/account](https://dashboard.zed.dev/account) and clicking `Invoice history` within the embedded Orb portal. If you require historical Stripe invoices, email [billing-support@zed.dev](mailto:billing-support@zed.dev) diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index feef6d36d29eca4157254cc4c209f4a614a927de..afa9a08a41b5946f4bdc97a2b6a0ed97a0167d14 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -58,7 +58,8 @@ In these cases, `alt-tab` is used instead to accept the prediction. When the lan On Linux, `alt-tab` is often used by the window manager for switching windows, so `alt-l` is provided as the default binding for accepting predictions. `tab` and `alt-tab` also work, but aren't displayed by default. -{#action editor::AcceptPartialEditPrediction} ({#kb editor::AcceptPartialEditPrediction}) can be used to accept the current edit prediction up to the next word boundary. +{#action editor::AcceptNextWordEditPrediction} ({#kb editor::AcceptNextWordEditPrediction}) can be used to accept the current edit prediction up to the next word boundary. +{#action editor::AcceptNextLineEditPrediction} ({#kb editor::AcceptNextLineEditPrediction}) can be used to accept the current edit prediction up to the new line boundary. ## Configuring Edit Prediction Keybindings {#edit-predictions-keybinding} @@ -304,7 +305,7 @@ To use GitHub Copilot as your provider, set this within `settings.json`: } ``` -You should be able to sign-in to GitHub Copilot by clicking on the Copilot icon in the status bar and following the setup instructions. +To sign in to GitHub Copilot, click on the Copilot icon in the status bar. A popup window appears displaying a device code. Click the copy button to copy the code, then click "Connect to GitHub" to open the GitHub verification page in your browser. Paste the code when prompted. The popup window closes automatically after successful authorization. #### Using GitHub Copilot Enterprise @@ -347,10 +348,17 @@ You should be able to sign-in to Supermaven by clicking on the Supermaven icon i ### Codestral {#codestral} -To use Mistral's Codestral as your provider, start by going to the Agent Panel settings view by running the {#action agent::OpenSettings} action. -Look for the Mistral item and add a Codestral API key in the corresponding text input. +To use Mistral's Codestral as your provider: -After that, you should be able to switch your provider to it in your `settings.json` file: +1. Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) +2. Search for "Edit Predictions" and click **Configure Providers** +3. Find the Codestral section and enter your API key from the + [Codestral dashboard](https://console.mistral.ai/codestral) + +Alternatively, click the edit prediction icon in the status bar and select +**Configure Providers** from the menu. + +After adding your API key, set Codestral as your provider in `settings.json`: ```json [settings] { diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 3e40d7ae0283b3dbd1c50ba1bef5ae410d969305..ee495b1ba7e67a6cc15359453fd7d3ae41b17233 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -89,12 +89,32 @@ To do this: #### Cross-Region Inference -The Zed implementation of Amazon Bedrock uses [Cross-Region inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html) for all the models and region combinations that support it. +The Zed implementation of Amazon Bedrock uses [Cross-Region inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html) to improve availability and throughput. With Cross-Region inference, you can distribute traffic across multiple AWS Regions, enabling higher throughput. -For example, if you use `Claude Sonnet 3.7 Thinking` from `us-east-1`, it may be processed across the US regions, namely: `us-east-1`, `us-east-2`, or `us-west-2`. -Cross-Region inference requests are kept within the AWS Regions that are part of the geography where the data originally resides. -For example, a request made within the US is kept within the AWS Regions in the US. +##### Regional vs Global Inference Profiles + +Bedrock supports two types of cross-region inference profiles: + +- **Regional profiles** (default): Route requests within a specific geography (US, EU, APAC). For example, `us-east-1` uses the `us.*` profile which routes across `us-east-1`, `us-east-2`, and `us-west-2`. +- **Global profiles**: Route requests across all commercial AWS Regions for maximum availability and performance. + +By default, Zed uses **regional profiles** which keep your data within the same geography. You can opt into global profiles by adding `"allow_global": true` to your Bedrock configuration: + +```json [settings] +{ + "language_models": { + "bedrock": { + "authentication_method": "named_profile", + "region": "your-aws-region", + "profile": "your-profile-name", + "allow_global": true + } + } +} +``` + +**Note:** Only select newer models support global inference profiles. See the [AWS Bedrock supported models documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system) for the current list of models that support global inference. If you encounter availability issues with a model in your region, enabling `allow_global` may resolve them. Although the data remains stored only in the source Region, your input prompts and output results might move outside of your source Region during cross-Region inference. All data will be transmitted encrypted across Amazon's secure network. @@ -327,6 +347,33 @@ Download and install Ollama from [ollama.com/download](https://ollama.com/downlo 3. In the Agent Panel, select one of the Ollama models using the model dropdown. +#### Ollama Autodiscovery + +Zed will automatically discover models that Ollama has pulled. You can turn this off by setting +the `auto_discover` field in the Ollama settings. If you do this, you should manually specify which +models are available. + +```json [settings] +{ + "language_models": { + "ollama": { + "api_url": "http://localhost:11434", + "auto_discover": false, + "available_models": [ + { + "name": "qwen2.5-coder", + "display_name": "qwen 2.5 coder", + "max_tokens": 32768, + "supports_tools": true, + "supports_thinking": true, + "supports_images": true + } + ] + } + } +} +``` + #### Ollama Context Length {#ollama-context} Zed has pre-configured maximum context lengths (`max_tokens`) to match the capabilities of common models. diff --git a/docs/src/ai/plans-and-usage.md b/docs/src/ai/plans-and-usage.md index fc59a894aacd524a10e31b65ababd4f8d79e3b8e..63f72211aa70b19b820fb9b368d47a3b008b726d 100644 --- a/docs/src/ai/plans-and-usage.md +++ b/docs/src/ai/plans-and-usage.md @@ -12,11 +12,11 @@ Usage of Zed's hosted models is measured on a token basis, converted to dollars Zed Pro comes with $5 of monthly dollar credit. A trial of Zed Pro includes $20 of credit, usable for 14 days. Monthly included credit resets on your monthly billing date. -To view your current usage, you can visit your account at [zed.dev/account](https://zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. +To view your current usage, you can visit your account at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. ## Spend Limits {#usage-spend-limits} -At the top of [the Account page](https://zed.dev/account), you'll find an input for `Maximum Token Spend`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription. +At the top of [the Account page](https://dashboard.zed.dev/account), you'll find an input for `Maximum Token Spend`. The dollar amount here specifies your _monthly_ limit for spend on tokens, _not counting_ the $5/month included with your Pro subscription. The default value for all Pro users is $10, for a total monthly spend with Zed of $20 ($10 for your Pro subscription, $10 in incremental token spend). This can be set to $0 to limit your spend with Zed to exactly $10/month. If you adjust this limit _higher_ than $10 and consume more than $10 of incremental token spend, you'll be billed via [threshold billing](./billing.md#threshold-billing). diff --git a/docs/src/ai/privacy-and-security.md b/docs/src/ai/privacy-and-security.md index 6921567b9165e863cd4303752a669e641e6fcdca..d72cc8c476a83f60d8342962fcdd410e541e7356 100644 --- a/docs/src/ai/privacy-and-security.md +++ b/docs/src/ai/privacy-and-security.md @@ -2,7 +2,7 @@ ## Philosophy -Zed aims to collect on the minimum data necessary to serve and improve our product. +Zed aims to collect only the minimum data necessary to serve and improve our product. We believe in opt-in data sharing as the default in building AI products, rather than opt-out, like most of our competitors. Privacy Mode is not a setting to be toggled, it's a default stance. @@ -12,6 +12,8 @@ It is entirely possible to use Zed, including Zed's AI capabilities, without sha ## Documentation +- [Worktree trust](../worktree-trust.md): How Zed opens files and directories in restricted mode. + - [Telemetry](../telemetry.md): How Zed collects general telemetry data. - [AI Improvement](./ai-improvement.md): Zed's opt-in-only approach to data collection for AI improvement, whether our Agentic offering or Edit Predictions. diff --git a/docs/src/ai/rules.md b/docs/src/ai/rules.md index 4169920425e66eb41a895deb60da3a198d74df08..972bbc94e82937502739cf585cc8f60dbcda8808 100644 --- a/docs/src/ai/rules.md +++ b/docs/src/ai/rules.md @@ -46,7 +46,7 @@ Having a series of rules files specifically tailored to prompt engineering can a Here are a couple of helpful resources for writing better rules: -- [Anthropic: Prompt Engineering](https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview) +- [Anthropic: Prompt Engineering](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/overview) - [OpenAI: Prompt Engineering](https://platform.openai.com/docs/guides/prompt-engineering) ### Editing the Default Rules {#default-rules} diff --git a/docs/src/completions.md b/docs/src/completions.md index ff96ede7503cd461bbd3d7b4afdedcaa2f36a2e5..7b35ec2d09d91a7ba7dc5ae4b968157e0184227f 100644 --- a/docs/src/completions.md +++ b/docs/src/completions.md @@ -15,6 +15,10 @@ When there is an appropriate language server available, Zed will provide complet You can manually trigger completions with `ctrl-space` or by triggering the `editor::ShowCompletions` action from the command palette. +> Note: Using `ctrl-space` in Zed requires disabling the macOS global shortcut. +> Open **System Settings** > **Keyboard** > **Keyboard Shortcut**s > +> **Input Sources** and uncheck **Select the previous input source**. + For more information, see: - [Configuring Supported Languages](./configuring-languages.md) diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 3b90120407fe56643e4b3f279d88443b9740e154..81318aa8885fe883acc394e7fe983d7721dd33a5 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -1451,6 +1451,47 @@ or `boolean` values +### Session + +- Description: Controls Zed lifecycle-related behavior. +- Setting: `session` +- Default: + +```json +{ + "session": { + "restore_unsaved_buffers": true, + "trust_all_worktrees": false + } +} +``` + +**Options** + +1. Whether or not to restore unsaved buffers on restart: + +```json [settings] +{ + "session": { + "restore_unsaved_buffers": true + } +} +``` + +If this is true, user won't be prompted whether to save/discard dirty files when closing the application. + +2. Whether or not to skip worktree and workspace trust checks: + +```json [settings] +{ + "session": { + "trust_all_worktrees": false + } +} +``` + +When trusted, project settings are synchronized automatically, language and MCP servers are downloaded and started automatically. + ### Drag And Drop Selection - Description: Whether to allow drag and drop text selection in buffer. `delay` is the milliseconds that must elapse before drag and drop is allowed. Otherwise, a new text selection is created. @@ -1544,6 +1585,26 @@ Positive `integer` value between 1 and 32. Values outside of this range will be `boolean` values +## Extend List On Newline + +- Description: Whether to continue lists when pressing Enter at the end of a list item. Supports unordered, ordered, and task lists. Pressing Enter on an empty list item removes the marker and exits the list. +- Setting: `extend_list_on_newline` +- Default: `true` + +**Options** + +`boolean` values + +## Indent List On Tab + +- Description: Whether to indent list items when pressing Tab on a line containing only a list marker. This enables quick creation of nested lists. +- Setting: `indent_list_on_tab` +- Default: `true` + +**Options** + +`boolean` values + ## Status Bar - Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere. @@ -2861,11 +2922,25 @@ Configuration object for defining settings profiles. Example: ```json [settings] "preview_tabs": { "enabled": true, + "enable_preview_from_project_panel": true, "enable_preview_from_file_finder": false, - "enable_preview_from_code_navigation": false, + "enable_preview_from_multibuffer": true, + "enable_preview_multibuffer_from_code_navigation": false, + "enable_preview_file_from_code_navigation": true, + "enable_keep_preview_on_code_navigation": false, } ``` +### Enable preview from project panel + +- Description: Determines whether to open files in preview mode when opened from the project panel with a single click. +- Setting: `enable_preview_from_project_panel` +- Default: `true` + +**Options** + +`boolean` values + ### Enable preview from file finder - Description: Determines whether to open files in preview mode when selected from the file finder. @@ -2876,10 +2951,40 @@ Configuration object for defining settings profiles. Example: `boolean` values -### Enable preview from code navigation +### Enable preview from multibuffer -- Description: Determines whether a preview tab gets replaced when code navigation is used to navigate away from the tab. -- Setting: `enable_preview_from_code_navigation` +- Description: Determines whether to open files in preview mode when opened from a multibuffer. +- Setting: `enable_preview_from_multibuffer` +- Default: `true` + +**Options** + +`boolean` values + +### Enable preview multibuffer from code navigation + +- Description: Determines whether to open tabs in preview mode when code navigation is used to open a multibuffer. +- Setting: `enable_preview_multibuffer_from_code_navigation` +- Default: `false` + +**Options** + +`boolean` values + +### Enable preview file from code navigation + +- Description: Determines whether to open tabs in preview mode when code navigation is used to open a single file. +- Setting: `enable_preview_file_from_code_navigation` +- Default: `true` + +**Options** + +`boolean` values + +### Enable keep preview on code navigation + +- Description: Determines whether to keep tabs in preview mode when code navigation is used to navigate away from them. If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. +- Setting: `enable_keep_preview_on_code_navigation` - Default: `false` **Options** @@ -3098,7 +3203,15 @@ List of strings containing any combination of: ```json [settings] { - "restore_on_startup": "none" + "restore_on_startup": "empty_tab" +} +``` + +4. Always start with the welcome launchpad: + +```json [settings] +{ + "restore_on_startup": "launchpad" } ``` @@ -4265,6 +4378,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a "show_project_items": true, "show_onboarding_banner": true, "show_user_picture": true, + "show_user_menu": true, "show_sign_in": true, "show_menus": false } @@ -4277,6 +4391,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - `show_project_items`: Whether to show the project host and name in the titlebar - `show_onboarding_banner`: Whether to show onboarding banners in the titlebar - `show_user_picture`: Whether to show user picture in the titlebar +- `show_user_menu`: Whether to show the user menu button in the titlebar (the one that displays your avatar by default and contains options like Settings, Keymap, Themes, etc.) - `show_sign_in`: Whether to show the sign in button in the titlebar - `show_menus`: Whether to show the menus in the titlebar diff --git a/docs/src/dev-containers.md b/docs/src/dev-containers.md new file mode 100644 index 0000000000000000000000000000000000000000..c87b204ee9cded48edb95752dd234fa55df71338 --- /dev/null +++ b/docs/src/dev-containers.md @@ -0,0 +1,50 @@ +# Dev Containers + +Dev Containers provide a consistent, reproducible development environment by defining your project's dependencies, tools, and settings in a container configuration. + +If your repository includes a `.devcontainer/devcontainer.json` file, Zed can open a project inside a development container. + +## Requirements + +- Docker must be installed and available in your `PATH`. Zed requires the `docker` command to be present. If you use Podman, you can alias it to `docker` (e.g., `alias docker=podman`). +- Your project must contain a `.devcontainer/devcontainer.json` directory/file. + +## Using Dev Containers in Zed + +### Automatic prompt + +When you open a project that contains the `.devcontainer/devcontainer.json` directory/file, Zed will display a prompt asking whether to open the project inside the dev container. Choosing "Open in Container" will: + +1. Build the dev container image (if needed). +2. Launch the container. +3. Reopen the project connected to the container environment. + +### Manual open + +If you dismiss the prompt or want to reopen the project inside a container later, you can use Zed's command palette to run the "Project: Open Remote" command and select the option to open the project in a dev container. +Alternatively, you can reach for the Remote Projects modal (through the {#kb projects::OpenRemote} binding) and choose the "Connect Dev Container" option. + +## Editing the dev container configuration + +If you modify `.devcontainer/devcontainer.json`, Zed does not currently rebuild or reload the container automatically. After changing configuration: + +- Stop or kill the existing container manually (e.g., via `docker kill `). +- Reopen the project in the container. + +## Working in a Dev Container + +Once connected, Zed operates inside the container environment for tasks, terminals, and language servers. +Files are linked from your workspace into the container according to the dev container specification. + +## Known Limitations + +> **Note:** This feature is still in development. + +- **Extensions:** Zed does not yet manage extensions separately for container environments. The host's extensions are used as-is. +- **Port forwarding:** Only the `appPort` field is supported. `forwardPorts` and other advanced port-forwarding features are not implemented. +- **Configuration changes:** Updates to `devcontainer.json` do not trigger automatic rebuilds or reloads; containers must be manually restarted. + +## See also + +- [Remote Development](./remote-development.md) for connecting to remote servers over SSH. +- [Tasks](./tasks.md) for running commands in the integrated terminal. diff --git a/docs/src/development.md b/docs/src/development.md index 31bb245ac42f80c830a0faba405323d1097e3f51..8f341dbb1506d4a6fa6c3ffa21960191ec5ecfcf 100644 --- a/docs/src/development.md +++ b/docs/src/development.md @@ -6,10 +6,6 @@ See the platform-specific instructions for building Zed from source: - [Linux](./development/linux.md) - [Windows](./development/windows.md) -If you'd like to develop collaboration features, additionally see: - -- [Local Collaboration](./development/local-collaboration.md) - ## Keychain access Zed stores secrets in the system keychain. diff --git a/docs/src/development/debuggers.md b/docs/src/development/debuggers.md index a5713f6c8aae1123e48ab6ab9f85f2147dfc7819..11f49390d41b89cfb1f527e1adabfd8b1b6d401a 100644 --- a/docs/src/development/debuggers.md +++ b/docs/src/development/debuggers.md @@ -5,7 +5,7 @@ ## Using Zed's built-in debugger -While the Zed project is open you can open the `New Process Modal` and select the `Debug` tab. There you can see to debug configurations to debug Zed with, one for GDB and one for LLDB. Select the configuration you want and Zed will build and launch the binary. +While the Zed project is open you can open the `New Process Modal` and select the `Debug` tab. There you can see two debug configurations to debug Zed with, one for GDB and one for LLDB. Select the configuration you want and Zed will build and launch the binary. Please note, GDB isn't supported on arm Macbooks diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md index 34172ec9a590fdae537ff78920e1fadda2c331fa..0e0f984e214fe1a46e0aff790ab5e85bb46a8674 100644 --- a/docs/src/development/glossary.md +++ b/docs/src/development/glossary.md @@ -73,7 +73,7 @@ h_flex() - `Window`: A struct in zed representing a zed window in your desktop environment (see image below). There can be multiple if you have multiple zed instances open. Mostly passed around for rendering. - `Modal`: A UI element that floats on top of the rest of the UI -- `Picker`: A struct representing a list of items in floating on top of the UI (Modal). You can select an item and confirm. What happens on select or confirm is determined by the picker's delegate. (The 'Model' in the image below is a picker.) +- `Picker`: A struct representing a list of items floating on top of the UI (Modal). You can select an item and confirm. What happens on select or confirm is determined by the picker's delegate. (The 'Modal' in the image below is a picker.) - `PickerDelegate`: A trait used to specialize behavior for a `Picker`. The `Picker` stores the `PickerDelegate` in the field delegate. - `Center`: The middle of the zed window, the center is split into multiple `Pane`s. In the codebase this is a field on the `Workspace` struct. (see image below). - `Pane`: An area in the `Center` where we can place items, such as an editor, multi-buffer or terminal (see image below). diff --git a/docs/src/development/linux.md b/docs/src/development/linux.md index df3b840fa17a547efd4324f3bdaa119b8ade8738..3269d4b4dd51b224ab2b0cf7cfe15333232d0915 100644 --- a/docs/src/development/linux.md +++ b/docs/src/development/linux.md @@ -16,10 +16,6 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). If you prefer to install the system libraries manually, you can find the list of required packages in the `script/linux` file. -### Backend Dependencies (optional) {#backend-dependencies} - -If you are looking to develop Zed collaboration features using a local collaboration server, please see: [Local Collaboration](./local-collaboration.md) docs. - ### Linkers {#linker} On Linux, Rust's default linker is [LLVM's `lld`](https://blog.rust-lang.org/2025/09/18/Rust-1.90.0/). Alternative linkers, especially [Wild](https://github.com/davidlattimore/wild) and [Mold](https://github.com/rui314/mold) can significantly improve clean and incremental build time. diff --git a/docs/src/development/local-collaboration.md b/docs/src/development/local-collaboration.md deleted file mode 100644 index 393c6f0bbf797cf9aa86d297633734444bdfb328..0000000000000000000000000000000000000000 --- a/docs/src/development/local-collaboration.md +++ /dev/null @@ -1,207 +0,0 @@ -# Local Collaboration - -1. Ensure you have access to our cloud infrastructure. If you don't have access, you can't collaborate locally at this time. - -2. Make sure you've installed Zed's dependencies for your platform: - -- [macOS](#macos) -- [Linux](#linux) -- [Windows](#backend-windows) - -Note that `collab` can be compiled only with MSVC toolchain on Windows - -3. Clone down our cloud repository and follow the instructions in the cloud README - -4. Setup the local database for your platform: - -- [macOS & Linux](#database-unix) -- [Windows](#database-windows) - -5. Run collab: - -- [macOS & Linux](#run-collab-unix) -- [Windows](#run-collab-windows) - -## Backend Dependencies - -If you are developing collaborative features of Zed, you'll need to install the dependencies of zed's `collab` server: - -- PostgreSQL -- LiveKit -- Foreman - -You can install these dependencies natively or run them under Docker. - -### macOS - -1. Install [Postgres.app](https://postgresapp.com) or [postgresql via homebrew](https://formulae.brew.sh/formula/postgresql@15): - - ```sh - brew install postgresql@15 - ``` - -2. Install [Livekit](https://formulae.brew.sh/formula/livekit) and [Foreman](https://formulae.brew.sh/formula/foreman) - - ```sh - brew install livekit foreman - ``` - -- Follow the steps in the [collab README](https://github.com/zed-industries/zed/blob/main/crates/collab/README.md) to configure the Postgres database for integration tests - -Alternatively, if you have [Docker](https://www.docker.com/) installed you can bring up all the `collab` dependencies using Docker Compose. - -### Linux - -1. Install [Postgres](https://www.postgresql.org/download/linux/) - - ```sh - sudo apt-get install postgresql # Ubuntu/Debian - sudo pacman -S postgresql # Arch Linux - sudo dnf install postgresql postgresql-server # RHEL/Fedora - sudo zypper install postgresql postgresql-server # OpenSUSE - ``` - -2. Install [Livekit](https://github.com/livekit/livekit-cli) - - ```sh - curl -sSL https://get.livekit.io/cli | bash - ``` - -3. Install [Foreman](https://theforeman.org/manuals/3.15/quickstart_guide.html) - -### Windows {#backend-windows} - -> This section is still in development. The instructions are not yet complete. - -- Install [Postgres](https://www.postgresql.org/download/windows/) -- Install [Livekit](https://github.com/livekit/livekit), optionally you can add the `livekit-server` binary to your `PATH`. - -Alternatively, if you have [Docker](https://www.docker.com/) installed you can bring up all the `collab` dependencies using Docker Compose. - -### Docker {#Docker} - -If you have docker or podman available, you can run the backend dependencies inside containers with Docker Compose: - -```sh -docker compose up -d -``` - -## Database setup - -Before you can run the `collab` server locally, you'll need to set up a `zed` Postgres database. - -### On macOS and Linux {#database-unix} - -```sh -script/bootstrap -``` - -This script will set up the `zed` Postgres database, and populate it with some users. It requires internet access, because it fetches some users from the GitHub API. - -The script will seed the database with various content defined by: - -```sh -cat crates/collab/seed.default.json -``` - -To use a different set of admin users, you can create your own version of that json file and export the `SEED_PATH` environment variable. Note that the usernames listed in the admins list currently must correspond to valid GitHub users. - -```json [settings] -{ - "admins": ["admin1", "admin2"], - "channels": ["zed"] -} -``` - -### On Windows {#database-windows} - -```powershell -.\script\bootstrap.ps1 -``` - -## Testing collaborative features locally - -### On macOS and Linux {#run-collab-unix} - -Ensure that Postgres is configured and running, then run Zed's collaboration server and the `livekit` dev server: - -```sh -foreman start -# OR -docker compose up -``` - -Alternatively, if you're not testing voice and screenshare, you can just run `collab` and `cloud`, and not the `livekit` dev server: - -```sh -cargo run -p collab -- serve all -``` - -```sh -cd ../cloud; cargo make dev -``` - -In a new terminal, run two or more instances of Zed. - -```sh -script/zed-local -3 -``` - -This script starts one to four instances of Zed, depending on the `-2`, `-3` or `-4` flags. Each instance will be connected to the local `collab` server, signed in as a different user from `.admins.json` or `.admins.default.json`. - -### On Windows {#run-collab-windows} - -Since `foreman` is not available on Windows, you can run the following commands in separate terminals: - -```powershell -cargo run --package=collab -- serve all -``` - -If you have added the `livekit-server` binary to your `PATH`, you can run: - -```powershell -livekit-server --dev -``` - -Otherwise, - -```powershell -.\path\to\livekit-serve.exe --dev -``` - -You'll also need to start the cloud server: - -```powershell -cd ..\cloud; cargo make dev -``` - -In a new terminal, run two or more instances of Zed. - -```powershell -node .\script\zed-local -2 -``` - -Note that this requires `node.exe` to be in your `PATH`. - -## Running a local collab server - -> [!NOTE] -> Because of recent changes to our authentication system, Zed will not be able to authenticate itself with, and therefore use, a local collab server. - -If you want to run your own version of the zed collaboration service, you can, but note that this is still under development, and there is no support for authentication nor extensions. - -Configuration is done through environment variables. By default it will read the configuration from [`.env.toml`](https://github.com/zed-industries/zed/blob/main/crates/collab/.env.toml) and you should use that as a guide for setting this up. - -By default Zed assumes that the DATABASE_URL is a Postgres database, but you can make it use Sqlite by compiling with `--features sqlite` and using a sqlite DATABASE_URL with `?mode=rwc`. - -To authenticate you must first configure the server by creating a seed.json file that contains at a minimum your github handle. This will be used to create the user on demand. - -```json [settings] -{ - "admins": ["nathansobo"] -} -``` - -By default the collab server will seed the database when first creating it, but if you want to add more users you can explicitly reseed them with `SEED_PATH=./seed.json cargo run -p collab seed` - -Then when running the zed client you must specify two environment variables, `ZED_ADMIN_API_TOKEN` (which should match the value of `API_TOKEN` in .env.toml) and `ZED_IMPERSONATE` (which should match one of the users in your seed.json) diff --git a/docs/src/development/macos.md b/docs/src/development/macos.md index 9c99e5f8da62594c774e109e15f914788f51793d..9e2908dd6e393acd8d3903d86743dcbc4e9ae9eb 100644 --- a/docs/src/development/macos.md +++ b/docs/src/development/macos.md @@ -31,10 +31,6 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). brew install cmake ``` -### Backend Dependencies (optional) {#backend-dependencies} - -If you are looking to develop Zed collaboration features using a local collaboration server, please see: [Local Collaboration](./local-collaboration.md) docs. - ## Building Zed from Source Once you have the dependencies installed, you can build Zed using [Cargo](https://doc.rust-lang.org/cargo/). diff --git a/docs/src/development/windows.md b/docs/src/development/windows.md index 17382e0bee5b97c2ffc2d74794cf3881a3cb98a1..509f30a05b45175f7e66026aec5b5d433b928e4d 100644 --- a/docs/src/development/windows.md +++ b/docs/src/development/windows.md @@ -66,10 +66,6 @@ The list can be obtained as follows: - Click on `More` in the `Installed` tab - Click on `Export configuration` -### Backend Dependencies (optional) {#backend-dependencies} - -If you are looking to develop Zed collaboration features using a local collaboration server, please see: [Local Collaboration](./local-collaboration.md) docs. - ### Notes You should modify the `pg_hba.conf` file in the `data` directory to use `trust` instead of `scram-sha-256` for the `host` method. Otherwise, the connection will fail with the error `password authentication failed`. The `pg_hba.conf` file typically locates at `C:\Program Files\PostgreSQL\17\data\pg_hba.conf`. After the modification, the file should look like this: diff --git a/docs/src/extensions/capabilities.md b/docs/src/extensions/capabilities.md index 4d935a0725a1285065070eccb5112dc636e26a27..9af038497d20aca298515bb87f8e505f9ea03c87 100644 --- a/docs/src/extensions/capabilities.md +++ b/docs/src/extensions/capabilities.md @@ -62,7 +62,7 @@ The `download_file` capability grants extensions the ability to download files u To allow any file to be downloaded: ```toml -{ kind = "download_file", host = "github.com", path = ["**"] } +{ kind = "download_file", host = "*", path = ["**"] } ``` To allow any file to be downloaded from `github.com`: diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index 539cbe3d3044afe30b6ced4f3ceb61f537ebde75..dc8a69329176c8dbb7f9785913ae4b7aac6fb230 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -109,18 +109,6 @@ git submodule init git submodule update ``` -## Update Your Extension - -When developing/updating your extension, you will likely need to update its content from its submodule in the extensions repository. -To quickly fetch the latest code for only specific extension (and avoid updating all others), use the specific path: - -```sh -# From the root of the repository: -git submodule update --remote extensions/your-extension-name -``` - -> Note: If you need to update all submodules (e.g., if multiple extensions have changed, or for a full clean build), you can run `git submodule update` without a path, but this will take longer. - ## Extension License Requirements As of October 1st, 2025, extension repositories must include a license. @@ -177,7 +165,15 @@ To update an extension, open a PR to [the `zed-industries/extensions` repo](http In your PR do the following: -1. Update the extension's submodule to the commit of the new version. +1. Update the extension's submodule to the commit of the new version. For this, you can run + +```sh +# From the root of the repository: +git submodule update --remote extensions/your-extension-name +``` + +to update your extension to the latest commit available in your remote repository. + 2. Update the `version` field for the extension in `extensions.toml` - Make sure the `version` matches the one set in `extension.toml` at the particular commit. diff --git a/docs/src/git.md b/docs/src/git.md index d562eb4d0a3b07f4de7df1b0831f6e91a2767c1d..8a94a79973b390f1d4e8075469b610d51b6f2016 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -145,7 +145,6 @@ You can specify your preferred model to use by providing a `commit_message_model ```json [settings] { "agent": { - "version": "2", "commit_message_model": { "provider": "anthropic", "model": "claude-3-5-haiku" diff --git a/docs/src/installation.md b/docs/src/installation.md index 7802ef7776a78deefb196ab005297e1f54314ea6..7d2009e3a0266160ce4e13056287c36ef7660008 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -22,6 +22,12 @@ brew install --cask zed@preview Get the latest stable builds via [the download page](https://zed.dev/download). If you want to download our preview build, you can find it on its [releases page](https://zed.dev/releases/preview). After the first manual installation, Zed will periodically check for install updates. +Additionally, you can install Zed using winget: + +```sh +winget install -e --id ZedIndustries.Zed +``` + ### Linux For most Linux users, the easiest way to install Zed is through our installation script: diff --git a/docs/src/languages/astro.md b/docs/src/languages/astro.md index 5691a0de4844b2e2d924713d523f4651da6fe984..cbfe8de74e7444e2e02f6240265e00eb043a2084 100644 --- a/docs/src/languages/astro.md +++ b/docs/src/languages/astro.md @@ -3,7 +3,7 @@ Astro support is available through the [Astro extension](https://github.com/zed-extensions/astro). - Tree-sitter: [virchau13/tree-sitter-astro](https://github.com/virchau13/tree-sitter-astro) -- Language Server: [withastro/language-tools](https://github.com/withastro/language-tools) +- Language Server: [withastro/language-tools](https://github.com/withastro/astro/tree/main/packages/language-tools/language-server) + +# Migration Research Notes + +## Completed Guides + +All three JetBrains migration guides have been populated with full content: + +1. **pycharm.md** - Python development, virtual environments, Ruff/Pyright, Django/Flask workflows +2. **webstorm.md** - JavaScript/TypeScript development, npm workflows, framework considerations +3. **rustrover.md** - Rust development, rust-analyzer parity, Cargo workflows, licensing notes + +## Key Sources Used + +- IntelliJ IDEA migration doc (structural template) +- JetBrains PyCharm Getting Started docs +- JetBrains WebStorm Getting Started docs +- JetBrains RustRover Quick Start Guide +- External community feedback (Reddit, Hacker News, Medium) + +## External Quotes Incorporated + +### WebStorm Guide + +> "I work for AWS and the applications I deal with are massive. Often I need to keep many projects open due to tight dependencies. I'm talking about complex microservices and micro frontend infrastructure which oftentimes lead to 2-15 minutes of indexing wait time whenever I open a project or build the system locally." + +### RustRover Guide + +- Noted rust-analyzer shared foundation between RustRover and Zed +- Addressed licensing/telemetry concerns that motivate some users to switch +- Included debugger caveats based on community feedback + +## Cross-Cutting Themes Applied to All Guides + +### Universal Pain Points Addressed + +1. Indexing (instant in Zed) +2. Resource usage (Zed is lightweight) +3. Startup time (Zed is near-instant) +4. UI clutter (Zed is minimal by design) + +### Universal Missing Features Documented + +- No project model / SDK management +- No database tools +- No framework-specific integration +- No visual run configurations (use tasks) +- No built-in HTTP client + +### JetBrains Keymap Emphasized + +All three guides emphasize: + +- Select JetBrains keymap during onboarding or in settings +- `Shift Shift` for Search Everywhere works +- Most familiar shortcuts preserved + +## Next Steps (Optional Enhancements) + +- [ ] Cross-link guides to JetBrains docs for users who want to reference original IDE features +- [ ] Add a consolidated "hub page" linking to all migration guides +- [ ] Consider adding VS Code migration guide using similar structure +- [ ] Review for tone consistency against Zed Documentation Guidelines diff --git a/docs/src/migrate/intellij.md b/docs/src/migrate/intellij.md new file mode 100644 index 0000000000000000000000000000000000000000..24c85774ec5686f605d1d781913d0873ac0abd7f --- /dev/null +++ b/docs/src/migrate/intellij.md @@ -0,0 +1,357 @@ +# How to Migrate from IntelliJ IDEA to Zed + +This guide covers how to set up Zed if you're coming from IntelliJ IDEA, including keybindings, settings, and the differences you should expect. + +## Install Zed + +Zed is available on macOS, Windows, and Linux. + +For macOS, you can download it from zed.dev/download, or install via Homebrew: + +```sh +brew install --cask zed +``` + +For Windows, download the installer from zed.dev/download, or install via winget: + +```sh +winget install Zed.Zed +``` + +For most Linux users, the easiest way to install Zed is through our installation script: + +```sh +curl -f https://zed.dev/install.sh | sh +``` + +After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using: +`zed .` +This opens the current directory in Zed. + +## Set Up the JetBrains Keymap + +If you're coming from IntelliJ, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Search for `Base Keymap` +3. Select `JetBrains` + +Or add this directly to your `settings.json`: + +```json +{ + "base_keymap": "JetBrains" +} +``` + +This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. + +## Set Up Editor Preferences + +You can configure settings manually in the Settings Editor. + +To edit your settings: + +1. `Cmd+,` to open the Settings Editor. +2. Run `zed: open settings` in the Command Palette. + +Settings IntelliJ users typically configure first: + +| Zed Setting | What it does | +| ----------------------- | ------------------------------------------------------------------------------- | +| `format_on_save` | Auto-format when saving. Set to `"on"` to enable. | +| `soft_wrap` | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` | +| `preferred_line_length` | Column width for wrapping and rulers. Default is 80. | +| `inlay_hints` | Show parameter names and type hints inline, like IntelliJ's hints. | +| `relative_line_numbers` | Useful if you're coming from IdeaVim. | + +Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in IntelliJ. + +> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line. + +## Open or Create a Project + +After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike IntelliJ, there's no project configuration wizard, no `.iml` files, and no SDK setup required. + +To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. + +You can also launch Zed from the terminal inside any folder with: +`zed .` + +Once inside a project: + +- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like IntelliJ's "Recent Files") +- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like IntelliJ's "Search Everywhere") +- Use `Cmd+O` to search for symbols (like IntelliJ's "Go to Class") + +Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like IntelliJ's Project tool window). + +## Differences in Keybindings + +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to IntelliJ. + +### Common Shared Keybindings (Zed with JetBrains keymap ↔ IntelliJ) + +| Action | Shortcut | +| ----------------------------- | ----------------------- | +| Search Everywhere | `Shift Shift` | +| Find Action / Command Palette | `Cmd + Shift + A` | +| Go to File | `Cmd + Shift + O` | +| Go to Symbol / Class | `Cmd + O` | +| Recent Files | `Cmd + E` | +| Go to Definition | `Cmd + B` | +| Find Usages | `Alt + F7` | +| Rename Symbol | `Shift + F6` | +| Reformat Code | `Cmd + Alt + L` | +| Toggle Project Panel | `Cmd + 1` | +| Toggle Terminal | `Alt + F12` | +| Duplicate Line | `Cmd + D` | +| Delete Line | `Cmd + Backspace` | +| Move Line Up/Down | `Shift + Alt + Up/Down` | +| Expand/Shrink Selection | `Alt + Up/Down` | +| Comment Line | `Cmd + /` | +| Go Back / Forward | `Cmd + [` / `Cmd + ]` | +| Toggle Breakpoint | `Ctrl + F8` | + +### Different Keybindings (IntelliJ → Zed) + +| Action | IntelliJ | Zed (JetBrains keymap) | +| ---------------------- | ----------- | ------------------------ | +| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | +| Navigate to Next Error | `F2` | `F2` | +| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | +| Debug | `Ctrl + D` | `Alt + Shift + F9` | +| Stop | `Cmd + F2` | `Ctrl + F2` | + +### Unique to Zed + +| Action | Shortcut | Notes | +| ----------------- | -------------------------- | ------------------------------ | +| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | +| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | + +### How to Customize Keybindings + +- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) +- Run `Zed: Open Keymap Editor` + +This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. + +Zed also supports key sequences (multi-key shortcuts). + +## Differences in User Interfaces + +### No Indexing + +If you've used IntelliJ on large projects, you know the wait: "Indexing..." can take anywhere from 30 seconds to 15 minutes depending on project size. IntelliJ builds a comprehensive index of your entire codebase to power its code intelligence, and it re-indexes when dependencies change or after builds. + +Zed doesn't index. You open a folder and start working immediately. File search and navigation work instantly regardless of project size. + +IntelliJ's index powers features like finding all usages across your entire codebase, understanding class hierarchies, and detecting dead code. Zed delegates this work to language servers, which may not analyze at the same depth. + +**How to adapt:** + +- For project-wide symbol search, use `Cmd+O` / Go to Symbol (relies on your language server) +- For finding files by name, use `Cmd+Shift+O` / Go to File +- For text search across files, use `Cmd+Shift+F`—this is fast even on large codebases +- If you need deep static analysis for JVM code, consider running IntelliJ's inspections as a separate step or using standalone tools like Checkstyle, PMD, or SpotBugs + +### LSP vs. Native Language Intelligence + +IntelliJ has its own language analysis engine built from scratch for each supported language. For Java, Kotlin, and other JVM languages, this engine understands your code thoroughly: it resolves types, tracks data flow, knows about framework annotations, and offers dozens of specialized refactorings. + +Zed uses the Language Server Protocol (LSP) for code intelligence. Each language has its own server: `jdtls` for Java, `rust-analyzer` for Rust, and so on. + +For some languages, the LSP experience is excellent. TypeScript, Rust, and Go have mature language servers that provide fast, accurate completions, diagnostics, and refactorings. For JVM languages, the gap might be more noticeable. The Eclipse-based Java language server is capable, but it won't match IntelliJ's depth for things like: + +- Spring and Jakarta EE annotation processing +- Complex refactorings (extract interface, pull members up, change signature with all callers) +- Framework-aware inspections +- Automatic import optimization with custom ordering rules + +**How to adapt:** + +- Use `Alt+Enter` for available code actions—the list will vary by language server +- For Java, ensure `jdtls` is properly configured with your JDK path in settings + +### No Project Model + +IntelliJ manages projects through `.idea` folders containing XML configuration files, `.iml` module definitions, SDK assignments, and run configurations. This model enables IntelliJ to understand multi-module projects, manage dependencies automatically, and persist complex run/debug setups. + +Zed has no project model. A project is a folder. There's no wizard, no SDK selection screen, no module configuration. + +This means: + +- Build commands are manual. Zed doesn't detect Maven or Gradle projects. +- Run configurations don't exist. You define tasks or use the terminal. +- SDK management is external. Your language server uses whatever JDK is on your PATH. +- There are no module boundaries. Zed sees folders, not project structure. + +**How to adapt:** + +- Create a `.zed/settings.json` in your project root for project-specific settings +- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): + +```json +[ + { + "label": "build", + "command": "./gradlew build" + }, + { + "label": "run", + "command": "./gradlew bootRun" + }, + { + "label": "test current file", + "command": "./gradlew test --tests $ZED_STEM" + } +] +``` + +- Use `Ctrl+Alt+R` to run tasks quickly +- Lean on your terminal (`Alt+F12`) for anything tasks don't cover +- For multi-module projects, you can open each module as a separate Zed window, or open the root and navigate via file finder + +### No Framework Integration + +IntelliJ's value for enterprise Java development comes largely from its framework integration. Spring beans are understood and navigable. JPA entities get special treatment. Endpoints are indexed and searchable. Jakarta EE annotations modify how the IDE analyzes your code. + +Zed has none of this. The language server sees Java code as Java code, so it doesn't understand that `@Autowired` means something special or that this class is a REST controller. + +Similarly for other ecosystems: no Rails integration, no Django awareness, no Angular/React-specific tooling beyond what the TypeScript language server provides. + +**How to adapt:** + +- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find endpoint definitions, bean names, or annotation usages. +- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context +- For Spring Boot, keep the Actuator endpoints or a separate tool for understanding bean wiring +- Consider using framework-specific CLI tools (Spring CLI, Rails generators) from Zed's terminal + +> **Tip:** For database work, pick up a dedicated tool like DataGrip, DBeaver, or TablePlus. Many developers who switch to Zed keep DataGrip around specifically for SQL—it integrates well with your existing JetBrains license. + +If your daily work depends heavily on framework-aware navigation and refactoring, you'll feel the gap. Zed works best when you're comfortable navigating code through search rather than specialized tooling, or when your language has strong LSP support that covers most of what you need. + +### Tool Windows vs. Docks + +IntelliJ organizes auxiliary views into numbered tool windows (Project = 1, Git = 9, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": + +| IntelliJ Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | +| -------------------- | -------------- | --------------------------- | +| Project (1) | Project Panel | `Cmd + 1` | +| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | +| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | +| Structure (7) | Outline Panel | `Cmd + 7` | +| Problems (6) | Diagnostics | `Cmd + 6` | +| Debug (5) | Debug Panel | `Cmd + 5` | + +Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. + +> **Tip:** IntelliJ has an "Override IDE shortcuts" setting that lets terminal shortcuts like `Ctrl+Left/Right` work normally. In Zed, terminal keybindings are separate—check your keymap if familiar shortcuts aren't working in the terminal panel. + +### Debugging + +Both IntelliJ and Zed offer integrated debugging, but the experience differs: + +- Zed's debugger uses the Debug Adapter Protocol (DAP), supporting multiple languages +- Set breakpoints with `Ctrl+F8` +- Start debugging with `Alt+Shift+F9` +- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) +- Continue execution with `F9` + +The Debug Panel (`Cmd+5`) shows variables, call stack, and breakpoints—similar to IntelliJ's Debug tool window. + +### Extensions vs. Plugins + +IntelliJ has a massive plugin ecosystem covering everything from language support to database tools to deployment integrations. + +Zed's extension ecosystem is smaller and more focused: + +- Language support and syntax highlighting +- Themes +- Slash commands for AI +- Context servers + +Several features that require plugins in other editors are built into Zed: + +- Real-time collaboration with voice chat +- AI coding assistance +- Built-in terminal +- Task runner +- LSP-based code intelligence + +You won't find one-to-one replacements for every IntelliJ plugin, especially for framework-specific tools, database clients, or application server integrations. For those workflows, you may need to use external tools alongside Zed. + +## Collaboration in Zed vs. IntelliJ + +IntelliJ offers Code With Me as a separate plugin for collaboration. Zed has collaboration built into the core experience. + +- Open the Collab Panel in the left dock +- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join +- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly + +Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins. + +## Using AI in Zed + +If you're used to AI assistants in IntelliJ (like GitHub Copilot or JetBrains AI), Zed offers similar capabilities with more flexibility. + +### Configuring GitHub Copilot + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Navigate to **AI → Edit Predictions** +3. Click **Configure** next to "Configure Providers" +4. Under **GitHub Copilot**, click **Sign in to GitHub** + +Once signed in, just start typing. Zed will offer suggestions inline for you to accept. + +### Additional AI Options + +To use other AI models in Zed, you have several options: + +- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html). +- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed +- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html) + +## Advanced Config and Productivity Tweaks + +Zed exposes advanced settings for power users who want to fine-tune their environment. + +Here are a few useful tweaks: + +**Format on Save:** + +```json +"format_on_save": "on" +``` + +**Enable direnv support:** + +```json +"load_direnv": "shell_hook" +``` + +**Configure language servers**: For Java development, you may want to configure the Java language server in your settings: + +```json +{ + "lsp": { + "jdtls": { + "settings": { + "java_home": "/path/to/jdk" + } + } + } +} +``` + +## Next Steps + +Now that you're set up, here are some resources to help you get the most out of Zed: + +- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior +- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap +- [Tasks](../tasks.md) — Set up build and run commands for your projects +- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion +- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time +- [Languages](../languages.md) — Language-specific setup guides, including Java and Kotlin diff --git a/docs/src/migrate/pycharm.md b/docs/src/migrate/pycharm.md new file mode 100644 index 0000000000000000000000000000000000000000..636bc69eeba1c09b3e0e8a0d74ccd859aedbb342 --- /dev/null +++ b/docs/src/migrate/pycharm.md @@ -0,0 +1,438 @@ +# How to Migrate from PyCharm to Zed + +This guide covers how to set up Zed if you're coming from PyCharm, including keybindings, settings, and the differences you should expect. + +## Install Zed + +Zed is available on macOS, Windows, and Linux. + +For macOS, you can download it from zed.dev/download, or install via Homebrew: + +```sh +brew install --cask zed +``` + +For Windows, download the installer from zed.dev/download, or install via winget: + +```sh +winget install Zed.Zed +``` + +For most Linux users, the easiest way to install Zed is through our installation script: + +```sh +curl -f https://zed.dev/install.sh | sh +``` + +After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using: +`zed .` +This opens the current directory in Zed. + +## Set Up the JetBrains Keymap + +If you're coming from PyCharm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Search for `Base Keymap` +3. Select `JetBrains` + +Or add this directly to your `settings.json`: + +```json +{ + "base_keymap": "JetBrains" +} +``` + +This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. + +## Set Up Editor Preferences + +You can configure settings manually in the Settings Editor. + +To edit your settings: + +1. `Cmd+,` to open the Settings Editor. +2. Run `zed: open settings` in the Command Palette. + +Settings PyCharm users typically configure first: + +| Zed Setting | What it does | +| ----------------------- | ------------------------------------------------------------------------------- | +| `format_on_save` | Auto-format when saving. Set to `"on"` to enable. | +| `soft_wrap` | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` | +| `preferred_line_length` | Column width for wrapping and rulers. Default is 80, PEP 8 recommends 79. | +| `inlay_hints` | Show parameter names and type hints inline, like PyCharm's hints. | +| `relative_line_numbers` | Useful if you're coming from IdeaVim. | + +Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in PyCharm. + +> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line. + +## Open or Create a Project + +After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike PyCharm, there's no project configuration wizard, no interpreter selection dialog, and no project structure setup required. + +To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. + +You can also launch Zed from the terminal inside any folder with: +`zed .` + +Once inside a project: + +- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like PyCharm's "Recent Files") +- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like PyCharm's "Search Everywhere") +- Use `Cmd+O` to search for symbols (like PyCharm's "Go to Symbol") + +Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like PyCharm's Project tool window). + +## Differences in Keybindings + +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to PyCharm. + +### Common Shared Keybindings + +| Action | Shortcut | +| ----------------------------- | ----------------------- | +| Search Everywhere | `Shift Shift` | +| Find Action / Command Palette | `Cmd + Shift + A` | +| Go to File | `Cmd + Shift + O` | +| Go to Symbol | `Cmd + O` | +| Recent Files | `Cmd + E` | +| Go to Definition | `Cmd + B` | +| Find Usages | `Alt + F7` | +| Rename Symbol | `Shift + F6` | +| Reformat Code | `Cmd + Alt + L` | +| Toggle Project Panel | `Cmd + 1` | +| Toggle Terminal | `Alt + F12` | +| Duplicate Line | `Cmd + D` | +| Delete Line | `Cmd + Backspace` | +| Move Line Up/Down | `Shift + Alt + Up/Down` | +| Expand/Shrink Selection | `Alt + Up/Down` | +| Comment Line | `Cmd + /` | +| Go Back / Forward | `Cmd + [` / `Cmd + ]` | +| Toggle Breakpoint | `Ctrl + F8` | + +### Different Keybindings (PyCharm → Zed) + +| Action | PyCharm | Zed (JetBrains keymap) | +| ---------------------- | ----------- | ------------------------ | +| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | +| Navigate to Next Error | `F2` | `F2` | +| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | +| Debug | `Ctrl + D` | `Alt + Shift + F9` | +| Stop | `Cmd + F2` | `Ctrl + F2` | + +### Unique to Zed + +| Action | Shortcut | Notes | +| ----------------- | -------------------------- | ------------------------------ | +| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | +| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | + +### How to Customize Keybindings + +- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) +- Run `Zed: Open Keymap Editor` + +This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. + +Zed also supports key sequences (multi-key shortcuts). + +## Differences in User Interfaces + +### No Indexing + +If you've used PyCharm on large projects, you know the wait: "Indexing..." can take anywhere from 30 seconds to several minutes depending on project size and dependencies. PyCharm builds a comprehensive index of your entire codebase to power its code intelligence, and it re-indexes when dependencies change or when you install new packages. + +Zed doesn't index. You open a folder and start working immediately. File search and navigation work instantly regardless of project size. For many PyCharm users, this alone is reason enough to switch—no more waiting, no more "Indexing paused" interruptions. + +PyCharm's index powers features like finding all usages across your entire codebase, understanding class hierarchies, and detecting unused imports project-wide. Zed delegates this work to language servers, which may not analyze as deeply or as broadly. + +**How to adapt:** + +- For project-wide symbol search, use `Cmd+O` / Go to Symbol (relies on your language server) +- For finding files by name, use `Cmd+Shift+O` / Go to File +- For text search across files, use `Cmd+Shift+F`—this is fast even on large codebases +- For deep static analysis, consider running tools like `mypy`, `pylint`, or `ruff check` from the terminal + +### LSP vs. Native Language Intelligence + +PyCharm has its own language analysis engine built specifically for Python. This engine understands your code deeply: it resolves types without annotations, tracks data flow, knows about Django models and Flask routes, and offers specialized refactorings. + +Zed uses the Language Server Protocol (LSP) for code intelligence. For Python, Zed provides several language servers out of the box: + +- **basedpyright** (default) — Fast type checking and completions +- **Ruff** (default) — Linting and formatting +- **Ty** — Up-and-coming language server from Astral, built for speed +- **Pyright** — Microsoft's type checker +- **PyLSP** — Plugin-based server with tool integrations + +The LSP experience for Python is strong. basedpyright provides accurate completions, type checking, and navigation. Ruff handles formatting and linting with excellent performance. + +Where you might notice differences: + +- Framework-specific intelligence (Django ORM, Flask routes) isn't built-in +- Some complex refactorings (extract method with proper scope analysis) may be less sophisticated +- Auto-import suggestions depend on what the language server knows about your environment + +**How to adapt:** + +- Use `Alt+Enter` for available code actions—the list will vary by language server +- Ensure your virtual environment is selected so the language server can resolve your dependencies +- Use Ruff for fast, consistent formatting (it's enabled by default) +- For code inspection similar to PyCharm's "Inspect Code," run `ruff check .` or check the Diagnostics panel (`Cmd+6`)—basedpyright and Ruff together catch many of the same issues + +### Virtual Environments and Interpreters + +In PyCharm, you select a Python interpreter through a GUI, and PyCharm manages the connection between your project and that interpreter. It shows available packages, lets you install new ones, and keeps track of which environment each project uses. + +Zed handles virtual environments through its toolchain system: + +- Zed automatically discovers virtual environments in common locations (`.venv`, `venv`, `.env`, `env`) +- When a virtual environment is detected, the terminal auto-activates it +- Language servers are automatically configured to use the discovered environment +- You can manually select a toolchain if auto-detection picks the wrong one + +**How to adapt:** + +- Create your virtual environment with `python -m venv .venv` or `uv sync` +- Open the folder in Zed—it will detect the environment automatically +- If you need to switch environments, use the toolchain selector +- For conda environments, ensure they're activated in your shell before launching Zed + +> **Tip:** If basedpyright shows import errors for packages you've installed, check that Zed has selected the correct virtual environment. Use the toolchain selector to verify or change the active environment. + +### No Project Model + +PyCharm manages projects through `.idea` folders containing XML configuration files, interpreter assignments, and run configurations. This model lets PyCharm remember your interpreter choice, manage dependencies through the UI, and persist complex run/debug setups. + +Zed has no project model. A project is a folder. There's no wizard, no interpreter selection screen, no project structure configuration. + +This means: + +- Run configurations don't exist. You define tasks or use the terminal. Your existing PyCharm run configs in `.idea/` won't be read—you'll recreate the ones you need in `tasks.json`. +- Interpreter management is external. Zed discovers environments but doesn't create them. +- Dependencies are managed through pip, uv, poetry, or conda—not through the editor. +- There's no Python Console (interactive REPL) panel. Use `python` or `ipython` in the terminal instead. + +**How to adapt:** + +- Create a `.zed/settings.json` in your project root for project-specific settings +- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): + +```json +[ + { + "label": "run", + "command": "python main.py" + }, + { + "label": "test", + "command": "pytest" + }, + { + "label": "test current file", + "command": "pytest $ZED_FILE" + } +] +``` + +- Use `Ctrl+Alt+R` to run tasks quickly +- Lean on your terminal (`Alt+F12`) for anything tasks don't cover + +### No Framework Integration + +PyCharm Professional's value for web development comes largely from its framework integration. Django templates are understood and navigable. Flask routes are indexed. SQLAlchemy models get special treatment. Template variables autocomplete. + +Zed has none of this. The language server sees Python code as Python code—it doesn't understand that `@app.route` defines an endpoint or that a Django model class creates database tables. + +**How to adapt:** + +- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find route definitions, model classes, or template usages. +- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context +- Consider using framework-specific CLI tools (`python manage.py`, `flask routes`) from Zed's terminal + +> **Tip:** For database work, pick up a dedicated tool like DataGrip, DBeaver, or TablePlus. Many developers who switch to Zed keep DataGrip around specifically for SQL. + +### Tool Windows vs. Docks + +PyCharm organizes auxiliary views into numbered tool windows (Project = 1, Python Console = 4, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": + +| PyCharm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | +| ------------------- | -------------- | --------------------------- | +| Project (1) | Project Panel | `Cmd + 1` | +| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | +| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | +| Structure (7) | Outline Panel | `Cmd + 7` | +| Problems (6) | Diagnostics | `Cmd + 6` | +| Debug (5) | Debug Panel | `Cmd + 5` | + +Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. + +### Debugging + +Both PyCharm and Zed offer integrated debugging, but the experience differs: + +- Zed uses `debugpy` (the same debug adapter that VS Code uses) +- Set breakpoints with `Ctrl+F8` +- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target +- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) +- Continue execution with `F9` + +Zed can automatically detect debuggable entry points. Press `F4` to see available options, including: + +- Python scripts +- Modules +- pytest tests + +For more control, create a `.zed/debug.json` file: + +```json +[ + { + "label": "Debug Current File", + "adapter": "Debugpy", + "program": "$ZED_FILE", + "request": "launch" + }, + { + "label": "Debug Flask App", + "adapter": "Debugpy", + "request": "launch", + "module": "flask", + "args": ["run", "--debug"], + "env": { + "FLASK_APP": "app.py" + } + } +] +``` + +### Running Tests + +PyCharm has a dedicated test runner with a visual interface showing pass/fail status for each test. Zed provides test running through: + +- **Gutter icons** — Click the play button next to test functions or classes +- **Tasks** — Define pytest or unittest commands in `tasks.json` +- **Terminal** — Run `pytest` directly + +The test output appears in the terminal panel. For pytest, use `--tb=short` for concise tracebacks or `-v` for verbose output. + +### Extensions vs. Plugins + +PyCharm has a plugin ecosystem covering everything from additional language support to database tools to deployment integrations. + +Zed's extension ecosystem is smaller and more focused: + +- Language support and syntax highlighting +- Themes +- Slash commands for AI +- Context servers + +Several features that require plugins in PyCharm are built into Zed: + +- Real-time collaboration with voice chat +- AI coding assistance +- Built-in terminal +- Task runner +- LSP-based code intelligence +- Ruff formatting and linting + +### What's Not in Zed + +To set expectations clearly, here's what PyCharm offers that Zed doesn't have: + +- **Scientific Mode / Jupyter integration** — For notebooks and data science workflows, use JupyterLab or VS Code with the Jupyter extension alongside Zed for your Python editing +- **Database tools** — Use DataGrip, DBeaver, or TablePlus +- **Django/Flask template navigation** — Use file search and grep +- **Visual package manager** — Use pip, uv, or poetry from the terminal +- **Remote interpreters** — Zed has remote development, but it works differently +- **Profiler integration** — Use cProfile, py-spy, or similar tools externally + +## Collaboration in Zed vs. PyCharm + +PyCharm offers Code With Me as a separate plugin for collaboration. Zed has collaboration built into the core experience. + +- Open the Collab Panel in the left dock +- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join +- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly + +Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins. + +## Using AI in Zed + +If you're used to AI assistants in PyCharm (like GitHub Copilot or JetBrains AI Assistant), Zed offers similar capabilities with more flexibility. + +### Configuring GitHub Copilot + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Navigate to **AI → Edit Predictions** +3. Click **Configure** next to "Configure Providers" +4. Under **GitHub Copilot**, click **Sign in to GitHub** + +Once signed in, just start typing. Zed will offer suggestions inline for you to accept. + +### Additional AI Options + +To use other AI models in Zed, you have several options: + +- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html). +- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed +- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html) + +## Advanced Config and Productivity Tweaks + +Zed exposes advanced settings for power users who want to fine-tune their environment. + +Here are a few useful tweaks: + +**Format on Save:** + +```json +"format_on_save": "on" +``` + +**Enable direnv support (useful for Python projects using direnv):** + +```json +"load_direnv": "shell_hook" +``` + +**Customize virtual environment detection:** + +```json +{ + "terminal": { + "detect_venv": { + "on": { + "directories": [".venv", "venv", ".env", "env"], + "activate_script": "default" + } + } + } +} +``` + +**Configure basedpyright type checking strictness:** + +If you find basedpyright too strict or too lenient, configure it in your project's `pyrightconfig.json`: + +```json +{ + "typeCheckingMode": "basic" +} +``` + +Options are `"off"`, `"basic"`, `"standard"` (default), `"strict"`, or `"all"`. + +## Next Steps + +Now that you're set up, here are some resources to help you get the most out of Zed: + +- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior +- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap +- [Tasks](../tasks.md) — Set up build and run commands for your projects +- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion +- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time +- [Python in Zed](../languages/python.md) — Python-specific setup and configuration diff --git a/docs/src/migrate/rustrover.md b/docs/src/migrate/rustrover.md new file mode 100644 index 0000000000000000000000000000000000000000..4d0e85cfe9b981243044290929070e87876987d3 --- /dev/null +++ b/docs/src/migrate/rustrover.md @@ -0,0 +1,501 @@ +# How to Migrate from RustRover to Zed + +This guide covers how to set up Zed if you're coming from RustRover, including keybindings, settings, and the differences you should expect as a Rust developer. + +## Install Zed + +Zed is available on macOS, Windows, and Linux. + +For macOS, you can download it from zed.dev/download, or install via Homebrew: + +```sh +brew install --cask zed +``` + +For Windows, download the installer from zed.dev/download, or install via winget: + +```sh +winget install Zed.Zed +``` + +For most Linux users, the easiest way to install Zed is through our installation script: + +```sh +curl -f https://zed.dev/install.sh | sh +``` + +After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using: +`zed .` +This opens the current directory in Zed. + +## Set Up the JetBrains Keymap + +If you're coming from RustRover, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Search for `Base Keymap` +3. Select `JetBrains` + +Or add this directly to your `settings.json`: + +```json +{ + "base_keymap": "JetBrains" +} +``` + +This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. + +## Set Up Editor Preferences + +You can configure settings manually in the Settings Editor. + +To edit your settings: + +1. `Cmd+,` to open the Settings Editor. +2. Run `zed: open settings` in the Command Palette. + +Settings RustRover users typically configure first: + +| Zed Setting | What it does | +| ----------------------- | ------------------------------------------------------------------------------- | +| `format_on_save` | Auto-format when saving. Set to `"on"` to enable (uses rustfmt by default). | +| `soft_wrap` | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` | +| `preferred_line_length` | Column width for wrapping and rulers. Rust convention is 100. | +| `inlay_hints` | Show type hints, parameter names, and chaining hints inline. | +| `relative_line_numbers` | Useful if you're coming from IdeaVim. | + +Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in RustRover. + +> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line. + +## Open or Create a Project + +After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike RustRover, there's no project configuration wizard, no toolchain selection dialog, and no Cargo project setup screen. + +To start a new project, use Cargo from the terminal: + +```sh +cargo new my_project +cd my_project +zed . +``` + +Or for a library: + +```sh +cargo new --lib my_library +``` + +You can also launch Zed from the terminal inside any existing Cargo project with: +`zed .` + +Once inside a project: + +- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like RustRover's "Recent Files") +- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like RustRover's "Search Everywhere") +- Use `Cmd+O` to search for symbols (like RustRover's "Go to Symbol") + +Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like RustRover's Project tool window). + +## Differences in Keybindings + +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to RustRover. + +### Common Shared Keybindings + +| Action | Shortcut | +| ----------------------------- | ----------------------- | +| Search Everywhere | `Shift Shift` | +| Find Action / Command Palette | `Cmd + Shift + A` | +| Go to File | `Cmd + Shift + O` | +| Go to Symbol | `Cmd + O` | +| Recent Files | `Cmd + E` | +| Go to Definition | `Cmd + B` | +| Find Usages | `Alt + F7` | +| Rename Symbol | `Shift + F6` | +| Reformat Code | `Cmd + Alt + L` | +| Toggle Project Panel | `Cmd + 1` | +| Toggle Terminal | `Alt + F12` | +| Duplicate Line | `Cmd + D` | +| Delete Line | `Cmd + Backspace` | +| Move Line Up/Down | `Shift + Alt + Up/Down` | +| Expand/Shrink Selection | `Alt + Up/Down` | +| Comment Line | `Cmd + /` | +| Go Back / Forward | `Cmd + [` / `Cmd + ]` | +| Toggle Breakpoint | `Ctrl + F8` | + +### Different Keybindings (RustRover → Zed) + +| Action | RustRover | Zed (JetBrains keymap) | +| ---------------------- | ----------- | ------------------------ | +| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | +| Navigate to Next Error | `F2` | `F2` | +| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | +| Debug | `Ctrl + D` | `Alt + Shift + F9` | +| Stop | `Cmd + F2` | `Ctrl + F2` | +| Expand Macro | `Alt+Enter` | `Cmd + Shift + M` | + +### Unique to Zed + +| Action | Shortcut | Notes | +| ----------------- | -------------------------- | ------------------------------ | +| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | +| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | + +### How to Customize Keybindings + +- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) +- Run `Zed: Open Keymap Editor` + +This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. + +Zed also supports key sequences (multi-key shortcuts). + +## Differences in User Interfaces + +### No Indexing + +RustRover indexes your project when you first open it to build a model of your codebase. This process runs whenever you open a project or when dependencies change via Cargo. + +Zed skips the indexing step. You open a folder and start working right away. Since both editors rely on rust-analyzer for Rust intelligence, the analysis still happens—but in Zed it runs in the background without blocking the UI or showing modal progress dialogs. + +**How to adapt:** + +- Use `Cmd+O` to search symbols across your crate (rust-analyzer handles this) +- Jump to files by name with `Cmd+Shift+O` +- `Cmd+Shift+F` gives you fast text search across the entire project +- For linting and deeper checks, run `cargo clippy` in the terminal + +### rust-analyzer: Shared Foundation, Different Integration + +Here's what makes the RustRover-to-Zed transition unique: **both editors use rust-analyzer** for Rust language intelligence. This means the core code analysis—completions, go-to-definition, find references, type inference—is fundamentally the same. + +RustRover integrates rust-analyzer into its JetBrains platform, adding a GUI layer, additional refactorings, and its own indexing on top. Zed uses rust-analyzer more directly through the Language Server Protocol (LSP). + +What this means for you: + +- **Completions** — Same quality, powered by rust-analyzer +- **Type inference** — Identical, it's the same engine +- **Go to definition / Find usages** — Works the same way +- **Macro expansion** — Available in both (use `Cmd+Shift+M` in Zed) +- **Inlay hints** — Both support type hints, parameter hints, and chaining hints + +Where you might notice differences: + +- Some refactorings available in RustRover may not have rust-analyzer equivalents +- RustRover's GUI for configuring rust-analyzer is replaced by JSON configuration in Zed +- RustRover-specific inspections (beyond Clippy) won't exist in Zed + +**How to adapt:** + +- Use `Alt+Enter` for available code actions—rust-analyzer provides many +- Configure rust-analyzer settings in `.zed/settings.json` for project-specific needs +- Run `cargo clippy` for linting (it integrates with rust-analyzer diagnostics) + +### No Project Model + +RustRover manages projects through `.idea` folders containing XML configuration files, toolchain assignments, and run configurations. The Cargo tool window provides a visual interface for your project structure, targets, and dependencies. + +Zed keeps it simpler: a project is a folder with a `Cargo.toml`. No project wizard, no toolchain dialogs, no visual Cargo management layer. + +In practice: + +- Run configurations don't carry over. Your `.idea/` setup stays behind—define the commands you need in `tasks.json` instead. +- Toolchains are managed externally via `rustup`. +- Dependencies live in `Cargo.toml`. Edit the file directly; rust-analyzer provides completions for crate names and versions. + +**How to adapt:** + +- Create a `.zed/settings.json` in your project root for project-specific settings +- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): + +```json +[ + { + "label": "cargo run", + "command": "cargo run" + }, + { + "label": "cargo build", + "command": "cargo build" + }, + { + "label": "cargo test", + "command": "cargo test" + }, + { + "label": "cargo clippy", + "command": "cargo clippy" + }, + { + "label": "cargo run --release", + "command": "cargo run --release" + } +] +``` + +- Use `Ctrl+Alt+R` to run tasks quickly +- Lean on your terminal (`Alt+F12`) for anything tasks don't cover + +### No Cargo Integration UI + +RustRover's Cargo tool window provides visual access to your project's targets, dependencies, and common Cargo commands. You can run builds, tests, and benchmarks with a click. + +Zed doesn't have a Cargo GUI. You work with Cargo through: + +- **Terminal** — Run any Cargo command directly +- **Tasks** — Define shortcuts for common commands +- **Gutter icons** — Run tests and binaries with clickable icons + +**How to adapt:** + +- Get comfortable with Cargo CLI commands: `cargo build`, `cargo run`, `cargo test`, `cargo clippy`, `cargo doc` +- Use tasks for commands you run frequently +- For dependency management, edit `Cargo.toml` directly (rust-analyzer provides completions for crate names and versions) + +### Tool Windows vs. Docks + +RustRover organizes auxiliary views into numbered tool windows (Project = 1, Cargo = Alt+1, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": + +| RustRover Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | +| --------------------- | -------------- | --------------------------- | +| Project (1) | Project Panel | `Cmd + 1` | +| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | +| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | +| Structure (7) | Outline Panel | `Cmd + 7` | +| Problems (6) | Diagnostics | `Cmd + 6` | +| Debug (5) | Debug Panel | `Cmd + 5` | + +Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. + +Note that there's no dedicated Cargo tool window in Zed. Use the terminal or define tasks for your common Cargo commands. + +### Debugging + +Both RustRover and Zed offer integrated debugging for Rust, but using different backends: + +- RustRover uses its own debugger integration +- Zed uses **CodeLLDB** (the same debug adapter popular in VS Code) + +To debug Rust code in Zed: + +- Set breakpoints with `Ctrl+F8` +- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target +- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) +- Continue execution with `F9` + +Zed can automatically detect debuggable targets in your Cargo project. Press `F4` to see available options. + +For more control, create a `.zed/debug.json` file: + +```json +[ + { + "label": "Debug Binary", + "adapter": "CodeLLDB", + "request": "launch", + "program": "${workspaceFolder}/target/debug/my_project" + }, + { + "label": "Debug Tests", + "adapter": "CodeLLDB", + "request": "launch", + "cargo": { + "args": ["test", "--no-run"], + "filter": { + "kind": "test" + } + } + }, + { + "label": "Debug with Arguments", + "adapter": "CodeLLDB", + "request": "launch", + "program": "${workspaceFolder}/target/debug/my_project", + "args": ["--config", "dev.toml"] + } +] +``` + +> **Note:** Some users have reported that RustRover's debugger can have issues with variable inspection and breakpoints in certain scenarios. CodeLLDB in Zed provides a solid alternative, though debugging Rust can be challenging in any editor due to optimizations and macro-generated code. + +### Running Tests + +RustRover has a dedicated test runner with a visual interface showing pass/fail status for each test. Zed provides test running through: + +- **Gutter icons** — Click the play button next to `#[test]` functions or test modules +- **Tasks** — Define `cargo test` commands in `tasks.json` +- **Terminal** — Run `cargo test` directly + +The test output appears in the terminal panel. For more detailed output, use: + +- `cargo test -- --nocapture` to see println! output +- `cargo test -- --test-threads=1` for sequential test execution +- `cargo test specific_test_name` to run a single test + +### Extensions vs. Plugins + +RustRover has a plugin ecosystem, though it's more limited than other JetBrains IDEs since Rust support is built-in. + +Zed's extension ecosystem is smaller and more focused: + +- Language support and syntax highlighting +- Themes +- Slash commands for AI +- Context servers + +Several features that might require plugins in other editors are built into Zed: + +- Real-time collaboration with voice chat +- AI coding assistance +- Built-in terminal +- Task runner +- rust-analyzer integration +- rustfmt formatting + +### What's Not in Zed + +To set expectations clearly, here's what RustRover offers that Zed doesn't have: + +- **Cargo.toml GUI editor** — Edit the file directly (rust-analyzer helps with completions) +- **Visual dependency management** — Use `cargo add`, `cargo remove`, or edit `Cargo.toml` +- **Profiler integration** — Use `cargo flamegraph`, `perf`, or external profiling tools +- **Database tools** — Use DataGrip, DBeaver, or TablePlus +- **HTTP Client** — Use tools like `curl`, `httpie`, or Postman +- **Coverage visualization** — Use `cargo tarpaulin` or `cargo llvm-cov` externally + +## A Note on Licensing and Telemetry + +If you're moving from RustRover partly due to licensing concerns or telemetry policies, you should know: + +- **Zed is open source** (MIT licensed for the editor, AGPL for collaboration services) +- **Telemetry is optional** and can be disabled during onboarding or in settings +- **No license tiers**: All features are available to everyone + +## Collaboration in Zed vs. RustRover + +RustRover offers Code With Me as a separate feature for collaboration. Zed has collaboration built into the core experience. + +- Open the Collab Panel in the left dock +- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join +- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly + +Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins. + +## Using AI in Zed + +If you're used to AI assistants in RustRover (like JetBrains AI Assistant), Zed offers similar capabilities with more flexibility. + +### Configuring GitHub Copilot + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Navigate to **AI → Edit Predictions** +3. Click **Configure** next to "Configure Providers" +4. Under **GitHub Copilot**, click **Sign in to GitHub** + +Once signed in, just start typing. Zed will offer suggestions inline for you to accept. + +### Additional AI Options + +To use other AI models in Zed, you have several options: + +- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html). +- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed +- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html) + +## Advanced Config and Productivity Tweaks + +Zed exposes advanced settings for power users who want to fine-tune their environment. + +Here are a few useful tweaks for Rust developers: + +**Format on Save (uses rustfmt by default):** + +```json +"format_on_save": "on" +``` + +**Configure inlay hints for Rust:** + +```json +{ + "inlay_hints": { + "enabled": true, + "show_type_hints": true, + "show_parameter_hints": true, + "show_other_hints": true + } +} +``` + +**Configure rust-analyzer settings:** + +```json +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "checkOnSave": { + "command": "clippy" + }, + "cargo": { + "allFeatures": true + }, + "procMacro": { + "enable": true + } + } + } + } +} +``` + +**Use a separate target directory for rust-analyzer (faster builds):** + +```json +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "rust-analyzer.cargo.targetDir": true + } + } + } +} +``` + +This tells rust-analyzer to use `target/rust-analyzer` instead of `target`, so IDE analysis doesn't conflict with your manual `cargo build` commands. + +**Enable direnv support (useful for Rust projects using direnv):** + +```json +"load_direnv": "shell_hook" +``` + +**Configure linked projects for workspaces:** + +If you work with multiple Cargo projects that aren't in a workspace, you can tell rust-analyzer about them: + +```json +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "linkedProjects": ["./project-a/Cargo.toml", "./project-b/Cargo.toml"] + } + } + } +} +``` + +## Next Steps + +Now that you're set up, here are some resources to help you get the most out of Zed: + +- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior +- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap +- [Tasks](../tasks.md) — Set up build and run commands for your projects +- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion +- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time +- [Rust in Zed](../languages/rust.md) — Rust-specific setup and configuration diff --git a/docs/src/migrate/webstorm.md b/docs/src/migrate/webstorm.md new file mode 100644 index 0000000000000000000000000000000000000000..78b80b355b47370a821f08fd6108d947182f0acf --- /dev/null +++ b/docs/src/migrate/webstorm.md @@ -0,0 +1,455 @@ +# How to Migrate from WebStorm to Zed + +This guide covers how to set up Zed if you're coming from WebStorm, including keybindings, settings, and the differences you should expect as a JavaScript/TypeScript developer. + +## Install Zed + +Zed is available on macOS, Windows, and Linux. + +For macOS, you can download it from zed.dev/download, or install via Homebrew: + +```sh +brew install --cask zed +``` + +For Windows, download the installer from zed.dev/download, or install via winget: + +```sh +winget install Zed.Zed +``` + +For most Linux users, the easiest way to install Zed is through our installation script: + +```sh +curl -f https://zed.dev/install.sh | sh +``` + +After installation, you can launch Zed from your Applications folder (macOS), Start menu (Windows), or directly from the terminal using: +`zed .` +This opens the current directory in Zed. + +## Set Up the JetBrains Keymap + +If you're coming from WebStorm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Search for `Base Keymap` +3. Select `JetBrains` + +Or add this directly to your `settings.json`: + +```json +{ + "base_keymap": "JetBrains" +} +``` + +This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. + +## Set Up Editor Preferences + +You can configure settings manually in the Settings Editor. + +To edit your settings: + +1. `Cmd+,` to open the Settings Editor. +2. Run `zed: open settings` in the Command Palette. + +Settings WebStorm users typically configure first: + +| Zed Setting | What it does | +| ----------------------- | ------------------------------------------------------------------------------- | +| `format_on_save` | Auto-format when saving. Set to `"on"` to enable. | +| `soft_wrap` | Wrap long lines. Options: `"none"`, `"editor_width"`, `"preferred_line_length"` | +| `preferred_line_length` | Column width for wrapping and rulers. Default is 80. | +| `inlay_hints` | Show parameter names and type hints inline, like WebStorm's hints. | +| `relative_line_numbers` | Useful if you're coming from IdeaVim. | + +Zed also supports per-project settings. Create a `.zed/settings.json` file in your project root to override global settings for that project, similar to how you might use `.idea` folders in WebStorm. + +> **Tip:** If you're joining an existing project, check `format_on_save` before making your first commit. Otherwise you might accidentally reformat an entire file when you only meant to change one line. + +## Open or Create a Project + +After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required. + +To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. For new projects, you'd typically run `npm init`, `pnpm create`, or your framework's CLI tool first, then open the resulting folder in Zed. + +You can also launch Zed from the terminal inside any folder with: +`zed .` + +Once inside a project: + +- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like WebStorm's "Recent Files") +- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like WebStorm's "Search Everywhere") +- Use `Cmd+O` to search for symbols (like WebStorm's "Go to Symbol") + +Open buffers appear as tabs across the top. The sidebar shows your file tree and Git status. Toggle it with `Cmd+1` (just like WebStorm's Project tool window). + +## Differences in Keybindings + +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to WebStorm. + +### Common Shared Keybindings + +| Action | Shortcut | +| ----------------------------- | ----------------------- | +| Search Everywhere | `Shift Shift` | +| Find Action / Command Palette | `Cmd + Shift + A` | +| Go to File | `Cmd + Shift + O` | +| Go to Symbol | `Cmd + O` | +| Recent Files | `Cmd + E` | +| Go to Definition | `Cmd + B` | +| Find Usages | `Alt + F7` | +| Rename Symbol | `Shift + F6` | +| Reformat Code | `Cmd + Alt + L` | +| Toggle Project Panel | `Cmd + 1` | +| Toggle Terminal | `Alt + F12` | +| Duplicate Line | `Cmd + D` | +| Delete Line | `Cmd + Backspace` | +| Move Line Up/Down | `Shift + Alt + Up/Down` | +| Expand/Shrink Selection | `Alt + Up/Down` | +| Comment Line | `Cmd + /` | +| Go Back / Forward | `Cmd + [` / `Cmd + ]` | +| Toggle Breakpoint | `Ctrl + F8` | + +### Different Keybindings (WebStorm → Zed) + +| Action | WebStorm | Zed (JetBrains keymap) | +| ---------------------- | ----------- | ------------------------ | +| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | +| Navigate to Next Error | `F2` | `F2` | +| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | +| Debug | `Ctrl + D` | `Alt + Shift + F9` | +| Stop | `Cmd + F2` | `Ctrl + F2` | + +### Unique to Zed + +| Action | Shortcut | Notes | +| ----------------- | -------------------------- | ------------------------------ | +| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | +| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | + +### How to Customize Keybindings + +- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) +- Run `Zed: Open Keymap Editor` + +This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. + +Zed also supports key sequences (multi-key shortcuts). + +## Differences in User Interfaces + +### No Indexing + +If you've used WebStorm on large projects, you know the wait. Opening a project with many dependencies can mean watching "Indexing..." for anywhere from 30 seconds to several minutes. WebStorm indexes your entire codebase and `node_modules` to power its code intelligence, and re-indexes when dependencies change. + +Zed doesn't index. You open a folder and start coding immediately—no progress bars, no "Indexing paused" banners. File search and navigation stay fast regardless of project size or how many `node_modules` dependencies you have. + +WebStorm's index enables features like finding all usages across your entire codebase, tracking import hierarchies, and flagging unused exports project-wide. Zed relies on language servers for this analysis, which may not cover as much ground. + +**How to adapt:** + +- Search symbols across the project with `Cmd+O` (powered by the TypeScript language server) +- Find files by name with `Cmd+Shift+O` +- Use `Cmd+Shift+F` for text search—it stays fast even in large monorepos +- Run `tsc --noEmit` or `eslint .` from the terminal when you need deeper project-wide analysis + +### LSP vs. Native Language Intelligence + +WebStorm has its own JavaScript and TypeScript analysis engine built by JetBrains. This engine understands your code deeply: it resolves types, tracks data flow, knows about framework-specific patterns, and offers specialized refactorings. + +Zed uses the Language Server Protocol (LSP) for code intelligence. For JavaScript and TypeScript, Zed supports: + +- **vtsls** (default) — Fast TypeScript language server with excellent performance +- **typescript-language-server** — The standard TypeScript LSP implementation +- **ESLint** — Linting integration +- **Prettier** — Code formatting (built-in) + +The TypeScript LSP experience is mature and robust. You get accurate completions, type checking, go-to-definition, and find-references. The experience is comparable to VS Code, which uses the same underlying TypeScript services. + +Where you might notice differences: + +- Framework-specific intelligence (Angular templates, Vue SFCs) may be less integrated +- Some complex refactorings (extract component with proper imports) may be less sophisticated +- Auto-import suggestions depend on what the language server knows about your project + +**How to adapt:** + +- Use `Alt+Enter` for available code actions—the list will vary by language server +- Ensure your `tsconfig.json` is properly configured so the language server understands your project structure +- Use Prettier for consistent formatting (it's enabled by default for JS/TS) +- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel (`Cmd+6`)—ESLint and TypeScript together catch many of the same issues + +### No Project Model + +WebStorm manages projects through `.idea` folders containing XML configuration files, framework detection, and run configurations. This model lets WebStorm remember your project settings, manage npm scripts through the UI, and persist run/debug setups. + +Zed takes a different approach: a project is just a folder. There's no setup wizard, no framework detection dialog, no project structure to configure. + +What this means in practice: + +- Run configurations aren't a thing. Define reusable commands in `tasks.json` instead. Note that your existing `.idea/` configurations won't carry over—you'll set up the ones you need fresh. +- npm scripts live in the terminal. Run `npm run dev`, `pnpm build`, or `yarn test` directly—there's no dedicated npm panel. +- No framework detection. Zed treats React, Angular, Vue, and vanilla JS/TS the same way. + +**How to adapt:** + +- Create a `.zed/settings.json` in your project root for project-specific settings +- Define common commands in `tasks.json` (open via Command Palette: `zed: open tasks`): + +```json +[ + { + "label": "dev", + "command": "npm run dev" + }, + { + "label": "build", + "command": "npm run build" + }, + { + "label": "test", + "command": "npm test" + }, + { + "label": "test current file", + "command": "npm test -- $ZED_FILE" + } +] +``` + +- Use `Ctrl+Alt+R` to run tasks quickly +- Lean on your terminal (`Alt+F12`) for anything tasks don't cover + +### No Framework Integration + +WebStorm's value for web development comes largely from its framework integration. React components get special treatment. Angular has dedicated tooling. Vue single-file components are fully understood. The npm tool window shows all your scripts. + +Zed has none of this built-in. The TypeScript language server sees your code as TypeScript—it doesn't understand that a function is a React component or that a file is an Angular service. + +**How to adapt:** + +- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find component definitions, route configurations, or API endpoints. +- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context +- Consider using framework-specific CLI tools (`ng`, `next`, `vite`) from Zed's terminal +- For React, JSX/TSX syntax and TypeScript types still provide good intelligence + +> **Tip:** For projects with complex configurations, keep your framework's documentation handy. Zed's speed comes with less hand-holding for framework-specific features. + +### Tool Windows vs. Docks + +WebStorm organizes auxiliary views into numbered tool windows (Project = 1, npm = Alt+F11, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": + +| WebStorm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | +| -------------------- | -------------- | --------------------------- | +| Project (1) | Project Panel | `Cmd + 1` | +| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | +| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | +| Structure (7) | Outline Panel | `Cmd + 7` | +| Problems (6) | Diagnostics | `Cmd + 6` | +| Debug (5) | Debug Panel | `Cmd + 5` | + +Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. + +Note that there's no dedicated npm tool window in Zed. Use the terminal or define tasks for your common npm scripts. + +### Debugging + +Both WebStorm and Zed offer integrated debugging for JavaScript and TypeScript: + +- Zed uses `vscode-js-debug` (the same debug adapter that VS Code uses) +- Set breakpoints with `Ctrl+F8` +- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target +- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) +- Continue execution with `F9` + +Zed can debug: + +- Node.js applications and scripts +- Chrome/browser JavaScript +- Jest, Mocha, Vitest, and other test frameworks +- Next.js (both server and client-side) + +For more control, create a `.zed/debug.json` file: + +```json +[ + { + "label": "Debug Current File", + "adapter": "JavaScript", + "program": "$ZED_FILE", + "request": "launch" + }, + { + "label": "Debug Node Server", + "adapter": "JavaScript", + "request": "launch", + "program": "${workspaceFolder}/src/server.js" + }, + { + "label": "Attach to Chrome", + "adapter": "JavaScript", + "request": "attach", + "port": 9222 + } +] +``` + +Zed also recognizes `.vscode/launch.json` configurations, so existing VS Code debug setups often work out of the box. + +### Running Tests + +WebStorm has a dedicated test runner with a visual interface showing pass/fail status for each test. Zed provides test running through: + +- **Gutter icons** — Click the play button next to test functions or describe blocks +- **Tasks** — Define test commands in `tasks.json` +- **Terminal** — Run `npm test`, `jest`, `vitest`, etc. directly + +Zed supports auto-detection for common test frameworks: + +- Jest +- Mocha +- Vitest +- Jasmine +- Bun test +- Node.js test runner + +The test output appears in the terminal panel. For Jest, use `--verbose` for detailed output or `--watch` for continuous testing during development. + +### Extensions vs. Plugins + +WebStorm has a plugin ecosystem covering additional language support, themes, and tool integrations. + +Zed's extension ecosystem is smaller and more focused: + +- Language support and syntax highlighting +- Themes +- Slash commands for AI +- Context servers + +Several features that require plugins in WebStorm are built into Zed: + +- Real-time collaboration with voice chat +- AI coding assistance +- Built-in terminal +- Task runner +- LSP-based code intelligence +- Prettier formatting +- ESLint integration + +### What's Not in Zed + +To set expectations clearly, here's what WebStorm offers that Zed doesn't have: + +- **npm tool window** — Use the terminal or tasks instead +- **HTTP Client** — Use tools like Postman, Insomnia, or curl +- **Database tools** — Use DataGrip, DBeaver, or TablePlus +- **Framework-specific tooling** (Angular schematics, React refactorings) — Use CLI tools +- **Visual package.json editor** — Edit the file directly +- **Built-in REST client** — Use external tools or extensions +- **Profiler integration** — Use Chrome DevTools or Node.js profiling tools + +## Collaboration in Zed vs. WebStorm + +WebStorm offers Code With Me as a separate feature for collaboration. Zed has collaboration built into the core experience. + +- Open the Collab Panel in the left dock +- Create a channel and [invite your collaborators](https://zed.dev/docs/collaboration#inviting-a-collaborator) to join +- [Share your screen or your codebase](https://zed.dev/docs/collaboration#share-a-project) directly + +Once connected, you'll see each other's cursors, selections, and edits in real time. Voice chat is included. There's no need for separate tools or third-party logins. + +## Using AI in Zed + +If you're used to AI assistants in WebStorm (like GitHub Copilot, JetBrains AI Assistant, or Junie), Zed offers similar capabilities with more flexibility. + +### Configuring GitHub Copilot + +1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +2. Navigate to **AI → Edit Predictions** +3. Click **Configure** next to "Configure Providers" +4. Under **GitHub Copilot**, click **Sign in to GitHub** + +Once signed in, just start typing. Zed will offer suggestions inline for you to accept. + +### Additional AI Options + +To use other AI models in Zed, you have several options: + +- Use Zed's hosted models, with higher rate limits. Requires [authentication](https://zed.dev/docs/accounts.html) and subscription to [Zed Pro](https://zed.dev/docs/ai/subscription.html). +- Bring your own [API keys](https://zed.dev/docs/ai/llm-providers.html), no authentication needed +- Use [external agents like Claude Code](https://zed.dev/docs/ai/external-agents.html) + +## Advanced Config and Productivity Tweaks + +Zed exposes advanced settings for power users who want to fine-tune their environment. + +Here are a few useful tweaks for JavaScript/TypeScript developers: + +**Format on Save:** + +```json +"format_on_save": "on" +``` + +**Configure Prettier as the default formatter:** + +```json +{ + "formatter": { + "external": { + "command": "prettier", + "arguments": ["--stdin-filepath", "{buffer_path}"] + } + } +} +``` + +**Enable ESLint code actions:** + +```json +{ + "lsp": { + "eslint": { + "settings": { + "codeActionOnSave": { + "rules": ["import/order"] + } + } + } + } +} +``` + +**Configure TypeScript strict mode hints:** + +In your `tsconfig.json`, enable strict mode for better type checking: + +```json +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true + } +} +``` + +**Enable direnv support (useful for projects using direnv for environment variables):** + +```json +"load_direnv": "shell_hook" +``` + +## Next Steps + +Now that you're set up, here are some resources to help you get the most out of Zed: + +- [Configuring Zed](../configuring-zed.md) — Customize settings, themes, and editor behavior +- [Key Bindings](../key-bindings.md) — Learn how to customize and extend your keymap +- [Tasks](../tasks.md) — Set up build and run commands for your projects +- [AI Features](../ai/overview.md) — Explore Zed's AI capabilities beyond code completion +- [Collaboration](../collaboration/overview.md) — Share your projects and code together in real time +- [JavaScript in Zed](../languages/javascript.md) — JavaScript-specific setup and configuration +- [TypeScript in Zed](../languages/typescript.md) — TypeScript-specific setup and configuration diff --git a/docs/src/performance.md b/docs/src/performance.md index a04d7c5c342d4f0dfa506451d4b890bfdfd1013c..544e39e94babbf9c335a847af8819ad5b00494d1 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -1,6 +1,6 @@ How to use our internal tools to profile and keep Zed fast. -# Flamechart/CPU profiling +# Rough quick CPU profiling (Flamechart) See what the CPU spends the most time on. Strongly recommend you use [samply](https://github.com/mstange/samply). It opens an interactive profile in @@ -12,6 +12,46 @@ The profile.json does not contain any symbols. Firefox profiler can add the loca image +# In depth CPU profiling (Tracing) + +See how long each annotated function call took and its arguments (if +configured). + +Annotate any function you need appear in the profile with instrument. For more +details see +[tracing-instrument](https://docs.rs/tracing/latest/tracing/attr.instrument.html): + +```rust +#[instrument(skip_all)] +fn should_appear_in_profile(kitty: Cat) { + sleep(QUITE_LONG) +} +``` + +Then either compile Zed with `ZTRACING=1 cargo r --features tracy --release`. The release build is optional but highly recommended as like every program Zeds performance characteristics change dramatically with optimizations. You do not want to chase slowdowns that do not exist in release. + +## One time Setup/Building the profiler: + +Download the profiler: +[linux x86_64](https://zed-tracy-import-miniprofiler.nyc3.digitaloceanspaces.com/tracy-profiler-linux-x86_64) +[macos aarch64](https://zed-tracy-import-miniprofiler.nyc3.digitaloceanspaces.com/tracy-profiler-0.13.0-macos-aarch64) + +### Alternative: Building it yourself + +- Clone the repo at git@github.com:wolfpld/tracy.git +- `cd profiler && mkdir build && cd build` +- Run cmake to generate build files: `cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ..` +- Build the profiler: `ninja` +- [Optional] move the profiler somewhere nice like ~/.local/bin on linux + +## Usage + +Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it. +image + +To find functions that take a long time follow this image: +image + # Task/Async profiling Get a profile of the zed foreground executor and background executors. Check if @@ -23,11 +63,17 @@ look at the results live. ## Setup/Building the importer: +Download the importer +[linux x86_64](https://zed-tracy-import-miniprofiler.nyc3.digitaloceanspaces.com/tracy-import-miniprofiler-linux-x86_64) +[mac aarch64](https://zed-tracy-import-miniprofiler.nyc3.digitaloceanspaces.com/tracy-import-miniprofiler-macos-aarch64) + +### Alternative: Building it yourself + - Clone the repo at git@github.com:zed-industries/tracy.git on v0.12.2 branch -- `cd profiler && mkdir build && cd build` +- `cd import && mkdir build && cd build` - Run cmake to generate build files: `cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ..` - Build the importer: `ninja` -- Run the impoter on the trace file: `./tracy-import-miniprofiler /path/to/trace.miniprof /path/to/output.tracy` +- Run the importer on the trace file: `./tracy-import-miniprofiler /path/to/trace.miniprof /path/to/output.tracy` - Open the trace in tracy: - If you're on windows download the v0.12.2 version from the releases on the upstream repo - If you're on other platforms open it on the website: https://tracy.nereid.pl/ (the version might mismatch so your luck might vary, we need to host our own ideally..) diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index f046fa44334554230d19f885e6e38ab0274f2b44..e576a7c662d8a7cde900d8234ae0190776272a26 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -174,14 +174,38 @@ When opening a remote project there are three relevant settings locations: Both the local Zed and the server Zed read the project settings, but they are not aware of the other's main `settings.json`. -Depending on the kind of setting you want to make, which settings file you should use: +Which settings file you should use depends on the kind of setting you want to make: - Project settings should be used for things that affect the project: indentation settings, which formatter / language server to use, etc. -- Server settings should be used for things that affect the server: paths to language servers, etc. +- Server settings should be used for things that affect the server: paths to language servers, proxy settings, etc. - Local settings should be used for things that affect the UI: font size, etc. In addition any extensions you have installed locally will be propagated to the remote server. This means that language servers, etc. will run correctly. +## Proxy Configuration + +The remote server will not use your local machine's proxy configuration because they may be under different network policies. If your remote server requires a proxy to access the internet, you must configure it on the remote server itself. + +In most cases, your remote server will already have proxy environment variables configured. Zed will automatically use them when downloading language servers, communicating with LLM models, etc. + +If needed, you can set these environment variables in the server's shell configuration (e.g., `~/.bashrc`): + +```bash +export http_proxy="http://proxy.example.com:8080" +export https_proxy="http://proxy.example.com:8080" +export no_proxy="localhost,127.0.0.1" +``` + +Alternatively, you can configure the proxy in the remote machine's `~/.config/zed/settings.json` (Linux) or `~/.zed/settings.json` (macOS): + +```json +{ + "proxy": "http://proxy.example.com:8080" +} +``` + +See the [proxy documentation](./configuring-zed.md#network-proxy) for supported proxy types and additional configuration options. + ## Initializing the remote server Once you provide the SSH options, Zed shells out to `ssh` on your local machine to create a ControlMaster connection with the options you provide. @@ -192,7 +216,7 @@ Once the master connection is established, Zed will check to see if the remote s If it is not there or the version mismatches, Zed will try to download the latest version. By default, it will download from `https://zed.dev` directly, but if you set: `{"upload_binary_over_ssh":true}` in your settings for that server, it will download the binary to your local machine and then upload it to the remote server. -If you'd like to maintain the server binary yourself you can. You can either download our prebuilt versions from [GitHub](https://github.com/zed-industries/zed/releases), or [build your own](https://zed.dev/docs/development) with `cargo build -p remote_server --release`. If you do this, you must upload it to `~/.zed_server/zed-remote-server-{RELEASE_CHANNEL}-{VERSION}` on the server, for example `~/.zed_server/zed-remote-server-stable-0.181.6`. The version must exactly match the version of Zed itself you are using. +If you'd like to maintain the server binary yourself you can. You can either download our prebuilt versions from [GitHub](https://github.com/zed-industries/zed/releases), or [build your own](https://zed.dev/docs/development) with `cargo build -p remote_server --release`. If you do this, you must upload it to `~/.zed_server/zed-remote-server-{RELEASE_CHANNEL}-{VERSION}` on the server, for example `~/.zed_server/zed-remote-server-stable-0.217.3+stable.105.80433cb239e868271457ac376673a5f75bc4adb1`. The version must exactly match the version of Zed itself you are using. ## Maintaining the SSH connection diff --git a/docs/src/tab-switcher.md b/docs/src/tab-switcher.md new file mode 100644 index 0000000000000000000000000000000000000000..5cc72be449c94c38fbe4814893595289cb499b5a --- /dev/null +++ b/docs/src/tab-switcher.md @@ -0,0 +1,46 @@ +# Tab Switcher + +The Tab Switcher provides a quick way to navigate between open tabs in Zed. It +displays a list of your open tabs sorted by recent usage, making it easy to jump +back to whatever you were just working on. + +![Tab Switcher with multiple panes](https://zed.dev/img/features/tab-switcher.png) + +## Quick Switching + +When the Tab Switcher is opened using {#kb tab_switcher::Toggle}, instead of +running the {#action tab_switcher::Toggle} from the command palette, it'll stay +active as long as the ctrl key is held down. + +While holding down ctrl, each subsequent tab press cycles to the next item (shift to cycle backwards) and, when ctrl is released, the selected item is confirmed and +the switcher is closed. + +## Opening the Tab Switcher + +The Tab Switcher can also be opened with either {#action tab_switcher::Toggle} +or {#action tab_switcher::ToggleAll}. Using {#kb tab_switcher::Toggle} will show +only the tabs for the current pane, while {#kb tab_switcher::ToggleAll} shows +all tabs for all panes. + +While the Tab Switcher is open, you can: + +- Press {#kb menu::SelectNext} to move to the next tab in the list +- Press {#kb menu::SelectPrevious} to move to the previous tab +- Press enter to confirm the selected tab and close the switcher +- Press escape to close the switcher and return to the original tab from which + the switcher was opened +- Press {#kb tab_switcher::CloseSelectedItem} to close the currently selected tab + +As you navigate through the list, Zed will update the pane's active item to +match the selected tab. + +## Action Reference + +| Action | Description | +| ----------------------------------------- | ------------------------------------------------- | +| {#action tab_switcher::Toggle} | Open the Tab Switcher for the current pane | +| {#action tab_switcher::ToggleAll} | Open the Tab Switcher showing tabs from all panes | +| {#action tab_switcher::CloseSelectedItem} | Close the selected tab in the Tab Switcher | diff --git a/docs/src/vim.md b/docs/src/vim.md index c9a0cd09f2dafb9f07a26ef07b71205f5ddbdf15..09baa9b54f7e1aeb5f16777f4292131315d18928 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -471,7 +471,7 @@ But you cannot use the same shortcuts to move between all the editor docks (the } ``` -Subword motion, which allows you to navigate and select individual words in camelCase or snake_case, is not enabled by default. To enable it, add these bindings to your keymap. +Subword motion, which allows you to navigate and select individual words in `camelCase` or `snake_case`, is not enabled by default. To enable it, add these bindings to your keymap. ```json [settings] { @@ -485,6 +485,9 @@ Subword motion, which allows you to navigate and select individual words in came } ``` +> Note: Operations like `dw` remain unaffected. If you would like operations to +> also use subword motion, remove `vim_mode != operator` from the `context`. + Vim mode comes with shortcuts to surround the selection in normal mode (`ys`), but it doesn't have a shortcut to add surrounds in visual mode. By default, `shift-s` substitutes the selection (erases the text and enters insert mode). To use `shift-s` to add surrounds in visual mode, you can add the following object to your keymap. ```json [settings] @@ -566,7 +569,8 @@ You can change the following settings to modify vim mode's behavior: | use_system_clipboard | Determines how system clipboard is used:
  • "always": use for all operations
  • "never": only use when explicitly specified
  • "on_yank": use for yank operations
| "always" | | use_multiline_find | deprecated | | use_smartcase_find | If `true`, `f` and `t` motions are case-insensitive when the target letter is lowercase. | false | -| toggle_relative_line_numbers | If `true`, line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | false | +| toggle_relative_line_numbers | deprecated | false | +| relative_line_numbers | If "enabled", line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | "disabled" | | custom_digraphs | An object that allows you to add custom digraphs. Read below for an example. | {} | | highlight_on_yank_duration | The duration of the highlight animation(in ms). Set to `0` to disable | 200 | @@ -590,7 +594,7 @@ Here's an example of these settings changed: "default_mode": "insert", "use_system_clipboard": "never", "use_smartcase_find": true, - "toggle_relative_line_numbers": true, + "relative_line_numbers": "enabled", "highlight_on_yank_duration": 50, "custom_digraphs": { "fz": "🧟‍♀️" diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index e5185719279dde488c40573d94fd842c06860f4d..234776b1d3223a4b8634b42df1973a27c736616c 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -118,6 +118,7 @@ To disable this behavior use: "show_project_items": true, // Show/hide project host and name "show_onboarding_banner": true, // Show/hide onboarding banners "show_user_picture": true, // Show/hide user avatar + "show_user_menu": true, // Show/hide app user button "show_sign_in": true, // Show/hide sign-in button "show_menus": false // Show/hide menus }, diff --git a/docs/src/windows.md b/docs/src/windows.md index 34a553dd5b032915ed52651f7f02b737995b959b..b7b4b6b7bf153a2cae7cbf2b7168d502cfbdaeb0 100644 --- a/docs/src/windows.md +++ b/docs/src/windows.md @@ -6,6 +6,14 @@ Get the latest stable builds via [the download page](https://zed.dev/download). You can also build zed from source, see [these docs](https://zed.dev/docs/development/windows) for instructions. +### Package managers + +Additionally, you can install Zed using winget: + +```sh +winget install -e --id ZedIndustries.Zed +``` + ## Uninstall - Installed via installer: Use `Settings` → `Apps` → `Installed apps`, search for Zed, and click Uninstall. diff --git a/docs/src/worktree-trust.md b/docs/src/worktree-trust.md new file mode 100644 index 0000000000000000000000000000000000000000..590f063a75ac5d77e60d50f03af4795d6ec2961f --- /dev/null +++ b/docs/src/worktree-trust.md @@ -0,0 +1,58 @@ +# Zed and trusted worktrees + +A worktree in Zed is either a directory or a single file that Zed opens as a standalone "project". +Zed opens a worktree every time `zed some/path` is invoked, on drag and dropping a file or directory into Zed, on opening user settings.json, etc. + +Every worktree opened may contain a `.zed/settings.json` file with extra configuration options that may require installing and spawning language servers or MCP servers. +In order to provide users the opportunity to make their own choices according to their unique threat model and risk tolerance, all worktrees will be started in Restricted mode, which prevents download and execution of any related items from `.zed/settings.json`. Until configured to trust the worktree(s), Zed will not perform any related untrusted actions and will wait for user confirmation. This gives users a chance to review and understand any pre-configured settings, MCP servers, or language servers associated with a project. + +Note that at this point, Zed trusts the tools it installs itself, hence global entities such as global MCP servers, language servers like prettier and copilot are still in installed and started as usual, independent of worktree trust. + +If a worktree is not trusted, Zed will indicate this with an exclamation mark icon in the title bar. Clicking this icon or using `workspace::ToggleWorktreeSecurity` action will bring up the security modal that allows the user to trust the worktree. + +Trusting any worktree will persist this information between restarts. It's possible to clear all trusted worktrees with `workspace::ClearTrustedWorktrees` command. +This command will restart Zed, to ensure no untrusted settings, language servers or MCP servers persist. + +This feature works locally and on SSH and WSL remote hosts. Zed tracks trust information per host in these cases. + +## What is restricted + +Restricted Mode prevents: + +- Project settings (`.zed/settings.json`) from being parsed and applied +- Language servers from being installed and spawned +- MCP servers from being installed and spawned + +## Configuring broad worktree trust + +By default, Zed won't trust any new worktrees and users will be required to trust each new worktree. Though not recommended, users may elect to trust all worktrees by configuring the following setting: + +```json [settings] +"session": { + "trust_all_worktrees": true +} +``` + +Note that auto trusted worktrees are not persisted between restarts, only manually trusted worktrees are. This ensures that new trust decisions must be made if a users elects to disable the `trust_all_worktrees` setting. + +## Trust hierarchy + +These are mostly internal details and may change in the future, but are helpful to understand how multiple different trust requests can be approved at once. +Zed has multiple layers of trust, based on the requests, from the least to most trusted level: + +- "single file worktree" + +After opening an empty Zed it's possible to open just a file, same as after opening a directory in Zed it's possible to open a file outside of this directory. +A typical scenario where a directory might be open and a single file is subsequently opened is opening Zed's settings.json file via `zed: open settings file` command: that starts a language server for a new file open, which originates from a newly created, single file worktree. + +Spawning a language server presents a risk should the language server experience a supply-chain attack; therefore, Zed restricts that by default. Each single file worktree requires a separate trust grant, unless the directory containing it is trusted or all worktrees are trusted. + +- "directory worktree" + +If a directory is open in Zed, it's a full worktree which may spawn multiple language servers associated with it or spawn MCP servers if contained in a project settings file.Therefore, each directory worktree requires a separate trust grant unless a parent directory worktree trust is granted (see below). + +When a directory worktree is trusted, language and MCP servers are permitted to be downloaded and started, hence we also enable single file worktree trust for the host in question automatically when this occurs: this helps when opening single files when using language server features in the trusted directory worktree. + +- "parent directory worktree" + +To permit trust decisions for multiple directory worktrees at once, it's possible to trust all subdirectories of a given parent directory worktree opened in Zed by checking the appropriate checkbox. This will grant trust to all its subdirectories, including all current and potential directory worktrees. diff --git a/extensions/html/Cargo.toml b/extensions/html/Cargo.toml index 22cdb401a7ebcf4bb6afab7702fb81f345b7aa14..2c89f86cb450b7ea8476bffdff003a94b137d213 100644 --- a/extensions/html/Cargo.toml +++ b/extensions/html/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_html" -version = "0.2.3" +version = "0.3.0" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/html/extension.toml b/extensions/html/extension.toml index 1ded7af6413d0f1990a178a19a4014caadf48240..68ab0e4b9d3f56fca17cbd518d5990edc2ec711a 100644 --- a/extensions/html/extension.toml +++ b/extensions/html/extension.toml @@ -1,7 +1,7 @@ id = "html" name = "HTML" description = "HTML support." -version = "0.2.3" +version = "0.3.0" schema_version = 1 authors = ["Isaac Clayton "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/proto/Cargo.toml b/extensions/proto/Cargo.toml index 1013d62cfa085275a1230d0816049da6c35ba38a..68a524ed944b0db1fd75b9ec5ca5e0b1aa99e89f 100644 --- a/extensions/proto/Cargo.toml +++ b/extensions/proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_proto" -version = "0.2.3" +version = "0.3.1" edition.workspace = true publish.workspace = true license = "Apache-2.0" @@ -13,4 +13,4 @@ path = "src/proto.rs" crate-type = ["cdylib"] [dependencies] -zed_extension_api = "0.1.0" +zed_extension_api = "0.7.0" diff --git a/extensions/proto/extension.toml b/extensions/proto/extension.toml index 9bb8625065fe957308c47488c4aeb9010a773984..70ebed1ca50635d9e818ce216920937a547b64c4 100644 --- a/extensions/proto/extension.toml +++ b/extensions/proto/extension.toml @@ -1,15 +1,24 @@ id = "proto" name = "Proto" description = "Protocol Buffers support." -version = "0.2.3" +version = "0.3.1" schema_version = 1 authors = ["Zed Industries "] repository = "https://github.com/zed-industries/zed" [grammars.proto] -repository = "https://github.com/zed-industries/tree-sitter-proto" -commit = "0848bd30a64be48772e15fbb9d5ba8c0cc5772ad" +repository = "https://github.com/coder3101/tree-sitter-proto" +commit = "a6caac94b5aa36b322b5b70040d5b67132f109d0" + + +[language_servers.buf] +name = "Buf" +languages = ["Proto"] [language_servers.protobuf-language-server] name = "Protobuf Language Server" languages = ["Proto"] + +[language_servers.protols] +name = "Protols" +languages = ["Proto"] diff --git a/extensions/proto/languages/proto/highlights.scm b/extensions/proto/languages/proto/highlights.scm index 5d0a513bee1ca4597c649fbe8e6ca31a00fe9dff..923e00bb1dfca30afcf41a6ab681846d8f20b900 100644 --- a/extensions/proto/languages/proto/highlights.scm +++ b/extensions/proto/languages/proto/highlights.scm @@ -9,6 +9,7 @@ "returns" "message" "enum" + "extend" "oneof" "repeated" "reserved" diff --git a/extensions/proto/src/language_servers.rs b/extensions/proto/src/language_servers.rs new file mode 100644 index 0000000000000000000000000000000000000000..47a5e72d8aadf5d0286667148f0a7dd95fea10ba --- /dev/null +++ b/extensions/proto/src/language_servers.rs @@ -0,0 +1,8 @@ +mod buf; +mod protobuf_language_server; +mod protols; +mod util; + +pub(crate) use buf::*; +pub(crate) use protobuf_language_server::*; +pub(crate) use protols::*; diff --git a/extensions/proto/src/language_servers/buf.rs b/extensions/proto/src/language_servers/buf.rs new file mode 100644 index 0000000000000000000000000000000000000000..92106298d3d1deb6ed2b0f4194ab09321fa09552 --- /dev/null +++ b/extensions/proto/src/language_servers/buf.rs @@ -0,0 +1,114 @@ +use std::fs; + +use zed_extension_api::{ + self as zed, Architecture, DownloadedFileType, GithubReleaseOptions, Os, Result, + settings::LspSettings, +}; + +use crate::language_servers::util; + +pub(crate) struct BufLsp { + cached_binary_path: Option, +} + +impl BufLsp { + pub(crate) const SERVER_NAME: &str = "buf"; + + pub(crate) fn new() -> Self { + BufLsp { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_else(|| ["lsp", "serve"].map(ToOwned::to_owned).into()); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } else if let Some(path) = self.cached_binary_path.clone() { + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } + + let latest_release = zed::latest_github_release( + "bufbuild/buf", + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (os, arch) = zed::current_platform(); + + let release_suffix = match (os, arch) { + (Os::Mac, Architecture::Aarch64) => "Darwin-arm64", + (Os::Mac, Architecture::X8664) => "Darwin-x86_64", + (Os::Linux, Architecture::Aarch64) => "Linux-aarch64", + (Os::Linux, Architecture::X8664) => "Linux-x86_64", + (Os::Windows, Architecture::Aarch64) => "Windows-arm64.exe", + (Os::Windows, Architecture::X8664) => "Windows-x86_64.exe", + _ => { + return Err("Platform and architecture not supported by buf CLI".to_string()); + } + }; + + let release_name = format!("buf-{release_suffix}"); + + let version_dir = format!("{}-{}", Self::SERVER_NAME, latest_release.version); + fs::create_dir_all(&version_dir).map_err(|_| "Could not create directory")?; + + let binary_path = format!("{version_dir}/buf"); + + let download_target = latest_release + .assets + .into_iter() + .find(|asset| asset.name == release_name) + .ok_or_else(|| { + format!( + "Could not find asset with name {} in buf CLI release", + &release_name + ) + })?; + + zed::download_file( + &download_target.download_url, + &binary_path, + DownloadedFileType::Uncompressed, + )?; + zed::make_file_executable(&binary_path)?; + + util::remove_outdated_versions(Self::SERVER_NAME, &version_dir)?; + + self.cached_binary_path = Some(binary_path.clone()); + + Ok(zed::Command { + command: binary_path, + args, + env: Default::default(), + }) + } +} diff --git a/extensions/proto/src/language_servers/protobuf_language_server.rs b/extensions/proto/src/language_servers/protobuf_language_server.rs new file mode 100644 index 0000000000000000000000000000000000000000..f4b13077f73182dd0c30486ee274ade26ec1e40e --- /dev/null +++ b/extensions/proto/src/language_servers/protobuf_language_server.rs @@ -0,0 +1,52 @@ +use zed_extension_api::{self as zed, Result, settings::LspSettings}; + +pub(crate) struct ProtobufLanguageServer { + cached_binary_path: Option, +} + +impl ProtobufLanguageServer { + pub(crate) const SERVER_NAME: &str = "protobuf-language-server"; + + pub(crate) fn new() -> Self { + ProtobufLanguageServer { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_else(|| vec!["-logs".into(), "".into()]); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else if let Some(path) = self.cached_binary_path.clone() { + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else { + Err(format!("{} not found in PATH", Self::SERVER_NAME)) + } + } +} diff --git a/extensions/proto/src/language_servers/protols.rs b/extensions/proto/src/language_servers/protols.rs new file mode 100644 index 0000000000000000000000000000000000000000..90d365eae7d99ccb27d60f774ed700b47323d8d0 --- /dev/null +++ b/extensions/proto/src/language_servers/protols.rs @@ -0,0 +1,113 @@ +use zed_extension_api::{ + self as zed, Architecture, DownloadedFileType, GithubReleaseOptions, Os, Result, + settings::LspSettings, +}; + +use crate::language_servers::util; + +pub(crate) struct ProtoLs { + cached_binary_path: Option, +} + +impl ProtoLs { + pub(crate) const SERVER_NAME: &str = "protols"; + + pub(crate) fn new() -> Self { + ProtoLs { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_default(); + + let env = worktree.shell_env(); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + return Ok(zed::Command { + command: path, + args, + env, + }); + } else if let Some(path) = self.cached_binary_path.clone() { + return Ok(zed::Command { + command: path, + args, + env, + }); + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + return Ok(zed::Command { + command: path, + args, + env, + }); + } + + let latest_release = zed::latest_github_release( + "coder3101/protols", + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (os, arch) = zed::current_platform(); + + let release_suffix = match (os, arch) { + (Os::Mac, Architecture::Aarch64) => "aarch64-apple-darwin.tar.gz", + (Os::Mac, Architecture::X8664) => "x86_64-apple-darwin.tar.gz", + (Os::Linux, Architecture::Aarch64) => "aarch64-unknown-linux-gnu.tar.gz", + (Os::Linux, Architecture::X8664) => "x86_64-unknown-linux-gnu.tar.gz", + (Os::Windows, Architecture::X8664) => "x86_64-pc-windows-msvc.zip", + _ => { + return Err("Platform and architecture not supported by Protols".to_string()); + } + }; + + let release_name = format!("protols-{release_suffix}"); + + let file_type = if os == Os::Windows { + DownloadedFileType::Zip + } else { + DownloadedFileType::GzipTar + }; + + let version_dir = format!("{}-{}", Self::SERVER_NAME, latest_release.version); + let binary_path = format!("{version_dir}/protols"); + + let download_target = latest_release + .assets + .into_iter() + .find(|asset| asset.name == release_name) + .ok_or_else(|| { + format!( + "Could not find asset with name {} in Protols release", + &release_name + ) + })?; + + zed::download_file(&download_target.download_url, &version_dir, file_type)?; + zed::make_file_executable(&binary_path)?; + + util::remove_outdated_versions(Self::SERVER_NAME, &version_dir)?; + + self.cached_binary_path = Some(binary_path.clone()); + + Ok(zed::Command { + command: binary_path, + args, + env, + }) + } +} diff --git a/extensions/proto/src/language_servers/util.rs b/extensions/proto/src/language_servers/util.rs new file mode 100644 index 0000000000000000000000000000000000000000..3036c9bc3aaf9cc3fccd462fe0ad70aa31892012 --- /dev/null +++ b/extensions/proto/src/language_servers/util.rs @@ -0,0 +1,19 @@ +use std::fs; + +use zed_extension_api::Result; + +pub(super) fn remove_outdated_versions( + language_server_id: &'static str, + version_dir: &str, +) -> Result<()> { + let entries = fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str().is_none_or(|file_name| { + file_name.starts_with(language_server_id) && file_name != version_dir + }) { + fs::remove_dir_all(entry.path()).ok(); + } + } + Ok(()) +} diff --git a/extensions/proto/src/proto.rs b/extensions/proto/src/proto.rs index 36ba0faf5feda66af8824387240e34a730a476b7..07e0ccedcee287f037576db56d5a9d7958ea83f9 100644 --- a/extensions/proto/src/proto.rs +++ b/extensions/proto/src/proto.rs @@ -1,48 +1,22 @@ use zed_extension_api::{self as zed, Result, settings::LspSettings}; -const PROTOBUF_LANGUAGE_SERVER_NAME: &str = "protobuf-language-server"; +use crate::language_servers::{BufLsp, ProtoLs, ProtobufLanguageServer}; -struct ProtobufLanguageServerBinary { - path: String, - args: Option>, -} - -struct ProtobufExtension; - -impl ProtobufExtension { - fn language_server_binary( - &self, - _language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result { - let binary_settings = LspSettings::for_worktree("protobuf-language-server", worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.binary); - let binary_args = binary_settings - .as_ref() - .and_then(|binary_settings| binary_settings.arguments.clone()); - - if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { - return Ok(ProtobufLanguageServerBinary { - path, - args: binary_args, - }); - } - - if let Some(path) = worktree.which(PROTOBUF_LANGUAGE_SERVER_NAME) { - return Ok(ProtobufLanguageServerBinary { - path, - args: binary_args, - }); - } +mod language_servers; - Err(format!("{PROTOBUF_LANGUAGE_SERVER_NAME} not found in PATH",)) - } +struct ProtobufExtension { + protobuf_language_server: Option, + protols: Option, + buf_lsp: Option, } impl zed::Extension for ProtobufExtension { fn new() -> Self { - Self + Self { + protobuf_language_server: None, + protols: None, + buf_lsp: None, + } } fn language_server_command( @@ -50,14 +24,24 @@ impl zed::Extension for ProtobufExtension { language_server_id: &zed_extension_api::LanguageServerId, worktree: &zed_extension_api::Worktree, ) -> zed_extension_api::Result { - let binary = self.language_server_binary(language_server_id, worktree)?; - Ok(zed::Command { - command: binary.path, - args: binary - .args - .unwrap_or_else(|| vec!["-logs".into(), "".into()]), - env: Default::default(), - }) + match language_server_id.as_ref() { + ProtobufLanguageServer::SERVER_NAME => self + .protobuf_language_server + .get_or_insert_with(ProtobufLanguageServer::new) + .language_server_binary(worktree), + + ProtoLs::SERVER_NAME => self + .protols + .get_or_insert_with(ProtoLs::new) + .language_server_binary(worktree), + + BufLsp::SERVER_NAME => self + .buf_lsp + .get_or_insert_with(BufLsp::new) + .language_server_binary(worktree), + + _ => Err(format!("Unknown language server ID {}", language_server_id)), + } } fn language_server_workspace_configuration( @@ -65,10 +49,8 @@ impl zed::Extension for ProtobufExtension { server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> Result> { - let settings = LspSettings::for_worktree(server_id.as_ref(), worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.settings); - Ok(settings) + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.settings) } fn language_server_initialization_options( @@ -76,10 +58,8 @@ impl zed::Extension for ProtobufExtension { server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> Result> { - let initialization_options = LspSettings::for_worktree(server_id.as_ref(), worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.initialization_options); - Ok(initialization_options) + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.initialization_options) } } diff --git a/extensions/workflows/bump_version.yml b/extensions/workflows/bump_version.yml index ad231298ec3848b165d8fd07ee664cb88ba6430d..7f4318dcf54ad8c9360ae622354530b2b54c6a03 100644 --- a/extensions/workflows/bump_version.yml +++ b/extensions/workflows/bump_version.yml @@ -8,6 +8,9 @@ on: push: branches: - main + paths-ignore: + - .github/** + workflow_dispatch: {} jobs: determine_bump_type: runs-on: namespace-profile-16x32-ubuntu-2204 diff --git a/extensions/workflows/run_tests.yml b/extensions/workflows/run_tests.yml index 28cd288400643052011d4032f6c12b056ac4d301..81ba76c483479ed827f0a91181557a2387b40722 100644 --- a/extensions/workflows/run_tests.yml +++ b/extensions/workflows/run_tests.yml @@ -5,6 +5,9 @@ on: pull_request: branches: - '**' + push: + branches: + - main jobs: call_extension_tests: uses: zed-industries/zed/.github/workflows/extension_tests.yml@main diff --git a/flake.lock b/flake.lock index 3074b947ef51c387b5d20aba85478636f48de557..561919d5745a2355aad14a4fe9972bf9fbf3d8d2 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1762538466, - "narHash": "sha256-8zrIPl6J+wLm9MH5ksHcW7BUHo7jSNOu0/hA0ohOOaM=", + "lastModified": 1765145449, + "narHash": "sha256-aBVHGWWRzSpfL++LubA0CwOOQ64WNLegrYHwsVuVN7A=", "owner": "ipetkov", "repo": "crane", - "rev": "0cea393fffb39575c46b7a0318386467272182fe", + "rev": "69f538cdce5955fcd47abfed4395dc6d5194c1c5", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "flake-compat": { "locked": { - "lastModified": 1761588595, - "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", + "lastModified": 1765121682, + "narHash": "sha256-4VBOP18BFeiPkyhy9o4ssBNQEvfvv1kXkasAYd0+rrA=", "owner": "edolstra", "repo": "flake-compat", - "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", + "rev": "65f23138d8d09a92e30f1e5c87611b23ef451bf3", "type": "github" }, "original": { @@ -32,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 315532800, - "narHash": "sha256-5CwQ80ucRHiqVbMEEbTFnjz70/axSJ0aliyzSaFSkmY=", - "rev": "f6b44b2401525650256b977063dbcf830f762369", + "lastModified": 1765772535, + "narHash": "sha256-I715zWsdVZ+CipmLtoCAeNG0etQywiWRE5PaWntnaYk=", + "rev": "09b8fda8959d761445f12b55f380d90375a1d6bb", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre891648.f6b44b240152/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre911985.09b8fda8959d/nixexprs.tar.xz" }, "original": { "type": "tarball", @@ -53,16 +53,14 @@ }, "rust-overlay": { "inputs": { - "nixpkgs": [ - "nixpkgs" - ] + "nixpkgs": ["nixpkgs"] }, "locked": { - "lastModified": 1762915112, - "narHash": "sha256-d9j1g8nKmYDHy+/bIOPQTh9IwjRliqaTM0QLHMV92Ic=", + "lastModified": 1765465581, + "narHash": "sha256-fCXT0aZXmTalM3NPCTedVs9xb0egBG5BOZkcrYo5PGE=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "aa1e85921cfa04de7b6914982a94621fbec5cc02", + "rev": "99cc5667eece98bb35dcf35f7e511031a8b7a125", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index fe7a09701beb714506742f2712cb3a74b676bc19..744ca708a3a2104f0050cd85e8ee05f04e49a713 100644 --- a/flake.nix +++ b/flake.nix @@ -37,14 +37,14 @@ rustToolchain = rustBin.fromRustupToolchainFile ./rust-toolchain.toml; }; in - rec { + { packages = forAllSystems (pkgs: rec { default = mkZed pkgs; debug = default.override { profile = "dev"; }; }); devShells = forAllSystems (pkgs: { default = pkgs.callPackage ./nix/shell.nix { - zed-editor = packages.${pkgs.hostPlatform.system}.default; + zed-editor = mkZed pkgs; }; }); formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style); diff --git a/nix/build.nix b/nix/build.nix index 9012a47c1fa1a1874ec4283bb73eae96087d4529..16b03e9a53bd2118c9b5bf45cf8fb7720ee5022b 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -83,70 +83,94 @@ let cargoLock = ../Cargo.lock; - nativeBuildInputs = - [ - cmake - copyDesktopItems - curl - perl - pkg-config - protobuf - cargo-about - rustPlatform.bindgenHook - ] - ++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ] - ++ lib.optionals stdenv'.hostPlatform.isDarwin [ - (cargo-bundle.overrideAttrs ( - new: old: { - version = "0.6.1-zed"; - src = fetchFromGitHub { - owner = "zed-industries"; - repo = "cargo-bundle"; - rev = "2be2669972dff3ddd4daf89a2cb29d2d06cad7c7"; - hash = "sha256-cSvW0ND148AGdIGWg/ku0yIacVgW+9f1Nsi+kAQxVrI="; - }; - cargoHash = "sha256-urn+A3yuw2uAO4HGmvQnKvWtHqvG9KHxNCCWTiytE4k="; - - # NOTE: can drop once upstream uses `finalAttrs` here: - # https://github.com/NixOS/nixpkgs/blob/10214747f5e6e7cb5b9bdf9e018a3c7b3032f5af/pkgs/build-support/rust/build-rust-package/default.nix#L104 - # - # See (for context): https://github.com/NixOS/nixpkgs/pull/382550 - cargoDeps = rustPlatform.fetchCargoVendor { - inherit (new) src; - hash = new.cargoHash; - patches = new.cargoPatches or []; - name = new.cargoDepsName or new.finalPackage.name; - }; - } - )) - ]; - - buildInputs = - [ - curl - fontconfig - freetype - # TODO: need staticlib of this for linking the musl remote server. - # should make it a separate derivation/flake output - # see https://crane.dev/examples/cross-musl.html - libgit2 - openssl - sqlite - zlib - zstd - ] - ++ lib.optionals stdenv'.hostPlatform.isLinux [ - alsa-lib - libxkbcommon - wayland - gpu-lib - xorg.libX11 - xorg.libxcb - ] - ++ lib.optionals stdenv'.hostPlatform.isDarwin [ - apple-sdk_15 - (darwinMinVersionHook "10.15") - ]; + nativeBuildInputs = [ + cmake + copyDesktopItems + curl + perl + pkg-config + protobuf + # Pin cargo-about to 0.8.2. Newer versions don't work with the current license identifiers + # See https://github.com/zed-industries/zed/pull/44012 + (cargo-about.overrideAttrs ( + new: old: rec { + version = "0.8.2"; + + src = fetchFromGitHub { + owner = "EmbarkStudios"; + repo = "cargo-about"; + tag = version; + sha256 = "sha256-cNKZpDlfqEXeOE5lmu79AcKOawkPpk4PQCsBzNtIEbs="; + }; + + cargoHash = "sha256-NnocSs6UkuF/mCM3lIdFk+r51Iz2bHuYzMT/gEbT/nk="; + + # NOTE: can drop once upstream uses `finalAttrs` here: + # https://github.com/NixOS/nixpkgs/blob/10214747f5e6e7cb5b9bdf9e018a3c7b3032f5af/pkgs/build-support/rust/build-rust-package/default.nix#L104 + # + # See (for context): https://github.com/NixOS/nixpkgs/pull/382550 + cargoDeps = rustPlatform.fetchCargoVendor { + inherit (new) src; + hash = new.cargoHash; + patches = new.cargoPatches or [ ]; + name = new.cargoDepsName or new.finalPackage.name; + }; + } + )) + rustPlatform.bindgenHook + ] + ++ lib.optionals stdenv'.hostPlatform.isLinux [ makeWrapper ] + ++ lib.optionals stdenv'.hostPlatform.isDarwin [ + (cargo-bundle.overrideAttrs ( + new: old: { + version = "0.6.1-zed"; + src = fetchFromGitHub { + owner = "zed-industries"; + repo = "cargo-bundle"; + rev = "2be2669972dff3ddd4daf89a2cb29d2d06cad7c7"; + hash = "sha256-cSvW0ND148AGdIGWg/ku0yIacVgW+9f1Nsi+kAQxVrI="; + }; + cargoHash = "sha256-urn+A3yuw2uAO4HGmvQnKvWtHqvG9KHxNCCWTiytE4k="; + + # NOTE: can drop once upstream uses `finalAttrs` here: + # https://github.com/NixOS/nixpkgs/blob/10214747f5e6e7cb5b9bdf9e018a3c7b3032f5af/pkgs/build-support/rust/build-rust-package/default.nix#L104 + # + # See (for context): https://github.com/NixOS/nixpkgs/pull/382550 + cargoDeps = rustPlatform.fetchCargoVendor { + inherit (new) src; + hash = new.cargoHash; + patches = new.cargoPatches or [ ]; + name = new.cargoDepsName or new.finalPackage.name; + }; + } + )) + ]; + + buildInputs = [ + curl + fontconfig + freetype + # TODO: need staticlib of this for linking the musl remote server. + # should make it a separate derivation/flake output + # see https://crane.dev/examples/cross-musl.html + libgit2 + openssl + sqlite + zlib + zstd + ] + ++ lib.optionals stdenv'.hostPlatform.isLinux [ + alsa-lib + libxkbcommon + wayland + gpu-lib + xorg.libX11 + xorg.libxcb + ] + ++ lib.optionals stdenv'.hostPlatform.isDarwin [ + apple-sdk_15 + (darwinMinVersionHook "10.15") + ]; cargoExtraArgs = "-p zed -p cli --locked --features=gpui/runtime_shaders"; @@ -177,6 +201,7 @@ let ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; RELEASE_VERSION = version; LK_CUSTOM_WEBRTC = livekit-libwebrtc; + PROTOC = "${protobuf}/bin/protoc"; CARGO_PROFILE = profile; # need to handle some profiles specially https://github.com/rust-lang/cargo/issues/11053 @@ -216,14 +241,13 @@ let # `webrtc-sys` expects a staticlib; nixpkgs' `livekit-webrtc` has been patched to # produce a `dylib`... patching `webrtc-sys`'s build script is the easier option # TODO: send livekit sdk a PR to make this configurable - postPatch = - '' - substituteInPlace webrtc-sys/build.rs --replace-fail \ - "cargo:rustc-link-lib=static=webrtc" "cargo:rustc-link-lib=dylib=webrtc" - '' - + lib.optionalString withGLES '' - cat ${glesConfig} >> .cargo/config/config.toml - ''; + postPatch = '' + substituteInPlace webrtc-sys/build.rs --replace-fail \ + "cargo:rustc-link-lib=static=webrtc" "cargo:rustc-link-lib=dylib=webrtc" + '' + + lib.optionalString withGLES '' + cat ${glesConfig} >> .cargo/config/config.toml + ''; in crates: drv: if hasWebRtcSys crates then diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 59765d94abe9c04e6668203de31b598dd6b34dc7..e7cc22421d71ba35b592dd2163da1927c4abf118 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.91.1" +channel = "1.92" profile = "minimal" components = [ "rustfmt", "clippy" ] targets = [ diff --git a/script/bundle-mac b/script/bundle-mac index c6c925f073600336f4aa3114a732609481ade26e..93ea07b162612d27784dbd0eb54598b0aa2252c3 100755 --- a/script/bundle-mac +++ b/script/bundle-mac @@ -106,6 +106,17 @@ mv Cargo.toml.backup Cargo.toml popd echo "Bundled ${app_path}" +# DocumentTypes.plist references CFBundleTypeIconFile "Document", so the bundle must contain Document.icns. +# We use the app icon as a placeholder document icon for now. +document_icon_source="crates/zed/resources/Document.icns" +document_icon_target="${app_path}/Contents/Resources/Document.icns" +if [[ -f "${document_icon_source}" ]]; then + mkdir -p "$(dirname "${document_icon_target}")" + cp "${document_icon_source}" "${document_icon_target}" +else + echo "cargo::warning=Missing ${document_icon_source}; macOS document icons may not appear in Finder." +fi + if [[ -n "${MACOS_CERTIFICATE:-}" && -n "${MACOS_CERTIFICATE_PASSWORD:-}" && -n "${APPLE_NOTARIZATION_KEY:-}" && -n "${APPLE_NOTARIZATION_KEY_ID:-}" && -n "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then can_code_sign=true diff --git a/script/create-migration b/script/create-migration deleted file mode 100755 index 187336be199ffc4d49f7f6e02b250090613a32fc..0000000000000000000000000000000000000000 --- a/script/create-migration +++ /dev/null @@ -1,3 +0,0 @@ -zed . \ - "crates/collab/migrations.sqlite/20221109000000_test_schema.sql" \ - "crates/collab/migrations/$(date -u +%Y%m%d%H%M%S)_$(echo $1 | sed 's/[^a-z0-9]/_/g').sql" diff --git a/script/danger/dangerfile.ts b/script/danger/dangerfile.ts index 88dc5c5e71c640a83315ac5f1b14c216763023fd..7151985021f3fdfb75c01c6e2f8c964fa51d3740 100644 --- a/script/danger/dangerfile.ts +++ b/script/danger/dangerfile.ts @@ -6,6 +6,9 @@ prHygiene({ rules: { // Don't enable this rule just yet, as it can have false positives. useImperativeMood: "off", + noConventionalCommits: { + bannedTypes: ["feat", "fix", "style", "refactor", "perf", "test", "chore", "build", "revert"], + }, }, }); diff --git a/script/danger/package.json b/script/danger/package.json index eaa1035e89c97da8ef2089e97eb638d649ee6877..be44da6233a1c5ee87f8445e13953031497acfa5 100644 --- a/script/danger/package.json +++ b/script/danger/package.json @@ -8,6 +8,6 @@ }, "devDependencies": { "danger": "13.0.4", - "danger-plugin-pr-hygiene": "0.6.1" + "danger-plugin-pr-hygiene": "0.7.1" } } diff --git a/script/danger/pnpm-lock.yaml b/script/danger/pnpm-lock.yaml index fd6b3f66acb627d57520e4ca928cc8ce2793b4b9..eea293cfed78fcf43ed926484b2f13b5b9c74843 100644 --- a/script/danger/pnpm-lock.yaml +++ b/script/danger/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 13.0.4 version: 13.0.4 danger-plugin-pr-hygiene: - specifier: 0.6.1 - version: 0.6.1 + specifier: 0.7.1 + version: 0.7.1 packages: @@ -134,8 +134,8 @@ packages: core-js@3.45.1: resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==} - danger-plugin-pr-hygiene@0.6.1: - resolution: {integrity: sha512-nb+iUQvirE3BlKXI1WoOND6sujyGzHar590mJm5tt4RLi65HXFaU5hqONxgDoWFujJNHYnXse9yaZdxnxEi4QA==} + danger-plugin-pr-hygiene@0.7.1: + resolution: {integrity: sha512-ll070nNaL3OeO2nooYWflPE/CRKLeq8GiH2C68u5zM3gW4gepH89GhVv0sYNNGLx4cYwa1zZ/TuiYYhC49z06Q==} danger@13.0.4: resolution: {integrity: sha512-IAdQ5nSJyIs4zKj6AN35ixt2B0Ce3WZUm3IFe/CMnL/Op7wV7IGg4D348U0EKNaNPP58QgXbdSk9pM+IXP1QXg==} @@ -573,7 +573,7 @@ snapshots: core-js@3.45.1: {} - danger-plugin-pr-hygiene@0.6.1: {} + danger-plugin-pr-hygiene@0.7.1: {} danger@13.0.4: dependencies: diff --git a/script/generate-action-metadata b/script/generate-action-metadata new file mode 100755 index 0000000000000000000000000000000000000000..146b1f0d78ef92c47322a70dccf0e9e1f3f530d3 --- /dev/null +++ b/script/generate-action-metadata @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "Generating action metadata..." +cargo run -p zed -- --dump-all-actions > crates/docs_preprocessor/actions.json + +echo "Generated crates/docs_preprocessor/actions.json with $(grep -c '"name":' crates/docs_preprocessor/actions.json) actions" diff --git a/script/prettier b/script/prettier index b1d28fb66d70c08a6d03b21be6f168fd0b2da5dc..d7a9ba787fca2343cd705ff0d37e502a7aa9f77c 100755 --- a/script/prettier +++ b/script/prettier @@ -3,14 +3,20 @@ set -euxo pipefail PRETTIER_VERSION=3.5.0 -pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --check || { +if [[ "${1:-}" == "--write" ]]; then + MODE="--write" +else + MODE="--check" +fi + +pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --parser=jsonc $MODE || { echo "To fix, run from the root of the Zed repo:" - echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --write" + echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --parser=jsonc --write" false } cd docs -pnpm dlx "prettier@${PRETTIER_VERSION}" . --check || { +pnpm dlx "prettier@${PRETTIER_VERSION}" . $MODE || { echo "To fix, run from the root of the Zed repo:" echo " cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .." false diff --git a/script/triage_watcher.jl b/script/triage_watcher.jl new file mode 100644 index 0000000000000000000000000000000000000000..905d2fdd4eb73c7bcbed304f9d4d94d0d94943f2 --- /dev/null +++ b/script/triage_watcher.jl @@ -0,0 +1,38 @@ +## Triage Watcher v0.1 +# This is a small script to watch for new issues on the Zed repository and open them in a new browser tab interactively. +# +## Installing Julia +# +# You need Julia installed on your system: +# curl -fsSL https://install.julialang.org | sh +# +## Running this script: +# 1. It only works on Macos/Linux +# Open a new Julia repl with `julia` inside the `zed` repo +# 2. Paste the following code +# 3. Whenever you close your computer, just type the Up arrow on the REPL + enter to rerun the loop again to resume +function get_issues() + entries = filter(x -> occursin("state:needs triage", x), split(read(`gh issue list -L 10`, String), '\n')) + top = findfirst.('\t', entries) .- 1 + [entries[i][begin:top[i]] for i in eachindex(entries)] +end + +nums = get_issues(); +while true + new_nums = get_issues() + # Open each new issue in a new browser tab + for issue_num in setdiff(new_nums, nums) + url = "https://github.com/zed-industries/zed/issues/" * issue_num + println("\nOpening $url") + open_tab = `/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome $url` + try + sound_file = "/Users/mrg/Downloads/mario_coin_sound.mp3" + run(`afplay -v 0.02 $sound_file`) + finally + end + run(open_tab) + end + nums = new_nums + print("🧘🏼") + sleep(60) +end diff --git a/script/update_top_ranking_issues/main.py b/script/update_top_ranking_issues/main.py index c6ea1a6cde6ea7641d48dddfec3cf26c3b5175bb..336c00497a4dd99e43fcea2fac1a6d763d8acded 100644 --- a/script/update_top_ranking_issues/main.py +++ b/script/update_top_ranking_issues/main.py @@ -1,29 +1,24 @@ import os -from datetime import datetime, timedelta -from typing import Optional +from datetime import date, datetime, timedelta +from typing import Any, Optional +import requests import typer -from github import Github -from github.Issue import Issue -from github.Repository import Repository from pytz import timezone from typer import Typer app: Typer = typer.Typer() -DATETIME_FORMAT: str = "%m/%d/%Y %I:%M %p" -ISSUES_PER_LABEL: int = 50 +AMERICA_NEW_YORK_TIMEZONE = "America/New_York" +DATETIME_FORMAT: str = "%B %d, %Y %I:%M %p" +ISSUES_PER_SECTION: int = 50 +ISSUES_TO_FETCH: int = 100 +REPO_OWNER = "zed-industries" +REPO_NAME = "zed" +GITHUB_API_BASE_URL = "https://api.github.com" -class IssueData: - def __init__(self, issue: Issue) -> None: - self.title = issue.title - self.url: str = issue.html_url - self.like_count: int = issue._rawData["reactions"]["+1"] # type: ignore [attr-defined] - self.creation_datetime: str = issue.created_at.strftime(DATETIME_FORMAT) - # TODO: Change script to support storing labels here, rather than directly in the script - self.labels: set[str] = {label["name"] for label in issue._rawData["labels"]} # type: ignore [attr-defined] - self._issue = issue +EXCLUDE_LABEL = "ignore top-ranking issues" @app.command() @@ -32,181 +27,135 @@ def main( issue_reference_number: Optional[int] = None, query_day_interval: Optional[int] = None, ) -> None: - start_time: datetime = datetime.now() - - start_date: datetime | None = None + script_start_time: datetime = datetime.now() + start_date: date | None = None if query_day_interval: - tz = timezone("america/new_york") - current_time = datetime.now(tz).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - start_date = current_time - timedelta(days=query_day_interval) + tz = timezone(AMERICA_NEW_YORK_TIMEZONE) + today = datetime.now(tz).date() + start_date = today - timedelta(days=query_day_interval) - # GitHub Workflow will pass in the token as an environment variable, + # GitHub Workflow will pass in the token as an argument, # but we can place it in our env when running the script locally, for convenience - github_token = github_token or os.getenv("GITHUB_ACCESS_TOKEN") - - with Github(github_token, per_page=100) as github: - remaining_requests_before: int = github.rate_limiting[0] - print(f"Remaining requests before: {remaining_requests_before}") - - repo_name: str = "zed-industries/zed" - repository: Repository = github.get_repo(repo_name) - - label_to_issue_data: dict[str, list[IssueData]] = get_issue_maps( - github, repository, start_date + token = github_token or os.getenv("GITHUB_ACCESS_TOKEN") + if not token: + raise typer.BadParameter( + "GitHub token is required. Pass --github-token or set GITHUB_ACCESS_TOKEN env var." ) - issue_text: str = get_issue_text(label_to_issue_data) - - if issue_reference_number: - top_ranking_issues_issue: Issue = repository.get_issue(issue_reference_number) - top_ranking_issues_issue.edit(body=issue_text) - else: - print(issue_text) - - remaining_requests_after: int = github.rate_limiting[0] - print(f"Remaining requests after: {remaining_requests_after}") - print(f"Requests used: {remaining_requests_before - remaining_requests_after}") - - run_duration: timedelta = datetime.now() - start_time - print(run_duration) - - -def get_issue_maps( - github: Github, - repository: Repository, - start_date: datetime | None = None, -) -> dict[str, list[IssueData]]: - label_to_issue_data: dict[str, list[IssueData]] = get_label_to_issue_data( - github, - repository, - start_date, - ) - - # Create a new dictionary with labels ordered by the summation the of likes on the associated issues - labels = list(label_to_issue_data.keys()) - - labels.sort( - key=lambda label: sum( - issue_data.like_count for issue_data in label_to_issue_data[label] - ), - reverse=True, - ) + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + } - label_to_issue_data = {label: label_to_issue_data[label] for label in labels} + section_to_issues = get_section_to_issues(headers, start_date) + issue_text: str = create_issue_text(section_to_issues) - return label_to_issue_data + if issue_reference_number: + update_reference_issue(headers, issue_reference_number, issue_text) + else: + print(issue_text) + run_duration: timedelta = datetime.now() - script_start_time + print(f"Ran for {run_duration}") -def get_label_to_issue_data( - github: Github, - repository: Repository, - start_date: datetime | None = None, -) -> dict[str, list[IssueData]]: - common_queries = [ - f"repo:{repository.full_name}", - "is:open", - "is:issue", - '-label:"ignore top-ranking issues"', - "sort:reactions-+1-desc", - ] - date_query: str | None = ( - f"created:>={start_date.strftime('%Y-%m-%d')}" if start_date else None - ) +def get_section_to_issues( + headers: dict[str, str], start_date: date | None = None +) -> dict[str, list[dict[str, Any]]]: + """Fetch top-ranked issues for each section from GitHub.""" - if date_query: - common_queries.append(date_query) - - common_query = " ".join(common_queries) - - # Because PyGithub doesn't seem to support logical operators `AND` and `OR` - # that GitHub issue queries can use, we use lists as values, rather than - # using `(label:bug OR type:Bug)`. This is not as efficient, as we might - # query the same issue multiple times. Issues that are potentially queried - # multiple times are deduplicated in the `label_to_issues` dictionary. If - # PyGithub ever supports logical operators, we should definitely make the - # switch. - section_queries: dict[str, list[str]] = { - "bug": ["label:bug", "type:Bug"], - "crash": ["label:crash", "type:Crash"], - "feature": ["label:feature", "type:Feature"], - "meta": ["type:Meta"], - "windows": ["label:windows"], - "unlabeled": ["no:label no:type"], + section_filters = { + "Bugs": "type:Bug", + "Crashes": "type:Crash", + "Features": "type:Feature", + "Tracking issues": "type:Tracking", + "Meta issues": "type:Meta", + "Windows": 'label:"platform:windows"', } - label_to_issue_data: dict[str, list[IssueData]] = {} - - for section, queries in section_queries.items(): - unique_issues = set() - - for query in queries: - query: str = f"{common_query} {query}" - issues = github.search_issues(query) - - for issue in issues: - unique_issues.add(issue) - - if len(unique_issues) <= 0: + section_to_issues: dict[str, list[dict[str, Any]]] = {} + for section, search_qualifier in section_filters.items(): + query_parts = [ + f"repo:{REPO_OWNER}/{REPO_NAME}", + "is:issue", + "is:open", + f'-label:"{EXCLUDE_LABEL}"', + search_qualifier, + ] + + if start_date: + query_parts.append(f"created:>={start_date.strftime('%Y-%m-%d')}") + + query = " ".join(query_parts) + url = f"{GITHUB_API_BASE_URL}/search/issues" + params = { + "q": query, + "sort": "reactions-+1", + "order": "desc", + "per_page": ISSUES_TO_FETCH, # this will work as long as it's ≤ 100 + } + + # we are only fetching one page on purpose + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + items = response.json()["items"] + + issues: list[dict[str, Any]] = [] + for item in items: + reactions = item["reactions"] + score = reactions["+1"] - reactions["-1"] + if score > 0: + issues.append({ + "url": item["html_url"], + "score": score, + "created_at": item["created_at"], + }) + + if not issues: continue - issue_data: list[IssueData] = [IssueData(issue) for issue in unique_issues] - issue_data.sort( - key=lambda issue_data: ( - -issue_data.like_count, - issue_data.creation_datetime, - ) - ) - - label_to_issue_data[section] = issue_data[0:ISSUES_PER_LABEL] - - return label_to_issue_data - + issues.sort(key=lambda x: (-x["score"], x["created_at"])) + section_to_issues[section] = issues[:ISSUES_PER_SECTION] -def get_issue_text( - label_to_issue_data: dict[str, list[IssueData]], -) -> str: - tz = timezone("america/new_york") - current_datetime: str = datetime.now(tz).strftime(f"{DATETIME_FORMAT} (%Z)") - - highest_ranking_issues_lines: list[str] = get_highest_ranking_issues_lines( - label_to_issue_data + # Sort sections by total score (highest total first) + section_to_issues = dict( + sorted( + section_to_issues.items(), + key=lambda item: sum(issue["score"] for issue in item[1]), + reverse=True, + ) ) + return section_to_issues - issue_text_lines: list[str] = [ - f"*Updated on {current_datetime}*", - *highest_ranking_issues_lines, - "\n---\n", - "*For details on how this issue is generated, [see the script](https://github.com/zed-industries/zed/blob/main/script/update_top_ranking_issues/main.py)*", - ] - return "\n".join(issue_text_lines) +def update_reference_issue( + headers: dict[str, str], issue_number: int, body: str +) -> None: + url = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}" + response = requests.patch(url, headers=headers, json={"body": body}) + response.raise_for_status() -def get_highest_ranking_issues_lines( - label_to_issue_data: dict[str, list[IssueData]], -) -> list[str]: - highest_ranking_issues_lines: list[str] = [] +def create_issue_text(section_to_issues: dict[str, list[dict[str, Any]]]) -> str: + tz = timezone(AMERICA_NEW_YORK_TIMEZONE) + current_datetime: str = datetime.now(tz).strftime(f"{DATETIME_FORMAT} (%Z)") - if label_to_issue_data: - for label, issue_data in label_to_issue_data.items(): - highest_ranking_issues_lines.append(f"\n## {label}\n") + lines: list[str] = [f"*Updated on {current_datetime}*"] - for i, issue_data in enumerate(issue_data): - markdown_bullet_point: str = ( - f"{issue_data.url} ({issue_data.like_count} :thumbsup:)" - ) + for section, issues in section_to_issues.items(): + lines.append(f"\n## {section}\n") + for i, issue in enumerate(issues): + lines.append(f"{i + 1}. {issue['url']} ({issue['score']} :thumbsup:)") - markdown_bullet_point = f"{i + 1}. {markdown_bullet_point}" - highest_ranking_issues_lines.append(markdown_bullet_point) + lines.append("\n---\n") + lines.append( + "*For details on how this issue is generated, " + "[see the script](https://github.com/zed-industries/zed/blob/main/script/update_top_ranking_issues/main.py)*" + ) - return highest_ranking_issues_lines + return "\n".join(lines) if __name__ == "__main__": app() - -# TODO: Sort label output into core and non core sections diff --git a/script/update_top_ranking_issues/pyproject.toml b/script/update_top_ranking_issues/pyproject.toml index ebd283850a1875835c74f1020904f99d96cc694d..aa3f8cc7ff55952d146dbf34c8db424de96e8e04 100644 --- a/script/update_top_ranking_issues/pyproject.toml +++ b/script/update_top_ranking_issues/pyproject.toml @@ -5,9 +5,10 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "mypy>=1.15.0", - "pygithub>=2.6.1", "pytz>=2025.1", + "requests>=2.32.0", "ruff>=0.9.7", "typer>=0.15.1", "types-pytz>=2025.1.0.20250204", + "types-requests>=2.32.0", ] diff --git a/script/update_top_ranking_issues/uv.lock b/script/update_top_ranking_issues/uv.lock index 062890b179f443130a7dd2211d55c4d94d7ad998..174f4e677fb2dcd73a31dfd2897d0b9d55a4c88e 100644 --- a/script/update_top_ranking_issues/uv.lock +++ b/script/update_top_ranking_issues/uv.lock @@ -1,60 +1,38 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.13" [[package]] name = "certifi" version = "2024.8.30" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, -] - -[[package]] -name = "cffi" -version = "1.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620, upload-time = "2024-10-09T07:40:20.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, - { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, - { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, - { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, - { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, - { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, - { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, - { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, - { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, - { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, - { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, - { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, - { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, - { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617, upload-time = "2024-10-09T07:39:07.317Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310, upload-time = "2024-10-09T07:39:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126, upload-time = "2024-10-09T07:39:09.327Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342, upload-time = "2024-10-09T07:39:10.322Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383, upload-time = "2024-10-09T07:39:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214, upload-time = "2024-10-09T07:39:13.059Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104, upload-time = "2024-10-09T07:39:14.815Z" }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255, upload-time = "2024-10-09T07:39:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251, upload-time = "2024-10-09T07:39:16.995Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474, upload-time = "2024-10-09T07:39:18.021Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849, upload-time = "2024-10-09T07:39:19.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781, upload-time = "2024-10-09T07:39:20.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970, upload-time = "2024-10-09T07:39:21.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973, upload-time = "2024-10-09T07:39:22.509Z" }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308, upload-time = "2024-10-09T07:39:23.524Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446, upload-time = "2024-10-09T07:40:19.383Z" }, ] [[package]] @@ -64,68 +42,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121, upload-time = "2023-08-17T17:29:11.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "cryptography" -version = "43.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/ba/0664727028b37e249e73879348cc46d45c5c1a2a2e81e8166462953c5755/cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", size = 686927 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/28/b92c98a04ba762f8cdeb54eba5c4c84e63cac037a7c5e70117d337b15ad6/cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", size = 6223222 }, - { url = "https://files.pythonhosted.org/packages/33/13/1193774705783ba364121aa2a60132fa31a668b8ababd5edfa1662354ccd/cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", size = 3794751 }, - { url = "https://files.pythonhosted.org/packages/5e/4b/39bb3c4c8cfb3e94e736b8d8859ce5c81536e91a1033b1d26770c4249000/cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", size = 3981827 }, - { url = "https://files.pythonhosted.org/packages/ce/dc/1471d4d56608e1013237af334b8a4c35d53895694fbb73882d1c4fd3f55e/cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", size = 3780034 }, - { url = "https://files.pythonhosted.org/packages/ad/43/7a9920135b0d5437cc2f8f529fa757431eb6a7736ddfadfdee1cc5890800/cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", size = 3993407 }, - { url = "https://files.pythonhosted.org/packages/cc/42/9ab8467af6c0b76f3d9b8f01d1cf25b9c9f3f2151f4acfab888d21c55a72/cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", size = 3886457 }, - { url = "https://files.pythonhosted.org/packages/a4/65/430509e31700286ec02868a2457d2111d03ccefc20349d24e58d171ae0a7/cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", size = 4081499 }, - { url = "https://files.pythonhosted.org/packages/bb/18/a04b6467e6e09df8c73b91dcee8878f4a438a43a3603dc3cd6f8003b92d8/cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", size = 2616504 }, - { url = "https://files.pythonhosted.org/packages/cc/73/0eacbdc437202edcbdc07f3576ed8fb8b0ab79d27bf2c5d822d758a72faa/cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", size = 3067456 }, - { url = "https://files.pythonhosted.org/packages/8a/b6/bc54b371f02cffd35ff8dc6baba88304d7cf8e83632566b4b42e00383e03/cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", size = 6225263 }, - { url = "https://files.pythonhosted.org/packages/00/0e/8217e348a1fa417ec4c78cd3cdf24154f5e76fd7597343a35bd403650dfd/cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", size = 3794368 }, - { url = "https://files.pythonhosted.org/packages/3d/ed/38b6be7254d8f7251fde8054af597ee8afa14f911da67a9410a45f602fc3/cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", size = 3981750 }, - { url = "https://files.pythonhosted.org/packages/64/f3/b7946c3887cf7436f002f4cbb1e6aec77b8d299b86be48eeadfefb937c4b/cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", size = 3778925 }, - { url = "https://files.pythonhosted.org/packages/ac/7e/ebda4dd4ae098a0990753efbb4b50954f1d03003846b943ea85070782da7/cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", size = 3993152 }, - { url = "https://files.pythonhosted.org/packages/43/f6/feebbd78a3e341e3913846a3bb2c29d0b09b1b3af1573c6baabc2533e147/cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", size = 3886392 }, - { url = "https://files.pythonhosted.org/packages/bd/4c/ab0b9407d5247576290b4fd8abd06b7f51bd414f04eef0f2800675512d61/cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", size = 4082606 }, - { url = "https://files.pythonhosted.org/packages/05/36/e532a671998d6fcfdb9122da16434347a58a6bae9465e527e450e0bc60a5/cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", size = 2617948 }, - { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445 }, -] - -[[package]] -name = "deprecated" -version = "1.2.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/14/1e41f504a246fc224d2ac264c227975427a85caf37c3979979edb9b1b232/Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3", size = 2974416 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/8d/778b7d51b981a96554f29136cd59ca7880bf58094338085bcf2a979a0e6a/Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c", size = 9561 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] @@ -135,18 +72,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] @@ -157,102 +94,42 @@ dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, - { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, - { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, - { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, - { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, - { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, - { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, ] [[package]] name = "mypy-extensions" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, -] - -[[package]] -name = "pycparser" -version = "2.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, -] - -[[package]] -name = "pygithub" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "pynacl" }, - { name = "requests" }, - { name = "typing-extensions" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/88/e08ab18dc74b2916f48703ed1a797d57cb64eca0e23b0a9254e13cfe3911/pygithub-2.6.1.tar.gz", hash = "sha256:b5c035392991cca63959e9453286b41b54d83bf2de2daa7d7ff7e4312cebf3bf", size = 3659473 } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433, upload-time = "2023-02-04T12:11:27.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/fc/a444cd19ccc8c4946a512f3827ed0b3565c88488719d800d54a75d541c0b/PyGithub-2.6.1-py3-none-any.whl", hash = "sha256:6f2fa6d076ccae475f9fc392cc6cdbd54db985d4f69b8833a28397de75ed6ca3", size = 410451 }, + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, ] [[package]] name = "pygments" version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, -] - -[[package]] -name = "pyjwt" -version = "2.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/68/ce067f09fca4abeca8771fe667d89cc347d1e99da3e093112ac329c6020e/pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c", size = 78825 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/84/0fdf9b18ba31d69877bd39c9cd6052b47f3761e9910c15de788e519f079f/PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", size = 22344 }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "pynacl" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905, upload-time = "2024-05-04T13:42:02.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920 }, - { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722 }, - { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087 }, - { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678 }, - { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660 }, - { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824 }, - { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912 }, - { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624 }, - { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141 }, + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513, upload-time = "2024-05-04T13:41:57.345Z" }, ] [[package]] name = "pytz" version = "2025.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617, upload-time = "2025-01-31T01:54:48.615Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930, upload-time = "2025-01-31T01:54:45.634Z" }, ] [[package]] @@ -265,9 +142,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, ] [[package]] @@ -278,43 +155,43 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/9e/1784d15b057b0075e5136445aaea92d23955aad2c93eaede673718a40d95/rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", size = 222843 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/9e/1784d15b057b0075e5136445aaea92d23955aad2c93eaede673718a40d95/rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", size = 222843, upload-time = "2024-10-04T11:50:31.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/91/5474b84e505a6ccc295b2d322d90ff6aa0746745717839ee0c5fb4fdcceb/rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1", size = 242117 }, + { url = "https://files.pythonhosted.org/packages/67/91/5474b84e505a6ccc295b2d322d90ff6aa0746745717839ee0c5fb4fdcceb/rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1", size = 242117, upload-time = "2024-10-04T11:50:29.123Z" }, ] [[package]] name = "ruff" version = "0.9.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/8b/a86c300359861b186f18359adf4437ac8e4c52e42daa9eedc731ef9d5b53/ruff-0.9.7.tar.gz", hash = "sha256:643757633417907510157b206e490c3aa11cab0c087c912f60e07fbafa87a4c6", size = 3669813 } +sdist = { url = "https://files.pythonhosted.org/packages/39/8b/a86c300359861b186f18359adf4437ac8e4c52e42daa9eedc731ef9d5b53/ruff-0.9.7.tar.gz", hash = "sha256:643757633417907510157b206e490c3aa11cab0c087c912f60e07fbafa87a4c6", size = 3669813, upload-time = "2025-02-20T13:26:52.111Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/f3/3a1d22973291226df4b4e2ff70196b926b6f910c488479adb0eeb42a0d7f/ruff-0.9.7-py3-none-linux_armv6l.whl", hash = "sha256:99d50def47305fe6f233eb8dabfd60047578ca87c9dcb235c9723ab1175180f4", size = 11774588 }, - { url = "https://files.pythonhosted.org/packages/8e/c9/b881f4157b9b884f2994fd08ee92ae3663fb24e34b0372ac3af999aa7fc6/ruff-0.9.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d59105ae9c44152c3d40a9c40d6331a7acd1cdf5ef404fbe31178a77b174ea66", size = 11746848 }, - { url = "https://files.pythonhosted.org/packages/14/89/2f546c133f73886ed50a3d449e6bf4af27d92d2f960a43a93d89353f0945/ruff-0.9.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f313b5800483770bd540cddac7c90fc46f895f427b7820f18fe1822697f1fec9", size = 11177525 }, - { url = "https://files.pythonhosted.org/packages/d7/93/6b98f2c12bf28ab9def59c50c9c49508519c5b5cfecca6de871cf01237f6/ruff-0.9.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042ae32b41343888f59c0a4148f103208bf6b21c90118d51dc93a68366f4e903", size = 11996580 }, - { url = "https://files.pythonhosted.org/packages/8e/3f/b3fcaf4f6d875e679ac2b71a72f6691a8128ea3cb7be07cbb249f477c061/ruff-0.9.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87862589373b33cc484b10831004e5e5ec47dc10d2b41ba770e837d4f429d721", size = 11525674 }, - { url = "https://files.pythonhosted.org/packages/f0/48/33fbf18defb74d624535d5d22adcb09a64c9bbabfa755bc666189a6b2210/ruff-0.9.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a17e1e01bee0926d351a1ee9bc15c445beae888f90069a6192a07a84af544b6b", size = 12739151 }, - { url = "https://files.pythonhosted.org/packages/63/b5/7e161080c5e19fa69495cbab7c00975ef8a90f3679caa6164921d7f52f4a/ruff-0.9.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c1f880ac5b2cbebd58b8ebde57069a374865c73f3bf41f05fe7a179c1c8ef22", size = 13416128 }, - { url = "https://files.pythonhosted.org/packages/4e/c8/b5e7d61fb1c1b26f271ac301ff6d9de5e4d9a9a63f67d732fa8f200f0c88/ruff-0.9.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e63fc20143c291cab2841dbb8260e96bafbe1ba13fd3d60d28be2c71e312da49", size = 12870858 }, - { url = "https://files.pythonhosted.org/packages/da/cb/2a1a8e4e291a54d28259f8fc6a674cd5b8833e93852c7ef5de436d6ed729/ruff-0.9.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91ff963baed3e9a6a4eba2a02f4ca8eaa6eba1cc0521aec0987da8d62f53cbef", size = 14786046 }, - { url = "https://files.pythonhosted.org/packages/ca/6c/c8f8a313be1943f333f376d79724260da5701426c0905762e3ddb389e3f4/ruff-0.9.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88362e3227c82f63eaebf0b2eff5b88990280fb1ecf7105523883ba8c3aaf6fb", size = 12550834 }, - { url = "https://files.pythonhosted.org/packages/9d/ad/f70cf5e8e7c52a25e166bdc84c082163c9c6f82a073f654c321b4dff9660/ruff-0.9.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0372c5a90349f00212270421fe91874b866fd3626eb3b397ede06cd385f6f7e0", size = 11961307 }, - { url = "https://files.pythonhosted.org/packages/52/d5/4f303ea94a5f4f454daf4d02671b1fbfe2a318b5fcd009f957466f936c50/ruff-0.9.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d76b8ab60e99e6424cd9d3d923274a1324aefce04f8ea537136b8398bbae0a62", size = 11612039 }, - { url = "https://files.pythonhosted.org/packages/eb/c8/bd12a23a75603c704ce86723be0648ba3d4ecc2af07eecd2e9fa112f7e19/ruff-0.9.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c439bdfc8983e1336577f00e09a4e7a78944fe01e4ea7fe616d00c3ec69a3d0", size = 12168177 }, - { url = "https://files.pythonhosted.org/packages/cc/57/d648d4f73400fef047d62d464d1a14591f2e6b3d4a15e93e23a53c20705d/ruff-0.9.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:115d1f15e8fdd445a7b4dc9a30abae22de3f6bcabeb503964904471691ef7606", size = 12610122 }, - { url = "https://files.pythonhosted.org/packages/49/79/acbc1edd03ac0e2a04ae2593555dbc9990b34090a9729a0c4c0cf20fb595/ruff-0.9.7-py3-none-win32.whl", hash = "sha256:e9ece95b7de5923cbf38893f066ed2872be2f2f477ba94f826c8defdd6ec6b7d", size = 9988751 }, - { url = "https://files.pythonhosted.org/packages/6d/95/67153a838c6b6ba7a2401241fd8a00cd8c627a8e4a0491b8d853dedeffe0/ruff-0.9.7-py3-none-win_amd64.whl", hash = "sha256:3770fe52b9d691a15f0b87ada29c45324b2ace8f01200fb0c14845e499eb0c2c", size = 11002987 }, - { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 }, + { url = "https://files.pythonhosted.org/packages/b1/f3/3a1d22973291226df4b4e2ff70196b926b6f910c488479adb0eeb42a0d7f/ruff-0.9.7-py3-none-linux_armv6l.whl", hash = "sha256:99d50def47305fe6f233eb8dabfd60047578ca87c9dcb235c9723ab1175180f4", size = 11774588, upload-time = "2025-02-20T13:25:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c9/b881f4157b9b884f2994fd08ee92ae3663fb24e34b0372ac3af999aa7fc6/ruff-0.9.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d59105ae9c44152c3d40a9c40d6331a7acd1cdf5ef404fbe31178a77b174ea66", size = 11746848, upload-time = "2025-02-20T13:25:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/14/89/2f546c133f73886ed50a3d449e6bf4af27d92d2f960a43a93d89353f0945/ruff-0.9.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f313b5800483770bd540cddac7c90fc46f895f427b7820f18fe1822697f1fec9", size = 11177525, upload-time = "2025-02-20T13:26:00.007Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/6b98f2c12bf28ab9def59c50c9c49508519c5b5cfecca6de871cf01237f6/ruff-0.9.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042ae32b41343888f59c0a4148f103208bf6b21c90118d51dc93a68366f4e903", size = 11996580, upload-time = "2025-02-20T13:26:03.274Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/b3fcaf4f6d875e679ac2b71a72f6691a8128ea3cb7be07cbb249f477c061/ruff-0.9.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87862589373b33cc484b10831004e5e5ec47dc10d2b41ba770e837d4f429d721", size = 11525674, upload-time = "2025-02-20T13:26:06.073Z" }, + { url = "https://files.pythonhosted.org/packages/f0/48/33fbf18defb74d624535d5d22adcb09a64c9bbabfa755bc666189a6b2210/ruff-0.9.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a17e1e01bee0926d351a1ee9bc15c445beae888f90069a6192a07a84af544b6b", size = 12739151, upload-time = "2025-02-20T13:26:08.964Z" }, + { url = "https://files.pythonhosted.org/packages/63/b5/7e161080c5e19fa69495cbab7c00975ef8a90f3679caa6164921d7f52f4a/ruff-0.9.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c1f880ac5b2cbebd58b8ebde57069a374865c73f3bf41f05fe7a179c1c8ef22", size = 13416128, upload-time = "2025-02-20T13:26:12.54Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c8/b5e7d61fb1c1b26f271ac301ff6d9de5e4d9a9a63f67d732fa8f200f0c88/ruff-0.9.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e63fc20143c291cab2841dbb8260e96bafbe1ba13fd3d60d28be2c71e312da49", size = 12870858, upload-time = "2025-02-20T13:26:16.794Z" }, + { url = "https://files.pythonhosted.org/packages/da/cb/2a1a8e4e291a54d28259f8fc6a674cd5b8833e93852c7ef5de436d6ed729/ruff-0.9.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91ff963baed3e9a6a4eba2a02f4ca8eaa6eba1cc0521aec0987da8d62f53cbef", size = 14786046, upload-time = "2025-02-20T13:26:19.85Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/c8f8a313be1943f333f376d79724260da5701426c0905762e3ddb389e3f4/ruff-0.9.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88362e3227c82f63eaebf0b2eff5b88990280fb1ecf7105523883ba8c3aaf6fb", size = 12550834, upload-time = "2025-02-20T13:26:23.082Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ad/f70cf5e8e7c52a25e166bdc84c082163c9c6f82a073f654c321b4dff9660/ruff-0.9.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0372c5a90349f00212270421fe91874b866fd3626eb3b397ede06cd385f6f7e0", size = 11961307, upload-time = "2025-02-20T13:26:26.738Z" }, + { url = "https://files.pythonhosted.org/packages/52/d5/4f303ea94a5f4f454daf4d02671b1fbfe2a318b5fcd009f957466f936c50/ruff-0.9.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d76b8ab60e99e6424cd9d3d923274a1324aefce04f8ea537136b8398bbae0a62", size = 11612039, upload-time = "2025-02-20T13:26:30.26Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/bd12a23a75603c704ce86723be0648ba3d4ecc2af07eecd2e9fa112f7e19/ruff-0.9.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c439bdfc8983e1336577f00e09a4e7a78944fe01e4ea7fe616d00c3ec69a3d0", size = 12168177, upload-time = "2025-02-20T13:26:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/57/d648d4f73400fef047d62d464d1a14591f2e6b3d4a15e93e23a53c20705d/ruff-0.9.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:115d1f15e8fdd445a7b4dc9a30abae22de3f6bcabeb503964904471691ef7606", size = 12610122, upload-time = "2025-02-20T13:26:37.365Z" }, + { url = "https://files.pythonhosted.org/packages/49/79/acbc1edd03ac0e2a04ae2593555dbc9990b34090a9729a0c4c0cf20fb595/ruff-0.9.7-py3-none-win32.whl", hash = "sha256:e9ece95b7de5923cbf38893f066ed2872be2f2f477ba94f826c8defdd6ec6b7d", size = 9988751, upload-time = "2025-02-20T13:26:40.366Z" }, + { url = "https://files.pythonhosted.org/packages/6d/95/67153a838c6b6ba7a2401241fd8a00cd8c627a8e4a0491b8d853dedeffe0/ruff-0.9.7-py3-none-win_amd64.whl", hash = "sha256:3770fe52b9d691a15f0b87ada29c45324b2ace8f01200fb0c14845e499eb0c2c", size = 11002987, upload-time = "2025-02-20T13:26:43.762Z" }, + { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763, upload-time = "2025-02-20T13:26:48.92Z" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] @@ -327,27 +204,39 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 } +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789, upload-time = "2024-12-04T17:44:58.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 }, + { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908, upload-time = "2024-12-04T17:44:57.291Z" }, ] [[package]] name = "types-pytz" version = "2025.1.0.20250204" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/d2/2190c54d53c04491ad72a1df019c5dfa692e6ab6c2dba1be7b6c9d530e30/types_pytz-2025.1.0.20250204.tar.gz", hash = "sha256:00f750132769f1c65a4f7240bc84f13985b4da774bd17dfbe5d9cd442746bd49", size = 10352 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/d2/2190c54d53c04491ad72a1df019c5dfa692e6ab6c2dba1be7b6c9d530e30/types_pytz-2025.1.0.20250204.tar.gz", hash = "sha256:00f750132769f1c65a4f7240bc84f13985b4da774bd17dfbe5d9cd442746bd49", size = 10352, upload-time = "2025-02-04T02:39:05.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/50/65ffad73746f1d8b15992c030e0fd22965fd5ae2c0206dc28873343b3230/types_pytz-2025.1.0.20250204-py3-none-any.whl", hash = "sha256:32ca4a35430e8b94f6603b35beb7f56c32260ddddd4f4bb305fdf8f92358b87e", size = 10059, upload-time = "2025-02-04T02:39:03.899Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250913" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/50/65ffad73746f1d8b15992c030e0fd22965fd5ae2c0206dc28873343b3230/types_pytz-2025.1.0.20250204-py3-none-any.whl", hash = "sha256:32ca4a35430e8b94f6603b35beb7f56c32260ddddd4f4bb305fdf8f92358b87e", size = 10059 }, + { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" }, ] [[package]] name = "typing-extensions" version = "4.12.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, ] [[package]] @@ -356,37 +245,30 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "mypy" }, - { name = "pygithub" }, { name = "pytz" }, + { name = "requests" }, { name = "ruff" }, { name = "typer" }, { name = "types-pytz" }, + { name = "types-requests" }, ] [package.metadata] requires-dist = [ { name = "mypy", specifier = ">=1.15.0" }, - { name = "pygithub", specifier = ">=2.6.1" }, { name = "pytz", specifier = ">=2025.1" }, + { name = "requests", specifier = ">=2.32.0" }, { name = "ruff", specifier = ">=0.9.7" }, { name = "typer", specifier = ">=0.15.1" }, { name = "types-pytz", specifier = ">=2025.1.0.20250204" }, + { name = "types-requests", specifier = ">=2.32.0" }, ] [[package]] name = "urllib3" version = "2.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, -] - -[[package]] -name = "wrapt" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972 } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362 }, + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" }, ] diff --git a/script/verify-macos-document-icon b/script/verify-macos-document-icon new file mode 100755 index 0000000000000000000000000000000000000000..de2581c9df764ee2019740048381d6d66dc3499d --- /dev/null +++ b/script/verify-macos-document-icon @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + script/verify-macos-document-icon /path/to/Zed.app + +Verifies that the given macOS app bundle's Info.plist references a document icon +named "Document" and that the corresponding icon file exists in the bundle. + +Specifically checks: + - CFBundleDocumentTypes[*].CFBundleTypeIconFile includes "Document" + - Contents/Resources/Document.icns exists + +Exit codes: + 0 - success + 1 - verification failed + 2 - invalid usage / missing prerequisites +USAGE +} + +fail() { + echo "error: $*" >&2 + exit 1 +} + +if [[ $# -ne 1 ]]; then + usage >&2 + exit 2 +fi + +app_path="$1" + +if [[ ! -d "${app_path}" ]]; then + fail "app bundle not found: ${app_path}" +fi + +info_plist="${app_path}/Contents/Info.plist" +if [[ ! -f "${info_plist}" ]]; then + fail "missing Info.plist: ${info_plist}" +fi + +if ! command -v plutil >/dev/null 2>&1; then + fail "plutil not found (required on macOS to read Info.plist)" +fi + +# Convert to JSON for robust parsing. plutil outputs JSON to stdout in this mode. +info_json="$(plutil -convert json -o - "${info_plist}")" + +# Check that CFBundleDocumentTypes exists and that at least one entry references "Document". +# We use Python for JSON parsing; macOS ships with Python 3 on many setups, but not all. +# If python3 isn't available, fall back to a simpler grep-based check. +has_document_icon_ref="false" +if command -v python3 >/dev/null 2>&1; then + has_document_icon_ref="$(python3 -c "import json,sys; d=json.load(sys.stdin); types=d.get('CFBundleDocumentTypes', []); vals=[t.get('CFBundleTypeIconFile') for t in types if isinstance(t, dict)]; print('true' if 'Document' in vals else 'false')" <<<"${info_json}")" +else + # This is a best-effort fallback. It may produce false negatives if the JSON formatting differs. + if echo "${info_json}" | grep -q '"CFBundleTypeIconFile"[[:space:]]*:[[:space:]]*"Document"'; then + has_document_icon_ref="true" + fi +fi + +if [[ "${has_document_icon_ref}" != "true" ]]; then + echo "Verification failed for: ${app_path}" >&2 + echo "Expected Info.plist to reference CFBundleTypeIconFile \"Document\" in CFBundleDocumentTypes." >&2 + echo "Tip: This bundle may be missing DocumentTypes.plist extensions or may have different icon naming." >&2 + exit 1 +fi + +document_icon_path="${app_path}/Contents/Resources/Document.icns" +if [[ ! -f "${document_icon_path}" ]]; then + echo "Verification failed for: ${app_path}" >&2 + echo "Expected document icon to exist: ${document_icon_path}" >&2 + echo "Tip: The bundle script should copy crates/zed/resources/Document.icns into Contents/Resources/Document.icns." >&2 + exit 1 +fi + +echo "OK: ${app_path}" +echo " - Info.plist references CFBundleTypeIconFile \"Document\"" +echo " - Found ${document_icon_path}" diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 717517402d619e54d30a502fcfe26418910aac35..fe476355203a69c962081c36fe350460b9df6f6b 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -5,6 +5,7 @@ use std::fs; use std::path::{Path, PathBuf}; mod after_release; +mod autofix_pr; mod cherry_pick; mod compare_perf; mod danger; @@ -111,6 +112,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(run_tests::run_tests), WorkflowFile::zed(release::release), WorkflowFile::zed(cherry_pick::cherry_pick), + WorkflowFile::zed(autofix_pr::autofix_pr), WorkflowFile::zed(compare_perf::compare_perf), WorkflowFile::zed(run_agent_evals::run_unit_evals), WorkflowFile::zed(run_agent_evals::run_cron_unit_evals), diff --git a/tooling/xtask/src/tasks/workflows/after_release.rs b/tooling/xtask/src/tasks/workflows/after_release.rs index c99173bfe7183b5a3440804a18e0133270744654..c475617197151c5e7227a98d0119c1025c7b7177 100644 --- a/tooling/xtask/src/tasks/workflows/after_release.rs +++ b/tooling/xtask/src/tasks/workflows/after_release.rs @@ -4,10 +4,18 @@ use crate::tasks::workflows::{ release::{self, notify_on_failure}, runners, steps::{CommonJobConditions, NamedJob, checkout_repo, dependant_job, named}, - vars::{self, StepOutput}, + vars::{self, StepOutput, WorkflowInput}, }; +const TAG_NAME: &str = "${{ github.event.release.tag_name || inputs.tag_name }}"; +const IS_PRERELEASE: &str = "${{ github.event.release.prerelease || inputs.prerelease }}"; +const RELEASE_BODY: &str = "${{ github.event.release.body || inputs.body }}"; + pub fn after_release() -> Workflow { + let tag_name = WorkflowInput::string("tag_name", None); + let prerelease = WorkflowInput::bool("prerelease", None); + let body = WorkflowInput::string("body", Some(String::new())); + let refresh_zed_dev = rebuild_releases_page(); let post_to_discord = post_to_discord(&[&refresh_zed_dev]); let publish_winget = publish_winget(); @@ -20,7 +28,14 @@ pub fn after_release() -> Workflow { ]); named::workflow() - .on(Event::default().release(Release::default().types(vec![ReleaseType::Published]))) + .on(Event::default() + .release(Release::default().types(vec![ReleaseType::Published])) + .workflow_dispatch( + WorkflowDispatch::default() + .add_input(tag_name.name, tag_name.input()) + .add_input(prerelease.name, prerelease.input()) + .add_input(body.name, body.input()), + )) .add_job(refresh_zed_dev.name, refresh_zed_dev.job) .add_job(post_to_discord.name, post_to_discord.job) .add_job(publish_winget.name, publish_winget.job) @@ -30,9 +45,9 @@ pub fn after_release() -> Workflow { fn rebuild_releases_page() -> NamedJob { fn refresh_cloud_releases() -> Step { - named::bash( - "curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name }}", - ) + named::bash(format!( + "curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag={TAG_NAME}" + )) } fn redeploy_zed_dev() -> Step { @@ -51,15 +66,16 @@ fn rebuild_releases_page() -> NamedJob { fn post_to_discord(deps: &[&NamedJob]) -> NamedJob { fn get_release_url() -> Step { - named::bash(indoc::indoc! {r#" - if [ "${{ github.event.release.prerelease }}" == "true" ]; then - URL="https://zed.dev/releases/preview" - else - URL="https://zed.dev/releases/stable" - fi - - echo "URL=$URL" >> "$GITHUB_OUTPUT" - "#}) + named::bash(format!( + r#"if [ "{IS_PRERELEASE}" == "true" ]; then + URL="https://zed.dev/releases/preview" +else + URL="https://zed.dev/releases/stable" +fi + +echo "URL=$URL" >> "$GITHUB_OUTPUT" +"# + )) .id("get-release-url") } @@ -72,11 +88,9 @@ fn post_to_discord(deps: &[&NamedJob]) -> NamedJob { .id("get-content") .add_with(( "stringToTruncate", - indoc::indoc! {r#" - 📣 Zed [${{ github.event.release.tag_name }}](<${{ steps.get-release-url.outputs.URL }}>) was just released! - - ${{ github.event.release.body }} - "#}, + format!( + "📣 Zed [{TAG_NAME}](<${{{{ steps.get-release-url.outputs.URL }}}}>) was just released!\n\n{RELEASE_BODY}\n" + ), )) .add_with(("maxLength", 2000)) .add_with(("truncationSymbol", "...")) @@ -102,16 +116,17 @@ fn post_to_discord(deps: &[&NamedJob]) -> NamedJob { fn publish_winget() -> NamedJob { fn set_package_name() -> (Step, StepOutput) { - let step = named::pwsh(indoc::indoc! {r#" - if ("${{ github.event.release.prerelease }}" -eq "true") { - $PACKAGE_NAME = "ZedIndustries.Zed.Preview" - } else { - $PACKAGE_NAME = "ZedIndustries.Zed" - } - - echo "PACKAGE_NAME=$PACKAGE_NAME" >> $env:GITHUB_OUTPUT - "#}) - .id("set-package-name"); + let script = format!( + r#"if ("{IS_PRERELEASE}" -eq "true") {{ + $PACKAGE_NAME = "ZedIndustries.Zed.Preview" +}} else {{ + $PACKAGE_NAME = "ZedIndustries.Zed" +}} + +echo "PACKAGE_NAME=$PACKAGE_NAME" >> $env:GITHUB_OUTPUT +"# + ); + let step = named::pwsh(&script).id("set-package-name"); let output = StepOutput::new(&step, "PACKAGE_NAME"); (step, output) @@ -124,6 +139,7 @@ fn publish_winget() -> NamedJob { "19e706d4c9121098010096f9c495a70a7518b30f", // v2 ) .add_with(("identifier", package_name.to_string())) + .add_with(("release-tag", TAG_NAME)) .add_with(("max-versions-to-keep", 5)) .add_with(("token", vars::WINGET_TOKEN)) } diff --git a/tooling/xtask/src/tasks/workflows/autofix_pr.rs b/tooling/xtask/src/tasks/workflows/autofix_pr.rs new file mode 100644 index 0000000000000000000000000000000000000000..231cbb68500145a55506c53306e90dd5b47baeda --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/autofix_pr.rs @@ -0,0 +1,162 @@ +use gh_workflow::*; + +use crate::tasks::workflows::{ + runners, + steps::{self, FluentBuilder, NamedJob, named}, + vars::{self, StepOutput, WorkflowInput}, +}; + +pub fn autofix_pr() -> Workflow { + let pr_number = WorkflowInput::string("pr_number", None); + let run_clippy = WorkflowInput::bool("run_clippy", Some(true)); + let run_autofix = run_autofix(&pr_number, &run_clippy); + let commit_changes = commit_changes(&pr_number, &run_autofix); + named::workflow() + .run_name(format!("autofix PR #{pr_number}")) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(pr_number.name, pr_number.input()) + .add_input(run_clippy.name, run_clippy.input()), + )) + .concurrency( + Concurrency::new(Expression::new(format!( + "${{{{ github.workflow }}}}-{pr_number}" + ))) + .cancel_in_progress(true), + ) + .add_job(run_autofix.name.clone(), run_autofix.job) + .add_job(commit_changes.name, commit_changes.job) +} + +const PATCH_ARTIFACT_NAME: &str = "autofix-patch"; +const PATCH_FILE_PATH: &str = "autofix.patch"; + +fn upload_patch_artifact() -> Step { + Step::new(format!("upload artifact {}", PATCH_ARTIFACT_NAME)) + .uses( + "actions", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 + ) + .add_with(("name", PATCH_ARTIFACT_NAME)) + .add_with(("path", PATCH_FILE_PATH)) + .add_with(("if-no-files-found", "ignore")) + .add_with(("retention-days", "1")) +} + +fn download_patch_artifact() -> Step { + named::uses( + "actions", + "download-artifact", + "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0 + ) + .add_with(("name", PATCH_ARTIFACT_NAME)) +} + +fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJob { + fn checkout_pr(pr_number: &WorkflowInput) -> Step { + named::bash(&format!("gh pr checkout {pr_number}")) + .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)) + } + + fn run_cargo_fmt() -> Step { + named::bash("cargo fmt --all") + } + + fn run_cargo_fix() -> Step { + named::bash( + "cargo fix --workspace --release --all-targets --all-features --allow-dirty --allow-staged", + ) + } + + fn run_clippy_fix() -> Step { + named::bash( + "cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged", + ) + } + + fn run_prettier_fix() -> Step { + named::bash("./script/prettier --write") + } + + fn create_patch() -> Step { + named::bash(indoc::indoc! {r#" + if git diff --quiet; then + echo "No changes to commit" + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + git diff > autofix.patch + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + "#}) + .id("create-patch") + } + + named::job( + Job::default() + .runs_on(runners::LINUX_DEFAULT) + .outputs([( + "has_changes".to_owned(), + "${{ steps.create-patch.outputs.has_changes }}".to_owned(), + )]) + .add_step(steps::checkout_repo()) + .add_step(checkout_pr(pr_number)) + .add_step(steps::setup_cargo_config(runners::Platform::Linux)) + .add_step(steps::cache_rust_dependencies_namespace()) + .map(steps::install_linux_dependencies) + .add_step(steps::setup_pnpm()) + .add_step(run_prettier_fix()) + .add_step(run_cargo_fmt()) + .add_step(run_cargo_fix().if_condition(Expression::new(run_clippy.to_string()))) + .add_step(run_clippy_fix().if_condition(Expression::new(run_clippy.to_string()))) + .add_step(create_patch()) + .add_step(upload_patch_artifact()) + .add_step(steps::cleanup_cargo_config(runners::Platform::Linux)), + ) +} + +fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob { + fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step { + named::bash(&format!("gh pr checkout {pr_number}")).add_env(("GITHUB_TOKEN", token)) + } + + fn apply_patch() -> Step { + named::bash("git apply autofix.patch") + } + + fn commit_and_push(token: &StepOutput) -> Step { + named::bash(indoc::indoc! {r#" + git commit -am "Autofix" + git push + "#}) + .add_env(("GIT_COMMITTER_NAME", "Zed Zippy")) + .add_env(( + "GIT_COMMITTER_EMAIL", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GIT_AUTHOR_NAME", "Zed Zippy")) + .add_env(( + "GIT_AUTHOR_EMAIL", + "234243425+zed-zippy[bot]@users.noreply.github.com", + )) + .add_env(("GITHUB_TOKEN", token)) + } + + let (authenticate, token) = steps::authenticate_as_zippy(); + + named::job( + Job::default() + .runs_on(runners::LINUX_SMALL) + .needs(vec![autofix_job.name.clone()]) + .cond(Expression::new(format!( + "needs.{}.outputs.has_changes == 'true'", + autofix_job.name + ))) + .add_step(authenticate) + .add_step(steps::checkout_repo_with_token(&token)) + .add_step(checkout_pr(pr_number, &token)) + .add_step(download_patch_artifact()) + .add_step(apply_patch()) + .add_step(commit_and_push(&token)), + ) +} diff --git a/tooling/xtask/src/tasks/workflows/cherry_pick.rs b/tooling/xtask/src/tasks/workflows/cherry_pick.rs index 105bf74c4194a46ad4ca62991fae3a945eea150d..eaa786837f84ebf4d4f7e1a579db0c7b4dcc5040 100644 --- a/tooling/xtask/src/tasks/workflows/cherry_pick.rs +++ b/tooling/xtask/src/tasks/workflows/cherry_pick.rs @@ -3,7 +3,7 @@ use gh_workflow::*; use crate::tasks::workflows::{ runners, steps::{self, NamedJob, named}, - vars::{self, StepOutput, WorkflowInput}, + vars::{StepOutput, WorkflowInput}, }; pub fn cherry_pick() -> Workflow { @@ -29,19 +29,6 @@ fn run_cherry_pick( commit: &WorkflowInput, channel: &WorkflowInput, ) -> NamedJob { - fn authenticate_as_zippy() -> (Step, StepOutput) { - let step = named::uses( - "actions", - "create-github-app-token", - "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", - ) // v2 - .add_with(("app-id", vars::ZED_ZIPPY_APP_ID)) - .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY)) - .id("get-app-token"); - let output = StepOutput::new(&step, "token"); - (step, output) - } - fn cherry_pick( branch: &WorkflowInput, commit: &WorkflowInput, @@ -54,7 +41,7 @@ fn run_cherry_pick( .add_env(("GITHUB_TOKEN", token)) } - let (authenticate, token) = authenticate_as_zippy(); + let (authenticate, token) = steps::authenticate_as_zippy(); named::job( Job::default() diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 356a3c6c782330528c165ebafb54ca23252e35b4..8772011a2d1f48550095a916ab516cc98ac2d1f7 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -1,4 +1,4 @@ -use gh_workflow::*; +use gh_workflow::{ctx::Context, *}; use indoc::indoc; use crate::tasks::workflows::{ @@ -23,15 +23,12 @@ pub(crate) fn extension_bump() -> Workflow { let force_bump = WorkflowInput::bool("force-bump", None); let (app_id, app_secret) = extension_workflow_secrets(); - - let test_extension = extension_tests::check_extension(); let (check_bump_needed, needs_bump, current_version) = check_bump_needed(); let needs_bump = needs_bump.as_job_output(&check_bump_needed); let current_version = current_version.as_job_output(&check_bump_needed); - let dependencies = [&test_extension, &check_bump_needed]; - + let dependencies = [&check_bump_needed]; let bump_version = bump_extension_version( &dependencies, ¤t_version, @@ -72,7 +69,6 @@ pub(crate) fn extension_bump() -> Workflow { "ZED_EXTENSION_CLI_SHA", extension_tests::ZED_EXTENSION_CLI_SHA, )) - .add_job(test_extension.name, test_extension.job) .add_job(check_bump_needed.name, check_bump_needed.job) .add_job(bump_version.name, bump_version.job) .add_job(create_label.name, create_label.job) @@ -291,7 +287,8 @@ fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) -> .add("base", "main") .add("delete-branch", true) .add("token", generated_token.to_string()) - .add("sign-commits", true), + .add("sign-commits", true) + .add("assignees", Context::github().actor().to_string()), ) } diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index 480559121413ebc88e37300fd632d32b1d79aa91..ae48daa53b24a745219008c8bd365f9fa443d7bb 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -48,7 +48,7 @@ fn run_clippy() -> Step { fn check_rust() -> NamedJob { let job = Job::default() .with_repository_owner_guard() - .runs_on(runners::LINUX_DEFAULT) + .runs_on(runners::LINUX_MEDIUM) .timeout_minutes(3u32) .add_step(steps::checkout_repo()) .add_step(steps::cache_rust_dependencies_namespace()) @@ -66,7 +66,7 @@ pub(crate) fn check_extension() -> NamedJob { let (cache_download, cache_hit) = cache_zed_extension_cli(); let job = Job::default() .with_repository_owner_guard() - .runs_on(runners::LINUX_SMALL) + .runs_on(runners::LINUX_LARGE_RAM) .timeout_minutes(2u32) .add_step(steps::checkout_repo()) .add_step(cache_download) diff --git a/tooling/xtask/src/tasks/workflows/extensions/mod.rs b/tooling/xtask/src/tasks/workflows/extensions.rs similarity index 100% rename from tooling/xtask/src/tasks/workflows/extensions/mod.rs rename to tooling/xtask/src/tasks/workflows/extensions.rs diff --git a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs index 44c72a11648fb1392d78437113fdf72148b9abed..1564fef448fc305897b9edcd64245255b8e0b168 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs @@ -1,5 +1,6 @@ use gh_workflow::{ - Event, Expression, Input, Job, PullRequest, PullRequestType, Push, Run, Step, UsesJob, Workflow, + Event, Expression, Input, Job, PullRequest, PullRequestType, Push, Run, Step, UsesJob, + Workflow, WorkflowDispatch, }; use indexmap::IndexMap; use indoc::indoc; @@ -18,8 +19,13 @@ pub(crate) fn bump_version() -> Workflow { named::workflow() .on(Event::default() - .push(Push::default().add_branch("main")) - .pull_request(PullRequest::default().add_type(PullRequestType::Labeled))) + .push( + Push::default() + .add_branch("main") + .add_ignored_path(".github/**"), + ) + .pull_request(PullRequest::default().add_type(PullRequestType::Labeled)) + .workflow_dispatch(WorkflowDispatch::default())) .concurrency(one_workflow_per_non_main_branch_and_token("labels")) .add_job(determine_bump_type.name, determine_bump_type.job) .add_job(call_bump_version.name, call_bump_version.job) diff --git a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs index 4e900e839d917bfa9920b12a4bd4a759fa1f31b7..885a8fd09fe0488c92162a9bccd0f70ed6c7fefd 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs @@ -1,4 +1,4 @@ -use gh_workflow::{Event, Job, PullRequest, UsesJob, Workflow}; +use gh_workflow::{Event, Job, PullRequest, Push, UsesJob, Workflow}; use crate::tasks::workflows::{ steps::{NamedJob, named}, @@ -8,7 +8,9 @@ use crate::tasks::workflows::{ pub(crate) fn run_tests() -> Workflow { let call_extension_tests = call_extension_tests(); named::workflow() - .on(Event::default().pull_request(PullRequest::default().add_branch("**"))) + .on(Event::default() + .pull_request(PullRequest::default().add_branch("**")) + .push(Push::default().add_branch("main"))) .concurrency(one_workflow_per_non_main_branch_and_token("pr")) .add_job(call_extension_tests.name, call_extension_tests.job) } diff --git a/tooling/xtask/src/tasks/workflows/release.rs b/tooling/xtask/src/tasks/workflows/release.rs index e06a71340192c036d442d65d9572e52ed2983cae..80fb075f7f6445b1a6a078d9defba2018a406851 100644 --- a/tooling/xtask/src/tasks/workflows/release.rs +++ b/tooling/xtask/src/tasks/workflows/release.rs @@ -97,17 +97,20 @@ pub(crate) fn create_sentry_release() -> Step { } fn auto_release_preview(deps: &[&NamedJob; 1]) -> NamedJob { + let (authenticate, token) = steps::authenticate_as_zippy(); + named::job( dependant_job(deps) .runs_on(runners::LINUX_SMALL) .cond(Expression::new(indoc::indoc!( r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"# ))) + .add_step(authenticate) .add_step( steps::script( r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#, ) - .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)), + .add_env(("GITHUB_TOKEN", &token)), ) ) } diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index e4443ad91313fd4511765fb7be6a8bb092757e9d..aceb575b647e7ea0b2d8a74da9fbc153767d149d 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -236,11 +236,11 @@ fn check_style() -> NamedJob { .add_step(steps::checkout_repo()) .add_step(steps::cache_rust_dependencies_namespace()) .add_step(steps::setup_pnpm()) - .add_step(steps::script("./script/prettier")) + .add_step(steps::prettier()) + .add_step(steps::cargo_fmt()) .add_step(steps::script("./script/check-todos")) .add_step(steps::script("./script/check-keymaps")) - .add_step(check_for_typos()) - .add_step(steps::cargo_fmt()), + .add_step(check_for_typos()), ) } @@ -368,6 +368,8 @@ pub(crate) fn check_postgres_and_protobuf_migrations() -> NamedJob { .runs_on(runners::LINUX_DEFAULT) .add_env(("GIT_AUTHOR_NAME", "Protobuf Action")) .add_env(("GIT_AUTHOR_EMAIL", "ci@zed.dev")) + .add_env(("GIT_COMMITTER_NAME", "Protobuf Action")) + .add_env(("GIT_COMMITTER_EMAIL", "ci@zed.dev")) .add_step(steps::checkout_repo().with(("fetch-depth", 0))) // fetch full history .add_step(remove_untracked_files()) .add_step(ensure_fresh_merge()) @@ -446,6 +448,7 @@ fn check_docs() -> NamedJob { lychee_link_check("./docs/src/**/*"), // check markdown links ) .map(steps::install_linux_dependencies) + .add_step(steps::script("./script/generate-action-metadata")) .add_step(install_mdbook()) .add_step(build_docs()) .add_step( diff --git a/tooling/xtask/src/tasks/workflows/runners.rs b/tooling/xtask/src/tasks/workflows/runners.rs index df98826f8afb7dccb3f9e268fe427634caec8dba..01e79359035380d60033ab2063a9259a730508be 100644 --- a/tooling/xtask/src/tasks/workflows/runners.rs +++ b/tooling/xtask/src/tasks/workflows/runners.rs @@ -8,6 +8,9 @@ pub const LINUX_MEDIUM: Runner = Runner("namespace-profile-4x8-ubuntu-2204"); pub const LINUX_X86_BUNDLER: Runner = Runner("namespace-profile-32x64-ubuntu-2004"); pub const LINUX_ARM_BUNDLER: Runner = Runner("namespace-profile-8x32-ubuntu-2004-arm-m4"); +// Larger Ubuntu runner with glibc 2.39 for extension bundling +pub const LINUX_LARGE_RAM: Runner = Runner("namespace-profile-8x32-ubuntu-2404"); + pub const MAC_DEFAULT: Runner = Runner("self-mini-macos"); pub const WINDOWS_DEFAULT: Runner = Runner("self-32vcpu-windows-2022"); diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 722a5f0704542889703fdbb42c691d01bc50ace6..a0b071cd6c31654b42adddbba47dd24c60da7df2 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -1,6 +1,6 @@ use gh_workflow::*; -use crate::tasks::workflows::{runners::Platform, vars}; +use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput}; pub const BASH_SHELL: &str = "bash -euxo pipefail {0}"; // https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell @@ -17,6 +17,16 @@ pub fn checkout_repo() -> Step { .add_with(("clean", false)) } +pub fn checkout_repo_with_token(token: &StepOutput) -> Step { + named::uses( + "actions", + "checkout", + "11bd71901bbe5b1630ceea73d27597364c9af683", // v4 + ) + .add_with(("clean", false)) + .add_with(("token", token.to_string())) +} + pub fn setup_pnpm() -> Step { named::uses( "pnpm", @@ -44,6 +54,10 @@ pub fn setup_sentry() -> Step { .add_with(("token", vars::SENTRY_AUTH_TOKEN)) } +pub fn prettier() -> Step { + named::bash("./script/prettier") +} + pub fn cargo_fmt() -> Step { named::bash("cargo fmt --all -- --check") } @@ -334,3 +348,16 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step { "git fetch origin {ref_name} && git checkout {ref_name}" )) } + +pub fn authenticate_as_zippy() -> (Step, StepOutput) { + let step = named::uses( + "actions", + "create-github-app-token", + "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", + ) + .add_with(("app-id", vars::ZED_ZIPPY_APP_ID)) + .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY)) + .id("get-app-token"); + let output = StepOutput::new(&step, "token"); + (step, output) +} diff --git a/typos.toml b/typos.toml index 6b1df15b40da0bded6d1f14bdf8b28e0b0ec649f..8e42bd674a64d8adc1e684df181c8e4ce67988e9 100644 --- a/typos.toml +++ b/typos.toml @@ -11,16 +11,12 @@ extend-exclude = [ "crates/theme/src/icon_theme.rs", "crates/extensions_ui/src/extension_suggest.rs", - # Some countries codes are flagged as typos. - "crates/anthropic/src/supported_countries.rs", - "crates/google_ai/src/supported_countries.rs", - "crates/open_ai/src/supported_countries.rs", - # Some mock data is flagged as typos. "crates/assistant_tools/src/web_search_tool.rs", - # Stripe IDs are flagged as typos. - "crates/collab/src/db/tests/processed_stripe_event_tests.rs", + # Suppress false positives in database schema. + "crates/collab/migrations/20251208000000_test_schema.sql", + # Not our typos. "crates/livekit_api/", # Vim makes heavy use of partial typing tables. @@ -35,6 +31,9 @@ extend-exclude = [ "crates/rpc/src/auth.rs", # glsl isn't recognized by this tool. "extensions/glsl/languages/glsl/", + # Protols is the name of the language server. + "extensions/proto/extension.toml", + "extensions/proto/src/language_servers/protols.rs", # Windows likes its abbreviations. "crates/gpui/src/platform/windows/directx_renderer.rs", "crates/gpui/src/platform/windows/events.rs", @@ -56,6 +55,8 @@ extend-exclude = [ "crates/project_panel/benches/linux_repo_snapshot.txt", # Some multibuffer test cases have word fragments that register as typos "crates/multi_buffer/src/multi_buffer_tests.rs", + # Macos apis + "crates/gpui/src/platform/mac/dispatcher.rs", ] [default] @@ -63,8 +64,6 @@ extend-ignore-re = [ 'cl\[ist]', '\[lan\]guage', '"ba"', - # :/ crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql - "COLUMN enviroment", "doas", # ProtoLS crate with tree-sitter Protobuf grammar. "protols",