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/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/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..ba0236fe91d3efe5975777e3df31eedf884e224d --- /dev/null +++ b/.github/workflows/docs_automation.yml @@ -0,0 +1,260 @@ +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 + +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) + - name: "Phase 2: Explore Repository" + id: phase2 + run: | + "$DROID_BIN" exec \ + --model "$DROID_MODEL" \ + --autonomy read-only \ + --prompt-file .factory/prompts/docs-automation/phase2-explore.md \ + --output /tmp/phase2-output.json \ + --format json + echo "Repository exploration complete" + cat /tmp/phase2-output.json + + # Phase 3: Analyze Changes (Read-Only) + - name: "Phase 3: Analyze Changes" + id: phase3 + run: | + CHANGED_FILES=$(tr '\n' ' ' < /tmp/changed_files.txt) + "$DROID_BIN" exec \ + --model "$DROID_MODEL" \ + --autonomy read-only \ + --prompt-file .factory/prompts/docs-automation/phase3-analyze.md \ + --context "Changed files: $CHANGED_FILES" \ + --context-file /tmp/phase2-output.json \ + --output /tmp/phase3-output.md \ + --format markdown + echo "Change analysis complete" + cat /tmp/phase3-output.md + + # Phase 4: Plan Documentation Impact (Read-Only) + - name: "Phase 4: Plan Documentation Impact" + id: phase4 + run: | + "$DROID_BIN" exec \ + --model "$DROID_MODEL" \ + --autonomy read-only \ + --prompt-file .factory/prompts/docs-automation/phase4-plan.md \ + --context-file /tmp/phase3-output.md \ + --context-file docs/AGENTS.md \ + --output /tmp/phase4-plan.md \ + --format markdown + echo "Documentation plan complete" + cat /tmp/phase4-plan.md + + # Check if updates are required + if grep -q "Documentation Updates Required: No" /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) + - name: "Phase 5: Apply Documentation Plan" + id: phase5 + if: steps.phase4.outputs.updates_required == 'true' + run: | + "$DROID_BIN" exec \ + --model "$DROID_MODEL" \ + --autonomy medium \ + --prompt-file .factory/prompts/docs-automation/phase5-apply.md \ + --context-file /tmp/phase4-plan.md \ + --context-file docs/AGENTS.md \ + --context-file docs/.rules \ + --output /tmp/phase5-report.md \ + --format markdown + 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 + - 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 \ + --model "$DROID_MODEL" \ + --autonomy read-only \ + --prompt-file .factory/prompts/docs-automation/phase6-summarize.md \ + --context-file /tmp/phase5-report.md \ + --context-file /tmp/phase3-output.md \ + --context "Trigger SHA: ${{ steps.changed.outputs.sha }}" \ + --output /tmp/phase6-summary.md \ + --format markdown + 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)" + + # 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 + + # 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_tests.yml b/.github/workflows/extension_tests.yml index 7a7fff9b97d694c1b02dd426f5d59301fe2be81e..9f0917e388c74cffed8f342f7504bc111e6f5147 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -61,8 +61,7 @@ jobs: uses: namespacelabs/nscloud-cache-action@v1 with: cache: rust - - id: cargo_fmt - name: steps::cargo_fmt + - name: steps::cargo_fmt run: cargo fmt --all -- --check shell: bash -euxo pipefail {0} - name: extension_tests::run_clippy diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 317d5a8df37a62887ce4ddcdd67c8d77b48d56d6..ffc2554a55e00a5bdb7bd1ee0bfeebd5667755d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,8 +26,7 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - id: clippy - name: steps::clippy + - name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} - name: steps::clear_target_dir_if_large @@ -72,15 +71,9 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - id: clippy - name: steps::clippy + - name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} - - id: record_clippy_failure - name: steps::record_clippy_failure - if: always() - run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT" - shell: bash -euxo pipefail {0} - name: steps::cargo_install_nextest uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large @@ -94,8 +87,6 @@ jobs: run: | rm -rf ./../.cargo shell: bash -euxo pipefail {0} - outputs: - clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }} timeout-minutes: 60 run_tests_windows: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') @@ -114,8 +105,7 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - id: clippy - name: steps::clippy + - name: steps::clippy run: ./script/clippy.ps1 shell: pwsh - name: steps::clear_target_dir_if_large diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index b23e4b7518a672c0d586ea5ba437db5cf8f94bb6..d76244175accc3e816cbd7d5dc322d2529a0a236 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -20,8 +20,7 @@ jobs: with: clean: false fetch-depth: 0 - - id: cargo_fmt - name: steps::cargo_fmt + - name: steps::cargo_fmt run: cargo fmt --all -- --check shell: bash -euxo pipefail {0} - name: ./script/clippy @@ -45,8 +44,7 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - id: clippy - name: steps::clippy + - name: steps::clippy run: ./script/clippy.ps1 shell: pwsh - name: steps::clear_target_dir_if_large diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index fac3221d63a080fa53b7ba1c5b7249e6a405c73c..47a84574e7c33fb8a40a90c67cd4f7dadb356978 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -74,19 +74,12 @@ jobs: uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 with: version: '9' - - id: prettier - name: steps::prettier + - name: steps::prettier run: ./script/prettier shell: bash -euxo pipefail {0} - - id: cargo_fmt - name: steps::cargo_fmt + - name: steps::cargo_fmt run: cargo fmt --all -- --check shell: bash -euxo pipefail {0} - - id: record_style_failure - name: steps::record_style_failure - if: always() - run: echo "failed=${{ steps.prettier.outcome == 'failure' || steps.cargo_fmt.outcome == 'failure' }}" >> "$GITHUB_OUTPUT" - shell: bash -euxo pipefail {0} - name: ./script/check-todos run: ./script/check-todos shell: bash -euxo pipefail {0} @@ -97,8 +90,6 @@ jobs: uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 with: config: ./typos.toml - outputs: - style_failed: ${{ steps.record_style_failure.outputs.failed == 'true' }} timeout-minutes: 60 run_tests_windows: needs: @@ -119,8 +110,7 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - id: clippy - name: steps::clippy + - name: steps::clippy run: ./script/clippy.ps1 shell: pwsh - name: steps::clear_target_dir_if_large @@ -167,15 +157,9 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - id: clippy - name: steps::clippy + - name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} - - id: record_clippy_failure - name: steps::record_clippy_failure - if: always() - run: echo "failed=${{ steps.clippy.outcome == 'failure' }}" >> "$GITHUB_OUTPUT" - shell: bash -euxo pipefail {0} - name: steps::cargo_install_nextest uses: taiki-e/install-action@nextest - name: steps::clear_target_dir_if_large @@ -189,8 +173,6 @@ jobs: run: | rm -rf ./../.cargo shell: bash -euxo pipefail {0} - outputs: - clippy_failed: ${{ steps.record_clippy_failure.outputs.failed == 'true' }} timeout-minutes: 60 run_tests_mac: needs: @@ -211,8 +193,7 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - - id: clippy - name: steps::clippy + - name: steps::clippy run: ./script/clippy shell: bash -euxo pipefail {0} - name: steps::clear_target_dir_if_large @@ -372,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: @@ -592,24 +576,6 @@ jobs: exit $EXIT_CODE shell: bash -euxo pipefail {0} - call_autofix: - needs: - - check_style - - run_tests_linux - if: always() && (needs.check_style.outputs.style_failed == 'true' || needs.run_tests_linux.outputs.clippy_failed == 'true') && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]' - runs-on: namespace-profile-2x4-ubuntu-2404 - steps: - - id: get-app-token - name: steps::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 - with: - app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} - private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - - name: run_tests::call_autofix::dispatch_autofix - run: gh workflow run autofix_pr.yml -f pr_number=${{ github.event.pull_request.number }} -f run_clippy=${{ needs.run_tests_linux.outputs.clippy_failed == 'true' }} - shell: bash -euxo pipefail {0} - env: - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }} cancel-in-progress: true diff --git a/.gitignore b/.gitignore index 54faaf1374299ee8f97925a95a93b375c349d707..c71417c32bff76af9d4c9c67661556e1625c9d15 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ DerivedData/ Packages xcuserdata/ +crates/docs_preprocessor/actions.json # Don't commit any secrets to the repo. .env diff --git a/Cargo.lock b/Cargo.lock index 0d83b2b9b912ab112d9b38fd1ef1d5ff21f9049c..f9acd6989be8734b6c5b528435fccea62d10f027 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5021,8 +5021,6 @@ name = "docs_preprocessor" version = "0.1.0" dependencies = [ "anyhow", - "command_palette", - "gpui", "mdbook", "regex", "serde", @@ -5031,7 +5029,6 @@ dependencies = [ "task", "theme", "util", - "zed", "zlog", ] @@ -8932,6 +8929,8 @@ dependencies = [ "credentials_provider", "deepseek", "editor", + "extension", + "extension_host", "fs", "futures 0.3.31", "google_ai", @@ -12571,6 +12570,7 @@ dependencies = [ "gpui", "language", "menu", + "notifications", "pretty_assertions", "project", "rayon", @@ -20265,6 +20265,16 @@ dependencies = [ "zlog", ] +[[package]] +name = "worktree_benchmarks" +version = "0.1.0" +dependencies = [ + "fs", + "gpui", + "settings", + "worktree", +] + [[package]] name = "writeable" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 825dc79e08978d8ccd03cea93883f698986ee12f..b507e8824484ea670619b5225fef9cfd41c81d4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -198,6 +198,7 @@ members = [ "crates/web_search_providers", "crates/workspace", "crates/worktree", + "crates/worktree_benchmarks", "crates/x_ai", "crates/zed", "crates/zed_actions", diff --git a/assets/settings/default.json b/assets/settings/default.json index 154fe2d6e34e6573e95e7ffedbb46df8bbf10634..746ccb5986d0fd1d5ef11df525303e344a7393d2 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1178,6 +1178,10 @@ "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, diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index a670ba601159ec323ad2c88695c30bf4aeae4118..598d0428174eb2fc124739a18ddeff1098521cb7 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -210,12 +210,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 { diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 43ed3b90f3556eb24e45440a7fe0038e7a1b9535..4baa7f4ea4004d2137b5cddb255346fa91523091 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -30,7 +30,7 @@ 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, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, @@ -93,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::>(); @@ -153,7 +153,10 @@ 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), + }), } } @@ -164,7 +167,7 @@ impl LanguageModels { 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::>(); @@ -1630,7 +1633,9 @@ mod internal_tests { 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 + )), }] )]) ); diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs index 1f50ce74321d393ba6c7f5083bd889bc3dc2c0e1..22af75a6e96edc4f597819e04e2e84b80ba0417a 100644 --- a/crates/agent_ui/src/acp/mode_selector.rs +++ b/crates/agent_ui/src/acp/mode_selector.rs @@ -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, )), diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index f3c07250de3cefc798b97d9ffad444489d153219..903d5fe425d99389aae0e2a8028d9a31b986fbb3 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -1,6 +1,6 @@ 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 agent_settings::AgentSettings; @@ -350,7 +350,11 @@ impl PickerDelegate for AcpModelPickerDelegate { }) .child( ModelSelectorListItem::new(ix, model_info.name.clone()) - .when_some(model_info.icon, |this, icon| this.icon(icon)) + .map(|this| match &model_info.icon { + Some(AgentModelIcon::Path(path)) => this.icon_path(path.clone()), + Some(AgentModelIcon::Named(icon)) => this.icon(*icon), + None => this, + }) .is_selected(is_selected) .is_focused(selected) .when(supports_favorites, |this| { diff --git a/crates/agent_ui/src/acp/model_selector_popover.rs b/crates/agent_ui/src/acp/model_selector_popover.rs index d6709081863c9545fba4c6e2304f195e77b013df..a15c01445dd8e9845f6744e795ed90a1ede6c7fc 100644 --- a/crates/agent_ui/src/acp/model_selector_popover.rs +++ b/crates/agent_ui/src/acp/model_selector_popover.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use std::sync::Arc; -use acp_thread::{AgentModelInfo, AgentModelSelector}; +use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector}; use agent_servers::AgentServer; use agent_settings::AgentSettings; use fs::Fs; @@ -70,7 +70,7 @@ impl Render for AcpModelSelectorPopover { .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(); @@ -125,7 +125,14 @@ impl Render for AcpModelSelectorPopover { 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) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 8364fd8c0f4d8fd55df8f2e74e990e603029db78..32b2de2c0d850676bf7a6a80ee88950d62aa24e0 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2718,7 +2718,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); })) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 24f019c605d1b167e62a6e68dfc1f3ed07c73f1c..562976453d963db65f9033536e528000de2b510f 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}; @@ -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( diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index ac57ed575d9d1b6de2c53d3e0e4a91b4bd16ab1a..45cefbf2b9f8d4b1639a9849f2ee2e4468e530b1 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -4,6 +4,7 @@ use crate::{ }; 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; @@ -103,7 +104,14 @@ impl Render for AgentModelSelector { 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( @@ -115,7 +123,7 @@ impl Render for AgentModelSelector { .child( Icon::new(IconName::ChevronDown) .color(color) - .size(IconSize::Small), + .size(IconSize::XSmall), ), move |_window, cx| { Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 294cd8b4888950f6ea92d6bea1eba78c3d6d6de2..a050f75120cd73949251c09c8424314e3616c705 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2428,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) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 02cb7e59948b10274302bd8cd6f74f1accbd30a3..401b506b302d9c2a86a36ddce0fc72df075f4c18 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -348,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); } _ => {} diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 77c8c95255908dc54639ad7ac6c55f1e8b8151f0..704e340ace35f33f757ab7708f96ffc940a8eb91 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -7,8 +7,8 @@ use gpui::{ Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task, }; use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProvider, - LanguageModelProviderId, LanguageModelRegistry, + AuthenticateError, ConfiguredModel, IconOrSvg, LanguageModel, LanguageModelId, + LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; @@ -55,7 +55,7 @@ pub fn language_model_selector( fn all_models(cx: &App) -> GroupedModels { let lm_registry = LanguageModelRegistry::global(cx).read(cx); - let providers = lm_registry.providers(); + let providers = lm_registry.visible_providers(); let mut favorites_index = FavoritesIndex::default(); @@ -94,7 +94,7 @@ type FavoritesIndex = HashMap> #[derive(Clone)] struct ModelInfo { model: Arc, - icon: IconName, + icon: IconOrSvg, is_favorite: bool, } @@ -203,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::>(); @@ -474,7 +474,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::>(); @@ -566,7 +566,10 @@ impl PickerDelegate for LanguageModelPickerDelegate { Some( ModelSelectorListItem::new(ix, model_info.model.name().0) - .icon(model_info.icon) + .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) @@ -702,7 +705,7 @@ mod tests { .any(|(fav_provider, fav_name)| *fav_provider == provider && *fav_name == name); ModelInfo { model: Arc::new(TestLanguageModel::new(name, provider)), - icon: IconName::Ai, + icon: IconOrSvg::Icon(IconName::Ai), is_favorite, } }) diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index ac08070fcefa92854b51bc8a66d4d388d08e087d..327d2c67e2d5e87e67935ecdfa7fb6cd41acbcb5 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -191,6 +191,9 @@ impl Render for ProfileSelector { 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() @@ -203,9 +206,6 @@ impl Render for ProfileSelector { cx, )), ) - .child(container().child(Label::new("Toggle Profile Menu")).child( - KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx), - )) .into_any() } }), diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 16d12cf261d3bbb8eb0b879394fedc1cc96e046c..514f45528427af89eeccf85512abf850a7a1be05 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -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}; @@ -2231,10 +2232,10 @@ 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); @@ -2244,6 +2245,13 @@ impl TextThreadEditor { (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 tooltip = Tooltip::element({ move |_, cx| { let focus_handle = focus_handle.clone(); @@ -2291,7 +2299,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) diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs index 061b4f58288798696b068a091fb392c033906627..beb0c13d761aa9e7e41c2ac4e35a8cfcc7e8d869 100644 --- a/crates/agent_ui/src/ui/model_selector_components.rs +++ b/crates/agent_ui/src/ui/model_selector_components.rs @@ -1,6 +1,11 @@ use gpui::{Action, FocusHandle, prelude::*}; use ui::{ElevationIndex, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; +enum ModelIcon { + Name(IconName), + Path(SharedString), +} + #[derive(IntoElement)] pub struct ModelSelectorHeader { title: SharedString, @@ -39,7 +44,7 @@ impl RenderOnce for ModelSelectorHeader { pub struct ModelSelectorListItem { index: usize, title: SharedString, - icon: Option, + icon: Option, is_selected: bool, is_focused: bool, is_favorite: bool, @@ -60,7 +65,12 @@ impl ModelSelectorListItem { } pub fn icon(mut self, icon: IconName) -> Self { - self.icon = Some(icon); + self.icon = Some(ModelIcon::Name(icon)); + self + } + + pub fn icon_path(mut self, path: SharedString) -> Self { + self.icon = Some(ModelIcon::Path(path)); self } @@ -105,9 +115,12 @@ impl RenderOnce for ModelSelectorListItem { .gap_1p5() .when_some(self.icon, |this, icon| { this.child( - Icon::new(icon) - .color(model_icon_color) - .size(IconSize::Small), + 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()), 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/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/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 2336a38fa7767fa6184608066f69d3b0520234ff..e5520be88e34307220126ebafdba6c6371a5db12 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -893,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), @@ -1615,8 +1615,12 @@ impl CodeActionsMenu { 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, "…"); + let is_truncated = line_wrapper.should_truncate_line( + &label, + CODE_ACTION_MENU_MAX_WIDTH, + "…", + gpui::TruncateFrom::End, + ); if is_truncated.is_none() { return None; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8560705802264dad55b87dbf21e1f9aa7625edf8..d985a4d269f2eaeb3fa6056192095b7913b579b6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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}; @@ -4787,82 +4788,146 @@ 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, newline_formatting) = - if let Some(language) = &language_scope { - let mut newline_formatting = - NewlineFormatting::new(&buffer, start..end, language); - - // Comment extension on newline is allowed only for cursor selections - let comment_delimiter = maybe!({ - if !selection_is_empty { - return None; - } + let (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, + ); - if !multi_buffer.language_settings(cx).extend_comment_on_newline - { - return None; - } + 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, + }; - return comment_delimiter_for_newline( - &start_point, - &buffer, - language, - ); - }); + let comment_delimiter = maybe!({ + if !selection_is_empty { + return None; + } - let doc_delimiter = maybe!({ - if !selection_is_empty { - return None; - } + if !multi_buffer.language_settings(cx).extend_comment_on_newline { + return None; + } - if !multi_buffer.language_settings(cx).extend_comment_on_newline - { - return None; - } + return comment_delimiter_for_newline( + &start_point, + &buffer, + language, + ); + }); - return documentation_delimiter_for_newline( - &start_point, - &buffer, - language, - &mut newline_formatting, - ); - }); + let doc_delimiter = maybe!({ + if !selection_is_empty { + return None; + } - (comment_delimiter, doc_delimiter, newline_formatting) - } else { - (None, None, NewlineFormatting::default()) - }; + if !multi_buffer.language_settings(cx).extend_comment_on_newline { + return None; + } - let prevent_auto_indent = doc_delimiter.is_some(); - let delimiter = comment_delimiter.or(doc_delimiter); + return documentation_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); + }); - 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 - + newline_formatting.indent_on_newline.len as usize - + newline_formatting.indent_on_extra_newline.len as usize, - ); - new_text.push('\n'); - new_text.extend(existing_indent.chars()); - new_text.extend(newline_formatting.indent_on_newline.chars()); + let list_delimiter = maybe!({ + if !selection_is_empty { + return None; + } - if let Some(delimiter) = &delimiter { - new_text.push_str(delimiter); - } + if !multi_buffer.language_settings(cx).extend_list_on_newline { + return None; + } - if newline_formatting.insert_extra_newline { - new_text.push('\n'); - new_text.extend(existing_indent.chars()); - new_text.extend(newline_formatting.indent_on_extra_newline.chars()); - } + return list_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); + }); + + ( + comment_delimiter.or(doc_delimiter).or(list_delimiter), + newline_config, + ) + } else { + ( + None, + NewlineConfig::Newline { + additional_indent: IndentSize::spaces(0), + extra_line_additional_indent: None, + prevent_auto_indent: false, + }, + ) + }; + + 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), - (newline_formatting.insert_extra_newline, new_selection), + ((edit_start..end, new_text), prevent_auto_indent), + (newline_config.has_extra_line(), new_selection), ) }) .unzip() @@ -10387,6 +10452,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 = @@ -20394,7 +20475,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, @@ -23355,7 +23436,7 @@ fn documentation_delimiter_for_newline( start_point: &Point, buffer: &MultiBufferSnapshot, language: &LanguageScope, - newline_formatting: &mut NewlineFormatting, + newline_config: &mut NewlineConfig, ) -> Option> { let BlockCommentConfig { start: start_tag, @@ -23407,6 +23488,9 @@ fn documentation_delimiter_for_newline( } }; + 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; @@ -23424,11 +23508,11 @@ fn documentation_delimiter_for_newline( let cursor_is_before_end_tag = column <= end_tag_offset; if cursor_is_after_start_tag { if cursor_is_before_end_tag { - newline_formatting.insert_extra_newline = true; + needs_extra_line = true; } let cursor_is_at_start_of_end_tag = column == end_tag_offset; if cursor_is_at_start_of_end_tag { - newline_formatting.indent_on_extra_newline.len = *len; + extra_line_additional_indent.len = *len; } } cursor_is_before_end_tag @@ -23440,39 +23524,240 @@ fn documentation_delimiter_for_newline( if (cursor_is_after_start_tag || cursor_is_after_delimiter) && cursor_is_before_end_tag_if_exists { - if cursor_is_after_start_tag { - newline_formatting.indent_on_newline.len = *len; - } + 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 } } -#[derive(Debug, Default)] -struct NewlineFormatting { - insert_extra_newline: bool, - indent_on_newline: IndentSize, - indent_on_extra_newline: IndentSize, +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 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 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(), + }; + } + } + + return None; + } + } + + 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 } -impl NewlineFormatting { - fn new( - buffer: &MultiBufferSnapshot, - range: Range, - language: &LanguageScope, - ) -> Self { - Self { - insert_extra_newline: Self::insert_extra_newline_brackets( - buffer, - range.clone(), - language, - ) || Self::insert_extra_newline_tree_sitter(buffer, range), - indent_on_newline: IndentSize::spaces(0), - indent_on_extra_newline: IndentSize::spaces(0), +fn is_list_prefix_row( + row: MultiBufferRow, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, +) -> bool { + let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else { + return false; + }; + + 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, @@ -25009,6 +25294,70 @@ impl EditorSnapshot { 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 { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index c0112c5eda406c9cb3b3b9d004d20853b710f6e1..613850428a8720ed37efa447a1312c262a05571a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -36,7 +36,8 @@ use languages::markdown_lang; use languages::rust_lang; use lsp::CompletionParams; use multi_buffer::{ - IndentGuide, MultiBufferFilterMode, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey, + ExcerptRange, IndentGuide, MultiBuffer, MultiBufferFilterMode, MultiBufferOffset, + MultiBufferOffsetUtf16, PathKey, }; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_ne}; @@ -28021,7 +28022,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { " }); - // Case 2: Test adding new line after nested list preserves indent of previous line + // Case 2: Test adding new line after nested list continues the list with unchecked task cx.set_state(&indoc! {" - [ ] Item 1 - [ ] Item 1.a @@ -28038,32 +28039,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - Λ‡" + - [ ] Λ‡" }); - // Case 3: Test adding a new nested list item preserves indent - cx.set_state(&indoc! {" - - [ ] Item 1 - - [ ] Item 1.a - - [x] Item 2 - - [x] Item 2.a - - [x] Item 2.b - Λ‡" - }); - cx.update_editor(|editor, window, cx| { - editor.handle_input("-", window, cx); - }); - cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - - [ ] Item 1 - - [ ] Item 1.a - - [x] Item 2 - - [x] Item 2.a - - [x] Item 2.b - -Λ‡" - }); + // Case 3: Test adding content to continued list item cx.update_editor(|editor, window, cx| { - editor.handle_input(" [x] Item 2.c", window, cx); + editor.handle_input("Item 2.c", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" @@ -28072,10 +28053,10 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - - [x] Item 2.cΛ‡" + - [ ] Item 2.cΛ‡" }); - // Case 4: Test adding new line after nested ordered list preserves indent of previous line + // 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 @@ -28092,44 +28073,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { 2. Item 2 1. Item 2.a 2. Item 2.b - Λ‡" + 3. Λ‡" }); - // Case 5: Adding new ordered list item preserves indent - cx.set_state(indoc! {" - 1. Item 1 - 1. Item 1.a - 2. Item 2 - 1. Item 2.a - 2. Item 2.b - Λ‡" - }); + // Case 5: Adding content to continued ordered list item cx.update_editor(|editor, window, cx| { - editor.handle_input("3", window, cx); - }); - cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - 1. Item 1 - 1. Item 1.a - 2. Item 2 - 1. Item 2.a - 2. Item 2.b - 3Λ‡" - }); - cx.update_editor(|editor, window, cx| { - editor.handle_input(".", window, cx); - }); - cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - 1. Item 1 - 1. Item 1.a - 2. Item 2 - 1. Item 2.a - 2. Item 2.b - 3.Λ‡" - }); - cx.update_editor(|editor, window, cx| { - editor.handle_input(" Item 2.c", window, cx); + editor.handle_input("Item 2.c", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" @@ -28685,6 +28634,130 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) { assert_eq!(sticky_headers(10.0), vec![]); } +#[gpui::test] +fn test_relative_line_numbers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + 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 + }); + + // 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, |_| {}); @@ -29497,6 +29570,524 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) { 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 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, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index f7b6aa949e74dca9bee73419fa2b87899f9986fd..b2e355dc5158214eabd07d519649591be8a325a8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -66,7 +66,7 @@ use project::{ }; use settings::{ GitGutterSetting, GitHunkStyleSetting, IndentGuideBackgroundColoring, IndentGuideColoring, - Settings, + RelativeLineNumbers, Settings, }; use smallvec::{SmallVec, smallvec}; use std::{ @@ -194,8 +194,6 @@ pub struct EditorElement { style: EditorStyle, } -type DisplayRowDelta = u32; - impl EditorElement { pub(crate) const SCROLLBAR_WIDTH: Pixels = px(15.); @@ -3225,64 +3223,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>, @@ -3292,7 +3232,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, @@ -3304,32 +3244,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_offset_on_selection, - 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); @@ -4652,6 +4576,8 @@ impl EditorElement { gutter_hitbox: &Hitbox, text_hitbox: &Hitbox, style: &EditorStyle, + relative_line_numbers: RelativeLineNumbers, + relative_to: Option, window: &mut Window, cx: &mut App, ) -> Option { @@ -4681,9 +4607,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( @@ -5417,6 +5355,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, @@ -9430,6 +9374,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) }); @@ -9447,7 +9413,7 @@ impl Element for EditorElement { start_row..end_row, &row_infos, &active_rows, - newest_selection_head, + relative_row_base, &snapshot, window, cx, @@ -9767,6 +9733,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, @@ -9778,6 +9745,8 @@ impl Element for EditorElement { &gutter_hitbox, &text_hitbox, &style, + relative, + relative_row_base, window, cx, ) @@ -11625,7 +11594,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); @@ -11665,7 +11634,7 @@ mod tests { }) .collect::>(), &BTreeMap::default(), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11677,10 +11646,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, ) }) @@ -11696,10 +11664,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, ) }) @@ -11713,10 +11680,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, ) }) @@ -11753,7 +11719,7 @@ mod tests { }) .collect::>(), &BTreeMap::default(), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11768,7 +11734,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); @@ -11813,7 +11779,7 @@ mod tests { }) .collect::>(), &BTreeMap::default(), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11825,10 +11791,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, ) }) @@ -11865,7 +11830,7 @@ mod tests { }) .collect::>(), &BTreeMap::from_iter([(DisplayRow(0), LineHighlightSpec::default())]), - Some(DisplayPoint::new(DisplayRow(0), 0)), + Some(DisplayRow(0)), &snapshot, window, cx, @@ -11880,10 +11845,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, ) }) 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/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 7c4c0e48d36dbb9f74a1c835c63fa2b91c5681d9..3e7c47c2ac5efeedde51f180bcfcb424aec31c86 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -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/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_host/benches/extension_compilation_benchmark.rs b/crates/extension_host/benches/extension_compilation_benchmark.rs index a28f617dc36e5cba3ad36d7ab6477e7a665dd5c4..605b98c67071155d8444639ef7043b9c8901161d 100644 --- a/crates/extension_host/benches/extension_compilation_benchmark.rs +++ b/crates/extension_host/benches/extension_compilation_benchmark.rs @@ -148,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_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 54b090347ffad3ffed444827f5cb60c120d25ad7..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, }, @@ -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, }, 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/git_panel.rs b/crates/git_ui/src/git_panel.rs index 0f967e68d1fab829fb37b626c23ecfebe69fb5dd..1323ee014f76ebde42b8dff436b2abed851d13f0 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2849,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) { @@ -5281,7 +5203,7 @@ impl GitPanel { this.child( self.entry_label(path_name, path_color) - .truncate() + .truncate_start() .when(strikethrough, Label::strikethrough), ) }) diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 5f50e4ef8029d8f57cd159bc7da68b668b628f48..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}, diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 75600a9ee1b440a092a89456cbe8fbabe6fdccfa..96f815ac0b592600f22b3c9b9686571487ff77a2 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1077,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. @@ -1096,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. diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 1b1bfd778c7bc746c67551eb31cf70f60b1485ea..770c1f871432afbecc9ffd4e903dfeddcfcba6ee 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -2,8 +2,8 @@ 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; @@ -354,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 { @@ -365,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); @@ -383,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)) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 22f4c46921132a7b8badfb7afd4fd38058c638b4..112775890ef6e478f0b2d347bc9c9ae56dac3c73 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -262,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)>>>; diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index aa056846e6bc56e53d95c41a44444dbb89a16237..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; 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 42fe1e5bf7a396a4eaa8ade26977a207d43b49b5..0000000000000000000000000000000000000000 --- a/crates/gpui/src/platform/mac/attributed_string.rs +++ /dev/null @@ -1,129 +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 crate::platform::mac::ns_string; - - use super::*; - use cocoa::appkit::NSImage; - use cocoa::base::nil; - use cocoa::foundation::NSAutoreleasePool; - #[test] - #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348 - fn test_nsattributed_string() { - // 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 = { - let img: id = msg_send![class!(NSImage), alloc]; - let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")]; - let img: id = msg_send![img, autorelease]; - img - }; - let _size = image.size(); - - let string = ns_string("Test String"); - let attr_string = NSMutableAttributedString::alloc(nil) - .init_attributed_string(string) - .autorelease(); - let hello_string = ns_string("Hello World"); - let hello_attr_string = NSAttributedString::alloc(nil) - .init_attributed_string(hello_string) - .autorelease(); - attr_string.appendAttributedString_(hello_attr_string); - - let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease]; - let _: () = msg_send![attachment, setImage: image]; - let image_attr_string = - msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment]; - attr_string.appendAttributedString_(image_attr_string); - - let another_string = ns_string("Another String"); - let another_attr_string = NSAttributedString::alloc(nil) - .init_attributed_string(another_string) - .autorelease(); - attr_string.appendAttributedString_(another_attr_string); - - let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length]; - - /////////////////////////////////////////////////// - // 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/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 ee67f465e34bd8109246f68b311e225aa8f9fd0a..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, - ns_string, 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,119 +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(ns_string("")) - .autorelease(); - - for entry in item.entries { - if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry - { - let to_append = NSAttributedString::alloc(nil) - .init_attributed_string(ns_string(&text)) - .autorelease(); - - 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> { @@ -1255,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 }; @@ -1605,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/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/queue.rs b/crates/gpui/src/queue.rs index 9e9da710977ee80df1853791918eebe5e7f01096..1ecec183c6c58a86e305343f7bcd1056cda7a581 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -1,4 +1,5 @@ use std::{ + collections::VecDeque, fmt, iter::FusedIterator, sync::{Arc, atomic::AtomicUsize}, @@ -9,9 +10,9 @@ use rand::{Rng, SeedableRng, rngs::SmallRng}; use crate::Priority; struct PriorityQueues { - high_priority: Vec, - medium_priority: Vec, - low_priority: Vec, + high_priority: VecDeque, + medium_priority: VecDeque, + low_priority: VecDeque, } impl PriorityQueues { @@ -42,9 +43,9 @@ impl PriorityQueueState { let mut queues = self.queues.lock(); match priority { Priority::Realtime(_) => unreachable!(), - Priority::High => queues.high_priority.push(item), - Priority::Medium => queues.medium_priority.push(item), - Priority::Low => queues.low_priority.push(item), + 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(()) @@ -141,9 +142,9 @@ impl PriorityQueueReceiver { pub(crate) fn new() -> (PriorityQueueSender, Self) { let state = PriorityQueueState { queues: parking_lot::Mutex::new(PriorityQueues { - high_priority: Vec::new(), - medium_priority: Vec::new(), - low_priority: Vec::new(), + high_priority: VecDeque::new(), + medium_priority: VecDeque::new(), + low_priority: VecDeque::new(), }), condvar: parking_lot::Condvar::new(), receiver_count: AtomicUsize::new(1), @@ -226,7 +227,7 @@ impl PriorityQueueReceiver { if !queues.high_priority.is_empty() { let flip = self.rand.random_ratio(P::High.probability(), mass); if flip { - return Ok(queues.high_priority.pop()); + return Ok(queues.high_priority.pop_front()); } mass -= P::High.probability(); } @@ -234,7 +235,7 @@ impl PriorityQueueReceiver { if !queues.medium_priority.is_empty() { let flip = self.rand.random_ratio(P::Medium.probability(), mass); if flip { - return Ok(queues.medium_priority.pop()); + return Ok(queues.medium_priority.pop_front()); } mass -= P::Medium.probability(); } @@ -242,7 +243,7 @@ impl PriorityQueueReceiver { if !queues.low_priority.is_empty() { let flip = self.rand.random_ratio(P::Low.probability(), mass); if flip { - return Ok(queues.low_priority.pop()); + return Ok(queues.low_priority.pop_front()); } } diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 4d6e6f490d81d967692a3e9d8316af75a7a4d306..7481b8001e5752599b90625450d7adb0c66ea2ca 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -334,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 diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index e8088a84d7fc141d0a320988c6399afe2b93ce07..c5eef0d4496edea4d30c665c82dc0a9f00bb83be 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -75,13 +75,21 @@ pub trait Styled: Sized { 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().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().text_overflow = Some(overflow); diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 95cd55d04443c6b2c351bf8533ccb57d49e8dcd9..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, @@ -129,29 +138,50 @@ impl LineWrapper { } /// 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: &str, truncate_width: Pixels, - truncation_suffix: &str, + truncation_affix: &str, + truncate_from: TruncateFrom, ) -> Option { let mut width = px(0.); - let 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 truncate_ix = 0; - for (ix, c) in line.char_indices() { - if width + suffix_width < truncate_width { - truncate_ix = ix; + match truncate_from { + TruncateFrom::Start => { + for (ix, c) in line.char_indices().rev() { + 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); + } + } } + 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; + let char_width = self.width_for_char(c); + width += char_width; - if width.floor() > truncate_width { - return Some(truncate_ix); + if width.floor() > truncate_width { + return Some(truncate_ix); + } + } } } @@ -163,16 +193,23 @@ impl LineWrapper { &mut self, line: SharedString, truncate_width: Pixels, - truncation_suffix: &str, + 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_suffix) + self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from) { - let result = - SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix)); + 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_suffix, &mut runs); + update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); (result, Cow::Owned(runs)) } else { (line, Cow::Borrowed(runs)) @@ -245,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; + } + } } } } @@ -503,7 +560,7 @@ mod tests { } #[test] - fn test_truncate_line() { + fn test_truncate_line_end() { let mut wrapper = build_wrapper(); fn perform_test( @@ -514,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()); } @@ -541,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( @@ -554,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); @@ -600,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); } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2ccd7edac86bced89048cbe5dbf196d8fbcf95f3..8df421feb968677be0abbb642a7127871881bcf3 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -876,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>, @@ -897,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, @@ -1047,7 +1094,7 @@ 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)); @@ -1075,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() { @@ -1088,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", || { @@ -1101,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(); @@ -1299,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(), @@ -3691,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(_) => { @@ -3793,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, diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 99e0c8d4ebdad709eea0e9ab6dbdf9d889d54ec5..5f46340b41a876443f1d12724450d2d8b30f9b33 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1490,19 +1490,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(); diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 54e2ef4065460547f4a3f86db7d3a3986dff65eb..2c2d93c8239f0f3fcb1de0956de2d3400f13e96b 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -1141,6 +1141,104 @@ fn test_text_objects(cx: &mut App) { ) } +#[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] diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index a573e3d78a4de03c6ccf382c80bc33eaf0b5690d..290cad4e4497015ef63f79e58a0dacf231168c9f 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -827,6 +827,15 @@ pub struct LanguageConfig { /// Delimiters and configuration for recognizing and formatting documentation comments. #[serde(default, alias = "documentation")] pub documentation_comment: Option, + /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). + #[serde(default)] + pub unordered_list: Vec>, + /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `). + #[serde(default)] + pub ordered_list: Vec, + /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `). + #[serde(default)] + pub task_list: Option, /// A list of additional regex patterns that should be treated as prefixes /// for creating boundaries during rewrapping, ensuring content from one /// prefixed section doesn't merge with another (e.g., markdown list items). @@ -898,6 +907,24 @@ pub struct DecreaseIndentConfig { pub valid_after: Vec, } +/// Configuration for continuing ordered lists with auto-incrementing numbers. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct OrderedListConfig { + /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `). + pub pattern: String, + /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `). + pub format: String, +} + +/// Configuration for continuing task lists on newline. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct TaskListConfig { + /// The list markers to match (e.g., `- [ ] `, `- [x] `). + pub prefixes: Vec>, + /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `). + pub continuation: Arc, +} + #[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)] pub struct LanguageMatcher { /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`. @@ -1068,6 +1095,9 @@ impl Default for LanguageConfig { line_comments: Default::default(), block_comment: Default::default(), documentation_comment: Default::default(), + unordered_list: Default::default(), + ordered_list: Default::default(), + task_list: Default::default(), rewrap_prefixes: Default::default(), scope_opt_in_language_servers: Default::default(), overrides: Default::default(), @@ -2153,6 +2183,21 @@ impl LanguageScope { self.language.config.documentation_comment.as_ref() } + /// Returns list markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). + pub fn unordered_list(&self) -> &[Arc] { + &self.language.config.unordered_list + } + + /// Returns configuration for ordered lists with auto-incrementing numbers (e.g., `1. ` becomes `2. `). + pub fn ordered_list(&self) -> &[OrderedListConfig] { + &self.language.config.ordered_list + } + + /// Returns configuration for task list continuation, if any (e.g., `- [x] ` continues as `- [ ] `). + pub fn task_list(&self) -> Option<&TaskListConfig> { + self.language.config.task_list.as_ref() + } + /// Returns additional regex patterns that act as prefix markers for creating /// boundaries during rewrapping. /// diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index fccaa545b79c1f24589889df8fcd163fbc5b6c7d..205f2431c6d9deeaa7661b583caa516bdc77ae79 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -122,6 +122,10 @@ pub struct LanguageSettings { pub whitespace_map: WhitespaceMap, /// Whether to start a new line with a comment when a previous line is a comment as well. pub extend_comment_on_newline: bool, + /// Whether to continue markdown lists when pressing enter. + pub extend_list_on_newline: bool, + /// Whether to indent list items when pressing tab after a list marker. + pub indent_list_on_tab: bool, /// Inlay hint related settings. pub inlay_hints: InlayHintSettings, /// Whether to automatically close brackets. @@ -567,6 +571,8 @@ impl settings::Settings for AllLanguageSettings { tab: SharedString::new(whitespace_map.tab.unwrap().to_string()), }, extend_comment_on_newline: settings.extend_comment_on_newline.unwrap(), + extend_list_on_newline: settings.extend_list_on_newline.unwrap(), + indent_list_on_tab: settings.indent_list_on_tab.unwrap(), inlay_hints: InlayHintSettings { enabled: inlay_hints.enabled.unwrap(), show_value_hints: inlay_hints.show_value_hints.unwrap(), diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 77e90c4ca89d0b6e5b8cb0a604175ec9a97e719e..db4ab4f459c35a98752bef1eb5be558084b5c906 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -19,7 +19,10 @@ use std::{ use streaming_iterator::StreamingIterator; use sum_tree::{Bias, Dimensions, SeekTarget, SumTree}; use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point, Rope, ToOffset, ToPoint}; -use tree_sitter::{Node, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatches, Tree}; +use tree_sitter::{ + Node, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatch, QueryMatches, + QueryPredicateArg, Tree, +}; pub const MAX_BYTES_TO_QUERY: usize = 16 * 1024; @@ -82,6 +85,7 @@ struct SyntaxMapMatchesLayer<'a> { next_captures: Vec>, has_next: bool, matches: QueryMatches<'a, 'a, TextProvider<'a>, &'a [u8]>, + query: &'a Query, grammar_index: usize, _query_cursor: QueryCursorHandle, } @@ -1163,6 +1167,7 @@ impl<'a> SyntaxMapMatches<'a> { depth: layer.depth, grammar_index, matches, + query, next_pattern_index: 0, next_captures: Vec::new(), has_next: false, @@ -1260,13 +1265,20 @@ impl SyntaxMapCapturesLayer<'_> { impl SyntaxMapMatchesLayer<'_> { fn advance(&mut self) { - if let Some(mat) = self.matches.next() { - self.next_captures.clear(); - self.next_captures.extend_from_slice(mat.captures); - self.next_pattern_index = mat.pattern_index; - self.has_next = true; - } else { - self.has_next = false; + loop { + if let Some(mat) = self.matches.next() { + if !satisfies_custom_predicates(self.query, mat) { + continue; + } + self.next_captures.clear(); + self.next_captures.extend_from_slice(mat.captures); + self.next_pattern_index = mat.pattern_index; + self.has_next = true; + return; + } else { + self.has_next = false; + return; + } } } @@ -1295,6 +1307,39 @@ impl<'a> Iterator for SyntaxMapCaptures<'a> { } } +fn satisfies_custom_predicates(query: &Query, mat: &QueryMatch) -> bool { + for predicate in query.general_predicates(mat.pattern_index) { + let satisfied = match predicate.operator.as_ref() { + "has-parent?" => has_parent(&predicate.args, mat), + "not-has-parent?" => !has_parent(&predicate.args, mat), + _ => true, + }; + if !satisfied { + return false; + } + } + true +} + +fn has_parent(args: &[QueryPredicateArg], mat: &QueryMatch) -> bool { + let ( + Some(QueryPredicateArg::Capture(capture_ix)), + Some(QueryPredicateArg::String(parent_kind)), + ) = (args.first(), args.get(1)) + else { + return false; + }; + + let Some(capture) = mat.captures.iter().find(|c| c.index == *capture_ix) else { + return false; + }; + + capture + .node + .parent() + .is_some_and(|p| p.kind() == parent_kind.as_ref()) +} + fn join_ranges( a: impl Iterator>, b: impl Iterator>, diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 5717ffb5143e38bce736c354b43febc86e321f32..815ece30a1ed46ae65ec4af2ba64501ff3489718 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -4,7 +4,10 @@ //! which is a set of tools used to interact with the projects written in said language. //! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override. -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use async_trait::async_trait; use collections::HashMap; @@ -36,7 +39,7 @@ pub struct Toolchain { /// - Only in the subproject they're currently in. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] pub enum ToolchainScope { - Subproject(WorktreeId, Arc), + Subproject(Arc, Arc), Project, /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines. Global, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 09d44b5b408324936af00a2a5e4f1deb4f351434..56a970404419ec6042c463d26c2844eb0904f829 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -797,11 +797,26 @@ pub enum AuthenticateError { Other(#[from] anyhow::Error), } +/// Either a built-in icon name or a path to an external SVG. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IconOrSvg { + /// A built-in icon from Zed's icon set. + Icon(IconName), + /// Path to a custom SVG icon file. + Svg(SharedString), +} + +impl Default for IconOrSvg { + fn default() -> Self { + Self::Icon(IconName::ZedAssistant) + } +} + pub trait LanguageModelProvider: 'static { fn id(&self) -> LanguageModelProviderId; fn name(&self) -> LanguageModelProviderName; - fn icon(&self) -> IconName { - IconName::ZedAssistant + fn icon(&self) -> IconOrSvg { + IconOrSvg::default() } fn default_model(&self, cx: &App) -> Option>; fn default_fast_model(&self, cx: &App) -> Option>; @@ -820,7 +835,7 @@ pub trait LanguageModelProvider: 'static { fn reset_credentials(&self, cx: &mut App) -> Task>; } -#[derive(Default, Clone)] +#[derive(Default, Clone, PartialEq, Eq)] pub enum ConfigurationViewTargetAgent { #[default] ZedAgent, diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index 27b8309810962981d3c0ec78e6e67dfdfba122bf..cf7718f7b102010cc0c8a981a0425583436176b7 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -2,12 +2,16 @@ use crate::{ LanguageModel, LanguageModelId, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderState, }; -use collections::BTreeMap; +use collections::{BTreeMap, HashSet}; use gpui::{App, Context, Entity, EventEmitter, Global, prelude::*}; use std::{str::FromStr, sync::Arc}; use thiserror::Error; use util::maybe; +/// Function type for checking if a built-in provider should be hidden. +/// Returns Some(extension_id) if the provider should be hidden when that extension is installed. +pub type BuiltinProviderHidingFn = Box Option<&'static str> + Send + Sync>; + pub fn init(cx: &mut App) { let registry = cx.new(|_cx| LanguageModelRegistry::default()); cx.set_global(GlobalLanguageModelRegistry(registry)); @@ -48,6 +52,11 @@ pub struct LanguageModelRegistry { thread_summary_model: Option, providers: BTreeMap>, inline_alternatives: Vec>, + /// Set of installed extension IDs that provide language models. + /// Used to determine which built-in providers should be hidden. + installed_llm_extension_ids: HashSet>, + /// Function to check if a built-in provider should be hidden by an extension. + builtin_provider_hiding_fn: Option, } #[derive(Debug)] @@ -104,6 +113,8 @@ pub enum Event { ProviderStateChanged(LanguageModelProviderId), AddedProvider(LanguageModelProviderId), RemovedProvider(LanguageModelProviderId), + /// Emitted when provider visibility changes due to extension install/uninstall. + ProvidersChanged, } impl EventEmitter for LanguageModelRegistry {} @@ -183,6 +194,60 @@ impl LanguageModelRegistry { providers } + /// Returns providers, filtering out hidden built-in providers. + pub fn visible_providers(&self) -> Vec> { + self.providers() + .into_iter() + .filter(|p| !self.should_hide_provider(&p.id())) + .collect() + } + + /// Sets the function used to check if a built-in provider should be hidden. + pub fn set_builtin_provider_hiding_fn(&mut self, hiding_fn: BuiltinProviderHidingFn) { + self.builtin_provider_hiding_fn = Some(hiding_fn); + } + + /// Called when an extension is installed/loaded. + /// If the extension provides language models, track it so we can hide the corresponding built-in. + pub fn extension_installed(&mut self, extension_id: Arc, cx: &mut Context) { + if self.installed_llm_extension_ids.insert(extension_id) { + cx.emit(Event::ProvidersChanged); + cx.notify(); + } + } + + /// Called when an extension is uninstalled/unloaded. + pub fn extension_uninstalled(&mut self, extension_id: &str, cx: &mut Context) { + if self.installed_llm_extension_ids.remove(extension_id) { + cx.emit(Event::ProvidersChanged); + cx.notify(); + } + } + + /// Sync the set of installed LLM extension IDs. + pub fn sync_installed_llm_extensions( + &mut self, + extension_ids: HashSet>, + cx: &mut Context, + ) { + if extension_ids != self.installed_llm_extension_ids { + self.installed_llm_extension_ids = extension_ids; + cx.emit(Event::ProvidersChanged); + cx.notify(); + } + } + + /// Returns true if a provider should be hidden from the UI. + /// Built-in providers are hidden when their corresponding extension is installed. + pub fn should_hide_provider(&self, provider_id: &LanguageModelProviderId) -> bool { + if let Some(ref hiding_fn) = self.builtin_provider_hiding_fn { + if let Some(extension_id) = hiding_fn(&provider_id.0) { + return self.installed_llm_extension_ids.contains(extension_id); + } + } + false + } + pub fn configuration_error( &self, model: Option, @@ -416,4 +481,132 @@ mod tests { let providers = registry.read(cx).providers(); assert!(providers.is_empty()); } + + #[gpui::test] + fn test_provider_hiding_on_extension_install(cx: &mut App) { + let registry = cx.new(|_| LanguageModelRegistry::default()); + + let provider = Arc::new(FakeLanguageModelProvider::default()); + let provider_id = provider.id(); + + registry.update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + + registry.set_builtin_provider_hiding_fn(Box::new(|id| { + if id == "fake" { + Some("fake-extension") + } else { + None + } + })); + }); + + let visible = registry.read(cx).visible_providers(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].id(), provider_id); + + registry.update(cx, |registry, cx| { + registry.extension_installed("fake-extension".into(), cx); + }); + + let visible = registry.read(cx).visible_providers(); + assert!(visible.is_empty()); + + let all = registry.read(cx).providers(); + assert_eq!(all.len(), 1); + } + + #[gpui::test] + fn test_provider_unhiding_on_extension_uninstall(cx: &mut App) { + let registry = cx.new(|_| LanguageModelRegistry::default()); + + let provider = Arc::new(FakeLanguageModelProvider::default()); + let provider_id = provider.id(); + + registry.update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + + registry.set_builtin_provider_hiding_fn(Box::new(|id| { + if id == "fake" { + Some("fake-extension") + } else { + None + } + })); + + registry.extension_installed("fake-extension".into(), cx); + }); + + let visible = registry.read(cx).visible_providers(); + assert!(visible.is_empty()); + + registry.update(cx, |registry, cx| { + registry.extension_uninstalled("fake-extension", cx); + }); + + let visible = registry.read(cx).visible_providers(); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].id(), provider_id); + } + + #[gpui::test] + fn test_should_hide_provider(cx: &mut App) { + let registry = cx.new(|_| LanguageModelRegistry::default()); + + registry.update(cx, |registry, cx| { + registry.set_builtin_provider_hiding_fn(Box::new(|id| { + if id == "anthropic" { + Some("anthropic") + } else if id == "openai" { + Some("openai") + } else { + None + } + })); + + registry.extension_installed("anthropic".into(), cx); + }); + + let registry_read = registry.read(cx); + + assert!(registry_read.should_hide_provider(&LanguageModelProviderId("anthropic".into()))); + + assert!(!registry_read.should_hide_provider(&LanguageModelProviderId("openai".into()))); + + assert!(!registry_read.should_hide_provider(&LanguageModelProviderId("unknown".into()))); + } + + #[gpui::test] + fn test_sync_installed_llm_extensions(cx: &mut App) { + let registry = cx.new(|_| LanguageModelRegistry::default()); + + let provider = Arc::new(FakeLanguageModelProvider::default()); + + registry.update(cx, |registry, cx| { + registry.register_provider(provider.clone(), cx); + + registry.set_builtin_provider_hiding_fn(Box::new(|id| { + if id == "fake" { + Some("fake-extension") + } else { + None + } + })); + }); + + let mut extension_ids = HashSet::default(); + extension_ids.insert(Arc::from("fake-extension")); + + registry.update(cx, |registry, cx| { + registry.sync_installed_llm_extensions(extension_ids, cx); + }); + + assert!(registry.read(cx).visible_providers().is_empty()); + + registry.update(cx, |registry, cx| { + registry.sync_installed_llm_extensions(HashSet::default(), cx); + }); + + assert_eq!(registry.read(cx).visible_providers().len(), 1); + } } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 5531e698ab7fccae736e800f38b16e35bcd35ac4..1bec5d94d2bb35f91305c6c77a9e85ed8579e1af 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -28,6 +28,8 @@ convert_case.workspace = true copilot.workspace = true credentials_provider.workspace = true deepseek = { workspace = true, features = ["schemars"] } +extension.workspace = true +extension_host.workspace = true fs.workspace = true futures.workspace = true google_ai = { workspace = true, features = ["schemars"] } diff --git a/crates/language_models/src/extension.rs b/crates/language_models/src/extension.rs new file mode 100644 index 0000000000000000000000000000000000000000..e0b46ab5e1d667fb61449a654769ecf7c221e720 --- /dev/null +++ b/crates/language_models/src/extension.rs @@ -0,0 +1,67 @@ +use collections::HashMap; +use extension::{ + ExtensionHostProxy, ExtensionLanguageModelProviderProxy, LanguageModelProviderRegistration, +}; +use gpui::{App, Entity}; +use language_model::{LanguageModelProviderId, LanguageModelRegistry}; +use std::sync::{Arc, LazyLock}; + +/// Maps built-in provider IDs to their corresponding extension IDs. +/// When an extension with this ID is installed, the built-in provider should be hidden. +static BUILTIN_TO_EXTENSION_MAP: LazyLock> = + LazyLock::new(|| { + let mut map = HashMap::default(); + map.insert("anthropic", "anthropic"); + map.insert("openai", "openai"); + map.insert("google", "google-ai"); + map.insert("openrouter", "openrouter"); + map.insert("copilot_chat", "copilot-chat"); + map + }); + +/// Returns the extension ID that should hide the given built-in provider. +pub fn extension_for_builtin_provider(provider_id: &str) -> Option<&'static str> { + BUILTIN_TO_EXTENSION_MAP.get(provider_id).copied() +} + +/// Proxy that registers extension language model providers with the LanguageModelRegistry. +pub struct LanguageModelProviderRegistryProxy { + registry: Entity, +} + +impl LanguageModelProviderRegistryProxy { + pub fn new(registry: Entity) -> Self { + Self { registry } + } +} + +impl ExtensionLanguageModelProviderProxy for LanguageModelProviderRegistryProxy { + fn register_language_model_provider( + &self, + _provider_id: Arc, + register_fn: LanguageModelProviderRegistration, + cx: &mut App, + ) { + register_fn(cx); + } + + fn unregister_language_model_provider(&self, provider_id: Arc, cx: &mut App) { + self.registry.update(cx, |registry, cx| { + registry.unregister_provider(LanguageModelProviderId::from(provider_id), cx); + }); + } +} + +/// Initialize the extension language model provider proxy. +/// This must be called BEFORE extension_host::init to ensure the proxy is available +/// when extensions try to register their language model providers. +pub fn init_proxy(cx: &mut App) { + let proxy = ExtensionHostProxy::default_global(cx); + let registry = LanguageModelRegistry::global(cx); + + registry.update(cx, |registry, _cx| { + registry.set_builtin_provider_hiding_fn(Box::new(extension_for_builtin_provider)); + }); + + proxy.register_language_model_provider_proxy(LanguageModelProviderRegistryProxy::new(registry)); +} diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 1038f5e233e0a5970b0e8bd969a65f6f0e2a7550..37d4ca5ddd4e5c1e7a0202c88c012d18b018cd4f 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -7,9 +7,12 @@ use gpui::{App, Context, Entity}; use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; +pub mod extension; pub mod provider; mod settings; +pub use crate::extension::init_proxy as init_extension_proxy; + use crate::provider::anthropic::AnthropicLanguageModelProvider; use crate::provider::bedrock::BedrockLanguageModelProvider; use crate::provider::cloud::CloudLanguageModelProvider; @@ -31,6 +34,56 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { register_language_model_providers(registry, user_store, client.clone(), cx); }); + // Subscribe to extension store events to track LLM extension installations + if let Some(extension_store) = extension_host::ExtensionStore::try_global(cx) { + cx.subscribe(&extension_store, { + let registry = registry.clone(); + move |extension_store, event, cx| match event { + extension_host::Event::ExtensionInstalled(extension_id) => { + if let Some(manifest) = extension_store + .read(cx) + .extension_manifest_for_id(extension_id) + { + if !manifest.language_model_providers.is_empty() { + registry.update(cx, |registry, cx| { + registry.extension_installed(extension_id.clone(), cx); + }); + } + } + } + extension_host::Event::ExtensionUninstalled(extension_id) => { + registry.update(cx, |registry, cx| { + registry.extension_uninstalled(extension_id, cx); + }); + } + extension_host::Event::ExtensionsUpdated => { + let mut new_ids = HashSet::default(); + for (extension_id, entry) in extension_store.read(cx).installed_extensions() { + if !entry.manifest.language_model_providers.is_empty() { + new_ids.insert(extension_id.clone()); + } + } + registry.update(cx, |registry, cx| { + registry.sync_installed_llm_extensions(new_ids, cx); + }); + } + _ => {} + } + }) + .detach(); + + // Initialize with currently installed extensions + registry.update(cx, |registry, cx| { + let mut initial_ids = HashSet::default(); + for (extension_id, entry) in extension_store.read(cx).installed_extensions() { + if !entry.manifest.language_model_providers.is_empty() { + initial_ids.insert(extension_id.clone()); + } + } + registry.sync_installed_llm_extensions(initial_ids, cx); + }); + } + let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx) .openai_compatible .keys() diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index d8c972399c33922386bfba4236e1369d03d338dc..598834f85c496cd54ddd956089715cac64420202 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -8,7 +8,7 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B use gpui::{AnyView, App, AsyncApp, Context, Entity, Task}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModel, + ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, @@ -125,8 +125,8 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiAnthropic + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiAnthropic) } fn default_model(&self, _cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 286f9ec1a4bf67c22868cf83e00e7b46e0737ba8..62237fbf376a0739fd2518bda44f51149b3457df 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -30,7 +30,7 @@ use gpui::{ use gpui_tokio::Tokio; use http_client::HttpClient; use language_model::{ - AuthenticateError, EnvVar, LanguageModel, LanguageModelCacheConfiguration, + AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, @@ -426,8 +426,8 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiBedrock + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiBedrock) } fn default_model(&self, _cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index def1cef84d3166d08dcc7638ca5a29cabbd149c5..65a42740eb9a8aff830d7544ed5aa972c6697d88 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -19,7 +19,7 @@ use gpui::{AnyElement, AnyView, App, AsyncApp, Context, Entity, Subscription, Ta use http_client::http::{HeaderMap, HeaderValue}; use http_client::{AsyncBody, HttpClient, HttpRequestExt, Method, Response, StatusCode}; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCacheConfiguration, + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, @@ -304,8 +304,8 @@ impl LanguageModelProvider for CloudLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiZed + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiZed) } fn default_model(&self, cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 70198b337e467e1618192e781d3e3be305fea9c5..68eaab1dbed33a8d983de6a919b75dc809410a70 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -18,12 +18,12 @@ use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task}; use http_client::StatusCode; use language::language_settings::all_language_settings; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelRequestMessage, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role, - StopReason, TokenUsage, + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice, + LanguageModelToolResultContent, LanguageModelToolSchemaFormat, LanguageModelToolUse, + MessageContent, RateLimiter, Role, StopReason, TokenUsage, }; use settings::SettingsStore; use ui::prelude::*; @@ -104,8 +104,8 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::Copilot + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::Copilot) } fn default_model(&self, cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index b00a5d82f5665a5c87c662d1af84fbeb9ac07ebb..b3264b869195aa34d7083cd31992d8c220d20349 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -7,7 +7,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture, stream::BoxStream use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, @@ -127,8 +127,8 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiDeepSeek + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiDeepSeek) } fn default_model(&self, _cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 989b99061b6d0f4c6680f08616c55946138ae0fe..7d567d60f405c7880cb6494f6d2ff604d7f53ac2 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -14,7 +14,7 @@ use language_model::{ LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason, }; use language_model::{ - LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider, + IconOrSvg, LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, Role, }; @@ -164,8 +164,8 @@ impl LanguageModelProvider for GoogleLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiGoogle + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiGoogle) } fn default_model(&self, _cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 94f99f10afc8928fb7fbc8526ab46e7dca37a5ce..237b64ac7d0ed728b057f6b553ad2a2a1ebae1db 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -10,7 +10,7 @@ use language_model::{ StopReason, TokenUsage, }; use language_model::{ - LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider, + IconOrSvg, LanguageModel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, Role, }; @@ -175,8 +175,8 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiLmStudio + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiLmStudio) } fn default_model(&self, _: &App) -> Option> { diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 64f3999e3aa96b2611e265a6eaf5df8063332c2a..0b8af405ade8fc00c0d1e2e57ba115560d94a71d 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -5,7 +5,7 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, @@ -176,8 +176,8 @@ impl LanguageModelProvider for MistralLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiMistral + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiMistral) } fn default_model(&self, _cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 860d635b6ac704c2762023c463432bebae08d4a5..f5d8820e710ea6c9f89de6da5a7aae2f204c6470 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -5,7 +5,7 @@ use futures::{Stream, TryFutureExt, stream}; use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, @@ -221,8 +221,8 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiOllama + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiOllama) } fn default_model(&self, _: &App) -> Option> { diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index afaffba3e53eb2496f9fae795d69b9e9c9f57249..905d2b37862eebf57c7fb56a540b388338cfd065 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -5,7 +5,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, @@ -122,8 +122,8 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiOpenAi + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiOpenAi) } fn default_model(&self, _cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index e6e7a9984da3d48b9e3c0f9571b8e916359fba03..f95f567739d76670d3cfa7b835bbfaf34ddef92f 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, @@ -133,8 +133,8 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider { self.name.clone() } - fn icon(&self) -> IconName { - IconName::AiOpenAiCompat + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiOpenAiCompat) } fn default_model(&self, cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index ad2e90d9dd5f4ece7e2582a867da50f6962c981c..48d68ddebff7e0c9bbe39dbca696dd2ffcf62605 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -4,7 +4,7 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, @@ -180,8 +180,8 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiOpenRouter + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiOpenRouter) } fn default_model(&self, _cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index 4dfe848df80123dc4c37d27b81f76db359e076f9..e2e692eafff94c56d481dfc2bd96dbfa7adda262 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var, @@ -117,8 +117,8 @@ impl LanguageModelProvider for VercelLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiVZero + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiVZero) } fn default_model(&self, _cx: &App) -> Option> { diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 19c50d71cf4e483b68d48c8b982a975f3091ff46..f0aa0e71a83ae1a201d76a33f63ca0aadc6936a9 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -4,7 +4,7 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window}; use http_client::HttpClient; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, @@ -118,8 +118,8 @@ impl LanguageModelProvider for XAiLanguageModelProvider { PROVIDER_NAME } - fn icon(&self) -> IconName { - IconName::AiXAi + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiXAi) } fn default_model(&self, _cx: &App) -> Option> { diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 335381c6f79d950498a0f0c1d330cb21c681f32e..7775586bf19539e13adc6b9df6d92914be6b7f21 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -127,6 +127,16 @@ impl LanguageServerState { return menu; }; + let server_versions = self + .lsp_store + .update(cx, |lsp_store, _| { + lsp_store + .language_server_statuses() + .map(|(server_id, status)| (server_id, status.server_version.clone())) + .collect::>() + }) + .unwrap_or_default(); + let mut first_button_encountered = false; for item in &self.items { if let LspMenuItem::ToggleServersButton { restart } = item { @@ -254,6 +264,22 @@ impl LanguageServerState { }; let server_name = server_info.name.clone(); + let server_version = server_versions + .get(&server_info.id) + .and_then(|version| version.clone()); + + let tooltip_text = match (&server_version, &message) { + (None, None) => None, + (Some(version), None) => { + Some(SharedString::from(format!("Version: {}", version.as_ref()))) + } + (None, Some(message)) => Some(message.clone()), + (Some(version), Some(message)) => Some(SharedString::from(format!( + "Version: {}\n\n{}", + version.as_ref(), + message.as_ref() + ))), + }; menu = menu.item(ContextMenuItem::custom_entry( move |_, _| { h_flex() @@ -355,11 +381,11 @@ impl LanguageServerState { } } }, - message.map(|server_message| { + tooltip_text.map(|tooltip_text| { DocumentationAside::new( DocumentationSide::Right, - DocumentationEdge::Bottom, - Rc::new(move |_| Label::new(server_message.clone()).into_any_element()), + DocumentationEdge::Top, + Rc::new(move |_| Label::new(tooltip_text.clone()).into_any_element()), ) }), )); diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index e34bbb46d35d5a524c08369fcc991dfe81865127..2b2575912ae4543d2bf3cbd0c6b667ace7c82e91 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -330,6 +330,8 @@ impl LspLogView { let server_info = format!( "* Server: {NAME} (id {ID}) +* Version: {VERSION} + * Binary: {BINARY} * Registered workspace folders: @@ -340,6 +342,12 @@ impl LspLogView { * Configuration: {CONFIGURATION}", NAME = info.status.name, ID = info.id, + VERSION = info + .status + .server_version + .as_ref() + .map(|version| version.as_ref()) + .unwrap_or("Unknown"), BINARY = info .status .binary @@ -1334,6 +1342,7 @@ impl ServerInfo { capabilities: server.capabilities(), status: LanguageServerStatus { name: server.name(), + server_version: server.version(), pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), diff --git a/crates/languages/src/javascript/textobjects.scm b/crates/languages/src/javascript/textobjects.scm index 1a273ddb5000ba920868272bb4ac31d270095442..eace658e6b9847bcc651deedad2bc27cbfbf6975 100644 --- a/crates/languages/src/javascript/textobjects.scm +++ b/crates/languages/src/javascript/textobjects.scm @@ -18,13 +18,47 @@ (_)* @function.inside "}")) @function.around -(arrow_function +((arrow_function body: (statement_block "{" (_)* @function.inside "}")) @function.around + (#not-has-parent? @function.around variable_declarator)) -(arrow_function) @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 + +; Arrow function in variable declaration (captures body for expression-bodied arrows) +([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) +]) @function.around + +; Catch-all for arrow functions in other contexts (callbacks, etc.) +((arrow_function + body: (_) @function.inside) @function.around + (#not-has-parent? @function.around variable_declarator)) (generator_function body: (_ diff --git a/crates/languages/src/markdown/config.toml b/crates/languages/src/markdown/config.toml index 84c79d2538a0af470ec16d55fe9cf2d1ae05805b..423a4c008f6e8a64f3c4e883b0d6e2bde65c88ae 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/languages/src/markdown/config.toml @@ -20,6 +20,9 @@ rewrap_prefixes = [ ">\\s*", "[-*+]\\s+\\[[\\sx]\\]\\s+" ] +unordered_list = ["- ", "* ", "+ "] +ordered_list = [{ pattern = "(\\d+)\\. ", format = "{1}. " }] +task_list = { prefixes = ["- [ ] ", "- [x] "], continuation = "- [ ] " } auto_indent_on_paste = false auto_indent_using_last_non_empty_line = false diff --git a/crates/languages/src/tsx/textobjects.scm b/crates/languages/src/tsx/textobjects.scm index 836fed35ba1c1093b84e48a8da19d89177a69944..628a921f3ac9ea04ff59654d72caf73cebbc9071 100644 --- a/crates/languages/src/tsx/textobjects.scm +++ b/crates/languages/src/tsx/textobjects.scm @@ -18,13 +18,47 @@ (_)* @function.inside "}")) @function.around -(arrow_function +((arrow_function body: (statement_block "{" (_)* @function.inside "}")) @function.around + (#not-has-parent? @function.around variable_declarator)) -(arrow_function) @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 + +; Arrow function in variable declaration (expression body fallback) +([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) +]) @function.around + +; Catch-all for arrow functions in other contexts (callbacks, etc.) +((arrow_function + body: (_) @function.inside) @function.around + (#not-has-parent? @function.around variable_declarator)) (function_signature) @function.around (generator_function diff --git a/crates/languages/src/typescript/textobjects.scm b/crates/languages/src/typescript/textobjects.scm index 836fed35ba1c1093b84e48a8da19d89177a69944..96289f058cd7b605a8f5b4c8966e3c372022d065 100644 --- a/crates/languages/src/typescript/textobjects.scm +++ b/crates/languages/src/typescript/textobjects.scm @@ -18,13 +18,48 @@ (_)* @function.inside "}")) @function.around -(arrow_function +((arrow_function body: (statement_block "{" (_)* @function.inside "}")) @function.around + (#not-has-parent? @function.around variable_declarator)) -(arrow_function) @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 + +; Arrow function in variable declaration - capture body as @function.inside +; (for statement blocks, the more specific pattern above captures just the contents) +([ + (lexical_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) + (variable_declaration + (variable_declarator + value: (arrow_function + body: (_) @function.inside))) +]) @function.around + +; Catch-all for arrow functions in other contexts (callbacks, etc.) +((arrow_function + body: (_) @function.inside) @function.around + (#not-has-parent? @function.around variable_declarator)) (function_signature) @function.around (generator_function diff --git a/crates/languages/src/yaml/config.toml b/crates/languages/src/yaml/config.toml index 51e8e1224a40904e0dfbb0204eb531e6b2664825..9a07a560b06766ac00dd73b6210023c4cddd491d 100644 --- a/crates/languages/src/yaml/config.toml +++ b/crates/languages/src/yaml/config.toml @@ -1,6 +1,6 @@ name = "YAML" grammar = "yaml" -path_suffixes = ["yml", "yaml", "pixi.lock", "clang-format", "clangd"] +path_suffixes = ["yml", "yaml", "pixi.lock", "clang-format", "clangd", "bst"] line_comments = ["# "] autoclose_before = ",]}" brackets = [ diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index faa094153d4a26fb1a2b96360f2691989e81aad9..36938f62a3048b87dd890ca6e7ca8fc2499689e4 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -89,6 +89,7 @@ pub struct LanguageServer { outbound_tx: channel::Sender, notification_tx: channel::Sender, name: LanguageServerName, + version: Option, process_name: Arc, binary: LanguageServerBinary, capabilities: RwLock, @@ -501,6 +502,7 @@ impl LanguageServer { response_handlers, io_handlers, name: server_name, + version: None, process_name: binary .path .file_name() @@ -925,6 +927,7 @@ impl LanguageServer { ) })?; if let Some(info) = response.server_info { + self.version = info.version.map(SharedString::from); self.process_name = info.name.into(); } self.capabilities = RwLock::new(response.capabilities); @@ -1155,6 +1158,11 @@ impl LanguageServer { self.name.clone() } + /// Get the version of the running language server. + pub fn version(&self) -> Option { + self.version.clone() + } + pub fn process_name(&self) -> &str { &self.process_name } diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 5093b6977a1bffe82339ede00d2e6e4b4b14b4c1..5841be02b2db80b2fa15667833b8a3d3eec4ec11 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -3864,6 +3864,7 @@ pub enum LspStoreEvent { #[derive(Clone, Debug, Serialize)] pub struct LanguageServerStatus { pub name: LanguageServerName, + pub server_version: Option, pub pending_work: BTreeMap, pub has_pending_diagnostic_updates: bool, pub progress_tokens: HashSet, @@ -8354,6 +8355,7 @@ impl LspStore { server_id, LanguageServerStatus { name, + server_version: None, pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), @@ -9389,6 +9391,7 @@ impl LspStore { server_id, LanguageServerStatus { name: server_name.clone(), + server_version: None, pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), @@ -11419,6 +11422,7 @@ impl LspStore { server_id, LanguageServerStatus { name: language_server.name(), + server_version: language_server.version(), pending_work: Default::default(), has_pending_diagnostic_updates: false, progress_tokens: Default::default(), @@ -13772,7 +13776,7 @@ impl From for CompletionDocumentation { match docs { lsp::Documentation::String(text) => { if text.lines().count() <= 1 { - CompletionDocumentation::SingleLine(text.into()) + CompletionDocumentation::SingleLine(text.trim().to_string().into()) } else { CompletionDocumentation::MultiLinePlainText(text.into()) } @@ -14364,4 +14368,22 @@ mod tests { ) ); } + + #[test] + fn test_trailing_newline_in_completion_documentation() { + let doc = lsp::Documentation::String( + "Inappropriate argument value (of correct type).\n".to_string(), + ); + let completion_doc: CompletionDocumentation = doc.into(); + assert!( + matches!(completion_doc, CompletionDocumentation::SingleLine(s) if s == "Inappropriate argument value (of correct type).") + ); + + let doc = lsp::Documentation::String(" some value \n".to_string()); + let completion_doc: CompletionDocumentation = doc.into(); + assert!(matches!( + completion_doc, + CompletionDocumentation::SingleLine(s) if s == "some value" + )); + } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5e31f2a90cf137f1e4d788952832e1eb2ee0ec35..25a19788fdb464f5f289ef3bc3513f21743e3a9a 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1330,7 +1330,12 @@ impl Project { cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); let toolchain_store = cx.new(|cx| { - ToolchainStore::remote(REMOTE_SERVER_PROJECT_ID, remote.read(cx).proto_client(), cx) + ToolchainStore::remote( + REMOTE_SERVER_PROJECT_ID, + worktree_store.clone(), + remote.read(cx).proto_client(), + cx, + ) }); let task_store = cx.new(|cx| { TaskStore::remote( diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 21b74bd784d1d9af12fe43e3fe82051afc103b0d..7afc70827f85e1a1bafcad436409936876fd3b45 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -32,6 +32,7 @@ use crate::{ pub struct ToolchainStore { mode: ToolchainStoreInner, user_toolchains: BTreeMap>, + worktree_store: Entity, _sub: Subscription, } @@ -66,7 +67,7 @@ impl ToolchainStore { ) -> Self { let entity = cx.new(|_| LocalToolchainStore { languages, - worktree_store, + worktree_store: worktree_store.clone(), project_environment, active_toolchains: Default::default(), manifest_tree, @@ -77,12 +78,18 @@ impl ToolchainStore { }); Self { mode: ToolchainStoreInner::Local(entity), + worktree_store, user_toolchains: Default::default(), _sub, } } - pub(super) fn remote(project_id: u64, client: AnyProtoClient, cx: &mut Context) -> Self { + pub(super) fn remote( + project_id: u64, + worktree_store: Entity, + client: AnyProtoClient, + cx: &mut Context, + ) -> Self { let entity = cx.new(|_| RemoteToolchainStore { client, project_id }); let _sub = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| { cx.emit(e.clone()) @@ -90,6 +97,7 @@ impl ToolchainStore { Self { mode: ToolchainStoreInner::Remote(entity), user_toolchains: Default::default(), + worktree_store, _sub, } } @@ -165,12 +173,22 @@ impl ToolchainStore { language_name: LanguageName, cx: &mut Context, ) -> Task> { + let Some(worktree) = self + .worktree_store + .read(cx) + .worktree_for_id(path.worktree_id, cx) + else { + return Task::ready(None); + }; + let target_root_path = worktree.read_with(cx, |this, _| this.abs_path()); + let user_toolchains = self .user_toolchains .iter() .filter(|(scope, _)| { - if let ToolchainScope::Subproject(worktree_id, relative_path) = scope { - path.worktree_id == *worktree_id && relative_path.starts_with(&path.path) + if let ToolchainScope::Subproject(subproject_root_path, relative_path) = scope { + target_root_path == *subproject_root_path + && relative_path.starts_with(&path.path) } else { true } diff --git a/crates/project/src/x.py b/crates/project/src/x.py new file mode 100644 index 0000000000000000000000000000000000000000..58947a58a41bcc2e2f8c2046b5e1c8c38c0fbbb8 --- /dev/null +++ b/crates/project/src/x.py @@ -0,0 +1 @@ +Gliwice makerspace \ No newline at end of file diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 2c47efd0b0e2490bbfd6125069fa5ca1438ffb51..0385c3789e923da95a1eca7a5a469bad00020639 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -45,6 +45,7 @@ workspace.workspace = true language.workspace = true zed_actions.workspace = true telemetry.workspace = true +notifications.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 00aba96ef428eea643e8868e513ab9c3aaa1b910..43f63d90789a65bce54814f3adbc6f1d53235568 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -29,6 +29,7 @@ use gpui::{ }; use language::DiagnosticSeverity; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ Entry, EntryKind, Fs, GitEntry, GitEntryRef, GitTraversal, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId, @@ -1140,6 +1141,12 @@ impl ProjectPanel { "Copy Relative Path", Box::new(zed_actions::workspace::CopyRelativePath), ) + .when(!is_dir && self.has_git_changes(entry_id), |menu| { + menu.separator().action( + "Restore File", + Box::new(git::RestoreFile { skip_prompt: false }), + ) + }) .when(has_git_repo, |menu| { menu.separator() .action("View File History", Box::new(git::FileHistory)) @@ -1180,6 +1187,19 @@ impl ProjectPanel { cx.notify(); } + fn has_git_changes(&self, entry_id: ProjectEntryId) -> bool { + for visible in &self.state.visible_entries { + if let Some(git_entry) = visible.entries.iter().find(|e| e.id == entry_id) { + let total_modified = + git_entry.git_summary.index.modified + git_entry.git_summary.worktree.modified; + let total_deleted = + git_entry.git_summary.index.deleted + git_entry.git_summary.worktree.deleted; + return total_modified > 0 || total_deleted > 0; + } + } + false + } + fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool { if !entry.is_dir() || self.state.unfolded_dir_ids.contains(&entry.id) { return false; @@ -2041,6 +2061,100 @@ impl ProjectPanel { self.remove(false, action.skip_prompt, window, cx); } + fn restore_file( + &mut self, + action: &git::RestoreFile, + window: &mut Window, + cx: &mut Context, + ) { + maybe!({ + let selection = self.state.selection?; + let project = self.project.read(cx); + + let (_worktree, entry) = self.selected_sub_entry(cx)?; + if entry.is_dir() { + return None; + } + + let project_path = project.path_for_entry(selection.entry_id, cx)?; + + let git_store = project.git_store(); + let (repository, repo_path) = git_store + .read(cx) + .repository_and_path_for_project_path(&project_path, cx)?; + + let snapshot = repository.read(cx).snapshot(); + let status = snapshot.status_for_path(&repo_path)?; + if !status.status.is_modified() && !status.status.is_deleted() { + return None; + } + + let file_name = entry.path.file_name()?.to_string(); + + let answer = if !action.skip_prompt { + let prompt = format!("Discard changes to {}?", file_name); + Some(window.prompt(PromptLevel::Info, &prompt, None, &["Restore", "Cancel"], cx)) + } else { + None + }; + + cx.spawn_in(window, async move |panel, cx| { + if let Some(answer) = answer + && answer.await != Ok(0) + { + return anyhow::Ok(()); + } + + let task = panel.update(cx, |_panel, cx| { + repository.update(cx, |repo, cx| { + repo.checkout_files("HEAD", vec![repo_path], cx) + }) + })?; + + if let Err(e) = task.await { + panel + .update(cx, |panel, cx| { + let message = format!("Failed to restore {}: {}", file_name, e); + let toast = StatusToast::new(message, cx, |this, _| { + this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error)) + .dismiss_button(true) + }); + panel + .workspace + .update(cx, |workspace, cx| { + workspace.toggle_status_toast(toast, cx); + }) + .ok(); + }) + .ok(); + } + + panel + .update(cx, |panel, cx| { + panel.project.update(cx, |project, cx| { + if let Some(buffer_id) = project + .buffer_store() + .read(cx) + .buffer_id_for_project_path(&project_path) + { + if let Some(buffer) = project.buffer_for_id(*buffer_id, cx) { + buffer.update(cx, |buffer, cx| { + let _ = buffer.reload(cx); + }); + } + } + }) + }) + .ok(); + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + Some(()) + }); + } + fn remove( &mut self, trash: bool, @@ -5631,6 +5745,7 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::copy)) .on_action(cx.listener(Self::paste)) .on_action(cx.listener(Self::duplicate)) + .on_action(cx.listener(Self::restore_file)) .when(!project.is_remote(), |el| { el.on_action(cx.listener(Self::trash)) }) diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 6c8eb49c1c2158322a275e064162b53e2f5f3d5e..d13e1c4934947e39b08e05eb32e2787548e621e1 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -32,8 +32,7 @@ use tempfile::TempDir; use util::{ paths::{PathStyle, RemotePathBuf}, rel_path::RelPath, - shell::{Shell, ShellKind}, - shell_builder::ShellBuilder, + shell::ShellKind, }; pub(crate) struct SshRemoteConnection { @@ -1544,8 +1543,6 @@ fn build_command( } else { write!(exec, "{ssh_shell} -l")?; }; - let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false) - .build(Some(exec.clone()), &[]); let mut args = Vec::new(); args.extend(ssh_args); @@ -1556,8 +1553,7 @@ fn build_command( } args.push("-t".into()); - args.push(command); - args.extend(command_args); + args.push(exec); Ok(CommandTemplate { program: "ssh".into(), @@ -1597,9 +1593,6 @@ mod tests { "-p", "2222", "-t", - "/bin/fish", - "-i", - "-c", "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2" ] ); @@ -1632,9 +1625,6 @@ mod tests { "-L", "1:foo:2", "-t", - "/bin/fish", - "-i", - "-c", "cd && exec env INPUT_VA=val /bin/fish -l" ] ); diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 12b283ab22937b7952d18d63b1378d2914211f9b..be3331048bc78a91a8d3c5a3637d6bf6ea007e4d 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -106,7 +106,10 @@ pub struct BufferSearchBar { replacement_editor_focused: bool, active_searchable_item: Option>, active_match_index: Option, - active_searchable_item_subscription: Option, + #[cfg(target_os = "macos")] + active_searchable_item_subscriptions: Option<[Subscription; 2]>, + #[cfg(not(target_os = "macos"))] + active_searchable_item_subscriptions: Option, active_search: Option>, searchable_items_with_matches: HashMap, AnyVec>, pending_search: Option>, @@ -472,7 +475,7 @@ impl ToolbarItemView for BufferSearchBar { cx: &mut Context, ) -> ToolbarItemLocation { cx.notify(); - self.active_searchable_item_subscription.take(); + self.active_searchable_item_subscriptions.take(); self.active_searchable_item.take(); self.pending_search.take(); @@ -482,18 +485,58 @@ impl ToolbarItemView for BufferSearchBar { { let this = cx.entity().downgrade(); - self.active_searchable_item_subscription = - Some(searchable_item_handle.subscribe_to_search_events( - window, - cx, - Box::new(move |search_event, window, cx| { - if let Some(this) = this.upgrade() { - this.update(cx, |this, cx| { - this.on_active_searchable_item_event(search_event, window, cx) - }); + let search_event_subscription = searchable_item_handle.subscribe_to_search_events( + window, + cx, + Box::new(move |search_event, window, cx| { + if let Some(this) = this.upgrade() { + this.update(cx, |this, cx| { + this.on_active_searchable_item_event(search_event, window, cx) + }); + } + }), + ); + + #[cfg(target_os = "macos")] + { + let item_focus_handle = searchable_item_handle.item_focus_handle(cx); + + self.active_searchable_item_subscriptions = Some([ + search_event_subscription, + cx.on_focus(&item_focus_handle, window, |this, window, cx| { + if this.query_editor_focused || this.replacement_editor_focused { + // no need to read pasteboard since focus came from toolbar + return; } + + cx.defer_in(window, |this, window, cx| { + if let Some(item) = cx.read_from_find_pasteboard() + && let Some(text) = item.text() + { + if this.query(cx) != text { + let search_options = item + .metadata() + .and_then(|m| m.parse().ok()) + .and_then(SearchOptions::from_bits) + .unwrap_or(this.search_options); + + drop(this.search( + &text, + Some(search_options), + true, + window, + cx, + )); + } + } + }); }), - )); + ]); + } + #[cfg(not(target_os = "macos"))] + { + self.active_searchable_item_subscriptions = Some(search_event_subscription); + } let is_project_search = searchable_item_handle.supported_options(cx).find_in_results; self.active_searchable_item = Some(searchable_item_handle); @@ -663,7 +706,7 @@ impl BufferSearchBar { replacement_editor, replacement_editor_focused: false, active_searchable_item: None, - active_searchable_item_subscription: None, + active_searchable_item_subscriptions: None, active_match_index: None, searchable_items_with_matches: Default::default(), default_options: search_options, @@ -904,11 +947,21 @@ impl BufferSearchBar { }); self.set_search_options(options, cx); self.clear_matches(window, cx); + #[cfg(target_os = "macos")] + self.update_find_pasteboard(cx); cx.notify(); } self.update_matches(!updated, add_to_history, window, cx) } + #[cfg(target_os = "macos")] + pub fn update_find_pasteboard(&mut self, cx: &mut App) { + cx.write_to_find_pasteboard(gpui::ClipboardItem::new_string_with_metadata( + self.query(cx), + self.search_options.bits().to_string(), + )); + } + pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context) { if let Some(active_editor) = self.active_searchable_item.as_ref() { let handle = active_editor.item_focus_handle(cx); @@ -1098,11 +1151,12 @@ impl BufferSearchBar { cx.spawn_in(window, async move |this, cx| { if search.await.is_ok() { this.update_in(cx, |this, window, cx| { - this.activate_current_match(window, cx) - }) - } else { - Ok(()) + this.activate_current_match(window, cx); + #[cfg(target_os = "macos")] + this.update_find_pasteboard(cx); + })?; } + anyhow::Ok(()) }) .detach_and_log_err(cx); } @@ -1293,6 +1347,7 @@ impl BufferSearchBar { .insert(active_searchable_item.downgrade(), matches); this.update_match_index(window, cx); + if add_to_history { this.search_history .add(&mut this.search_history_cursor, query_text); diff --git a/crates/settings/src/settings_content/language.rs b/crates/settings/src/settings_content/language.rs index f9c85f18f380a7ad82b0d8bc202fe3763ba3a832..cf8cf7b63589e84a96e6b9d92f23a4488479d1f3 100644 --- a/crates/settings/src/settings_content/language.rs +++ b/crates/settings/src/settings_content/language.rs @@ -363,6 +363,14 @@ pub struct LanguageSettingsContent { /// /// Default: true pub extend_comment_on_newline: Option, + /// Whether to continue markdown lists when pressing enter. + /// + /// Default: true + pub extend_list_on_newline: Option, + /// Whether to indent list items when pressing tab after a list marker. + /// + /// Default: true + pub indent_list_on_tab: Option, /// Inlay hint related settings. pub inlay_hints: Option, /// Whether to automatically type closing characters for you. For example, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index d77754f611e8eb1746ee9061ce5b5e1dfdbdafdb..64343b05fd57c33eb9cfb0d8cb8674971266b464 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -430,6 +430,8 @@ impl VsCodeSettings { enable_language_server: None, ensure_final_newline_on_save: self.read_bool("files.insertFinalNewline"), extend_comment_on_newline: None, + extend_list_on_newline: None, + indent_list_on_tab: None, format_on_save: self.read_bool("editor.guides.formatOnSave").map(|b| { if b { FormatOnSave::On diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index ed43d94e9d3d7c08c1ff4570e08726310360cd93..738a0b4502642423377bdf69b49d26250536761f 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -939,7 +939,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| { @@ -955,6 +954,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 => { 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/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 817b73c45ecd2df4a76e9a67f425b2b459c0c026..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); 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 23572677919509d859a141cb09cce8f5822697ef..9b75d35eccafa3c30f23329d9c0ee890ed2b2405 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -166,11 +166,11 @@ impl Render for TitleBar { .when(title_bar_settings.show_project_items, |title_bar| { title_bar .children(self.render_restricted_mode(cx)) - .children(self.render_project_host(cx)) - .child(self.render_project_name(cx)) + .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_repo(cx)) + title_bar.children(self.render_project_repo(window, cx)) }) }) }) @@ -350,7 +350,14 @@ impl TitleBar { .next() } - fn render_remote_project_connection(&self, cx: &mut Context) -> Option { + 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(); @@ -395,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() @@ -410,26 +417,35 @@ impl TitleBar { ) .child(Label::new(nickname).size(LabelSize::Small).truncate()), ) - .tooltip(move |_window, cx| { - Tooltip::with_meta( - tooltip_title, - 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(), ) @@ -447,39 +463,47 @@ impl TitleBar { return None; } - Some( - Button::new("restricted_mode_trigger", "Restricted Mode") - .style(ButtonStyle::Tinted(TintColor::Warning)) - .label_size(LabelSize::Small) - .color(Color::Warning) - .icon(IconName::Warning) - .icon_color(Color::Warning) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .tooltip(|_, cx| { - Tooltip::with_meta( - "You're in Restricted Mode", - Some(&ToggleWorktreeSecurity), - "Mark this project as trusted and unlock all features", - cx, - ) - }) - .on_click({ - cx.listener(move |this, _, window, cx| { - this.workspace - .update(cx, |workspace, cx| { - workspace.show_worktree_trust_security_modal(true, window, cx) - }) - .log_err(); - }) + 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(); }) - .into_any_element(), - ) + }); + + 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, cx: &mut Context) -> Option { + 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) { @@ -487,7 +511,6 @@ impl TitleBar { Button::new("disconnected", "Disconnected") .disabled(true) .color(Color::Disabled) - .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) .into_any_element(), ); @@ -500,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| { @@ -523,7 +550,14 @@ impl TitleBar { ) } - pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { + 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 name = self.project_name(cx); let is_project_selected = name.is_some(); let name = if let Some(name) = name { @@ -533,19 +567,25 @@ impl TitleBar { }; 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, @@ -553,84 +593,102 @@ impl TitleBar { .boxed_clone(), cx, ); - })) + }) } - pub fn render_project_repo(&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 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 (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), 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); + }); }), ) } @@ -722,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(); @@ -844,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/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index 03c152e3fd3df0c62ab2f5c7e4a4746875ac955a..06f7d1cdf3e27f43bdb5013038b943b9e5193680 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -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 f7262c248f15f0f68fcd7a903ee01cac6b22d0af..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( @@ -237,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 } @@ -400,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(), ), ]; @@ -693,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 { @@ -702,7 +718,10 @@ impl ToolchainSelector { }, window, cx, - )); + ) else { + return; + }; + self.state = State::AddToolchain(state); self.state.focus_handle(cx).focus(window, cx); cx.notify(); } @@ -899,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/context_menu.rs b/crates/ui/src/components/context_menu.rs index 756a2a9364193d6f1cdace8ed8c92cecf401a864..7e5e9032c9d4b0521f972b47d90d24cd502faf7b 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -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/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/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 31fb7bfd88f1343ac6145c86f228bdcbd6a22e10..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 @@ -233,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/vim/src/command.rs b/crates/vim/src/command.rs index 205097130d152fe255feb02a449956124586d8e6..2228c23f02beb954bdb26b2b36f078249e423d7d 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 { @@ -643,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 @@ -1338,6 +1447,27 @@ 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( + ("r", "ead"), + VimRead { + range: None, + filename: "".into(), + }, + ) + .filename(|_, filename| { + Some( + 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).filename(|_, filename| { Some( VimSplit { @@ -2575,6 +2705,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/object.rs b/crates/vim/src/object.rs index 02150332405c6d5ea4d5dd78f477348be968fddf..e9a2f4fc63d31f78a9a7abce8aac785b56eb1fd4 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -3407,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/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/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index 58667e7ffa8ad4fe5a22d293e4fc4aa71015a3bd..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), @@ -58,6 +67,7 @@ pub struct ActiveModal { _subscriptions: [Subscription; 2], previous_focus_handle: Option, focus_handle: FocusHandle, + placement: ModalPlacement, } pub struct ModalLayer { @@ -87,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(); @@ -96,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(); @@ -123,6 +151,7 @@ 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), cx); @@ -183,6 +212,30 @@ impl Render for ModalLayer { 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() @@ -199,21 +252,7 @@ impl Render for ModalLayer { this.hide_modal(window, cx); }), ) - .child( - v_flex() - .h(px(0.0)) - .top_20() - .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/pane.rs b/crates/workspace/src/pane.rs index f6256aee46b9e2b5c29c020e9ee12f6ff510210f..dd17c338a935571f4d0fe9d46b3b10fac9ffe218 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1846,6 +1846,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| { @@ -1861,7 +1862,7 @@ impl Pane { { Ok(success) => { if !success { - break; + should_close = false; } } Err(err) => { @@ -1880,23 +1881,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(); @@ -6614,6 +6617,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] diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 094d03494e726677dc43235d96fc62c076673bf5..8d20339ec952020416e4b8d5846bf44f5f8e9b98 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -24,7 +24,6 @@ use project::{ }; use language::{LanguageName, Toolchain, ToolchainScope}; -use project::WorktreeId; use remote::{ DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, }; @@ -845,6 +844,44 @@ impl Domain for WorkspaceDb { 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 @@ -1030,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) @@ -1048,7 +1085,7 @@ impl WorkspaceDb { for ( _workspace_id, - worktree_id, + worktree_root_path, relative_worktree_path, language_name, name, @@ -1058,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 { @@ -1159,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}"); @@ -1844,24 +1883,24 @@ impl WorkspaceDb { 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(), @@ -1869,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()?, )) }, @@ -1882,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, @@ -1904,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(), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0c5c9ffa5d0bfb1f70ce6a861b0209f321222fc0..fa8e3a3dc2af33054907ea8a8c1ba095a3259207 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1204,6 +1204,7 @@ pub struct Workspace { last_open_dock_positions: Vec, removing: bool, utility_panes: UtilityPaneState, + next_modal_placement: Option, } impl EventEmitter for Workspace {} @@ -1620,6 +1621,7 @@ impl Workspace { last_open_dock_positions: Vec::new(), removing: false, utility_panes: UtilityPaneState::default(), + next_modal_placement: None, } } @@ -1697,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; } @@ -4223,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()); } @@ -4280,7 +4296,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); @@ -4296,7 +4312,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; } @@ -4465,7 +4481,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); } @@ -4719,14 +4735,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); @@ -6307,12 +6328,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) }) } @@ -8212,9 +8246,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; @@ -9377,7 +9424,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() }); @@ -9389,9 +9436,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( @@ -9404,26 +9458,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); }); } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 7145bccd514fbb5d6093efda765a826162c91260..f5f632e65d71b683d1a491b1fc9e9a612f5c24a5 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1361,7 +1361,7 @@ impl LocalWorktree { } let content = fs.load_bytes(&abs_path).await?; - let (text, encoding, has_bom) = decode_byte(content); + let (text, encoding, has_bom) = decode_byte(content)?; let worktree = this.upgrade().context("worktree was dropped")?; let file = match entry.await? { @@ -1489,25 +1489,12 @@ impl LocalWorktree { let fs = fs.clone(); let abs_path = abs_path.clone(); async move { - let bom_bytes = if has_bom { - if encoding == encoding_rs::UTF_16LE { - vec![0xFF, 0xFE] - } else if encoding == encoding_rs::UTF_16BE { - vec![0xFE, 0xFF] - } else if encoding == encoding_rs::UTF_8 { - vec![0xEF, 0xBB, 0xBF] - } else { - vec![] - } - } else { - vec![] - }; - // For UTF-8, use the optimized `fs.save` which writes Rope chunks directly to disk // without allocating a contiguous string. if encoding == encoding_rs::UTF_8 && !has_bom { return fs.save(&abs_path, &text, line_ending).await; } + // For legacy encodings (e.g. Shift-JIS), we fall back to converting the entire Rope // to a String/Bytes in memory before writing. // @@ -1520,13 +1507,45 @@ impl LocalWorktree { LineEnding::Windows => text_string.replace('\n', "\r\n"), }; - let (cow, _, _) = encoding.encode(&normalized_text); - let bytes = if !bom_bytes.is_empty() { - let mut bytes = bom_bytes; - bytes.extend_from_slice(&cow); - bytes.into() + // 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 { - cow + // 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 @@ -5842,11 +5861,28 @@ impl fs::Watcher for NullWatcher { } } -fn decode_byte(bytes: Vec) -> (String, &'static Encoding, bool) { +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 (cow.into_owned(), encoding, true); + 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) { @@ -5867,14 +5903,66 @@ fn decode_byte(bytes: Vec) -> (String, &'static Encoding, bool) { // displaying raw escape sequences instead of the correct characters. if text.contains('\x1b') { let (s, enc) = detect_encoding(text.into_bytes()); - (s, enc, false) + Ok((s, enc, false)) } else { - (text, encoding_rs::UTF_8, false) + Ok((text, encoding_rs::UTF_8, false)) } } Err(e) => { let (s, enc) = detect_encoding(e.into_bytes()); - (s, enc, false) + 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 094a6d52ea4168752578eab06cea511a57e65c10..45d39710c6ea825aded4d29f447124ee4c2ecb33 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -1,5 +1,5 @@ use crate::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle}; -use anyhow::{Context as _, Result}; +use anyhow::Result; use encoding_rs; use fs::{FakeFs, Fs, RealFs, RemoveOptions}; use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE}; @@ -2568,71 +2568,87 @@ fn init_test(cx: &mut gpui::TestAppContext) { #[gpui::test] async fn test_load_file_encoding(cx: &mut TestAppContext) { init_test(cx); - let test_cases: Vec<(&str, &[u8], &str)> = vec![ - ("utf8.txt", "こんにけは".as_bytes(), "こんにけは"), // "こんにけは" is Japanese "Hello" - ( - "sjis.txt", - &[0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd], - "こんにけは", - ), - ( - "eucjp.txt", - &[0xa4, 0xb3, 0xa4, 0xf3, 0xa4, 0xcb, 0xa4, 0xc1, 0xa4, 0xcf], - "こんにけは", - ), - ( - "iso2022jp.txt", - &[ + + 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, ], - "こんにけは", - ), - // Western Europe (Windows-1252) - // "CafΓ©" -> 0xE9 is 'Γ©' in Windows-1252 (it is typically 0xC3 0xA9 in UTF-8) - ("win1252.txt", &[0x43, 0x61, 0x66, 0xe9], "CafΓ©"), - // Chinese Simplified (GBK) - // Note: We use a slightly longer string here because short byte sequences can be ambiguous - // in multi-byte encodings. Providing more context helps the heuristic detector guess correctly. - // Text: "δ»Šε€©ε€©ζ°”δΈι”™" (Today's weather is not bad / nice) - // Bytes: - // 今: BD F1 - // 倩: CC EC - // 倩: CC EC - // ζ°”: C6 F8 - // 不: B2 BB - // ι”™: B4 ED - ( - "gbk.txt", - &[ + 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, ], - "δ»Šε€©ε€©ζ°”δΈι”™", - ), - ( - "utf16le_bom.txt", - &[ + 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, // は + 0x53, 0x30, 0x93, 0x30, 0x6B, 0x30, 0x61, 0x30, 0x6F, 0x30, ], - "こんにけは", - ), - ( - "utf8_bom.txt", - &[ - 0xEF, 0xBB, 0xBF, // UTF-8 BOM - 0xE3, 0x81, 0x93, // こ - 0xE3, 0x82, 0x93, // γ‚“ - 0xE3, 0x81, 0xAB, // に - 0xE3, 0x81, 0xA1, // け - 0xE3, 0x81, 0xAF, // は + 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) { @@ -2642,15 +2658,11 @@ async fn test_load_file_encoding(cx: &mut TestAppContext) { }; let fs = FakeFs::new(cx.background_executor.clone()); + fs.create_dir(root_path).await.unwrap(); - let mut files_json = serde_json::Map::new(); - for (name, _, _) in &test_cases { - files_json.insert(name.to_string(), serde_json::Value::String("".to_string())); - } - - for (name, bytes, _) in &test_cases { - let path = root_path.join(name); - fs.write(&path, bytes).await.unwrap(); + 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( @@ -2667,34 +2679,54 @@ async fn test_load_file_encoding(cx: &mut TestAppContext) { cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) .await; - for (name, _, expected) in test_cases { - let loaded = tree - .update(cx, |tree, cx| tree.load_file(rel_path(name), cx)) - .await - .with_context(|| format!("Failed to load {}", name)) - .unwrap(); + 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, expected, + loaded.text, case.expected_text, "Encoding mismatch for file: {}", - name + 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 file_path = root_path.join("test.txt"); - - fs.insert_file(&file_path, "initial".into()).await; let worktree = Worktree::local( root_path, @@ -2707,33 +2739,107 @@ async fn test_write_file_encoding(cx: &mut gpui::TestAppContext) { .await .unwrap(); - let path: Arc = Path::new("test.txt").into(); - let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc(); + // Define test case structure + struct TestCase { + name: &'static str, + text: &'static str, + encoding: &'static encoding_rs::Encoding, + has_bom: bool, + expected_bytes: Vec, + } - let text = text::Rope::from("こんにけは"); + 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], + }, + ]; - let task = worktree.update(cx, |wt, cx| { - wt.write_file( - rel_path, - text, - text::LineEnding::Unix, - encoding_rs::SHIFT_JIS, - false, - cx, - ) - }); + 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); - task.await.unwrap(); + fs.insert_file(&file_path, "".into()).await; - let bytes = fs.load_bytes(&file_path).await.unwrap(); + let rel_path = RelPath::new(&path, PathStyle::local()).unwrap().into_arc(); + let text = text::Rope::from(case.text); - let expected_bytes = vec![ - 0x82, 0xb1, // こ - 0x82, 0xf1, // γ‚“ - 0x82, 0xc9, // に - 0x82, 0xbf, // け - 0x82, 0xcd, // は - ]; + 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, expected_bytes, "Should be saved as Shift-JIS"); + 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/zed/Cargo.toml b/crates/zed/Cargo.toml index fd160759f4440e2736d57cea62abb6bdb138ae72..80eca20e00309bb8d22552287a1c39cb9891307d 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -15,10 +15,6 @@ tracy = ["ztracing/tracy"] [[bin]] name = "zed" -path = "src/zed-main.rs" - -[lib] -name = "zed" path = "src/main.rs" [dependencies] diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 7008e491c5e2ade35fa96cafbd9d8969c008fa96..03e02bb0107d736c07eb3fc9626856943f8d80a6 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1,3 +1,6 @@ +// Disable command line from opening on release mode +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + mod reliability; mod zed; @@ -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; @@ -33,10 +38,12 @@ 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, }; @@ -163,9 +170,9 @@ fn fail_to_open_window(e: anyhow::Error, _cx: &mut App) { .detach(); } } -pub static STARTUP_TIME: OnceLock = OnceLock::new(); +static STARTUP_TIME: OnceLock = OnceLock::new(); -pub fn main() { +fn main() { STARTUP_TIME.get_or_init(|| Instant::now()); #[cfg(unix)] @@ -893,6 +900,41 @@ 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 = @@ -1301,7 +1343,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() } @@ -1547,14 +1589,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/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 d088df00839814e32a9c246a3486ac5ad5ca4b9e..3441cb88d96b06dfdbb65a58553d2c58f435d157 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4780,7 +4780,6 @@ mod tests { "activity_indicator", "agent", "agents", - #[cfg(not(target_os = "macos"))] "app_menu", "assistant", "assistant2", diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index d61de0a291f3d3e7869225c0e07424cc3523f69b..842f98520133c70f711d84d3f490bec1ec59e16f 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -25,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; @@ -58,6 +59,9 @@ pub enum OpenRequestKind { /// `None` opens settings without navigating to a specific path. setting_path: Option, }, + GitClone { + repo_url: SharedString, + }, GitCommit { sha: String, }, @@ -113,6 +117,8 @@ 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://") { @@ -143,6 +149,26 @@ 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 @@ -1087,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/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/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/languages/markdown.md b/docs/src/languages/markdown.md index 101dbfc6ee08d432a8005239451b0964499de14d..d78cfafa1dcd62074c574e7204f068ad62de3fe5 100644 --- a/docs/src/languages/markdown.md +++ b/docs/src/languages/markdown.md @@ -33,6 +33,40 @@ Zed supports using Prettier to automatically re-format Markdown documents. You c }, ``` +### List Continuation + +Zed automatically continues lists when you press Enter at the end of a list item. Supported list types: + +- Unordered lists (`-`, `*`, or `+` markers) +- Ordered lists (numbers are auto-incremented) +- Task lists (`- [ ]` and `- [x]`) + +Pressing Enter on an empty list item removes the marker and exits the list. + +To disable this behavior: + +```json [settings] + "languages": { + "Markdown": { + "extend_list_on_newline": false + } + }, +``` + +### List Indentation + +Zed indents list items when you press Tab while the cursor is on a line containing only a list marker. This allows you to quickly create nested lists. + +To disable this behavior: + +```json [settings] + "languages": { + "Markdown": { + "indent_list_on_tab": false + } + }, +``` + ### Trailing Whitespace By default Zed will remove trailing whitespace on save. If you rely on invisible trailing whitespace being converted to `
` in Markdown files you can disable this behavior with: 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/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/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index d0caab82b057f21735b7f828c8917a358dd548b2..aceb575b647e7ea0b2d8a74da9fbc153767d149d 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -45,15 +45,11 @@ pub(crate) fn run_tests() -> Workflow { &should_run_tests, ]); - let check_style = check_style(); - let run_tests_linux = run_platform_tests(Platform::Linux); - let call_autofix = call_autofix(&check_style, &run_tests_linux); - let mut jobs = vec![ orchestrate, - check_style, + check_style(), should_run_tests.guard(run_platform_tests(Platform::Windows)), - should_run_tests.guard(run_tests_linux), + should_run_tests.guard(run_platform_tests(Platform::Linux)), should_run_tests.guard(run_platform_tests(Platform::Mac)), should_run_tests.guard(doctests()), should_run_tests.guard(check_workspace_binaries()), @@ -110,7 +106,6 @@ pub(crate) fn run_tests() -> Workflow { workflow }) .add_job(tests_pass.name, tests_pass.job) - .add_job(call_autofix.name, call_autofix.job) } // Generates a bash script that checks changed files against regex patterns @@ -226,8 +221,6 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { named::job(job) } -pub const STYLE_FAILED_OUTPUT: &str = "style_failed"; - fn check_style() -> NamedJob { fn check_for_typos() -> Step { named::uses( @@ -245,56 +238,12 @@ fn check_style() -> NamedJob { .add_step(steps::setup_pnpm()) .add_step(steps::prettier()) .add_step(steps::cargo_fmt()) - .add_step(steps::record_style_failure()) .add_step(steps::script("./script/check-todos")) .add_step(steps::script("./script/check-keymaps")) - .add_step(check_for_typos()) - .outputs([( - STYLE_FAILED_OUTPUT.to_owned(), - format!( - "${{{{ steps.{}.outputs.failed == 'true' }}}}", - steps::RECORD_STYLE_FAILURE_STEP_ID - ), - )]), + .add_step(check_for_typos()), ) } -fn call_autofix(check_style: &NamedJob, run_tests_linux: &NamedJob) -> NamedJob { - fn dispatch_autofix(run_tests_linux_name: &str) -> Step { - let clippy_failed_expr = format!( - "needs.{}.outputs.{} == 'true'", - run_tests_linux_name, CLIPPY_FAILED_OUTPUT - ); - named::bash(format!( - "gh workflow run autofix_pr.yml -f pr_number=${{{{ github.event.pull_request.number }}}} -f run_clippy=${{{{ {} }}}}", - clippy_failed_expr - )) - .add_env(("GITHUB_TOKEN", "${{ steps.get-app-token.outputs.token }}")) - } - - let style_failed_expr = format!( - "needs.{}.outputs.{} == 'true'", - check_style.name, STYLE_FAILED_OUTPUT - ); - let clippy_failed_expr = format!( - "needs.{}.outputs.{} == 'true'", - run_tests_linux.name, CLIPPY_FAILED_OUTPUT - ); - let (authenticate, _token) = steps::authenticate_as_zippy(); - - let job = Job::default() - .runs_on(runners::LINUX_SMALL) - .cond(Expression::new(format!( - "always() && ({} || {}) && github.event_name == 'pull_request' && github.actor != 'zed-zippy[bot]'", - style_failed_expr, clippy_failed_expr - ))) - .needs(vec![check_style.name.clone(), run_tests_linux.name.clone()]) - .add_step(authenticate) - .add_step(dispatch_autofix(&run_tests_linux.name)); - - named::job(job) -} - fn check_dependencies() -> NamedJob { fn install_cargo_machete() -> Step { named::uses( @@ -355,8 +304,6 @@ fn check_workspace_binaries() -> NamedJob { ) } -pub const CLIPPY_FAILED_OUTPUT: &str = "clippy_failed"; - pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob { let runner = match platform { Platform::Windows => runners::WINDOWS_DEFAULT, @@ -378,24 +325,12 @@ pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob { ) .add_step(steps::setup_node()) .add_step(steps::clippy(platform)) - .when(platform == Platform::Linux, |job| { - job.add_step(steps::record_clippy_failure()) - }) .when(platform == Platform::Linux, |job| { job.add_step(steps::cargo_install_nextest()) }) .add_step(steps::clear_target_dir_if_large(platform)) .add_step(steps::cargo_nextest(platform)) - .add_step(steps::cleanup_cargo_config(platform)) - .when(platform == Platform::Linux, |job| { - job.outputs([( - CLIPPY_FAILED_OUTPUT.to_owned(), - format!( - "${{{{ steps.{}.outputs.failed == 'true' }}}}", - steps::RECORD_CLIPPY_FAILURE_STEP_ID - ), - )]) - }), + .add_step(steps::cleanup_cargo_config(platform)), } } @@ -513,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/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index eaa51dc35205f51e7fe3a56668ed0679e92999f0..a0b071cd6c31654b42adddbba47dd24c60da7df2 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -54,25 +54,12 @@ pub fn setup_sentry() -> Step { .add_with(("token", vars::SENTRY_AUTH_TOKEN)) } -pub const PRETTIER_STEP_ID: &str = "prettier"; -pub const CARGO_FMT_STEP_ID: &str = "cargo_fmt"; -pub const RECORD_STYLE_FAILURE_STEP_ID: &str = "record_style_failure"; - pub fn prettier() -> Step { - named::bash("./script/prettier").id(PRETTIER_STEP_ID) + named::bash("./script/prettier") } pub fn cargo_fmt() -> Step { - named::bash("cargo fmt --all -- --check").id(CARGO_FMT_STEP_ID) -} - -pub fn record_style_failure() -> Step { - named::bash(format!( - "echo \"failed=${{{{ steps.{}.outcome == 'failure' || steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"", - PRETTIER_STEP_ID, CARGO_FMT_STEP_ID - )) - .id(RECORD_STYLE_FAILURE_STEP_ID) - .if_condition(Expression::new("always()")) + named::bash("cargo fmt --all -- --check") } pub fn cargo_install_nextest() -> Step { @@ -118,25 +105,13 @@ pub fn clear_target_dir_if_large(platform: Platform) -> Step { } } -pub const CLIPPY_STEP_ID: &str = "clippy"; -pub const RECORD_CLIPPY_FAILURE_STEP_ID: &str = "record_clippy_failure"; - pub fn clippy(platform: Platform) -> Step { match platform { - Platform::Windows => named::pwsh("./script/clippy.ps1").id(CLIPPY_STEP_ID), - _ => named::bash("./script/clippy").id(CLIPPY_STEP_ID), + Platform::Windows => named::pwsh("./script/clippy.ps1"), + _ => named::bash("./script/clippy"), } } -pub fn record_clippy_failure() -> Step { - named::bash(format!( - "echo \"failed=${{{{ steps.{}.outcome == 'failure' }}}}\" >> \"$GITHUB_OUTPUT\"", - CLIPPY_STEP_ID - )) - .id(RECORD_CLIPPY_FAILURE_STEP_ID) - .if_condition(Expression::new("always()")) -} - pub fn cache_rust_dependencies_namespace() -> Step { named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "rust")) }