Merge branch 'main' into docs-reorganize

Katie Geer created

Change summary

.factory/prompts/docs-automation/phase2-explore.md               |  55 
.factory/prompts/docs-automation/phase3-analyze.md               |  57 
.factory/prompts/docs-automation/phase4-plan.md                  |  76 
.factory/prompts/docs-automation/phase5-apply.md                 |  67 
.factory/prompts/docs-automation/phase6-summarize.md             |  54 
.factory/prompts/docs-automation/phase7-commit.md                |  67 
.github/actions/build_docs/action.yml                            |  12 
.github/workflows/community_close_stale_issues.yml               |  25 
.github/workflows/docs_automation.yml                            | 260 
.github/workflows/extension_tests.yml                            |   3 
.github/workflows/release.yml                                    |  16 
.github/workflows/release_nightly.yml                            |   6 
.github/workflows/run_tests.yml                                  |  50 
.gitignore                                                       |   1 
Cargo.lock                                                       |  16 
Cargo.toml                                                       |   1 
assets/settings/default.json                                     |   4 
crates/acp_thread/src/connection.rs                              |  11 
crates/agent/src/agent.rs                                        |  15 
crates/agent_ui/src/acp/mode_selector.rs                         |  14 
crates/agent_ui/src/acp/model_selector.rs                        |   8 
crates/agent_ui/src/acp/model_selector_popover.rs                |  13 
crates/agent_ui/src/acp/thread_view.rs                           |   2 
crates/agent_ui/src/agent_configuration.rs                       |  16 
crates/agent_ui/src/agent_model_selector.rs                      |  12 
crates/agent_ui/src/agent_panel.rs                               |   2 
crates/agent_ui/src/agent_ui.rs                                  |   3 
crates/agent_ui/src/language_model_selector.rs                   |  19 
crates/agent_ui/src/profile_selector.rs                          |   6 
crates/agent_ui/src/text_thread_editor.rs                        |  20 
crates/agent_ui/src/ui/model_selector_components.rs              |  23 
crates/ai_onboarding/src/agent_api_keys_onboarding.rs            |  20 
crates/ai_onboarding/src/agent_panel_onboarding_content.rs       |  21 
crates/docs_preprocessor/Cargo.toml                              |   5 
crates/docs_preprocessor/src/main.rs                             |  88 
crates/editor/src/code_context_menus.rs                          |  10 
crates/editor/src/editor.rs                                      | 525 +
crates/editor/src/editor_tests.rs                                | 715 +
crates/editor/src/element.rs                                     | 182 
crates/editor/src/scroll/autoscroll.rs                           |  17 
crates/editor/src/test/editor_lsp_test_context.rs                |  86 
crates/extension/src/extension_host_proxy.rs                     |  48 
crates/extension/src/extension_manifest.rs                       |  14 
crates/extension_host/benches/extension_compilation_benchmark.rs |   1 
crates/extension_host/src/capability_granter.rs                  |   1 
crates/extension_host/src/extension_store_test.rs                |   3 
crates/git_ui/src/clone.rs                                       | 155 
crates/git_ui/src/git_panel.rs                                   |  94 
crates/git_ui/src/git_ui.rs                                      |   1 
crates/gpui/src/app.rs                                           |  36 
crates/gpui/src/elements/text.rs                                 |  24 
crates/gpui/src/platform.rs                                      |  12 
crates/gpui/src/platform/mac.rs                                  |   3 
crates/gpui/src/platform/mac/attributed_string.rs                | 129 
crates/gpui/src/platform/mac/pasteboard.rs                       | 344 
crates/gpui/src/platform/mac/platform.rs                         | 397 -
crates/gpui/src/platform/test/platform.rs                        |  24 
crates/gpui/src/queue.rs                                         |  25 
crates/gpui/src/style.rs                                         |   8 
crates/gpui/src/styled.rs                                        |  10 
crates/gpui/src/text_system/line_wrapper.rs                      | 228 
crates/gpui/src/window.rs                                        |  70 
crates/language/src/buffer.rs                                    |  18 
crates/language/src/buffer_tests.rs                              |  98 
crates/language/src/language.rs                                  |  45 
crates/language/src/language_settings.rs                         |   6 
crates/language/src/syntax_map.rs                                |  61 
crates/language/src/toolchain.rs                                 |   7 
crates/language_model/src/language_model.rs                      |  21 
crates/language_model/src/registry.rs                            | 195 
crates/language_models/Cargo.toml                                |   2 
crates/language_models/src/extension.rs                          |  67 
crates/language_models/src/language_models.rs                    |  53 
crates/language_models/src/provider/anthropic.rs                 |   6 
crates/language_models/src/provider/bedrock.rs                   |   6 
crates/language_models/src/provider/cloud.rs                     |   6 
crates/language_models/src/provider/copilot_chat.rs              |  16 
crates/language_models/src/provider/deepseek.rs                  |   6 
crates/language_models/src/provider/google.rs                    |   6 
crates/language_models/src/provider/lmstudio.rs                  |   6 
crates/language_models/src/provider/mistral.rs                   |   6 
crates/language_models/src/provider/ollama.rs                    |   6 
crates/language_models/src/provider/open_ai.rs                   |   6 
crates/language_models/src/provider/open_ai_compatible.rs        |   6 
crates/language_models/src/provider/open_router.rs               |   6 
crates/language_models/src/provider/vercel.rs                    |   6 
crates/language_models/src/provider/x_ai.rs                      |   6 
crates/language_tools/src/lsp_button.rs                          |  32 
crates/language_tools/src/lsp_log_view.rs                        |   9 
crates/languages/src/javascript/textobjects.scm                  |  38 
crates/languages/src/markdown/config.toml                        |   3 
crates/languages/src/tsx/textobjects.scm                         |  38 
crates/languages/src/typescript/textobjects.scm                  |  39 
crates/languages/src/yaml/config.toml                            |   2 
crates/lsp/src/lsp.rs                                            |   8 
crates/project/src/lsp_store.rs                                  |  24 
crates/project/src/project.rs                                    |   7 
crates/project/src/toolchain_store.rs                            |  26 
crates/project/src/x.py                                          |   1 
crates/project_panel/Cargo.toml                                  |   1 
crates/project_panel/src/project_panel.rs                        | 115 
crates/remote/src/transport/ssh.rs                               |  14 
crates/search/src/buffer_search.rs                               |  89 
crates/settings/src/settings_content/language.rs                 |   8 
crates/settings/src/vscode_import.rs                             |   2 
crates/terminal_view/src/terminal_panel.rs                       |   9 
crates/terminal_view/src/terminal_scrollbar.rs                   |  18 
crates/title_bar/build.rs                                        |  28 
crates/title_bar/src/application_menu.rs                         |  20 
crates/title_bar/src/platforms/platform_mac.rs                   |  14 
crates/title_bar/src/title_bar.rs                                | 330 
crates/toolchain_selector/src/active_toolchain.rs                |   9 
crates/toolchain_selector/src/toolchain_selector.rs              |  43 
crates/ui/src/components/context_menu.rs                         |  68 
crates/ui/src/components/icon.rs                                 |  22 
crates/ui/src/components/label/label.rs                          |   9 
crates/ui/src/components/label/label_like.rs                     |  23 
crates/vim/src/command.rs                                        | 200 
crates/vim/src/object.rs                                         | 386 +
crates/vim/src/visual.rs                                         |  16 
crates/workspace/src/modal_layer.rs                              |  77 
crates/workspace/src/pane.rs                                     |  81 
crates/workspace/src/persistence.rs                              |  81 
crates/workspace/src/workspace.rs                                |  91 
crates/worktree/src/worktree.rs                                  | 140 
crates/worktree/src/worktree_tests.rs                            | 306 
crates/worktree_benchmarks/src/main.rs                           |   4 
crates/zed/Cargo.toml                                            |   4 
crates/zed/src/main.rs                                           |  52 
crates/zed/src/zed-main.rs                                       |   8 
crates/zed/src/zed.rs                                            |   1 
crates/zed/src/zed/open_listener.rs                              | 102 
docs/AGENTS.md                                                   | 353 
docs/src/ai/rules.md                                             |   2 
docs/src/languages/markdown.md                                   |  34 
script/generate-action-metadata                                  |  10 
script/triage_watcher.jl                                         |  38 
tooling/xtask/src/tasks/workflows/run_tests.rs                   |  74 
tooling/xtask/src/tasks/workflows/steps.rs                       |  33 
139 files changed, 6,279 insertions(+), 1,760 deletions(-)

Detailed changes

.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

.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

.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

.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]
+```

.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

.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
+```

.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:

.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"

.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

.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

.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

.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

.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

.gitignore πŸ”—

@@ -36,6 +36,7 @@
 DerivedData/
 Packages
 xcuserdata/
+crates/docs_preprocessor/actions.json
 
 # Don't commit any secrets to the repo.
 .env

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"

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",

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,

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<SharedString>,
-    pub icon: Option<IconName>,
+    pub icon: Option<AgentModelIcon>,
 }
 
 impl From<acp::ModelInfo> for AgentModelInfo {

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::<Vec<_>>();
@@ -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::<Vec<_>>();
@@ -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
+                    )),
                 }]
             )])
         );

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,
                                     )),

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| {

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)

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);
                     }))

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<Self>) {
-        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<Self>,
     ) -> 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(

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)

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)

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);
             }
             _ => {}

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<LanguageModelProviderId, HashSet<LanguageModelId>>
 #[derive(Clone)]
 struct ModelInfo {
     model: Arc<dyn LanguageModel>,
-    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::<Vec<_>>();
@@ -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::<Vec<_>>();
@@ -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,
                 }
             })

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()
                 }
             }),

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)

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<IconName>,
+    icon: Option<ModelIcon>,
     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()),

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()

crates/ai_onboarding/src/agent_panel_onboarding_content.rs πŸ”—

@@ -11,7 +11,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding};
 pub struct AgentPanelOnboarding {
     user_store: Entity<UserStore>,
     client: Arc<Client>,
-    configured_providers: Vec<(IconName, SharedString)>,
+    has_configured_providers: bool,
     continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
 }
 
@@ -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())

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"

crates/docs_preprocessor/src/main.rs πŸ”—

@@ -22,16 +22,13 @@ static KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
     load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
 });
 
-static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
+static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(load_all_actions);
 
 const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
 
 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::<Vec<_>>();
 
     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<Prepr
         chapter.content = regex
             .replace_all(&chapter.content, |caps: &regex::Captures| {
                 let action = caps[1].trim();
-                if find_action_by_name(action).is_none() {
+                if is_missing_action(action) {
                     errors.insert(PreprocessorError::new_for_not_found_action(
                         action.to_string(),
                     ));
@@ -244,10 +241,12 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Preproces
             .replace_all(&chapter.content, |caps: &regex::Captures| {
                 let name = caps[1].trim();
                 let Some(action) = find_action_by_name(name) else {
-                    errors.insert(PreprocessorError::new_for_not_found_action(
-                        name.to_string(),
-                    ));
-                    return String::new();
+                    if actions_available() {
+                        errors.insert(PreprocessorError::new_for_not_found_action(
+                            name.to_string(),
+                        ));
+                    }
+                    return format!("<code class=\"hljs\">{}</code>", name);
                 };
                 format!("<code class=\"hljs\">{}</code>", &action.human_name)
             })
@@ -257,11 +256,19 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Preproces
 
 fn find_action_by_name(name: &str) -> 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<String> {
     let keymap = match os {
         "macos" => &KEYMAP_MACOS,
@@ -384,18 +391,13 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet<Pre
                 let keymap = settings::KeymapFile::parse(&snippet_json_fixed)
                     .context("Failed to parse keymap JSON")?;
                 for section in keymap.sections() {
-                    for (keystrokes, action) in section.bindings() {
-                        keystrokes
-                            .split_whitespace()
-                            .map(|source| gpui::Keystroke::parse(source))
-                            .collect::<std::result::Result<Vec<_>, _>>()
-                            .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<String>,
+    #[serde(rename = "documentation")]
+    docs: Option<String>,
 }
 
-fn dump_all_gpui_actions() -> Vec<ActionDef> {
-    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::<Vec<ActionDef>>();
-
-    actions.sort_by_key(|a| a.name);
-
-    actions
+fn load_all_actions() -> Vec<ActionDef> {
+    let asset_path = concat!(env!("CARGO_MANIFEST_DIR"), "/actions.json");
+    match std::fs::read_to_string(asset_path) {
+        Ok(content) => {
+            let mut actions: Vec<ActionDef> =
+                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::<Vec<_>>();
-    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("<dl style=\"line-height: 1.8;\">\n");
@@ -664,7 +674,7 @@ fn generate_big_table_of_actions() -> String {
         output.push_str("<dd style=\"margin-left: 2em; margin-bottom: 1em;\">\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("&", "&amp;")
@@ -674,7 +684,7 @@ fn generate_big_table_of_actions() -> String {
             output.push_str("<br>\n");
         }
         output.push_str("Keymap Name: <code>");
-        output.push_str(action.name);
+        output.push_str(&action.name);
         output.push_str("</code><br>\n");
         if !action.deprecated_aliases.is_empty() {
             output.push_str("Deprecated Alias(es): ");

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;

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<Arc<str>> {
     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<Arc<str>> {
+    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<MultiBufferOffset>,
-        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::<Vec<_>>()
+        })
+        .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<IndentSize>,
+        prevent_auto_indent: bool,
+    },
+    /// Clear the current line
+    ClearCurrentLine,
+    /// Unindent the current line and add continuation
+    UnindentCurrentLine { continuation: Arc<str> },
+}
+
+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<MultiBufferOffset>,
@@ -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<DisplayRow>,
+        relative_to: DisplayRow,
+        count_wrapped_lines: bool,
+    ) -> HashMap<DisplayRow, u32> {
+        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 {

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, |_| {});

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<DisplayRow>,
-        relative_to: Option<DisplayRow>,
-        count_wrapped_lines: bool,
-    ) -> HashMap<DisplayRow, DisplayRowDelta> {
-        let mut relative_rows: HashMap<DisplayRow, DisplayRowDelta> = 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::<Vec<_>>();
-
-        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<DisplayRow>,
         buffer_rows: &[RowInfo],
         active_rows: &BTreeMap<DisplayRow, LineHighlightSpec>,
-        newest_selection_head: Option<DisplayPoint>,
+        relative_line_base: Option<DisplayRow>,
         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::<Point>(&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<DisplayRow>,
         window: &mut Window,
         cx: &mut App,
     ) -> Option<StickyHeaders> {
@@ -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::<Point>(&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::<Vec<_>>(),
                     &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::<Vec<_>>(),
                     &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::<Vec<_>>(),
                     &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::<Vec<_>>(),
                     &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,
                 )
             })

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;

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");

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<dyn FnOnce(&mut App) + Send>;
+
 #[derive(Default)]
 pub struct ExtensionHostProxy {
     theme_proxy: RwLock<Option<Arc<dyn ExtensionThemeProxy>>>,
@@ -29,6 +32,7 @@ pub struct ExtensionHostProxy {
     slash_command_proxy: RwLock<Option<Arc<dyn ExtensionSlashCommandProxy>>>,
     context_server_proxy: RwLock<Option<Arc<dyn ExtensionContextServerProxy>>>,
     debug_adapter_provider_proxy: RwLock<Option<Arc<dyn ExtensionDebugAdapterProviderProxy>>>,
+    language_model_provider_proxy: RwLock<Option<Arc<dyn ExtensionLanguageModelProviderProxy>>>,
 }
 
 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<str>,
+        register_fn: LanguageModelProviderRegistration,
+        cx: &mut App,
+    );
+
+    fn unregister_language_model_provider(&self, provider_id: Arc<str>, cx: &mut App);
+}
+
+impl ExtensionLanguageModelProviderProxy for ExtensionHostProxy {
+    fn register_language_model_provider(
+        &self,
+        provider_id: Arc<str>,
+        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<str>, cx: &mut App) {
+        let Some(proxy) = self.language_model_provider_proxy.read().clone() else {
+            return;
+        };
+
+        proxy.unregister_language_model_provider(provider_id, cx)
+    }
+}

crates/extension/src/extension_manifest.rs πŸ”—

@@ -93,6 +93,8 @@ pub struct ExtensionManifest {
     pub debug_adapters: BTreeMap<Arc<str>, DebugAdapterManifestEntry>,
     #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
     pub debug_locators: BTreeMap<Arc<str>, DebugLocatorManifestEntry>,
+    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+    pub language_model_providers: BTreeMap<Arc<str>, 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<String>,
+}
+
 impl ExtensionManifest {
     pub async fn load(fs: Arc<dyn Fs>, extension_dir: &Path) -> Result<Self> {
         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(),
         }
     }
 

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,
         },

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<Workspace>,
+    window: &mut Window,
+    cx: &mut App,
+    on_success: Arc<
+        dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + 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();
+}

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<Self>) {
-        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<Self>) {
@@ -5281,7 +5203,7 @@ impl GitPanel {
 
                 this.child(
                     self.entry_label(path_name, path_color)
-                        .truncate()
+                        .truncate_start()
                         .when(strikethrough, Label::strikethrough),
                 )
             })

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},

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<ClipboardItem> {
+        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<ClipboardItem> {
-        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<ClipboardItem> {
+        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.

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))

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<ClipboardItem>;
     fn write_to_clipboard(&self, item: ClipboardItem);
+
     #[cfg(any(target_os = "linux", target_os = "freebsd"))]
     fn read_from_primary(&self) -> Option<ClipboardItem>;
-    fn read_from_clipboard(&self) -> Option<ClipboardItem>;
+    #[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<ClipboardItem>;
+    #[cfg(target_os = "macos")]
+    fn write_to_find_pasteboard(&self, item: ClipboardItem);
 
     fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>>;
     fn read_credentials(&self, url: &str) -> Task<Result<Option<(String, Vec<u8>)>>>;

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;
 

crates/gpui/src/platform/mac/attributed_string.rs πŸ”—

@@ -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);
-        }
-    }
-}

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<ClipboardItem> {
+        // 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<ClipboardItem> {
+        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::<UTType>::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<ImageFormat> 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()))
+        );
+    }
+}

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<dyn PlatformTextSystem>,
     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<Box<dyn FnMut()>>,
     on_keyboard_layout_change: Option<Box<dyn FnMut()>>,
     quit: Option<Box<dyn FnMut()>>,
@@ -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<Menu>,
@@ -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<ClipboardItem> {
         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<ClipboardItem> {
+        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<Result<()>> {
@@ -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::<UTType>::into(image.format).inner_mut());
-        }
-    }
-}
-
-fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option<ClipboardItem> {
-    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<ImageFormat> 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
-    }
-}

crates/gpui/src/platform/test/platform.rs πŸ”—

@@ -32,6 +32,8 @@ pub(crate) struct TestPlatform {
     current_clipboard_item: Mutex<Option<ClipboardItem>>,
     #[cfg(any(target_os = "linux", target_os = "freebsd"))]
     current_primary_item: Mutex<Option<ClipboardItem>>,
+    #[cfg(target_os = "macos")]
+    current_find_pasteboard_item: Mutex<Option<ClipboardItem>>,
     pub(crate) prompts: RefCell<TestPrompts>,
     screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
     pub opened_url: RefCell<Option<String>>,
@@ -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<ClipboardItem> {
+        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<ClipboardItem> {
-        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<ClipboardItem> {
+        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<Result<()>> {

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<T> {
-    high_priority: Vec<T>,
-    medium_priority: Vec<T>,
-    low_priority: Vec<T>,
+    high_priority: VecDeque<T>,
+    medium_priority: VecDeque<T>,
+    low_priority: VecDeque<T>,
 }
 
 impl<T> PriorityQueues<T> {
@@ -42,9 +43,9 @@ impl<T> PriorityQueueState<T> {
         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<T> PriorityQueueReceiver<T> {
     pub(crate) fn new() -> (PriorityQueueSender<T>, 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<T> PriorityQueueReceiver<T> {
         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<T> PriorityQueueReceiver<T> {
         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<T> PriorityQueueReceiver<T> {
         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());
             }
         }
 

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

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);

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<dyn PlatformTextSystem>,
@@ -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<usize> {
         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<TextRun>) {
+fn update_runs_after_truncation(
+    result: &str,
+    ellipsis: &str,
+    runs: &mut Vec<TextRun>,
+    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);
             }

crates/gpui/src/window.rs πŸ”—

@@ -876,7 +876,9 @@ pub struct Window {
     active: Rc<Cell<bool>>,
     hovered: Rc<Cell<bool>>,
     pub(crate) needs_present: Rc<Cell<bool>>,
-    pub(crate) last_input_timestamp: Rc<Cell<Instant>>,
+    /// 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<RefCell<InputRateTracker>>,
     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<Instant>,
+    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<RefCell<Vec<FrameCallback>>> = 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,

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();

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::<Vec<_>>();
+
+    // 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::<Vec<_>>();
+
+    // 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]

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<BlockCommentConfig>,
+    /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
+    #[serde(default)]
+    pub unordered_list: Vec<Arc<str>>,
+    /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `).
+    #[serde(default)]
+    pub ordered_list: Vec<OrderedListConfig>,
+    /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `).
+    #[serde(default)]
+    pub task_list: Option<TaskListConfig>,
     /// 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<String>,
 }
 
+/// 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<Arc<str>>,
+    /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `).
+    pub continuation: Arc<str>,
+}
+
 #[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<str>] {
+        &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.
     ///

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(),

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<QueryCapture<'a>>,
     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<Item = Range<usize>>,
     b: impl Iterator<Item = Range<usize>>,

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<RelPath>),
+    Subproject(Arc<Path>, Arc<RelPath>),
     Project,
     /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines.
     Global,

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<Arc<dyn LanguageModel>>;
     fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>>;
@@ -820,7 +835,7 @@ pub trait LanguageModelProvider: 'static {
     fn reset_credentials(&self, cx: &mut App) -> Task<Result<()>>;
 }
 
-#[derive(Default, Clone)]
+#[derive(Default, Clone, PartialEq, Eq)]
 pub enum ConfigurationViewTargetAgent {
     #[default]
     ZedAgent,

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<dyn Fn(&str) -> 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<ConfiguredModel>,
     providers: BTreeMap<LanguageModelProviderId, Arc<dyn LanguageModelProvider>>,
     inline_alternatives: Vec<Arc<dyn LanguageModel>>,
+    /// Set of installed extension IDs that provide language models.
+    /// Used to determine which built-in providers should be hidden.
+    installed_llm_extension_ids: HashSet<Arc<str>>,
+    /// Function to check if a built-in provider should be hidden by an extension.
+    builtin_provider_hiding_fn: Option<BuiltinProviderHidingFn>,
 }
 
 #[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<Event> for LanguageModelRegistry {}
@@ -183,6 +194,60 @@ impl LanguageModelRegistry {
         providers
     }
 
+    /// Returns providers, filtering out hidden built-in providers.
+    pub fn visible_providers(&self) -> Vec<Arc<dyn LanguageModelProvider>> {
+        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<str>, cx: &mut Context<Self>) {
+        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<Self>) {
+        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<Arc<str>>,
+        cx: &mut Context<Self>,
+    ) {
+        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<ConfiguredModel>,
@@ -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);
+    }
 }

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"] }

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<HashMap<&'static str, &'static str>> =
+    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<LanguageModelRegistry>,
+}
+
+impl LanguageModelProviderRegistryProxy {
+    pub fn new(registry: Entity<LanguageModelRegistry>) -> Self {
+        Self { registry }
+    }
+}
+
+impl ExtensionLanguageModelProviderProxy for LanguageModelProviderRegistryProxy {
+    fn register_language_model_provider(
+        &self,
+        _provider_id: Arc<str>,
+        register_fn: LanguageModelProviderRegistration,
+        cx: &mut App,
+    ) {
+        register_fn(cx);
+    }
+
+    fn unregister_language_model_provider(&self, provider_id: Arc<str>, 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));
+}

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<UserStore>, client: Arc<Client>, 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()

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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<Arc<dyn LanguageModel>> {

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::<HashMap<_, _>>()
+            })
+            .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()),
                     )
                 }),
             ));

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(),

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: (_

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

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

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

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 = [

crates/lsp/src/lsp.rs πŸ”—

@@ -89,6 +89,7 @@ pub struct LanguageServer {
     outbound_tx: channel::Sender<String>,
     notification_tx: channel::Sender<NotificationSerializer>,
     name: LanguageServerName,
+    version: Option<SharedString>,
     process_name: Arc<str>,
     binary: LanguageServerBinary,
     capabilities: RwLock<ServerCapabilities>,
@@ -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<SharedString> {
+        self.version.clone()
+    }
+
     pub fn process_name(&self) -> &str {
         &self.process_name
     }

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<SharedString>,
     pub pending_work: BTreeMap<ProgressToken, LanguageServerProgress>,
     pub has_pending_diagnostic_updates: bool,
     pub progress_tokens: HashSet<ProgressToken>,
@@ -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<lsp::Documentation> 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"
+        ));
+    }
 }

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(

crates/project/src/toolchain_store.rs πŸ”—

@@ -32,6 +32,7 @@ use crate::{
 pub struct ToolchainStore {
     mode: ToolchainStoreInner,
     user_toolchains: BTreeMap<ToolchainScope, IndexSet<Toolchain>>,
+    worktree_store: Entity<WorktreeStore>,
     _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>) -> Self {
+    pub(super) fn remote(
+        project_id: u64,
+        worktree_store: Entity<WorktreeStore>,
+        client: AnyProtoClient,
+        cx: &mut Context<Self>,
+    ) -> 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<Self>,
     ) -> Task<Option<Toolchains>> {
+        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
                 }

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"] }

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<Self>,
+    ) {
+        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))
                         })

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"
             ]
         );

crates/search/src/buffer_search.rs πŸ”—

@@ -106,7 +106,10 @@ pub struct BufferSearchBar {
     replacement_editor_focused: bool,
     active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
     active_match_index: Option<usize>,
-    active_searchable_item_subscription: Option<Subscription>,
+    #[cfg(target_os = "macos")]
+    active_searchable_item_subscriptions: Option<[Subscription; 2]>,
+    #[cfg(not(target_os = "macos"))]
+    active_searchable_item_subscriptions: Option<Subscription>,
     active_search: Option<Arc<SearchQuery>>,
     searchable_items_with_matches: HashMap<Box<dyn WeakSearchableItemHandle>, AnyVec<dyn Send>>,
     pending_search: Option<Task<()>>,
@@ -472,7 +475,7 @@ impl ToolbarItemView for BufferSearchBar {
         cx: &mut Context<Self>,
     ) -> 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<Self>) {
         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);

crates/settings/src/settings_content/language.rs πŸ”—

@@ -363,6 +363,14 @@ pub struct LanguageSettingsContent {
     ///
     /// Default: true
     pub extend_comment_on_newline: Option<bool>,
+    /// Whether to continue markdown lists when pressing enter.
+    ///
+    /// Default: true
+    pub extend_list_on_newline: Option<bool>,
+    /// Whether to indent list items when pressing tab after a list marker.
+    ///
+    /// Default: true
+    pub indent_list_on_tab: Option<bool>,
     /// Inlay hint related settings.
     pub inlay_hints: Option<InlayHintSettingsContent>,
     /// Whether to automatically type closing characters for you. For example,

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

crates/terminal_view/src/terminal_panel.rs πŸ”—

@@ -939,7 +939,6 @@ impl TerminalPanel {
         cx: &mut Context<Self>,
     ) -> Task<Result<WeakEntity<Terminal>>> {
         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 => {

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<Pixels> {
         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<Pixels>) {
         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

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<u32> = 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");
+        }
+    }
+}

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);

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.;

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<Self>) -> Option<AnyElement> {
+    fn render_remote_project_connection(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
+        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<Self>) -> Option<AnyElement> {
+    pub fn render_project_host(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
         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<Self>) -> impl IntoElement {
+    pub fn render_project_name(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> 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<Self>) -> Option<impl IntoElement> {
-        let settings = TitleBarSettings::get_global(cx);
+    pub fn render_project_repo(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<impl IntoElement> {
         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::<String>()
-                })
-            })?;
-        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::<String>()
+                    })
+                });
+
+            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<Self>) -> 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<Self>) -> bool {
+        self.workspace
+            .update(cx, |workspace, cx| workspace.has_active_modal(window, cx))
+            .unwrap_or(false)
+    }
 }

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(),
                         )

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<ToolchainSelector>,
+    worktree_root_path: Arc<Path>,
 }
 
 struct ScopePickerState {
@@ -99,12 +101,17 @@ impl AddToolchainState {
         root_path: ProjectPath,
         window: &mut Window,
         cx: &mut Context<ToolchainSelector>,
-    ) -> Entity<Self> {
+    ) -> anyhow::Result<Entity<Self>> {
         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<Self>,
     ) {
         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

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()
             }
         }

crates/ui/src/components/icon.rs πŸ”—

@@ -126,17 +126,6 @@ enum IconSource {
     ExternalSvg(SharedString),
 }
 
-impl IconSource {
-    fn from_path(path: impl Into<SharedString>) -> 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<SharedString>) -> 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(),

crates/ui/src/components/label/label.rs πŸ”—

@@ -56,6 +56,12 @@ impl Label {
     pub fn set_text(&mut self, text: impl Into<SharedString>) {
         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()),
                         ],
                     ),
                 ])

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(

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<CommandRange>,
+    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>) {
         });
     });
 
+    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::<Point>(&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> {
         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::<VimRead>().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;

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 (
+                    <div>
+                      <div onClick={() => {
+                        alert("Hello world!");
+                        console.log(Λ‡"clicked");
+                      }}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={Β«() => {
+                        alert("Hello world!");
+                        console.log("clicked");
+                      }Λ‡Β»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() => console.log("clickˇed")}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={Β«() => console.log("clicked")Λ‡Β»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={Λ‡() => console.log("clicked")}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={Β«() => console.log("clicked")Λ‡Β»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() => console.log("clicked"Λ‡)}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={Β«() => console.log("clicked")Λ‡Β»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() =Λ‡> console.log("clicked")}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={Β«() => console.log("clicked")Λ‡Β»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() => {
+                        console.log("cliˇcked");
+                      }}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={Β«() => {
+                        console.log("clicked");
+                      }Λ‡Β»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+
+        cx.set_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={() => fˇoo()}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("v a f");
+        cx.assert_state(
+            indoc! {r#"
+                export const MyComponent = () => {
+                  return (
+                    <div>
+                      <div onClick={Β«() => foo()Λ‡Β»}>Hello world!</div>
+                    </div>
+                  );
+                };
+            "#},
+            Mode::VisualLine,
+        );
+    }
 }

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
+                                            }
                                         }
                                     }
                                 }

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<Pixels>,
+    },
+}
+
 #[derive(Debug)]
 pub enum DismissDecision {
     Dismiss(bool),
@@ -58,6 +67,7 @@ pub struct ActiveModal {
     _subscriptions: [Subscription; 2],
     previous_focus_handle: Option<FocusHandle>,
     focus_handle: FocusHandle,
+    placement: ModalPlacement,
 }
 
 pub struct ModalLayer {
@@ -87,6 +97,19 @@ impl ModalLayer {
     where
         V: ModalView,
         B: FnOnce(&mut Window, &mut Context<V>) -> V,
+    {
+        self.toggle_modal_with_placement(window, cx, ModalPlacement::Centered, build_view);
+    }
+
+    pub fn toggle_modal_with_placement<V, B>(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+        placement: ModalPlacement,
+        build_view: B,
+    ) where
+        V: ModalView,
+        B: FnOnce(&mut Window, &mut Context<V>) -> V,
     {
         if let Some(active_modal) = &self.active_modal {
             let is_close = active_modal.modal.view().downcast::<V>().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<V>(&mut self, new_modal: Entity<V>, window: &mut Window, cx: &mut Context<Self>)
-    where
+    fn show_modal<V>(
+        &mut self,
+        new_modal: Entity<V>,
+        placement: ModalPlacement,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) 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()
     }
 }

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]

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<RemoteConnectionId>,
     ) -> BTreeMap<ToolchainScope, IndexSet<Toolchain>> {
-        type RowKind = (WorkspaceId, u64, String, String, String, String, String);
+        type RowKind = (WorkspaceId, String, String, String, String, String, String);
 
         let toolchains: Vec<RowKind> = 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<Vec<(Toolchain, WorktreeId, Arc<RelPath>)>> {
+    ) -> Result<Vec<(Toolchain, Arc<Path>, Arc<RelPath>)>> {
         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<Path>,
         relative_worktree_path: Arc<RelPath>,
         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(),

crates/workspace/src/workspace.rs πŸ”—

@@ -1204,6 +1204,7 @@ pub struct Workspace {
     last_open_dock_positions: Vec<DockPosition>,
     removing: bool,
     utility_panes: UtilityPaneState,
+    next_modal_placement: Option<ModalPlacement>,
 }
 
 impl EventEmitter<Event> 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>,
     ) {
         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<Self>) {
+    fn active_item_path_changed(
+        &mut self,
+        focus_changed: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         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<V: 'static>(&self, cx: &App) -> bool {
+        self.modal_layer.read(cx).active_modal::<V>().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<V: ModalView, B>(&mut self, window: &mut Window, cx: &mut App, build: B)
     where
         B: FnOnce(&mut Window, &mut Context<V>) -> 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<Vec<Option<Box<dyn ItemHandle>>>> {
     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);
         });
     }

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<u8>) -> (String, &'static Encoding, bool) {
+fn decode_byte(bytes: Vec<u8>) -> 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<u8>) -> (String, &'static Encoding) {
@@ -5867,14 +5903,66 @@ fn decode_byte(bytes: Vec<u8>) -> (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
+}

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<u8>,
+        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> = 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<u8>,
+    }
 
-    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> = 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
+        );
+    }
 }

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

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]

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<Instant> = OnceLock::new();
+static STARTUP_TIME: OnceLock<Instant> = 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<AppState>, 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::<ProjectPanel>(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::<ProjectPanel>(window, cx);
+                                    }),
+                                );
+                            }
+                        }
+                    })));
+                });
+            }
             OpenRequestKind::GitCommit { sha } => {
                 cx.spawn(async move |cx| {
                     let paths_with_position =
@@ -1301,7 +1343,7 @@ fn init_paths() -> HashMap<io::ErrorKind, Vec<&'static Path>> {
     })
 }
 
-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::<Vec<ActionDef>>();

crates/zed/src/zed-main.rs πŸ”—

@@ -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();
-}

crates/zed/src/zed.rs πŸ”—

@@ -4780,7 +4780,6 @@ mod tests {
                 "activity_indicator",
                 "agent",
                 "agents",
-                #[cfg(not(target_os = "macos"))]
                 "app_menu",
                 "assistant",
                 "assistant2",

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<String>,
     },
+    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=<url> or ?repo=<url>
+        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: <sha>?repo=<path>
         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"),
+        }
+    }
 }

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

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}

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 `<br />` in Markdown files you can disable this behavior with:

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"

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

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<Use> {
         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<Run> {
-        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<Use> {
         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(

tooling/xtask/src/tasks/workflows/steps.rs πŸ”—

@@ -54,25 +54,12 @@ pub fn setup_sentry() -> Step<Use> {
     .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<Run> {
-    named::bash("./script/prettier").id(PRETTIER_STEP_ID)
+    named::bash("./script/prettier")
 }
 
 pub fn cargo_fmt() -> Step<Run> {
-    named::bash("cargo fmt --all -- --check").id(CARGO_FMT_STEP_ID)
-}
-
-pub fn record_style_failure() -> Step<Run> {
-    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<Use> {
@@ -118,25 +105,13 @@ pub fn clear_target_dir_if_large(platform: Platform) -> Step<Run> {
     }
 }
 
-pub const CLIPPY_STEP_ID: &str = "clippy";
-pub const RECORD_CLIPPY_FAILURE_STEP_ID: &str = "record_clippy_failure";
-
 pub fn clippy(platform: Platform) -> Step<Run> {
     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<Run> {
-    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<Use> {
     named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "rust"))
 }