Merge branch 'main' of https://github.com/zed-industries/zed

morgankrey 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/workflows/community_close_stale_issues.yml   |   2 
.github/workflows/docs_automation.yml                | 256 +++++
assets/settings/default.json                         |   4 
crates/editor/src/code_context_menus.rs              |   8 
crates/editor/src/editor.rs                          | 525 ++++++++-
crates/editor/src/editor_tests.rs                    | 715 ++++++++++++-
crates/editor/src/element.rs                         | 176 +--
crates/editor/src/scroll/autoscroll.rs               |  17 
crates/git_ui/src/git_panel.rs                       |   2 
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/language.rs                      |  45 
crates/language/src/language_settings.rs             |   6 
crates/language/src/toolchain.rs                     |   7 
crates/languages/src/markdown/config.toml            |   3 
crates/project/src/project.rs                        |   7 
crates/project/src/toolchain_store.rs                |  26 
crates/project/src/x.py                              |   1 
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/title_bar/build.rs                            |  28 
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/label/label.rs              |   9 
crates/ui/src/components/label/label_like.rs         |  23 
crates/vim/src/command.rs                            | 200 +++
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 +
docs/AGENTS.md                                       | 353 ++++++
docs/src/configuring-zed.md                          |  20 
docs/src/languages/markdown.md                       |  34 
55 files changed, 3,895 insertions(+), 1,106 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/workflows/docs_automation.yml 🔗

@@ -0,0 +1,256 @@
+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
+        run: |
+          curl -fsSL https://cli.factory.ai/install.sh | bash
+          echo "${HOME}/.factory/bin" >> "$GITHUB_PATH"
+
+      - 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 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 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 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 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 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

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/editor/src/code_context_menus.rs 🔗

@@ -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(
@@ -9436,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)
                     });
@@ -9453,7 +9413,7 @@ impl Element for EditorElement {
                         start_row..end_row,
                         &row_infos,
                         &active_rows,
-                        newest_selection_head,
+                        relative_row_base,
                         &snapshot,
                         window,
                         cx,
@@ -9773,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,
@@ -9784,6 +9745,8 @@ impl Element for EditorElement {
                             &gutter_hitbox,
                             &text_hitbox,
                             &style,
+                            relative,
+                            relative_row_base,
                             window,
                             cx,
                         )
@@ -11631,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);
@@ -11671,7 +11634,7 @@ mod tests {
                         })
                         .collect::<Vec<_>>(),
                     &BTreeMap::default(),
-                    Some(DisplayPoint::new(DisplayRow(0), 0)),
+                    Some(DisplayRow(0)),
                     &snapshot,
                     window,
                     cx,
@@ -11683,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,
                 )
             })
@@ -11702,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,
                 )
             })
@@ -11719,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,
                 )
             })
@@ -11759,7 +11719,7 @@ mod tests {
                         })
                         .collect::<Vec<_>>(),
                     &BTreeMap::default(),
-                    Some(DisplayPoint::new(DisplayRow(0), 0)),
+                    Some(DisplayRow(0)),
                     &snapshot,
                     window,
                     cx,
@@ -11774,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);
@@ -11819,7 +11779,7 @@ mod tests {
                         })
                         .collect::<Vec<_>>(),
                     &BTreeMap::default(),
-                    Some(DisplayPoint::new(DisplayRow(0), 0)),
+                    Some(DisplayRow(0)),
                     &snapshot,
                     window,
                     cx,
@@ -11831,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,
                 )
             })
@@ -11871,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,
@@ -11886,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/git_ui/src/git_panel.rs 🔗

@@ -5203,7 +5203,7 @@ impl GitPanel {
 
                 this.child(
                     self.entry_label(path_name, path_color)
-                        .truncate()
+                        .truncate_start()
                         .when(strikethrough, Label::strikethrough),
                 )
             })

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

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/configuring-zed.md 🔗

@@ -1585,6 +1585,26 @@ Positive `integer` value between 1 and 32. Values outside of this range will be
 
 `boolean` values
 
+## Extend List On Newline
+
+- Description: Whether to continue lists when pressing Enter at the end of a list item. Supports unordered, ordered, and task lists. Pressing Enter on an empty list item removes the marker and exits the list.
+- Setting: `extend_list_on_newline`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## Indent List On Tab
+
+- Description: Whether to indent list items when pressing Tab on a line containing only a list marker. This enables quick creation of nested lists.
+- Setting: `indent_list_on_tab`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
 ## Status Bar
 
 - Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere.

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: