Merge remote-tracking branch 'origin/main' into agent-drawer

Eric Holk created

Change summary

.github/workflows/extension_auto_bump.yml                                 |  72 
.github/workflows/extension_bump.yml                                      |  49 
.github/workflows/extension_tests.yml                                     |  61 
.github/workflows/run_tests.yml                                           |  32 
Cargo.lock                                                                |  58 
Cargo.toml                                                                |   2 
assets/icons/thread.svg                                                   |   3 
assets/keymaps/default-linux.json                                         |   2 
assets/settings/default.json                                              |   5 
crates/agent_servers/src/acp.rs                                           |   2 
crates/agent_ui/src/agent_configuration.rs                                |  45 
crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs         |  18 
crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs |   8 
crates/agent_ui/src/agent_diff.rs                                         |   9 
crates/agent_ui/src/agent_model_selector.rs                               |  17 
crates/agent_ui/src/agent_panel.rs                                        | 213 
crates/agent_ui/src/agent_registry_ui.rs                                  |  17 
crates/agent_ui/src/config_options.rs                                     |   5 
crates/agent_ui/src/connection_view.rs                                    | 235 
crates/agent_ui/src/connection_view/thread_view.rs                        | 104 
crates/agent_ui/src/inline_prompt_editor.rs                               |   8 
crates/agent_ui/src/mention_set.rs                                        |   2 
crates/agent_ui/src/message_editor.rs                                     |  90 
crates/agent_ui/src/mode_selector.rs                                      |   5 
crates/agent_ui/src/model_selector_popover.rs                             |  17 
crates/agent_ui/src/profile_selector.rs                                   |   8 
crates/agent_ui/src/sidebar.rs                                            | 438 
crates/agent_ui/src/text_thread_editor.rs                                 |  29 
crates/agent_ui/src/thread_history_view.rs                                |  18 
crates/agent_ui/src/threads_archive_view.rs                               |  11 
crates/agent_ui/src/ui/acp_onboarding_modal.rs                            |   9 
crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs                   |   9 
crates/agent_ui/src/ui/mention_crease.rs                                  |  13 
crates/breadcrumbs/src/breadcrumbs.rs                                     |  10 
crates/channel/src/channel_store.rs                                       |   4 
crates/collab/tests/integration/editor_tests.rs                           |   3 
crates/collab_ui/src/collab_panel.rs                                      |  17 
crates/collab_ui/src/notification_panel.rs                                |   4 
crates/copilot_ui/src/sign_in.rs                                          |  27 
crates/debugger_ui/src/debugger_panel.rs                                  |  36 
crates/edit_prediction/src/edit_prediction.rs                             |   4 
crates/edit_prediction/src/mercury.rs                                     |  74 
crates/edit_prediction_ui/src/edit_prediction_button.rs                   | 195 
crates/edit_prediction_ui/src/rate_prediction_modal.rs                    |   8 
crates/editor/src/editor.rs                                               |  38 
crates/editor/src/element.rs                                              |  36 
crates/editor/src/hover_popover.rs                                        |  27 
crates/editor/src/items.rs                                                |  15 
crates/editor/src/movement.rs                                             |  20 
crates/editor/src/runnables.rs                                            | 194 
crates/editor/src/split.rs                                                |  10 
crates/extensions_ui/src/extensions_ui.rs                                 |  31 
crates/file_finder/Cargo.toml                                             |   3 
crates/file_finder/src/file_finder.rs                                     | 189 
crates/file_finder/src/file_finder_tests.rs                               |   4 
crates/fs/src/fs.rs                                                       |  11 
crates/git/src/blame.rs                                                   |   2 
crates/git/src/commit.rs                                                  |   4 
crates/git/src/repository.rs                                              | 115 
crates/git_graph/src/git_graph.rs                                         |  21 
crates/git_ui/src/blame_ui.rs                                             |  18 
crates/git_ui/src/commit_modal.rs                                         |   9 
crates/git_ui/src/commit_tooltip.rs                                       |  13 
crates/git_ui/src/commit_view.rs                                          |   9 
crates/git_ui/src/conflict_view.rs                                        |   9 
crates/git_ui/src/file_diff_view.rs                                       |   8 
crates/git_ui/src/file_history_view.rs                                    |  14 
crates/git_ui/src/git_ui.rs                                               |   3 
crates/git_ui/src/multi_diff_view.rs                                      |   8 
crates/git_ui/src/project_diff.rs                                         |  16 
crates/gpui/src/app/headless_app_context.rs                               |   8 
crates/gpui_linux/src/linux/x11/client.rs                                 |   3 
crates/image_viewer/src/image_viewer.rs                                   |  28 
crates/keymap_editor/src/keymap_editor.rs                                 |   8 
crates/language_models/src/provider/bedrock.rs                            |   3 
crates/language_models/src/provider/cloud.rs                              |   6 
crates/language_models/src/provider/lmstudio.rs                           |  34 
crates/language_models/src/provider/ollama.rs                             |  38 
crates/language_models/src/provider/open_ai.rs                            |   8 
crates/language_models/src/provider/open_ai_compatible.rs                 |   4 
crates/language_onboarding/src/python.rs                                  |   4 
crates/language_tools/src/lsp_log_view.rs                                 |  35 
crates/onboarding/src/basics_page.rs                                      |   8 
crates/onboarding/src/multibuffer_hint.rs                                 |   9 
crates/open_path_prompt/src/file_finder_settings.rs                       |   2 
crates/panel/src/panel.rs                                                 |   1 
crates/project/src/lsp_store.rs                                           |   5 
crates/project/src/lsp_store/semantic_tokens.rs                           |   8 
crates/project_panel/src/project_panel.rs                                 |  31 
crates/project_panel/src/project_panel_settings.rs                        |  13 
crates/recent_projects/src/disconnected_overlay.rs                        |   9 
crates/recent_projects/src/remote_servers.rs                              |   6 
crates/repl/src/components/kernel_options.rs                              |   9 
crates/repl/src/notebook/notebook_ui.rs                                   |   9 
crates/rules_library/src/rules_library.rs                                 |   9 
crates/search/src/project_search.rs                                       |  20 
crates/settings/src/vscode_import.rs                                      |   7 
crates/settings_content/src/settings_content.rs                           |   4 
crates/settings_content/src/workspace.rs                                  |  23 
crates/settings_ui/src/page_data.rs                                       |  28 
crates/settings_ui/src/pages/tool_permissions_setup.rs                    |  17 
crates/settings_ui/src/settings_ui.rs                                     |  31 
crates/terminal_view/src/terminal_view.rs                                 |  18 
crates/theme_selector/src/icon_theme_selector.rs                          |   9 
crates/theme_selector/src/theme_selector.rs                               |   9 
crates/title_bar/src/title_bar.rs                                         |  25 
crates/ui/src/components/ai.rs                                            |   2 
crates/ui/src/components/ai/configured_api_card.rs                        |  64 
crates/ui/src/components/ai/copilot_configuration_callout.rs              |   1 
crates/ui/src/components/ai/thread_item.rs                                |  14 
crates/ui/src/components/ai/thread_sidebar_toggle.rs                      | 177 
crates/ui/src/components/banner.rs                                        |  10 
crates/ui/src/components/button.rs                                        |   1 
crates/ui/src/components/button/button.rs                                 | 226 
crates/ui/src/components/button/button_icon.rs                            | 199 
crates/ui/src/components/button/icon_button.rs                            |  49 
crates/ui/src/components/diff_stat.rs                                     |  12 
crates/ui/src/components/dropdown_menu.rs                                 |   9 
crates/workspace/src/item.rs                                              |  20 
crates/workspace/src/notifications.rs                                     |  28 
crates/workspace/src/persistence.rs                                       |  16 
crates/workspace/src/workspace.rs                                         |   9 
docs/src/SUMMARY.md                                                       |   1 
docs/src/extensions.md                                                    |   1 
docs/src/extensions/developing-extensions.md                              |   6 
docs/src/extensions/snippets.md                                           |  27 
docs/src/reference/all-settings.md                                        |  35 
extensions/glsl/languages/glsl/config.toml                                |   2 
extensions/html/languages/html/brackets.scm                               |   4 
tooling/xtask/src/tasks/workflows.rs                                      |   2 
tooling/xtask/src/tasks/workflows/extension_auto_bump.rs                  | 113 
tooling/xtask/src/tasks/workflows/extension_bump.rs                       |  87 
tooling/xtask/src/tasks/workflows/extension_tests.rs                      |  97 
tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs           |   5 
tooling/xtask/src/tasks/workflows/run_tests.rs                            | 124 
tooling/xtask/src/tasks/workflows/steps.rs                                |   9 
tooling/xtask/src/tasks/workflows/vars.rs                                 |  33 
137 files changed, 3,198 insertions(+), 1,497 deletions(-)

Detailed changes

.github/workflows/extension_auto_bump.yml 🔗

@@ -0,0 +1,72 @@
+# Generated from xtask::workflows::extension_auto_bump
+# Rebuild with `cargo xtask workflows`.
+name: extension_auto_bump
+on:
+  push:
+    branches:
+    - main
+    paths:
+    - extensions/**
+    - '!extensions/workflows/**'
+    - '!extensions/*.md'
+jobs:
+  detect_changed_extensions:
+    if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
+    runs-on: namespace-profile-2x4-ubuntu-2404
+    steps:
+    - name: steps::checkout_repo
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      with:
+        clean: false
+        fetch-depth: 2
+    - id: detect
+      name: extension_auto_bump::detect_changed_extensions
+      run: |
+        COMPARE_REV="$(git rev-parse HEAD~1)"
+        CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
+        # Detect changed extension directories (excluding extensions/workflows)
+        CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true)
+        if [ -n "$CHANGED_EXTENSIONS" ]; then
+            EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))')
+        else
+            EXTENSIONS_JSON="[]"
+        fi
+        # Filter out newly added or entirely removed extensions
+        FILTERED="[]"
+        for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do
+            if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \
+               [ -f "$ext/extension.toml" ]; then
+                FILTERED=$(echo "$FILTERED" | jq -c --arg e "$ext" '. + [$e]')
+            fi
+        done
+        echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT"
+    outputs:
+      changed_extensions: ${{ steps.detect.outputs.changed_extensions }}
+    timeout-minutes: 5
+  bump_extension_versions:
+    needs:
+    - detect_changed_extensions
+    if: needs.detect_changed_extensions.outputs.changed_extensions != '[]'
+    permissions:
+      actions: write
+      contents: write
+      issues: write
+      pull-requests: write
+    strategy:
+      matrix:
+        extension: ${{ fromJson(needs.detect_changed_extensions.outputs.changed_extensions) }}
+      fail-fast: false
+      max-parallel: 1
+    uses: ./.github/workflows/extension_bump.yml
+    secrets:
+      app-id: ${{ secrets.ZED_ZIPPY_APP_ID }}
+      app-secret: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }}
+    with:
+      working-directory: ${{ matrix.extension }}
+      force-bump: false
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
+  cancel-in-progress: true
+defaults:
+  run:
+    shell: bash -euxo pipefail {0}

.github/workflows/extension_bump.yml 🔗

@@ -17,6 +17,10 @@ on:
         description: force-bump
         required: true
         type: boolean
+      working-directory:
+        description: working-directory
+        type: string
+        default: .
     secrets:
       app-id:
         description: The app ID used to create the PR
@@ -42,8 +46,6 @@ jobs:
         if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
             PR_FORK_POINT="$(git merge-base origin/main HEAD)"
             git checkout "$PR_FORK_POINT"
-        elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then
-            git checkout "$BRANCH_PARENT_SHA"
         else
             git checkout "$(git log -1 --format=%H)"~1
         fi
@@ -59,6 +61,10 @@ jobs:
       version_changed: ${{ steps.compare-versions-check.outputs.version_changed }}
       current_version: ${{ steps.compare-versions-check.outputs.current_version }}
     timeout-minutes: 1
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   bump_extension_version:
     needs:
     - check_version_changed
@@ -98,18 +104,35 @@ jobs:
         fi
 
         NEW_VERSION="$(sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]')"
+        EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
+        EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
+
+        if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
+            {
+                echo "title=Bump version to ${NEW_VERSION}";
+                echo "body=This PR bumps the version of this extension to v${NEW_VERSION}";
+                echo "branch_name=zed-zippy-autobump";
+            } >> "$GITHUB_OUTPUT"
+        else
+            {
+                echo "title=${EXTENSION_ID}: Bump to v${NEW_VERSION}";
+                echo "body=This PR bumps the version of the ${EXTENSION_NAME} extension to v${NEW_VERSION}";
+                echo "branch_name=zed-zippy-${EXTENSION_ID}-autobump";
+            } >> "$GITHUB_OUTPUT"
+        fi
 
         echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT"
       env:
         OLD_VERSION: ${{ needs.check_version_changed.outputs.current_version }}
         BUMP_TYPE: ${{ inputs.bump-type }}
+        WORKING_DIR: ${{ inputs.working-directory }}
     - name: extension_bump::create_pull_request
       uses: peter-evans/create-pull-request@v7
       with:
-        title: Bump version to ${{ steps.bump-version.outputs.new_version }}
-        body: This PR bumps the version of this extension to v${{ steps.bump-version.outputs.new_version }}
-        commit-message: Bump version to v${{ steps.bump-version.outputs.new_version }}
-        branch: zed-zippy-autobump
+        title: ${{ steps.bump-version.outputs.title }}
+        body: ${{ steps.bump-version.outputs.body }}
+        commit-message: ${{ steps.bump-version.outputs.title }}
+        branch: ${{ steps.bump-version.outputs.branch_name }}
         committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
         base: main
         delete-branch: true
@@ -117,6 +140,10 @@ jobs:
         sign-commits: true
         assignees: ${{ github.actor }}
     timeout-minutes: 3
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   create_version_label:
     needs:
     - check_version_changed
@@ -145,6 +172,10 @@ jobs:
           })
         github-token: ${{ steps.generate-token.outputs.token }}
     timeout-minutes: 1
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   trigger_release:
     needs:
     - check_version_changed
@@ -178,8 +209,12 @@ jobs:
         tag: v${{ needs.check_version_changed.outputs.current_version }}
       env:
         COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }}
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
 concurrency:
-  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
+  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}extension-bump
   cancel-in-progress: true
 defaults:
   run:

.github/workflows/extension_tests.yml 🔗

@@ -9,7 +9,12 @@ env:
   RUSTUP_TOOLCHAIN: stable
   CARGO_BUILD_TARGET: wasm32-wasip2
 on:
-  workflow_call: {}
+  workflow_call:
+    inputs:
+      working-directory:
+        description: working-directory
+        type: string
+        default: .
 jobs:
   orchestrate:
     if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
@@ -34,6 +39,14 @@ jobs:
         fi
         CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
 
+        # When running from a subdirectory, git diff returns repo-root-relative paths.
+        # Filter to only files within the current working directory and strip the prefix.
+        REPO_SUBDIR="$(git rev-parse --show-prefix)"
+        REPO_SUBDIR="${REPO_SUBDIR%/}"
+        if [ -n "$REPO_SUBDIR" ]; then
+            CHANGED_FILES="$(echo "$CHANGED_FILES" | grep "^${REPO_SUBDIR}/" | sed "s|^${REPO_SUBDIR}/||" || true)"
+        fi
+
         check_pattern() {
           local output_name="$1"
           local pattern="$2"
@@ -49,6 +62,10 @@ jobs:
     outputs:
       check_rust: ${{ steps.filter.outputs.check_rust }}
       check_extension: ${{ steps.filter.outputs.check_extension }}
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   check_rust:
     needs:
     - orchestrate
@@ -66,17 +83,31 @@ jobs:
         path: ~/.rustup
     - name: extension_tests::install_rust_target
       run: rustup target add wasm32-wasip2
-    - name: steps::cargo_fmt
-      run: cargo fmt --all -- --check
+    - id: get-package-name
+      name: extension_tests::get_package_name
+      run: |
+        PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < Cargo.toml | head -1 | tr -d '[:space:]')"
+        echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
+    - name: extension_tests::cargo_fmt_package
+      run: cargo fmt -p "$PACKAGE_NAME" -- --check
+      env:
+        PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }}
     - name: extension_tests::run_clippy
-      run: cargo clippy --release --all-features -- --deny warnings
+      run: cargo clippy -p "$PACKAGE_NAME" --release --all-features -- --deny warnings
+      env:
+        PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }}
     - name: steps::cargo_install_nextest
       uses: taiki-e/install-action@nextest
-    - name: steps::cargo_nextest
-      run: 'cargo nextest run --workspace --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"'
+    - name: extension_tests::run_nextest
+      run: 'cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"'
       env:
+        PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }}
         NEXTEST_NO_TESTS: warn
     timeout-minutes: 6
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   check_extension:
     needs:
     - orchestrate
@@ -97,8 +128,8 @@ jobs:
     - name: extension_tests::download_zed_extension_cli
       if: steps.cache-zed-extension-cli.outputs.cache-hit != 'true'
       run: |
-        wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension"
-        chmod +x zed-extension
+        wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension"
+        chmod +x "$GITHUB_WORKSPACE/zed-extension"
     - name: steps::cache_rust_dependencies_namespace
       uses: namespacelabs/nscloud-cache-action@v1
       with:
@@ -108,7 +139,7 @@ jobs:
       run: |
         mkdir -p /tmp/ext-scratch
         mkdir -p /tmp/ext-output
-        ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
+        "$GITHUB_WORKSPACE/zed-extension" --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
     - name: run_tests::fetch_ts_query_ls
       uses: dsaltares/fetch-gh-release-asset@aa37ae5c44d3c9820bc12fe675e8670ecd93bd1c
       with:
@@ -117,8 +148,8 @@ jobs:
         file: ts_query_ls-x86_64-unknown-linux-gnu.tar.gz
     - name: run_tests::run_ts_query_ls
       run: |-
-        tar -xf ts_query_ls-x86_64-unknown-linux-gnu.tar.gz
-        ./ts_query_ls format --check . || {
+        tar -xf "$GITHUB_WORKSPACE/ts_query_ls-x86_64-unknown-linux-gnu.tar.gz" -C "$GITHUB_WORKSPACE"
+        "$GITHUB_WORKSPACE/ts_query_ls" format --check . || {
             echo "Found unformatted queries, please format them with ts_query_ls."
             echo "For easy use, install the Tree-sitter query extension:"
             echo "zed://extension/tree-sitter-query"
@@ -132,8 +163,6 @@ jobs:
         if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
             PR_FORK_POINT="$(git merge-base origin/main HEAD)"
             git checkout "$PR_FORK_POINT"
-        elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then
-            git checkout "$BRANCH_PARENT_SHA"
         else
             git checkout "$(git log -1 --format=%H)"~1
         fi
@@ -156,6 +185,10 @@ jobs:
         VERSION_CHANGED: ${{ steps.compare-versions-check.outputs.version_changed }}
         PR_USER_LOGIN: ${{ github.event.pull_request.user.login }}
     timeout-minutes: 6
+    defaults:
+      run:
+        shell: bash -euxo pipefail {0}
+        working-directory: ${{ inputs.working-directory }}
   tests_pass:
     needs:
     - orchestrate
@@ -184,7 +217,7 @@ jobs:
         RESULT_CHECK_RUST: ${{ needs.check_rust.result }}
         RESULT_CHECK_EXTENSION: ${{ needs.check_extension.result }}
 concurrency:
-  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
+  group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}extension-tests
   cancel-in-progress: true
 defaults:
   run:

.github/workflows/run_tests.yml 🔗

@@ -103,13 +103,22 @@ jobs:
         check_pattern "run_action_checks" '^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/' -qP
         check_pattern "run_docs" '^(docs/|crates/.*\.rs)' -qP
         check_pattern "run_licenses" '^(Cargo.lock|script/.*licenses)' -qP
-        check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))' -qvP
+        check_pattern "run_tests" '^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)' -qvP
+        # Detect changed extension directories (excluding extensions/workflows)
+        CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true)
+        if [ -n "$CHANGED_EXTENSIONS" ]; then
+            EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))')
+        else
+            EXTENSIONS_JSON="[]"
+        fi
+        echo "changed_extensions=$EXTENSIONS_JSON" >> "$GITHUB_OUTPUT"
     outputs:
       changed_packages: ${{ steps.filter.outputs.changed_packages }}
       run_action_checks: ${{ steps.filter.outputs.run_action_checks }}
       run_docs: ${{ steps.filter.outputs.run_docs }}
       run_licenses: ${{ steps.filter.outputs.run_licenses }}
       run_tests: ${{ steps.filter.outputs.run_tests }}
+      changed_extensions: ${{ steps.filter.outputs.changed_extensions }}
   check_style:
     if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
     runs-on: namespace-profile-4x8-ubuntu-2204
@@ -147,8 +156,8 @@ jobs:
         file: ts_query_ls-x86_64-unknown-linux-gnu.tar.gz
     - name: run_tests::run_ts_query_ls
       run: |-
-        tar -xf ts_query_ls-x86_64-unknown-linux-gnu.tar.gz
-        ./ts_query_ls format --check . || {
+        tar -xf "$GITHUB_WORKSPACE/ts_query_ls-x86_64-unknown-linux-gnu.tar.gz" -C "$GITHUB_WORKSPACE"
+        "$GITHUB_WORKSPACE/ts_query_ls" format --check . || {
             echo "Found unformatted queries, please format them with ts_query_ls."
             echo "For easy use, install the Tree-sitter query extension:"
             echo "zed://extension/tree-sitter-query"
@@ -711,6 +720,20 @@ jobs:
     - name: run_tests::check_postgres_and_protobuf_migrations::check_protobuf_formatting
       run: buf format --diff --exit-code crates/proto/proto
     timeout-minutes: 60
+  extension_tests:
+    needs:
+    - orchestrate
+    if: needs.orchestrate.outputs.changed_extensions != '[]'
+    permissions:
+      contents: read
+    strategy:
+      matrix:
+        extension: ${{ fromJson(needs.orchestrate.outputs.changed_extensions) }}
+      fail-fast: false
+      max-parallel: 1
+    uses: ./.github/workflows/extension_tests.yml
+    with:
+      working-directory: ${{ matrix.extension }}
   tests_pass:
     needs:
     - orchestrate
@@ -728,6 +751,7 @@ jobs:
     - check_docs
     - check_licenses
     - check_scripts
+    - extension_tests
     if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && always()
     runs-on: namespace-profile-2x4-ubuntu-2404
     steps:
@@ -756,6 +780,7 @@ jobs:
         check_result "check_docs" "$RESULT_CHECK_DOCS"
         check_result "check_licenses" "$RESULT_CHECK_LICENSES"
         check_result "check_scripts" "$RESULT_CHECK_SCRIPTS"
+        check_result "extension_tests" "$RESULT_EXTENSION_TESTS"
 
         exit $EXIT_CODE
       env:
@@ -774,6 +799,7 @@ jobs:
         RESULT_CHECK_DOCS: ${{ needs.check_docs.result }}
         RESULT_CHECK_LICENSES: ${{ needs.check_licenses.result }}
         RESULT_CHECK_SCRIPTS: ${{ needs.check_scripts.result }}
+        RESULT_EXTENSION_TESTS: ${{ needs.extension_tests.result }}
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
   cancel-in-progress: true

Cargo.lock 🔗

@@ -2193,7 +2193,7 @@ version = "3.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365"
 dependencies = [
- "darling",
+ "darling 0.20.11",
  "ident_case",
  "prettyplease",
  "proc-macro2",
@@ -2459,7 +2459,7 @@ version = "0.25.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201"
 dependencies = [
- "darling",
+ "darling 0.20.11",
  "proc-macro2",
  "quote",
  "syn 2.0.117",
@@ -4513,8 +4513,18 @@ version = "0.20.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
 dependencies = [
- "darling_core",
- "darling_macro",
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core 0.21.3",
+ "darling_macro 0.21.3",
 ]
 
 [[package]]
@@ -4531,13 +4541,38 @@ dependencies = [
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
+]
+
 [[package]]
 name = "darling_macro"
 version = "0.20.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
 dependencies = [
- "darling_core",
+ "darling_core 0.20.11",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core 0.21.3",
  "quote",
  "syn 2.0.117",
 ]
@@ -4808,11 +4843,11 @@ dependencies = [
 
 [[package]]
 name = "derive_setters"
-version = "0.1.8"
+version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae5c625eda104c228c06ecaf988d1c60e542176bd7a490e60eeda3493244c0c9"
+checksum = "b7e6f6fa1f03c14ae082120b84b3c7fbd7b8588d924cf2d7c3daf9afd49df8b9"
 dependencies = [
- "darling",
+ "darling 0.21.3",
  "proc-macro2",
  "quote",
  "syn 2.0.117",
@@ -6242,6 +6277,8 @@ name = "file_finder"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "channel",
+ "client",
  "collections",
  "ctor",
  "editor",
@@ -6255,6 +6292,7 @@ dependencies = [
  "pretty_assertions",
  "project",
  "project_panel",
+ "remote_connection",
  "serde",
  "serde_json",
  "settings",
@@ -7140,7 +7178,7 @@ dependencies = [
 [[package]]
 name = "gh-workflow"
 version = "0.8.0"
-source = "git+https://github.com/zed-industries/gh-workflow?rev=c9eac0ed361583e1072860d96776fa52775b82ac#c9eac0ed361583e1072860d96776fa52775b82ac"
+source = "git+https://github.com/zed-industries/gh-workflow?rev=37f3c0575d379c218a9c455ee67585184e40d43f#37f3c0575d379c218a9c455ee67585184e40d43f"
 dependencies = [
  "async-trait",
  "derive_more",
@@ -7157,7 +7195,7 @@ dependencies = [
 [[package]]
 name = "gh-workflow-macros"
 version = "0.8.0"
-source = "git+https://github.com/zed-industries/gh-workflow?rev=c9eac0ed361583e1072860d96776fa52775b82ac#c9eac0ed361583e1072860d96776fa52775b82ac"
+source = "git+https://github.com/zed-industries/gh-workflow?rev=37f3c0575d379c218a9c455ee67585184e40d43f#37f3c0575d379c218a9c455ee67585184e40d43f"
 dependencies = [
  "heck 0.5.0",
  "quote",

Cargo.toml 🔗

@@ -558,7 +558,7 @@ fork = "0.4.0"
 futures = "0.3"
 futures-concurrency = "7.7.1"
 futures-lite = "1.13"
-gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "c9eac0ed361583e1072860d96776fa52775b82ac" }
+gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" }
 git2 = { version = "0.20.1", default-features = false, features = ["vendored-libgit2"] }
 globset = "0.4"
 handlebars = "4.3"

assets/icons/thread.svg 🔗

@@ -1,3 +1,4 @@
 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M6.31254 12.549C7.3841 13.0987 8.61676 13.2476 9.78839 12.9688C10.96 12.6901 11.9936 12.0021 12.7028 11.0287C13.412 10.0554 13.7503 8.8607 13.6566 7.66002C13.5629 6.45934 13.0435 5.33159 12.1919 4.48C11.3403 3.62841 10.2126 3.10898 9.01188 3.01531C7.8112 2.92164 6.61655 3.2599 5.64319 3.96912C4.66984 4.67834 3.9818 5.71188 3.70306 6.88351C3.42432 8.05514 3.5732 9.2878 4.12289 10.3594L3 13.6719L6.31254 12.549Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path opacity="0.12" d="M6.31254 12.549C7.3841 13.0987 8.61676 13.2476 9.78839 12.9688C10.96 12.6901 11.9936 12.0021 12.7028 11.0287C13.412 10.0554 13.7503 8.8607 13.6566 7.66002C13.5629 6.45934 13.0435 5.33159 12.1919 4.48C11.3403 3.62841 10.2126 3.10898 9.01188 3.01531C7.8112 2.92164 6.61655 3.2599 5.64319 3.96912C4.66984 4.67834 3.9818 5.71188 3.70306 6.88351C3.42432 8.05514 3.5732 9.2878 4.12289 10.3594L3 13.6719L6.31254 12.549Z" fill="#C6CAD0"/>
+<path d="M5.97658 12.549C7.04814 13.0987 8.2808 13.2476 9.45243 12.9688C10.624 12.6901 11.6576 12.0021 12.3668 11.0287C13.076 10.0554 13.4143 8.8607 13.3206 7.66002C13.2269 6.45934 12.7075 5.33159 11.8559 4.48C11.0043 3.62841 9.87664 3.10898 8.67592 3.01531C7.47524 2.92164 6.28059 3.2599 5.30723 3.96912C4.33388 4.67834 3.64584 5.71188 3.3671 6.88351C3.08836 8.05514 3.23724 9.2878 3.78693 10.3594L2.66404 13.6719L5.97658 12.549Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
 </svg>

assets/keymaps/default-linux.json 🔗

@@ -226,8 +226,8 @@
     "context": "ContextEditor > Editor",
     "bindings": {
       "ctrl-enter": "assistant::Assist",
-      "ctrl-s": "workspace::Save",
       "save": "workspace::Save",
+      "ctrl-s": "workspace::Save",
       "ctrl-<": "assistant::InsertIntoEditor",
       "shift-enter": "assistant::Split",
       "ctrl-r": "assistant::CycleMessageRole",

assets/settings/default.json 🔗

@@ -768,6 +768,9 @@
       // 5. Never show the scrollbar:
       //    "never"
       "show": null,
+      // Whether to allow horizontal scrolling in the project panel.
+      // When false, the view is locked to the leftmost position and long file names are clipped.
+      "horizontal_scroll": true,
     },
     // Which files containing diagnostic errors/warnings to mark in the project panel.
     // This setting can take the following three values:
@@ -1282,6 +1285,8 @@
     //   * "indexed": Use only the files Zed had indexed
     //   * "smart": Be smart and search for ignored when called from a gitignored worktree
     "include_ignored": "smart",
+    // Whether to include text channels in file finder results.
+    "include_channels": false,
   },
   // Whether or not to remove any trailing whitespace from lines of a buffer
   // before saving it.

crates/agent_servers/src/acp.rs 🔗

@@ -753,7 +753,7 @@ impl AgentConnection for AcpConnection {
         session_id: &acp::SessionId,
         cx: &mut App,
     ) -> Task<Result<()>> {
-        if !self.agent_capabilities.session_capabilities.close.is_none() {
+        if !self.supports_close_session() {
             return Task::ready(Err(anyhow!(LoadError::Other(
                 "Closing sessions is not supported by this agent.".into()
             ))));

crates/agent_ui/src/agent_configuration.rs 🔗

@@ -332,10 +332,11 @@ impl AgentConfiguration {
                             .full_width()
                             .style(ButtonStyle::Outlined)
                             .layer(ElevationIndex::ModalSurface)
-                            .icon_position(IconPosition::Start)
-                            .icon(IconName::Thread)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
+                            .start_icon(
+                                Icon::new(IconName::Thread)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .label_size(LabelSize::Small)
                             .on_click(cx.listener({
                                 let provider = provider.clone();
@@ -357,10 +358,11 @@ impl AgentConfiguration {
                                 )
                                 .full_width()
                                 .style(ButtonStyle::Outlined)
-                                .icon_position(IconPosition::Start)
-                                .icon(IconName::Trash)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
+                                .start_icon(
+                                    Icon::new(IconName::Trash)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .label_size(LabelSize::Small)
                                 .on_click(cx.listener({
                                     let provider = provider.clone();
@@ -426,10 +428,11 @@ impl AgentConfiguration {
             .trigger(
                 Button::new("add-provider", "Add Provider")
                     .style(ButtonStyle::Outlined)
-                    .icon_position(IconPosition::Start)
-                    .icon(IconName::Plus)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
+                    .start_icon(
+                        Icon::new(IconName::Plus)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .label_size(LabelSize::Small),
             )
             .menu({
@@ -525,10 +528,11 @@ impl AgentConfiguration {
             .trigger(
                 Button::new("add-server", "Add Server")
                     .style(ButtonStyle::Outlined)
-                    .icon_position(IconPosition::Start)
-                    .icon(IconName::Plus)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
+                    .start_icon(
+                        Icon::new(IconName::Plus)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .label_size(LabelSize::Small),
             )
             .menu({
@@ -970,10 +974,11 @@ impl AgentConfiguration {
             .trigger(
                 Button::new("add-agent", "Add Agent")
                     .style(ButtonStyle::Outlined)
-                    .icon_position(IconPosition::Start)
-                    .icon(IconName::Plus)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
+                    .start_icon(
+                        Icon::new(IconName::Plus)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .label_size(LabelSize::Small),
             )
             .menu({

crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs 🔗

@@ -340,10 +340,11 @@ impl AddLlmProviderModal {
                     .child(Label::new("Models").size(LabelSize::Small))
                     .child(
                         Button::new("add-model", "Add Model")
-                            .icon(IconName::Plus)
-                            .icon_position(IconPosition::Start)
-                            .icon_size(IconSize::XSmall)
-                            .icon_color(Color::Muted)
+                            .start_icon(
+                                Icon::new(IconName::Plus)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Muted),
+                            )
                             .label_size(LabelSize::Small)
                             .on_click(cx.listener(|this, _, window, cx| {
                                 this.input.add_model(window, cx);
@@ -446,10 +447,11 @@ impl AddLlmProviderModal {
             .when(has_more_than_one_model, |this| {
                 this.child(
                     Button::new(("remove-model", ix), "Remove Model")
-                        .icon(IconName::Trash)
-                        .icon_position(IconPosition::Start)
-                        .icon_size(IconSize::XSmall)
-                        .icon_color(Color::Muted)
+                        .start_icon(
+                            Icon::new(IconName::Trash)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
                         .label_size(LabelSize::Small)
                         .style(ButtonStyle::Outlined)
                         .full_width()

crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs 🔗

@@ -693,9 +693,11 @@ impl ConfigureContextServerModal {
                 {
                     Some(
                         Button::new("open-repository", "Open Repository")
-                            .icon(IconName::ArrowUpRight)
-                            .icon_color(Color::Muted)
-                            .icon_size(IconSize::Small)
+                            .end_icon(
+                                Icon::new(IconName::ArrowUpRight)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .tooltip({
                                 let repository_url = repository_url.clone();
                                 move |_window, cx| {

crates/agent_ui/src/agent_diff.rs 🔗

@@ -686,10 +686,11 @@ impl Render for AgentDiffPane {
                         .child(
                             Button::new("continue-iterating", "Continue Iterating")
                                 .style(ButtonStyle::Filled)
-                                .icon(IconName::ForwardArrow)
-                                .icon_position(IconPosition::Start)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
+                                .start_icon(
+                                    Icon::new(IconName::ForwardArrow)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .full_width()
                                 .key_binding(KeyBinding::for_action_in(
                                     &ToggleFocus,

crates/agent_ui/src/agent_model_selector.rs 🔗

@@ -9,7 +9,7 @@ use language_model::IconOrSvg;
 use picker::popover_menu::PickerPopoverMenu;
 use settings::update_settings_file;
 use std::sync::Arc;
-use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
+use ui::{PopoverMenuHandle, Tooltip, prelude::*};
 
 pub struct AgentModelSelector {
     selector: Entity<LanguageModelSelector>,
@@ -112,9 +112,11 @@ impl Render for AgentModelSelector {
 
         PickerPopoverMenu::new(
             self.selector.clone(),
-            ButtonLike::new("active-model")
+            Button::new("active-model", model_name)
+                .label_size(LabelSize::Small)
+                .color(color)
                 .when_some(provider_icon, |this, icon| {
-                    this.child(
+                    this.start_icon(
                         match icon {
                             IconOrSvg::Svg(path) => Icon::from_external_svg(path),
                             IconOrSvg::Icon(name) => Icon::new(name),
@@ -123,14 +125,7 @@ impl Render for AgentModelSelector {
                         .size(IconSize::XSmall),
                     )
                 })
-                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                .child(
-                    Label::new(model_name)
-                        .color(color)
-                        .size(LabelSize::Small)
-                        .ml_0p5(),
-                )
-                .child(
+                .end_icon(
                     Icon::new(IconName::ChevronDown)
                         .color(color)
                         .size(IconSize::XSmall),

crates/agent_ui/src/agent_panel.rs 🔗

@@ -79,9 +79,8 @@ use search::{BufferSearchBar, buffer_search};
 use settings::{Settings, update_settings_file};
 use theme::ThemeSettings;
 use ui::{
-    Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding,
-    PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*,
-    utils::WithRemSize,
+    Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
+    PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize,
 };
 use util::{ResultExt as _, debug_panic};
 use workspace::{
@@ -827,7 +826,7 @@ pub struct AgentPanel {
     zoomed: bool,
     pending_serialization: Option<Task<Result<()>>>,
     onboarding: Entity<AgentPanelOnboarding>,
-    selected_agent: AgentType,
+    selected_agent_type: AgentType,
     start_thread_in: StartThreadIn,
     worktree_creation_status: Option<WorktreeCreationStatus>,
     _thread_view_subscription: Option<Subscription>,
@@ -846,7 +845,7 @@ impl AgentPanel {
         };
 
         let width = self.width;
-        let selected_agent = self.selected_agent.clone();
+        let selected_agent_type = self.selected_agent_type.clone();
         let start_thread_in = Some(self.start_thread_in);
 
         let last_active_thread = self.active_agent_thread(cx).map(|thread| {
@@ -854,7 +853,7 @@ impl AgentPanel {
             let title = thread.title();
             SerializedActiveThread {
                 session_id: thread.session_id().0.to_string(),
-                agent_type: self.selected_agent.clone(),
+                agent_type: self.selected_agent_type.clone(),
                 title: if title.as_ref() != DEFAULT_THREAD_TITLE {
                     Some(title.to_string())
                 } else {
@@ -869,7 +868,7 @@ impl AgentPanel {
                 workspace_id,
                 SerializedAgentPanel {
                     width,
-                    selected_agent: Some(selected_agent),
+                    selected_agent: Some(selected_agent_type),
                     last_active_thread,
                     start_thread_in,
                 },
@@ -955,7 +954,7 @@ impl AgentPanel {
                     panel.update(cx, |panel, cx| {
                         panel.width = serialized_panel.width.map(|w| w.round());
                         if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
-                            panel.selected_agent = selected_agent;
+                            panel.selected_agent_type = selected_agent;
                         }
                         if let Some(start_thread_in) = serialized_panel.start_thread_in {
                             let is_worktree_flag_enabled =
@@ -983,8 +982,18 @@ impl AgentPanel {
                 if let Some(thread_info) = last_active_thread {
                     let agent_type = thread_info.agent_type.clone();
                     panel.update(cx, |panel, cx| {
-                        panel.selected_agent = agent_type;
-                        panel.load_agent_thread_inner(thread_info.session_id.into(), thread_info.cwd, thread_info.title.map(SharedString::from), false, window, cx);
+                        panel.selected_agent_type = agent_type;
+                        if let Some(agent) = panel.selected_agent() {
+                            panel.load_agent_thread(
+                                agent,
+                                thread_info.session_id.into(),
+                                thread_info.cwd,
+                                thread_info.title.map(SharedString::from),
+                                false,
+                                window,
+                                cx,
+                            );
+                        }
                     });
                 }
                 panel
@@ -1152,7 +1161,7 @@ impl AgentPanel {
             onboarding,
             text_thread_history,
             thread_store,
-            selected_agent: AgentType::default(),
+            selected_agent_type: AgentType::default(),
             start_thread_in: StartThreadIn::default(),
             worktree_creation_status: None,
             _thread_view_subscription: None,
@@ -1335,8 +1344,8 @@ impl AgentPanel {
             editor
         });
 
-        if self.selected_agent != AgentType::TextThread {
-            self.selected_agent = AgentType::TextThread;
+        if self.selected_agent_type != AgentType::TextThread {
+            self.selected_agent_type = AgentType::TextThread;
             self.serialize(cx);
         }
 
@@ -1396,7 +1405,7 @@ impl AgentPanel {
             .detach();
 
             let server = agent.server(fs, thread_store);
-            self.create_external_thread(
+            self.create_agent_thread(
                 server,
                 resume_session_id,
                 cwd,
@@ -1429,7 +1438,7 @@ impl AgentPanel {
 
                 let server = ext_agent.server(fs, thread_store);
                 this.update_in(cx, |agent_panel, window, cx| {
-                    agent_panel.create_external_thread(
+                    agent_panel.create_agent_thread(
                         server,
                         resume_session_id,
                         cwd,
@@ -1490,7 +1499,7 @@ impl AgentPanel {
     }
 
     fn has_history_for_selected_agent(&self, cx: &App) -> bool {
-        match &self.selected_agent {
+        match &self.selected_agent_type {
             AgentType::TextThread | AgentType::NativeAgent => true,
             AgentType::Custom { name } => {
                 let agent = Agent::Custom { name: name.clone() };
@@ -1507,7 +1516,7 @@ impl AgentPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Option<History> {
-        match &self.selected_agent {
+        match &self.selected_agent_type {
             AgentType::TextThread => Some(History::TextThreads),
             AgentType::NativeAgent => {
                 let history = self
@@ -1519,7 +1528,7 @@ impl AgentPanel {
                     .clone();
 
                 Some(History::AgentThreads {
-                    view: self.create_thread_history_view(history, window, cx),
+                    view: self.create_thread_history_view(Agent::NativeAgent, history, window, cx),
                 })
             }
             AgentType::Custom { name } => {
@@ -1533,7 +1542,7 @@ impl AgentPanel {
                     .clone();
                 if history.read(cx).has_session_list() {
                     Some(History::AgentThreads {
-                        view: self.create_thread_history_view(history, window, cx),
+                        view: self.create_thread_history_view(agent, history, window, cx),
                     })
                 } else {
                     None
@@ -1544,22 +1553,29 @@ impl AgentPanel {
 
     fn create_thread_history_view(
         &self,
+        agent: Agent,
         history: Entity<ThreadHistory>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Entity<ThreadHistoryView> {
         let view = cx.new(|cx| ThreadHistoryView::new(history.clone(), window, cx));
-        cx.subscribe_in(&view, window, |this, _, event, window, cx| match event {
-            ThreadHistoryViewEvent::Open(thread) => {
-                this.load_agent_thread(
-                    thread.session_id.clone(),
-                    thread.cwd.clone(),
-                    thread.title.clone(),
-                    window,
-                    cx,
-                );
-            }
-        })
+        cx.subscribe_in(
+            &view,
+            window,
+            move |this, _, event, window, cx| match event {
+                ThreadHistoryViewEvent::Open(thread) => {
+                    this.load_agent_thread(
+                        agent.clone(),
+                        thread.session_id.clone(),
+                        thread.cwd.clone(),
+                        thread.title.clone(),
+                        true,
+                        window,
+                        cx,
+                    );
+                }
+            },
+        )
         .detach();
         view
     }
@@ -1623,8 +1639,8 @@ impl AgentPanel {
             )
         });
 
-        if self.selected_agent != AgentType::TextThread {
-            self.selected_agent = AgentType::TextThread;
+        if self.selected_agent_type != AgentType::TextThread {
+            self.selected_agent_type = AgentType::TextThread;
             self.serialize(cx);
         }
 
@@ -2198,13 +2214,17 @@ impl AgentPanel {
                             let entry = entry.clone();
                             panel
                                 .update(cx, move |this, cx| {
-                                    this.load_agent_thread(
-                                        entry.session_id.clone(),
-                                        entry.cwd.clone(),
-                                        entry.title.clone(),
-                                        window,
-                                        cx,
-                                    );
+                                    if let Some(agent) = this.selected_agent() {
+                                        this.load_agent_thread(
+                                            agent,
+                                            entry.session_id.clone(),
+                                            entry.cwd.clone(),
+                                            entry.title.clone(),
+                                            true,
+                                            window,
+                                            cx,
+                                        );
+                                    }
                                 })
                                 .ok();
                         }
@@ -2254,10 +2274,6 @@ impl AgentPanel {
         menu.separator()
     }
 
-    pub fn selected_agent(&self) -> AgentType {
-        self.selected_agent.clone()
-    }
-
     fn subscribe_to_active_thread_view(
         server_view: &Entity<ConnectionView>,
         window: &mut Window,
@@ -2328,8 +2344,8 @@ impl AgentPanel {
         }
     }
 
-    fn selected_external_agent(&self) -> Option<Agent> {
-        match &self.selected_agent {
+    pub(crate) fn selected_agent(&self) -> Option<Agent> {
+        match &self.selected_agent_type {
             AgentType::NativeAgent => Some(Agent::NativeAgent),
             AgentType::Custom { name } => Some(Agent::Custom { name: name.clone() }),
             AgentType::TextThread => None,
@@ -2425,17 +2441,7 @@ impl AgentPanel {
 
     pub fn load_agent_thread(
         &mut self,
-        session_id: acp::SessionId,
-        cwd: Option<PathBuf>,
-        title: Option<SharedString>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        self.load_agent_thread_inner(session_id, cwd, title, true, window, cx);
-    }
-
-    fn load_agent_thread_inner(
-        &mut self,
+        agent: Agent,
         session_id: acp::SessionId,
         cwd: Option<PathBuf>,
         title: Option<SharedString>,
@@ -2473,9 +2479,6 @@ impl AgentPanel {
             }
         }
 
-        let Some(agent) = self.selected_external_agent() else {
-            return;
-        };
         self.external_thread(
             Some(agent),
             Some(session_id),
@@ -2488,7 +2491,7 @@ impl AgentPanel {
         );
     }
 
-    pub(crate) fn create_external_thread(
+    pub(crate) fn create_agent_thread(
         &mut self,
         server: Rc<dyn AgentServer>,
         resume_session_id: Option<acp::SessionId>,
@@ -2503,8 +2506,8 @@ impl AgentPanel {
         cx: &mut Context<Self>,
     ) {
         let selected_agent = AgentType::from(ext_agent.clone());
-        if self.selected_agent != selected_agent {
-            self.selected_agent = selected_agent;
+        if self.selected_agent_type != selected_agent {
+            self.selected_agent_type = selected_agent;
             self.serialize(cx);
         }
         let thread_store = server
@@ -2757,8 +2760,8 @@ impl AgentPanel {
     ) {
         self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
         if matches!(self.active_view, ActiveView::Uninitialized) {
-            let selected_agent = self.selected_agent.clone();
-            self.new_agent_thread(selected_agent, window, cx);
+            let selected_agent_type = self.selected_agent_type.clone();
+            self.new_agent_thread(selected_agent_type, window, cx);
         }
         cx.notify();
     }
@@ -3150,8 +3153,8 @@ impl Panel for AgentPanel {
                 Some(WorktreeCreationStatus::Creating)
             )
         {
-            let selected_agent = self.selected_agent.clone();
-            self.new_agent_thread_inner(selected_agent, false, window, cx);
+            let selected_agent_type = self.selected_agent_type.clone();
+            self.new_agent_thread_inner(selected_agent_type, false, window, cx);
         }
     }
 
@@ -3560,11 +3563,7 @@ impl AgentPanel {
         };
 
         let trigger_button = Button::new("thread-target-trigger", trigger_label)
-            .icon(icon)
-            .icon_size(IconSize::XSmall)
-            .icon_position(IconPosition::End)
-            .icon_color(Color::Muted)
-            .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+            .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
             .disabled(is_creating);
 
         let dock_position = AgentSettings::get_global(cx).dock;
@@ -3682,16 +3681,16 @@ impl AgentPanel {
         let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
 
         let (selected_agent_custom_icon, selected_agent_label) =
-            if let AgentType::Custom { name, .. } = &self.selected_agent {
+            if let AgentType::Custom { name, .. } = &self.selected_agent_type {
                 let store = agent_server_store.read(cx);
                 let icon = store.agent_icon(&ExternalAgentServerName(name.clone()));
 
                 let label = store
                     .agent_display_name(&ExternalAgentServerName(name.clone()))
-                    .unwrap_or_else(|| self.selected_agent.label());
+                    .unwrap_or_else(|| self.selected_agent_type.label());
                 (icon, label)
             } else {
-                (None, self.selected_agent.label())
+                (None, self.selected_agent_type.label())
             };
 
         let active_thread = match &self.active_view {
@@ -3705,7 +3704,7 @@ impl AgentPanel {
         let new_thread_menu_builder: Rc<
             dyn Fn(&mut Window, &mut App) -> Option<Entity<ContextMenu>>,
         > = {
-            let selected_agent = self.selected_agent.clone();
+            let selected_agent = self.selected_agent_type.clone();
             let is_agent_selected = move |agent_type: AgentType| selected_agent == agent_type;
 
             let workspace = self.workspace.clone();
@@ -4021,7 +4020,7 @@ impl AgentPanel {
 
         let has_custom_icon = selected_agent_custom_icon.is_some();
         let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone();
-        let selected_agent_builtin_icon = self.selected_agent.icon();
+        let selected_agent_builtin_icon = self.selected_agent_type.icon();
         let selected_agent_label_for_tooltip = selected_agent_label.clone();
 
         let selected_agent = div()
@@ -4031,7 +4030,7 @@ impl AgentPanel {
                     .child(Icon::from_external_svg(icon_path).color(Color::Muted))
             })
             .when(!has_custom_icon, |this| {
-                this.when_some(self.selected_agent.icon(), |this, icon| {
+                this.when_some(self.selected_agent_type.icon(), |this, icon| {
                     this.px_1().child(Icon::new(icon).color(Color::Muted))
                 })
             })
@@ -4091,32 +4090,22 @@ impl AgentPanel {
                     (IconName::ChevronDown, Color::Muted, Color::Default)
                 };
 
-            let agent_icon_element: AnyElement =
-                if let Some(icon_path) = selected_agent_custom_icon_for_button {
-                    Icon::from_external_svg(icon_path)
-                        .size(IconSize::Small)
-                        .color(icon_color)
-                        .into_any_element()
-                } else {
-                    let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
-                    Icon::new(icon_name)
-                        .size(IconSize::Small)
-                        .color(icon_color)
-                        .into_any_element()
-                };
+            let agent_icon = if let Some(icon_path) = selected_agent_custom_icon_for_button {
+                Icon::from_external_svg(icon_path)
+                    .size(IconSize::Small)
+                    .color(icon_color)
+            } else {
+                let icon_name = selected_agent_builtin_icon.unwrap_or(IconName::ZedAgent);
+                Icon::new(icon_name).size(IconSize::Small).color(icon_color)
+            };
 
-            let agent_selector_button = ButtonLike::new("agent-selector-trigger")
-                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                .child(
-                    h_flex()
-                        .gap_1()
-                        .child(agent_icon_element)
-                        .child(Label::new(selected_agent_label).color(label_color).ml_0p5())
-                        .child(
-                            Icon::new(chevron_icon)
-                                .color(icon_color)
-                                .size(IconSize::XSmall),
-                        ),
+            let agent_selector_button = Button::new("agent-selector-trigger", selected_agent_label)
+                .start_icon(agent_icon)
+                .color(label_color)
+                .end_icon(
+                    Icon::new(chevron_icon)
+                        .color(icon_color)
+                        .size(IconSize::XSmall),
                 );
 
             let agent_selector_menu = PopoverMenu::new("new_thread_menu")
@@ -4993,7 +4982,7 @@ impl AgentPanel {
             name: server.name(),
         };
 
-        self.create_external_thread(
+        self.create_agent_thread(
             server, None, None, None, None, workspace, project, ext_agent, true, window, cx,
         );
     }
@@ -5141,7 +5130,7 @@ mod tests {
             );
         });
 
-        let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
+        let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent_type.clone());
 
         // --- Set up workspace B: ClaudeCode, width=400, no active thread ---
         let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
@@ -5151,7 +5140,7 @@ mod tests {
 
         panel_b.update(cx, |panel, _cx| {
             panel.width = Some(px(400.0));
-            panel.selected_agent = AgentType::Custom {
+            panel.selected_agent_type = AgentType::Custom {
                 name: "claude-acp".into(),
             };
         });
@@ -5184,7 +5173,7 @@ mod tests {
                 "workspace A width should be restored"
             );
             assert_eq!(
-                panel.selected_agent, agent_type_a,
+                panel.selected_agent_type, agent_type_a,
                 "workspace A agent type should be restored"
             );
             assert!(
@@ -5201,7 +5190,7 @@ mod tests {
                 "workspace B width should be restored"
             );
             assert_eq!(
-                panel.selected_agent,
+                panel.selected_agent_type,
                 AgentType::Custom {
                     name: "claude-acp".into()
                 },
@@ -5685,7 +5674,15 @@ mod tests {
 
         // Load thread A back via load_agent_thread — should promote from background.
         panel.update_in(&mut cx, |panel, window, cx| {
-            panel.load_agent_thread(session_id_a.clone(), None, None, window, cx);
+            panel.load_agent_thread(
+                panel.selected_agent().expect("selected agent must be set"),
+                session_id_a.clone(),
+                None,
+                None,
+                true,
+                window,
+                cx,
+            );
         });
 
         // Thread A should now be the active view, promoted from background.

crates/agent_ui/src/agent_registry_ui.rs 🔗

@@ -467,10 +467,11 @@ impl AgentRegistryPage {
                 let agent_id = agent.id().to_string();
                 Button::new(button_id, "Install")
                     .style(ButtonStyle::Tinted(ui::TintColor::Accent))
-                    .icon(IconName::Download)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
-                    .icon_position(IconPosition::Start)
+                    .start_icon(
+                        Icon::new(IconName::Download)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .on_click(move |_, _, cx| {
                         let agent_id = agent_id.clone();
                         update_settings_file(fs.clone(), cx, move |settings, _| {
@@ -541,9 +542,11 @@ impl Render for AgentRegistryPage {
                                 Button::new("learn-more", "Learn More")
                                     .style(ButtonStyle::Outlined)
                                     .size(ButtonSize::Medium)
-                                    .icon(IconName::ArrowUpRight)
-                                    .icon_color(Color::Muted)
-                                    .icon_size(IconSize::Small)
+                                    .end_icon(
+                                        Icon::new(IconName::ArrowUpRight)
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    )
                                     .on_click(move |_, _, cx| {
                                         cx.open_url(&zed_urls::acp_registry_blog(cx))
                                     }),

crates/agent_ui/src/config_options.rs 🔗

@@ -350,10 +350,7 @@ impl ConfigOptionSelector {
         )
         .label_size(LabelSize::Small)
         .color(Color::Muted)
-        .icon(icon)
-        .icon_size(IconSize::XSmall)
-        .icon_position(IconPosition::End)
-        .icon_color(Color::Muted)
+        .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
         .disabled(self.setting_value)
     }
 }

crates/agent_ui/src/connection_view.rs 🔗

@@ -462,10 +462,13 @@ impl ConnectedServerState {
     }
 
     pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> {
-        let tasks = self
-            .threads
-            .keys()
-            .map(|id| self.connection.clone().close_session(id, cx));
+        let tasks = self.threads.keys().filter_map(|id| {
+            if self.connection.supports_close_session() {
+                Some(self.connection.clone().close_session(id, cx))
+            } else {
+                None
+            }
+        });
         let task = futures::future::join_all(tasks);
         cx.background_spawn(async move {
             task.await;
@@ -6535,4 +6538,228 @@ pub(crate) mod tests {
             "Main editor should have existing content and queued message separated by two newlines"
         );
     }
+
+    #[gpui::test]
+    async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+
+        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
+        let connection_store =
+            cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
+
+        // StubAgentConnection defaults to supports_close_session() -> false
+        let thread_view = cx.update(|window, cx| {
+            cx.new(|cx| {
+                ConnectionView::new(
+                    Rc::new(StubAgentServer::default_response()),
+                    connection_store,
+                    Agent::Custom {
+                        name: "Test".into(),
+                    },
+                    None,
+                    None,
+                    None,
+                    None,
+                    workspace.downgrade(),
+                    project,
+                    Some(thread_store),
+                    None,
+                    window,
+                    cx,
+                )
+            })
+        });
+
+        cx.run_until_parked();
+
+        thread_view.read_with(cx, |view, _cx| {
+            let connected = view.as_connected().expect("Should be connected");
+            assert!(
+                !connected.threads.is_empty(),
+                "There should be at least one thread"
+            );
+            assert!(
+                !connected.connection.supports_close_session(),
+                "StubAgentConnection should not support close"
+            );
+        });
+
+        thread_view
+            .update(cx, |view, cx| {
+                view.as_connected()
+                    .expect("Should be connected")
+                    .close_all_sessions(cx)
+            })
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_close_all_sessions_calls_close_when_supported(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let (thread_view, cx) =
+            setup_thread_view(StubAgentServer::new(CloseCapableConnection::new()), cx).await;
+
+        cx.run_until_parked();
+
+        let close_capable = thread_view.read_with(cx, |view, _cx| {
+            let connected = view.as_connected().expect("Should be connected");
+            assert!(
+                !connected.threads.is_empty(),
+                "There should be at least one thread"
+            );
+            assert!(
+                connected.connection.supports_close_session(),
+                "CloseCapableConnection should support close"
+            );
+            connected
+                .connection
+                .clone()
+                .into_any()
+                .downcast::<CloseCapableConnection>()
+                .expect("Should be CloseCapableConnection")
+        });
+
+        thread_view
+            .update(cx, |view, cx| {
+                view.as_connected()
+                    .expect("Should be connected")
+                    .close_all_sessions(cx)
+            })
+            .await;
+
+        let closed_count = close_capable.closed_sessions.lock().len();
+        assert!(
+            closed_count > 0,
+            "close_session should have been called for each thread"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_close_session_returns_error_when_unsupported(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
+
+        cx.run_until_parked();
+
+        let result = thread_view
+            .update(cx, |view, cx| {
+                let connected = view.as_connected().expect("Should be connected");
+                assert!(
+                    !connected.connection.supports_close_session(),
+                    "StubAgentConnection should not support close"
+                );
+                let session_id = connected
+                    .threads
+                    .keys()
+                    .next()
+                    .expect("Should have at least one thread")
+                    .clone();
+                connected.connection.clone().close_session(&session_id, cx)
+            })
+            .await;
+
+        assert!(
+            result.is_err(),
+            "close_session should return an error when close is not supported"
+        );
+        assert!(
+            result.unwrap_err().to_string().contains("not supported"),
+            "Error message should indicate that closing is not supported"
+        );
+    }
+
+    #[derive(Clone)]
+    struct CloseCapableConnection {
+        closed_sessions: Arc<Mutex<Vec<acp::SessionId>>>,
+    }
+
+    impl CloseCapableConnection {
+        fn new() -> Self {
+            Self {
+                closed_sessions: Arc::new(Mutex::new(Vec::new())),
+            }
+        }
+    }
+
+    impl AgentConnection for CloseCapableConnection {
+        fn telemetry_id(&self) -> SharedString {
+            "close-capable".into()
+        }
+
+        fn new_session(
+            self: Rc<Self>,
+            project: Entity<Project>,
+            cwd: &Path,
+            cx: &mut gpui::App,
+        ) -> Task<gpui::Result<Entity<AcpThread>>> {
+            let action_log = cx.new(|_| ActionLog::new(project.clone()));
+            let thread = cx.new(|cx| {
+                AcpThread::new(
+                    None,
+                    "CloseCapableConnection",
+                    Some(cwd.to_path_buf()),
+                    self,
+                    project,
+                    action_log,
+                    SessionId::new("close-capable-session"),
+                    watch::Receiver::constant(
+                        acp::PromptCapabilities::new()
+                            .image(true)
+                            .audio(true)
+                            .embedded_context(true),
+                    ),
+                    cx,
+                )
+            });
+            Task::ready(Ok(thread))
+        }
+
+        fn supports_close_session(&self) -> bool {
+            true
+        }
+
+        fn close_session(
+            self: Rc<Self>,
+            session_id: &acp::SessionId,
+            _cx: &mut App,
+        ) -> Task<Result<()>> {
+            self.closed_sessions.lock().push(session_id.clone());
+            Task::ready(Ok(()))
+        }
+
+        fn auth_methods(&self) -> &[acp::AuthMethod] {
+            &[]
+        }
+
+        fn authenticate(
+            &self,
+            _method_id: acp::AuthMethodId,
+            _cx: &mut App,
+        ) -> Task<gpui::Result<()>> {
+            Task::ready(Ok(()))
+        }
+
+        fn prompt(
+            &self,
+            _id: Option<acp_thread::UserMessageId>,
+            _params: acp::PromptRequest,
+            _cx: &mut App,
+        ) -> Task<gpui::Result<acp::PromptResponse>> {
+            Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
+        }
+
+        fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
+
+        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+            self
+        }
+    }
 }

crates/agent_ui/src/connection_view/thread_view.rs 🔗

@@ -3557,6 +3557,7 @@ impl ThreadView {
         let message_editor = self.message_editor.clone();
         let workspace = self.workspace.clone();
         let supports_images = self.prompt_capabilities.borrow().image;
+        let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
 
         let has_editor_selection = workspace
             .upgrade()
@@ -3672,6 +3673,20 @@ impl ThreadView {
                             }
                         }),
                 )
+                .item(
+                    ContextMenuEntry::new("Branch Diff")
+                        .icon(IconName::GitBranch)
+                        .icon_color(Color::Muted)
+                        .icon_size(IconSize::XSmall)
+                        .disabled(!supports_embedded_context)
+                        .handler({
+                            move |window, cx| {
+                                message_editor.update(cx, |editor, cx| {
+                                    editor.insert_branch_diff_crease(window, cx);
+                                });
+                            }
+                        }),
+                )
         })
     }
 
@@ -3811,11 +3826,8 @@ impl ThreadView {
                                 .child(Divider::horizontal())
                                 .child(
                                     Button::new("restore-checkpoint", "Restore Checkpoint")
-                                        .icon(IconName::Undo)
-                                        .icon_size(IconSize::XSmall)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(Icon::new(IconName::Undo).size(IconSize::XSmall).color(Color::Muted))
                                         .label_size(LabelSize::XSmall)
-                                        .icon_color(Color::Muted)
                                         .color(Color::Muted)
                                         .tooltip(Tooltip::text("Restores all files in the project to the content they had at this point in the conversation."))
                                         .on_click(cx.listener(move |this, _, _window, cx| {
@@ -5768,10 +5780,11 @@ impl ThreadView {
                     .gap_0p5()
                     .child(
                         Button::new(("allow-btn", entry_ix), "Allow")
-                            .icon(IconName::Check)
-                            .icon_color(Color::Success)
-                            .icon_position(IconPosition::Start)
-                            .icon_size(IconSize::XSmall)
+                            .start_icon(
+                                Icon::new(IconName::Check)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Success),
+                            )
                             .label_size(LabelSize::Small)
                             .when(is_first, |this| {
                                 this.key_binding(
@@ -5802,10 +5815,11 @@ impl ThreadView {
                     )
                     .child(
                         Button::new(("deny-btn", entry_ix), "Deny")
-                            .icon(IconName::Close)
-                            .icon_color(Color::Error)
-                            .icon_position(IconPosition::Start)
-                            .icon_size(IconSize::XSmall)
+                            .start_icon(
+                                Icon::new(IconName::Close)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Error),
+                            )
                             .label_size(LabelSize::Small)
                             .when(is_first, |this| {
                                 this.key_binding(
@@ -5872,9 +5886,11 @@ impl ThreadView {
             .with_handle(permission_dropdown_handle)
             .trigger(
                 Button::new(("granularity-trigger", entry_ix), current_label)
-                    .icon(IconName::ChevronDown)
-                    .icon_size(IconSize::XSmall)
-                    .icon_color(Color::Muted)
+                    .end_icon(
+                        Icon::new(IconName::ChevronDown)
+                            .size(IconSize::XSmall)
+                            .color(Color::Muted),
+                    )
                     .label_size(LabelSize::Small)
                     .when(is_first, |this| {
                         this.key_binding(
@@ -5947,24 +5963,35 @@ impl ThreadView {
                 let option_id = SharedString::from(option.option_id.0.clone());
                 Button::new((option_id, entry_ix), option.name.clone())
                     .map(|this| {
-                        let (this, action) = match option.kind {
+                        let (icon, action) = match option.kind {
                             acp::PermissionOptionKind::AllowOnce => (
-                                this.icon(IconName::Check).icon_color(Color::Success),
+                                Icon::new(IconName::Check)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Success),
                                 Some(&AllowOnce as &dyn Action),
                             ),
                             acp::PermissionOptionKind::AllowAlways => (
-                                this.icon(IconName::CheckDouble).icon_color(Color::Success),
+                                Icon::new(IconName::CheckDouble)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Success),
                                 Some(&AllowAlways as &dyn Action),
                             ),
                             acp::PermissionOptionKind::RejectOnce => (
-                                this.icon(IconName::Close).icon_color(Color::Error),
+                                Icon::new(IconName::Close)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Error),
                                 Some(&RejectOnce as &dyn Action),
                             ),
-                            acp::PermissionOptionKind::RejectAlways | _ => {
-                                (this.icon(IconName::Close).icon_color(Color::Error), None)
-                            }
+                            acp::PermissionOptionKind::RejectAlways | _ => (
+                                Icon::new(IconName::Close)
+                                    .size(IconSize::XSmall)
+                                    .color(Color::Error),
+                                None,
+                            ),
                         };
 
+                        let this = this.start_icon(icon);
+
                         let Some(action) = action else {
                             return this;
                         };
@@ -5980,8 +6007,6 @@ impl ThreadView {
                                 .map(|kb| kb.size(rems_from_px(10.))),
                         )
                     })
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::XSmall)
                     .label_size(LabelSize::Small)
                     .on_click(cx.listener({
                         let session_id = session_id.clone();
@@ -6358,9 +6383,11 @@ impl ThreadView {
                     .color(Color::Muted)
                     .truncate(true)
                     .when(is_file.is_none(), |this| {
-                        this.icon(IconName::ArrowUpRight)
-                            .icon_size(IconSize::XSmall)
-                            .icon_color(Color::Muted)
+                        this.end_icon(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
                     })
                     .on_click(cx.listener({
                         let workspace = self.workspace.clone();
@@ -7455,19 +7482,16 @@ impl ThreadView {
             .title("Codex on Windows")
             .description("For best performance, run Codex in Windows Subsystem for Linux (WSL2)")
             .actions_slot(
-                Button::new("open-wsl-modal", "Open in WSL")
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
-                    .on_click(cx.listener({
-                        move |_, _, _window, cx| {
-                            #[cfg(windows)]
-                            _window.dispatch_action(
-                                zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
-                                cx,
-                            );
-                            cx.notify();
-                        }
-                    })),
+                Button::new("open-wsl-modal", "Open in WSL").on_click(cx.listener({
+                    move |_, _, _window, cx| {
+                        #[cfg(windows)]
+                        _window.dispatch_action(
+                            zed_actions::wsl_actions::OpenWsl::default().boxed_clone(),
+                            cx,
+                        );
+                        cx.notify();
+                    }
+                })),
             )
             .dismiss_action(
                 IconButton::new("dismiss", IconName::Close)

crates/agent_ui/src/inline_prompt_editor.rs 🔗

@@ -796,9 +796,11 @@ impl<T: 'static> PromptEditor<T> {
                 vec![
                     Button::new("start", mode.start_label())
                         .label_size(LabelSize::Small)
-                        .icon(IconName::Return)
-                        .icon_size(IconSize::XSmall)
-                        .icon_color(Color::Muted)
+                        .end_icon(
+                            Icon::new(IconName::Return)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
                         .on_click(
                             cx.listener(|_, _, _, cx| cx.emit(PromptEditorEvent::StartRequested)),
                         )

crates/agent_ui/src/mention_set.rs 🔗

@@ -604,7 +604,7 @@ impl MentionSet {
         })
     }
 
-    fn confirm_mention_for_git_diff(
+    pub fn confirm_mention_for_git_diff(
         &self,
         base_ref: SharedString,
         cx: &mut Context<Self>,

crates/agent_ui/src/message_editor.rs 🔗

@@ -33,7 +33,7 @@ use rope::Point;
 use settings::Settings;
 use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc};
 use theme::ThemeSettings;
-use ui::{ButtonLike, ButtonStyle, ContextMenu, Disclosure, ElevationIndex, prelude::*};
+use ui::{ContextMenu, Disclosure, ElevationIndex, prelude::*};
 use util::paths::PathStyle;
 use util::{ResultExt, debug_panic};
 use workspace::{CollaboratorId, Workspace};
@@ -1041,6 +1041,88 @@ impl MessageEditor {
         });
     }
 
+    pub fn insert_branch_diff_crease(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        let project = workspace.read(cx).project().clone();
+
+        let Some(repo) = project.read(cx).active_repository(cx) else {
+            return;
+        };
+
+        let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false));
+        let editor = self.editor.clone();
+        let mention_set = self.mention_set.clone();
+        let weak_workspace = self.workspace.clone();
+
+        window
+            .spawn(cx, async move |cx| {
+                let base_ref: SharedString = default_branch_receiver
+                    .await
+                    .ok()
+                    .and_then(|r| r.ok())
+                    .flatten()
+                    .ok_or_else(|| anyhow!("Could not determine default branch"))?;
+
+                cx.update(|window, cx| {
+                    let mention_uri = MentionUri::GitDiff {
+                        base_ref: base_ref.to_string(),
+                    };
+                    let mention_text = mention_uri.as_link().to_string();
+
+                    let (excerpt_id, text_anchor, content_len) = editor.update(cx, |editor, cx| {
+                        let buffer = editor.buffer().read(cx);
+                        let snapshot = buffer.snapshot(cx);
+                        let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
+                        let text_anchor = editor
+                            .selections
+                            .newest_anchor()
+                            .start
+                            .text_anchor
+                            .bias_left(&buffer_snapshot);
+
+                        editor.insert(&mention_text, window, cx);
+                        editor.insert(" ", window, cx);
+
+                        (excerpt_id, text_anchor, mention_text.len())
+                    });
+
+                    let Some((crease_id, tx)) = insert_crease_for_mention(
+                        excerpt_id,
+                        text_anchor,
+                        content_len,
+                        mention_uri.name().into(),
+                        mention_uri.icon_path(cx),
+                        mention_uri.tooltip_text(),
+                        Some(mention_uri.clone()),
+                        Some(weak_workspace),
+                        None,
+                        editor,
+                        window,
+                        cx,
+                    ) else {
+                        return;
+                    };
+                    drop(tx);
+
+                    let confirm_task = mention_set.update(cx, |mention_set, cx| {
+                        mention_set.confirm_mention_for_git_diff(base_ref, cx)
+                    });
+
+                    let mention_task = cx
+                        .spawn(async move |_cx| confirm_task.await.map_err(|e| e.to_string()))
+                        .shared();
+
+                    mention_set.update(cx, |mention_set, _| {
+                        mention_set.insert_mention(crease_id, mention_uri, mention_task);
+                    });
+                })
+            })
+            .detach_and_log_err(cx);
+    }
+
     fn insert_crease_impl(
         &mut self,
         text: String,
@@ -1079,11 +1161,9 @@ impl MessageEditor {
                 render: Arc::new({
                     let title = title.clone();
                     move |_fold_id, _fold_range, _cx| {
-                        ButtonLike::new("crease")
-                            .style(ButtonStyle::Filled)
+                        Button::new("crease", title.clone())
                             .layer(ElevationIndex::ElevatedSurface)
-                            .child(Icon::new(icon))
-                            .child(Label::new(title.clone()).single_line())
+                            .start_icon(Icon::new(icon))
                             .into_any_element()
                     }
                 }),

crates/agent_ui/src/mode_selector.rs 🔗

@@ -169,10 +169,7 @@ impl Render for ModeSelector {
         let trigger_button = Button::new("mode-selector-trigger", current_mode_name)
             .label_size(LabelSize::Small)
             .color(Color::Muted)
-            .icon(icon)
-            .icon_size(IconSize::XSmall)
-            .icon_position(IconPosition::End)
-            .icon_color(Color::Muted)
+            .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
             .disabled(self.setting_mode);
 
         PopoverMenu::new("mode-selector")

crates/agent_ui/src/model_selector_popover.rs 🔗

@@ -5,7 +5,7 @@ use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector};
 use fs::Fs;
 use gpui::{AnyView, Entity, FocusHandle};
 use picker::popover_menu::PickerPopoverMenu;
-use ui::{ButtonLike, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
+use ui::{PopoverMenuHandle, Tooltip, prelude::*};
 
 use crate::ui::ModelSelectorTooltip;
 use crate::{ModelSelector, model_selector::acp_model_selector};
@@ -96,11 +96,12 @@ impl Render for ModelSelectorPopover {
 
         PickerPopoverMenu::new(
             self.selector.clone(),
-            ButtonLike::new("active-model")
+            Button::new("active-model", model_name)
+                .label_size(LabelSize::Small)
+                .color(color)
                 .disabled(self.disabled)
-                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                 .when_some(model_icon, |this, icon| {
-                    this.child(
+                    this.start_icon(
                         match icon {
                             AgentModelIcon::Path(path) => Icon::from_external_svg(path),
                             AgentModelIcon::Named(icon_name) => Icon::new(icon_name),
@@ -109,13 +110,7 @@ impl Render for ModelSelectorPopover {
                         .size(IconSize::XSmall),
                     )
                 })
-                .child(
-                    Label::new(model_name)
-                        .color(color)
-                        .size(LabelSize::Small)
-                        .ml_0p5(),
-                )
-                .child(
+                .end_icon(
                     Icon::new(icon)
                         .map(|this| {
                             if self.disabled {

crates/agent_ui/src/profile_selector.rs 🔗

@@ -16,7 +16,7 @@ use std::{
 };
 use ui::{
     DocumentationAside, DocumentationSide, HighlightedLabel, KeyBinding, LabelSize, ListItem,
-    ListItemSpacing, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
+    ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*,
 };
 
 /// Trait for types that can provide and manage agent profiles
@@ -192,11 +192,7 @@ impl Render for ProfileSelector {
             .disabled(self.disabled)
             .label_size(LabelSize::Small)
             .color(Color::Muted)
-            .icon(icon)
-            .icon_size(IconSize::XSmall)
-            .icon_position(IconPosition::End)
-            .icon_color(Color::Muted)
-            .selected_style(ButtonStyle::Tinted(TintColor::Accent));
+            .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
 
         let disabled = self.disabled;
 

crates/agent_ui/src/sidebar.rs 🔗

@@ -1,5 +1,5 @@
 use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
-use crate::{AgentPanel, AgentPanelEvent, NewThread};
+use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread};
 use acp_thread::ThreadStatus;
 use action_log::DiffStats;
 use agent::ThreadStore;
@@ -111,6 +111,7 @@ enum ThreadEntryWorkspace {
 
 #[derive(Clone)]
 struct ThreadEntry {
+    agent: Agent,
     session_info: acp_thread::AgentSessionInfo,
     icon: IconName,
     icon_from_external_svg: Option<SharedString>,
@@ -196,7 +197,7 @@ fn root_repository_snapshots(
     workspace: &Entity<Workspace>,
     cx: &App,
 ) -> Vec<project::git_store::RepositorySnapshot> {
-    let (path_list, _) = workspace_path_list_and_label(workspace, cx);
+    let path_list = workspace_path_list(workspace, cx);
     let project = workspace.read(cx).project().read(cx);
     project
         .repositories(cx)
@@ -212,34 +213,23 @@ fn root_repository_snapshots(
         .collect()
 }
 
-fn workspace_path_list_and_label(
-    workspace: &Entity<Workspace>,
-    cx: &App,
-) -> (PathList, SharedString) {
-    let workspace_ref = workspace.read(cx);
-    let mut paths = Vec::new();
-    let mut names = Vec::new();
-
-    for worktree in workspace_ref.worktrees(cx) {
-        let worktree_ref = worktree.read(cx);
-        if !worktree_ref.is_visible() {
-            continue;
-        }
-        let abs_path = worktree_ref.abs_path();
-        paths.push(abs_path.to_path_buf());
+fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
+    PathList::new(&workspace.read(cx).root_paths(cx))
+}
+
+fn workspace_label_from_path_list(path_list: &PathList) -> SharedString {
+    let mut names = Vec::with_capacity(path_list.paths().len());
+    for abs_path in path_list.paths() {
         if let Some(name) = abs_path.file_name() {
             names.push(name.to_string_lossy().to_string());
         }
     }
-
-    let label: SharedString = if names.is_empty() {
+    if names.is_empty() {
         // TODO: Can we do something better in this case?
         "Empty Workspace".into()
     } else {
         names.join(", ").into()
-    };
-
-    (PathList::new(&paths), label)
+    }
 }
 
 pub struct Sidebar {
@@ -599,7 +589,8 @@ impl Sidebar {
                 continue;
             }
 
-            let (path_list, label) = workspace_path_list_and_label(workspace, cx);
+            let path_list = workspace_path_list(workspace, cx);
+            let label = workspace_label_from_path_list(&path_list);
 
             let is_collapsed = self.collapsed_groups.contains(&path_list);
             let should_load_threads = !is_collapsed || !query.is_empty();
@@ -613,6 +604,7 @@ impl Sidebar {
                     for meta in thread_store.read(cx).threads_for_paths(&path_list) {
                         seen_session_ids.insert(meta.id.clone());
                         threads.push(ThreadEntry {
+                            agent: Agent::NativeAgent,
                             session_info: meta.into(),
                             icon: IconName::ZedAgent,
                             icon_from_external_svg: None,
@@ -665,6 +657,7 @@ impl Sidebar {
                                 continue;
                             }
                             threads.push(ThreadEntry {
+                                agent: Agent::NativeAgent,
                                 session_info: meta.into(),
                                 icon: IconName::ZedAgent,
                                 icon_from_external_svg: None,
@@ -1243,7 +1236,7 @@ impl Sidebar {
         // contains other folders.
         let mut to_remove: Vec<Entity<Workspace>> = Vec::new();
         for workspace in &workspaces {
-            let (path_list, _) = workspace_path_list_and_label(workspace, cx);
+            let path_list = workspace_path_list(workspace, cx);
             if path_list.paths().len() != 1 {
                 continue;
             }
@@ -1391,10 +1384,17 @@ impl Sidebar {
                 match &thread.workspace {
                     ThreadEntryWorkspace::Open(workspace) => {
                         let workspace = workspace.clone();
-                        self.activate_thread(session_info, &workspace, window, cx);
+                        self.activate_thread(
+                            thread.agent.clone(),
+                            session_info,
+                            &workspace,
+                            window,
+                            cx,
+                        );
                     }
                     ThreadEntryWorkspace::Closed(path_list) => {
                         self.open_workspace_and_activate_thread(
+                            thread.agent.clone(),
                             session_info,
                             path_list.clone(),
                             window,
@@ -1426,6 +1426,7 @@ impl Sidebar {
 
     fn activate_thread(
         &mut self,
+        agent: Agent,
         session_info: acp_thread::AgentSessionInfo,
         workspace: &Entity<Workspace>,
         window: &mut Window,
@@ -1446,18 +1447,23 @@ impl Sidebar {
         if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
             agent_panel.update(cx, |panel, cx| {
                 panel.load_agent_thread(
+                    agent,
                     session_info.session_id,
                     session_info.cwd,
                     session_info.title,
+                    true,
                     window,
                     cx,
                 );
             });
         }
+
+        self.update_entries(cx);
     }
 
     fn open_workspace_and_activate_thread(
         &mut self,
+        agent: Agent,
         session_info: acp_thread::AgentSessionInfo,
         path_list: PathList,
         window: &mut Window,
@@ -1475,13 +1481,69 @@ impl Sidebar {
         cx.spawn_in(window, async move |this, cx| {
             let workspace = open_task.await?;
             this.update_in(cx, |this, window, cx| {
-                this.activate_thread(session_info, &workspace, window, cx);
+                this.activate_thread(agent, session_info, &workspace, window, cx);
             })?;
             anyhow::Ok(())
         })
         .detach_and_log_err(cx);
     }
 
+    fn find_open_workspace_for_path_list(
+        &self,
+        path_list: &PathList,
+        cx: &App,
+    ) -> Option<Entity<Workspace>> {
+        let multi_workspace = self.multi_workspace.upgrade()?;
+        multi_workspace
+            .read(cx)
+            .workspaces()
+            .iter()
+            .find(|workspace| workspace_path_list(workspace, cx).paths() == path_list.paths())
+            .cloned()
+    }
+
+    fn activate_archived_thread(
+        &mut self,
+        agent: Agent,
+        session_info: acp_thread::AgentSessionInfo,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let saved_path_list = ThreadStore::try_global(cx).and_then(|thread_store| {
+            thread_store
+                .read(cx)
+                .thread_from_session_id(&session_info.session_id)
+                .map(|thread| thread.folder_paths.clone())
+        });
+        let path_list = saved_path_list.or_else(|| {
+            // we don't have saved metadata, so create path list based on the cwd
+            session_info
+                .cwd
+                .as_ref()
+                .map(|cwd| PathList::new(&[cwd.to_path_buf()]))
+        });
+
+        if let Some(path_list) = path_list {
+            if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) {
+                self.activate_thread(agent, session_info, &workspace, window, cx);
+            } else {
+                self.open_workspace_and_activate_thread(agent, session_info, path_list, window, cx);
+            }
+            return;
+        }
+
+        let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
+            w.read(cx)
+                .workspaces()
+                .get(w.read(cx).active_workspace_index())
+                .cloned()
+        });
+
+        if let Some(workspace) = active_workspace {
+            self.activate_thread(agent, session_info, &workspace, window, cx);
+        }
+    }
+
     fn expand_selected_entry(
         &mut self,
         _: &ExpandSelectedEntry,
@@ -1610,22 +1672,32 @@ impl Sidebar {
             .selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
             .focused(is_selected)
             .docked_right(docked_right)
-            .on_click(cx.listener(move |this, _, window, cx| {
-                this.selection = None;
-                match &thread_workspace {
-                    ThreadEntryWorkspace::Open(workspace) => {
-                        this.activate_thread(session_info.clone(), workspace, window, cx);
-                    }
-                    ThreadEntryWorkspace::Closed(path_list) => {
-                        this.open_workspace_and_activate_thread(
-                            session_info.clone(),
-                            path_list.clone(),
-                            window,
-                            cx,
-                        );
+            .on_click({
+                let agent = thread.agent.clone();
+                cx.listener(move |this, _, window, cx| {
+                    this.selection = None;
+                    match &thread_workspace {
+                        ThreadEntryWorkspace::Open(workspace) => {
+                            this.activate_thread(
+                                agent.clone(),
+                                session_info.clone(),
+                                workspace,
+                                window,
+                                cx,
+                            );
+                        }
+                        ThreadEntryWorkspace::Closed(path_list) => {
+                            this.open_workspace_and_activate_thread(
+                                agent.clone(),
+                                session_info.clone(),
+                                path_list.clone(),
+                                window,
+                                cx,
+                            );
+                        }
                     }
-                }
-            }))
+                })
+            })
             .into_any_element()
     }
 
@@ -1724,10 +1796,11 @@ impl Sidebar {
                 )
                 .full_width()
                 .style(ButtonStyle::Outlined)
-                .icon(IconName::Plus)
-                .icon_color(Color::Muted)
-                .icon_size(IconSize::Small)
-                .icon_position(IconPosition::Start)
+                .start_icon(
+                    Icon::new(IconName::Plus)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
                 .toggle_state(is_selected)
                 .on_click(cx.listener(move |this, _, window, cx| {
                     this.selection = None;
@@ -1771,10 +1844,11 @@ impl Sidebar {
                     .full_width()
                     .label_size(LabelSize::Small)
                     .style(ButtonStyle::Outlined)
-                    .icon(IconName::Archive)
-                    .icon_color(Color::Muted)
-                    .icon_size(IconSize::XSmall)
-                    .icon_position(IconPosition::Start)
+                    .start_icon(
+                        Icon::new(IconName::Archive)
+                            .size(IconSize::XSmall)
+                            .color(Color::Muted),
+                    )
                     .on_click(cx.listener(|this, _, window, cx| {
                         this.show_archive(window, cx);
                     })),
@@ -1828,8 +1902,12 @@ impl Sidebar {
                 ThreadsArchiveViewEvent::Close => {
                     this.show_thread_list(window, cx);
                 }
-                ThreadsArchiveViewEvent::OpenThread(_session_info) => {
-                    //TODO: Actually open thread once we support it
+                ThreadsArchiveViewEvent::OpenThread {
+                    agent,
+                    session_info,
+                } => {
+                    this.show_thread_list(window, cx);
+                    this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx);
                 }
             },
         );
@@ -2542,6 +2620,7 @@ mod tests {
                 },
                 // Thread with default (Completed) status, not active
                 ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
                     session_info: acp_thread::AgentSessionInfo {
                         session_id: acp::SessionId::new(Arc::from("t-1")),
                         cwd: None,
@@ -2563,6 +2642,7 @@ mod tests {
                 }),
                 // Active thread with Running status
                 ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
                     session_info: acp_thread::AgentSessionInfo {
                         session_id: acp::SessionId::new(Arc::from("t-2")),
                         cwd: None,
@@ -2584,6 +2664,7 @@ mod tests {
                 }),
                 // Active thread with Error status
                 ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
                     session_info: acp_thread::AgentSessionInfo {
                         session_id: acp::SessionId::new(Arc::from("t-3")),
                         cwd: None,
@@ -2605,6 +2686,7 @@ mod tests {
                 }),
                 // Thread with WaitingForConfirmation status, not active
                 ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
                     session_info: acp_thread::AgentSessionInfo {
                         session_id: acp::SessionId::new(Arc::from("t-4")),
                         cwd: None,
@@ -2626,6 +2708,7 @@ mod tests {
                 }),
                 // Background thread that completed (should show notification)
                 ListEntry::Thread(ThreadEntry {
+                    agent: Agent::NativeAgent,
                     session_info: acp_thread::AgentSessionInfo {
                         session_id: acp::SessionId::new(Arc::from("t-5")),
                         cwd: None,
@@ -3976,6 +4059,7 @@ mod tests {
         // ── 2. Click thread in workspace A via sidebar ───────────────────────
         sidebar.update_in(cx, |sidebar, window, cx| {
             sidebar.activate_thread(
+                Agent::NativeAgent,
                 acp_thread::AgentSessionInfo {
                     session_id: session_id_a.clone(),
                     cwd: None,
@@ -4043,6 +4127,7 @@ mod tests {
         // which also triggers a workspace switch.
         sidebar.update_in(cx, |sidebar, window, cx| {
             sidebar.activate_thread(
+                Agent::NativeAgent,
                 acp_thread::AgentSessionInfo {
                     session_id: session_id_b.clone(),
                     cwd: None,
@@ -4505,9 +4590,8 @@ mod tests {
             mw.workspaces()[1].clone()
         });
 
-        let (new_path_list, _) = new_workspace.read_with(cx, |_, cx| {
-            workspace_path_list_and_label(&new_workspace, cx)
-        });
+        let new_path_list =
+            new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx));
         assert_eq!(
             new_path_list,
             PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
@@ -4629,4 +4713,250 @@ mod tests {
             "clicking an absorbed worktree thread should activate the worktree workspace"
         );
     }
+
+    #[gpui::test]
+    async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        // Thread has saved metadata in ThreadStore. A matching workspace is
+        // already open. Expected: activates the matching workspace.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
+            .await;
+        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b, window, cx);
+        });
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Save a thread with path_list pointing to project-b.
+        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
+        let session_id = acp::SessionId::new(Arc::from("archived-1"));
+        save_thread_to_store(&session_id, &path_list_b, cx).await;
+
+        // Ensure workspace A is active.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(0, window, cx);
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            0
+        );
+
+        // Call activate_archived_thread – should resolve saved paths and
+        // switch to the workspace for project-b.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_archived_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: session_id.clone(),
+                    cwd: Some("/project-b".into()),
+                    title: Some("Archived Thread".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1,
+            "should have activated the workspace matching the saved path_list"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        // Thread has no saved metadata but session_info has cwd. A matching
+        // workspace is open. Expected: uses cwd to find and activate it.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
+            .await;
+        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b, window, cx);
+        });
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Start with workspace A active.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(0, window, cx);
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            0
+        );
+
+        // No thread saved to the store – cwd is the only path hint.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_archived_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: acp::SessionId::new(Arc::from("unknown-session")),
+                    cwd: Some(std::path::PathBuf::from("/project-b")),
+                    title: Some("CWD Thread".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1,
+            "should have activated the workspace matching the cwd"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        // Thread has no saved metadata and no cwd. Expected: falls back to
+        // the currently active workspace.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
+            .await;
+        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+        let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b, window, cx);
+        });
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Activate workspace B (index 1) to make it the active one.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(1, window, cx);
+        });
+        cx.run_until_parked();
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1
+        );
+
+        // No saved thread, no cwd – should fall back to the active workspace.
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_archived_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: acp::SessionId::new(Arc::from("no-context-session")),
+                    cwd: None,
+                    title: Some("Contextless Thread".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()),
+            1,
+            "should have stayed on the active workspace when no path info is available"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_activate_archived_thread_saved_paths_opens_new_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        // Thread has saved metadata pointing to a path with no open workspace.
+        // Expected: opens a new workspace for that path.
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
+            .await;
+        fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+
+        let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Save a thread with path_list pointing to project-b – which has no
+        // open workspace.
+        let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
+        let session_id = acp::SessionId::new(Arc::from("archived-new-ws"));
+        save_thread_to_store(&session_id, &path_list_b, cx).await;
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
+            1,
+            "should start with one workspace"
+        );
+
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_archived_thread(
+                Agent::NativeAgent,
+                acp_thread::AgentSessionInfo {
+                    session_id: session_id.clone(),
+                    cwd: None,
+                    title: Some("New WS Thread".into()),
+                    updated_at: None,
+                    created_at: None,
+                    meta: None,
+                },
+                window,
+                cx,
+            );
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()),
+            2,
+            "should have opened a second workspace for the archived thread's saved paths"
+        );
+    }
 }

crates/agent_ui/src/text_thread_editor.rs 🔗

@@ -1191,11 +1191,11 @@ impl TextThreadEditor {
                                     Button::new("show-error", "Error")
                                         .color(Color::Error)
                                         .selected_label_color(Color::Error)
-                                        .selected_icon_color(Color::Error)
-                                        .icon(IconName::XCircle)
-                                        .icon_color(Color::Error)
-                                        .icon_size(IconSize::XSmall)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(
+                                            Icon::new(IconName::XCircle)
+                                                .size(IconSize::XSmall)
+                                                .color(Color::Error),
+                                        )
                                         .tooltip(Tooltip::text("View Details"))
                                         .on_click({
                                             let text_thread = text_thread.clone();
@@ -2287,20 +2287,11 @@ impl TextThreadEditor {
 
         PickerPopoverMenu::new(
             self.language_model_selector.clone(),
-            ButtonLike::new("active-model")
-                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                .child(
-                    h_flex()
-                        .gap_0p5()
-                        .child(provider_icon_element)
-                        .child(
-                            Label::new(model_name)
-                                .color(color)
-                                .size(LabelSize::Small)
-                                .ml_0p5(),
-                        )
-                        .child(Icon::new(icon).color(color).size(IconSize::XSmall)),
-                ),
+            Button::new("active-model", model_name)
+                .color(color)
+                .label_size(LabelSize::Small)
+                .start_icon(provider_icon_element)
+                .end_icon(Icon::new(icon).color(color).size(IconSize::XSmall)),
             tooltip,
             gpui::Corner::BottomRight,
             cx,

crates/agent_ui/src/thread_history_view.rs 🔗

@@ -751,13 +751,17 @@ impl RenderOnce for HistoryEntryElement {
                     {
                         if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
                             panel.update(cx, |panel, cx| {
-                                panel.load_agent_thread(
-                                    entry.session_id.clone(),
-                                    entry.cwd.clone(),
-                                    entry.title.clone(),
-                                    window,
-                                    cx,
-                                );
+                                if let Some(agent) = panel.selected_agent() {
+                                    panel.load_agent_thread(
+                                        agent,
+                                        entry.session_id.clone(),
+                                        entry.cwd.clone(),
+                                        entry.title.clone(),
+                                        true,
+                                        window,
+                                        cx,
+                                    );
+                                }
                             });
                         }
                     }

crates/agent_ui/src/threads_archive_view.rs 🔗

@@ -89,7 +89,10 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option<Vec<usize>> {
 
 pub enum ThreadsArchiveViewEvent {
     Close,
-    OpenThread(AgentSessionInfo),
+    OpenThread {
+        agent: Agent,
+        session_info: AgentSessionInfo,
+    },
 }
 
 impl EventEmitter<ThreadsArchiveViewEvent> for ThreadsArchiveView {}
@@ -263,7 +266,10 @@ impl ThreadsArchiveView {
     ) {
         self.selection = None;
         self.reset_filter_editor_text(window, cx);
-        cx.emit(ThreadsArchiveViewEvent::OpenThread(session_info));
+        cx.emit(ThreadsArchiveViewEvent::OpenThread {
+            agent: self.selected_agent.clone(),
+            session_info,
+        });
     }
 
     fn is_selectable_item(&self, ix: usize) -> bool {
@@ -413,7 +419,6 @@ impl ThreadsArchiveView {
 
                 ListItem::new(id)
                     .toggle_state(is_selected)
-                    .disabled(true)
                     .child(
                         h_flex()
                             .min_w_0()

crates/agent_ui/src/ui/acp_onboarding_modal.rs 🔗

@@ -193,15 +193,16 @@ impl Render for AcpOnboardingModal {
         let copy = "Bring the agent of your choice to Zed via our new Agent Client Protocol (ACP), starting with Google's Gemini CLI integration.";
 
         let open_panel_button = Button::new("open-panel", "Start with Gemini CLI")
-            .icon_size(IconSize::Indicator)
             .style(ButtonStyle::Tinted(TintColor::Accent))
             .full_width()
             .on_click(cx.listener(Self::open_panel));
 
         let docs_button = Button::new("add-other-agents", "Add Other Agents")
-            .icon(IconName::ArrowUpRight)
-            .icon_size(IconSize::Indicator)
-            .icon_color(Color::Muted)
+            .end_icon(
+                Icon::new(IconName::ArrowUpRight)
+                    .size(IconSize::Indicator)
+                    .color(Color::Muted),
+            )
             .full_width()
             .on_click(cx.listener(Self::open_agent_registry));
 

crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs 🔗

@@ -201,15 +201,16 @@ impl Render for ClaudeCodeOnboardingModal {
         let copy = "Powered by the Agent Client Protocol, you can now run Claude Agent as\na first-class citizen in Zed's agent panel.";
 
         let open_panel_button = Button::new("open-panel", "Start with Claude Agent")
-            .icon_size(IconSize::Indicator)
             .style(ButtonStyle::Tinted(TintColor::Accent))
             .full_width()
             .on_click(cx.listener(Self::open_panel));
 
         let docs_button = Button::new("add-other-agents", "Add Other Agents")
-            .icon(IconName::ArrowUpRight)
-            .icon_size(IconSize::Indicator)
-            .icon_color(Color::Muted)
+            .end_icon(
+                Icon::new(IconName::ArrowUpRight)
+                    .size(IconSize::Indicator)
+                    .color(Color::Muted),
+            )
             .full_width()
             .on_click(cx.listener(Self::view_docs));
 

crates/agent_ui/src/ui/mention_crease.rs 🔗

@@ -13,6 +13,8 @@ use theme::ThemeSettings;
 use ui::{ButtonLike, TintColor, Tooltip, prelude::*};
 use workspace::{OpenOptions, Workspace};
 
+use crate::Agent;
+
 #[derive(IntoElement)]
 pub struct MentionCrease {
     id: ElementId,
@@ -275,8 +277,17 @@ fn open_thread(
         return;
     };
 
+    // Right now we only support loading threads in the native agent
     panel.update(cx, |panel, cx| {
-        panel.load_agent_thread(id, None, Some(name.into()), window, cx)
+        panel.load_agent_thread(
+            Agent::NativeAgent,
+            id,
+            None,
+            Some(name.into()),
+            true,
+            window,
+            cx,
+        )
     });
 }
 

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -1,14 +1,15 @@
 use gpui::{
-    AnyElement, App, Context, EventEmitter, Global, IntoElement, Render, Subscription, Window,
+    AnyElement, App, Context, EventEmitter, Font, Global, IntoElement, Render, Subscription, Window,
 };
 use ui::prelude::*;
 use workspace::{
     ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
-    item::{BreadcrumbText, ItemEvent, ItemHandle},
+    item::{HighlightedText, ItemEvent, ItemHandle},
 };
 
 type RenderBreadcrumbTextFn = fn(
-    Vec<BreadcrumbText>,
+    Vec<HighlightedText>,
+    Option<Font>,
     Option<AnyElement>,
     &dyn ItemHandle,
     bool,
@@ -57,7 +58,7 @@ impl Render for Breadcrumbs {
             return element.into_any_element();
         };
 
-        let Some(segments) = active_item.breadcrumbs(cx) else {
+        let Some((segments, breadcrumb_font)) = active_item.breadcrumbs(cx) else {
             return element.into_any_element();
         };
 
@@ -66,6 +67,7 @@ impl Render for Breadcrumbs {
         if let Some(render_fn) = cx.try_global::<RenderBreadcrumbText>() {
             (render_fn.0)(
                 segments,
+                breadcrumb_font,
                 prefix_element,
                 active_item.as_ref(),
                 false,

crates/channel/src/channel_store.rs 🔗

@@ -156,6 +156,10 @@ impl ChannelStore {
         cx.global::<GlobalChannelStore>().0.clone()
     }
 
+    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalChannelStore>().map(|g| g.0.clone())
+    }
+
     pub fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
         let rpc_subscriptions = [
             client.add_message_handler(cx.weak_entity(), Self::handle_update_channels),

crates/collab/tests/integration/editor_tests.rs 🔗

@@ -5691,7 +5691,7 @@ async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont
     executor.run_until_parked();
 
     editor_a.update(cx_a, |editor, cx| {
-        let breadcrumbs = editor
+        let (breadcrumbs, _) = editor
             .breadcrumbs(cx)
             .expect("Host should have breadcrumbs");
         let texts: Vec<_> = breadcrumbs.iter().map(|b| b.text.as_str()).collect();
@@ -5727,6 +5727,7 @@ async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppCont
             editor
                 .breadcrumbs(cx)
                 .expect("Client B should have breadcrumbs")
+                .0
                 .iter()
                 .map(|b| b.text.as_str())
                 .collect::<Vec<_>>(),

crates/collab_ui/src/collab_panel.rs 🔗

@@ -36,8 +36,8 @@ use ui::{
 };
 use util::{ResultExt, TryFutureExt, maybe};
 use workspace::{
-    CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, ScreenShare,
-    ShareProject, Workspace,
+    CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, OpenChannelNotesById,
+    ScreenShare, ShareProject, Workspace,
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotifyResultExt},
 };
@@ -114,6 +114,13 @@ pub fn init(cx: &mut App) {
                 });
             }
         });
+        workspace.register_action(|_, action: &OpenChannelNotesById, window, cx| {
+            let channel_id = client::ChannelId(action.channel_id);
+            let workspace = cx.entity();
+            window.defer(cx, move |window, cx| {
+                ChannelView::open(channel_id, None, workspace, window, cx).detach_and_log_err(cx)
+            });
+        });
         // TODO: make it possible to bind this one to a held key for push to talk?
         // how to make "toggle_on_modifiers_press" contextual?
         workspace.register_action(|_, _: &Mute, _, cx| title_bar::collab::toggle_mute(cx));
@@ -2340,9 +2347,7 @@ impl CollabPanel {
                     .gap_2()
                     .child(
                         Button::new("sign_in", button_label)
-                            .icon_color(Color::Muted)
-                            .icon(IconName::Github)
-                            .icon_position(IconPosition::Start)
+                            .start_icon(Icon::new(IconName::Github).color(Color::Muted))
                             .style(ButtonStyle::Filled)
                             .full_width()
                             .disabled(is_signing_in)
@@ -2590,9 +2595,9 @@ impl CollabPanel {
             Section::Channels => {
                 Some(
                     h_flex()
-                        .gap_1()
                         .child(
                             IconButton::new("filter-active-channels", IconName::ListFilter)
+                                .icon_size(IconSize::Small)
                                 .toggle_state(self.filter_active_channels)
                                 .when(!self.filter_active_channels, |button| {
                                     button.visible_on_hover("section-header")

crates/collab_ui/src/notification_panel.rs 🔗

@@ -544,9 +544,7 @@ impl Render for NotificationPanel {
                             .p_4()
                             .child(
                                 Button::new("connect_prompt_button", "Connect")
-                                    .icon_color(Color::Muted)
-                                    .icon(IconName::Github)
-                                    .icon_position(IconPosition::Start)
+                                    .start_icon(Icon::new(IconName::Github).color(Color::Muted))
                                     .style(ButtonStyle::Filled)
                                     .full_width()
                                     .on_click({

crates/copilot_ui/src/sign_in.rs 🔗

@@ -387,10 +387,11 @@ impl CopilotCodeVerification {
                     .full_width()
                     .style(ButtonStyle::Outlined)
                     .size(ButtonSize::Medium)
-                    .icon(IconName::Download)
-                    .icon_color(Color::Muted)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(
+                        Icon::new(IconName::Download)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .on_click(move |_, window, cx| {
                         reinstall_and_sign_in(copilot.clone(), window, cx)
                     }),
@@ -570,10 +571,11 @@ impl ConfigurationView {
                 }
             })
             .style(ButtonStyle::Outlined)
-            .icon(IconName::Github)
-            .icon_color(Color::Muted)
-            .icon_position(IconPosition::Start)
-            .icon_size(IconSize::Small)
+            .start_icon(
+                Icon::new(IconName::Github)
+                    .size(IconSize::Small)
+                    .color(Color::Muted),
+            )
             .when(edit_prediction, |this| this.tab_index(0isize))
             .on_click(|_, window, cx| {
                 if let Some(app_state) = AppState::global(cx).upgrade()
@@ -600,10 +602,11 @@ impl ConfigurationView {
                 }
             })
             .style(ButtonStyle::Outlined)
-            .icon(IconName::Download)
-            .icon_color(Color::Muted)
-            .icon_position(IconPosition::Start)
-            .icon_size(IconSize::Small)
+            .start_icon(
+                Icon::new(IconName::Download)
+                    .size(IconSize::Small)
+                    .color(Color::Muted),
+            )
             .on_click(|_, window, cx| {
                 if let Some(app_state) = AppState::global(cx).upgrade()
                     && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx)

crates/debugger_ui/src/debugger_panel.rs 🔗

@@ -1821,20 +1821,22 @@ impl Render for DebugPanel {
                         .gap_2()
                         .child(
                             Button::new("spawn-new-session-empty-state", "New Session")
-                                .icon(IconName::Plus)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
-                                .icon_position(IconPosition::Start)
+                                .start_icon(
+                                    Icon::new(IconName::Plus)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .on_click(|_, window, cx| {
                                     window.dispatch_action(crate::Start.boxed_clone(), cx);
                                 }),
                         )
                         .child(
                             Button::new("edit-debug-settings", "Edit debug.json")
-                                .icon(IconName::Code)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
-                                .icon_position(IconPosition::Start)
+                                .start_icon(
+                                    Icon::new(IconName::Code)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .on_click(|_, window, cx| {
                                     window.dispatch_action(
                                         zed_actions::OpenProjectDebugTasks.boxed_clone(),
@@ -1844,10 +1846,11 @@ impl Render for DebugPanel {
                         )
                         .child(
                             Button::new("open-debugger-docs", "Debugger Docs")
-                                .icon(IconName::Book)
-                                .icon_size(IconSize::Small)
-                                .icon_color(Color::Muted)
-                                .icon_position(IconPosition::Start)
+                                .start_icon(
+                                    Icon::new(IconName::Book)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .on_click(|_, _, cx| cx.open_url("https://zed.dev/docs/debugger")),
                         )
                         .child(
@@ -1855,10 +1858,11 @@ impl Render for DebugPanel {
                                 "spawn-new-session-install-extensions",
                                 "Debugger Extensions",
                             )
-                            .icon(IconName::Blocks)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
-                            .icon_position(IconPosition::Start)
+                            .start_icon(
+                                Icon::new(IconName::Blocks)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .on_click(|_, window, cx| {
                                 window.dispatch_action(
                                     zed_actions::Extensions {

crates/edit_prediction/src/edit_prediction.rs 🔗

@@ -967,6 +967,10 @@ impl EditPredictionStore {
         self.mercury.api_token.read(cx).has_key()
     }
 
+    pub fn mercury_has_payment_required_error(&self) -> bool {
+        self.mercury.has_payment_required_error()
+    }
+
     pub fn clear_history(&mut self) {
         for project_state in self.projects.values_mut() {
             project_state.events.clear();

crates/edit_prediction/src/mercury.rs 🔗

@@ -1,19 +1,19 @@
 use crate::{
     DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput,
-    EditPredictionStartedDebugEvent, open_ai_response::text_from_response,
+    EditPredictionStartedDebugEvent, EditPredictionStore, open_ai_response::text_from_response,
     prediction::EditPredictionResult, zeta::compute_edits,
 };
 use anyhow::{Context as _, Result};
 use cloud_llm_client::EditPredictionRejectReason;
 use futures::AsyncReadExt as _;
 use gpui::{
-    App, AppContext as _, Entity, Global, SharedString, Task,
-    http_client::{self, AsyncBody, HttpClient, Method},
+    App, AppContext as _, Context, Entity, Global, SharedString, Task,
+    http_client::{self, AsyncBody, HttpClient, Method, StatusCode},
 };
 use language::{ToOffset, ToPoint as _};
 use language_model::{ApiKeyState, EnvVar, env_var};
 use release_channel::AppVersion;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant};
 use zeta_prompt::ZetaPromptInput;
 
@@ -21,17 +21,27 @@ const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"
 
 pub struct Mercury {
     pub api_token: Entity<ApiKeyState>,
+    payment_required_error: bool,
 }
 
 impl Mercury {
     pub fn new(cx: &mut App) -> Self {
         Mercury {
             api_token: mercury_api_token(cx),
+            payment_required_error: false,
         }
     }
 
+    pub fn has_payment_required_error(&self) -> bool {
+        self.payment_required_error
+    }
+
+    pub fn set_payment_required_error(&mut self, payment_required_error: bool) {
+        self.payment_required_error = payment_required_error;
+    }
+
     pub(crate) fn request_prediction(
-        &self,
+        &mut self,
         EditPredictionModelInput {
             buffer,
             snapshot,
@@ -41,7 +51,7 @@ impl Mercury {
             debug_tx,
             ..
         }: EditPredictionModelInput,
-        cx: &mut App,
+        cx: &mut Context<EditPredictionStore>,
     ) -> Task<Result<Option<EditPredictionResult>>> {
         self.api_token.update(cx, |key_state, cx| {
             _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx);
@@ -163,6 +173,12 @@ impl Mercury {
 
             let response_received_at = Instant::now();
             if !response.status().is_success() {
+                if response.status() == StatusCode::PAYMENT_REQUIRED {
+                    anyhow::bail!(MercuryPaymentRequiredError(
+                        mercury_payment_required_message(&body),
+                    ));
+                }
+
                 anyhow::bail!(
                     "Request failed with status: {:?}\nBody: {}",
                     response.status(),
@@ -209,9 +225,22 @@ impl Mercury {
             anyhow::Ok((id, edits, snapshot, response_received_at, inputs))
         });
 
-        cx.spawn(async move |cx| {
-            let (id, edits, old_snapshot, response_received_at, inputs) =
-                result.await.context("Mercury edit prediction failed")?;
+        cx.spawn(async move |ep_store, cx| {
+            let result = result.await.context("Mercury edit prediction failed");
+
+            let has_payment_required_error = result
+                .as_ref()
+                .err()
+                .is_some_and(is_mercury_payment_required_error);
+
+            ep_store.update(cx, |store, cx| {
+                store
+                    .mercury
+                    .set_payment_required_error(has_payment_required_error);
+                cx.notify();
+            })?;
+
+            let (id, edits, old_snapshot, response_received_at, inputs) = result?;
             anyhow::Ok(Some(
                 EditPredictionResult::new(
                     EditPredictionId(id.into()),
@@ -315,6 +344,33 @@ fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce(
 pub const MERCURY_CREDENTIALS_URL: SharedString =
     SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions");
 pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token";
+
+#[derive(Debug, thiserror::Error)]
+#[error("{0}")]
+struct MercuryPaymentRequiredError(SharedString);
+
+#[derive(Deserialize)]
+struct MercuryErrorResponse {
+    error: MercuryErrorMessage,
+}
+
+#[derive(Deserialize)]
+struct MercuryErrorMessage {
+    message: String,
+}
+
+fn is_mercury_payment_required_error(error: &anyhow::Error) -> bool {
+    error
+        .downcast_ref::<MercuryPaymentRequiredError>()
+        .is_some()
+}
+
+fn mercury_payment_required_message(body: &[u8]) -> SharedString {
+    serde_json::from_slice::<MercuryErrorResponse>(body)
+        .map(|response| response.error.message.into())
+        .unwrap_or_else(|_| String::from_utf8_lossy(body).trim().to_string().into())
+}
+
 pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("MERCURY_AI_TOKEN");
 
 struct GlobalMercuryApiKey(Entity<ApiKeyState>);

crates/edit_prediction_ui/src/edit_prediction_button.rs 🔗

@@ -359,10 +359,16 @@ impl Render for EditPredictionButton {
                     }
                     EditPredictionProvider::Mercury => {
                         ep_icon = if enabled { icons.base } else { icons.disabled };
+                        let mercury_has_error =
+                            edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
+                                |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
+                            );
                         missing_token = edit_prediction::EditPredictionStore::try_global(cx)
                             .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx));
                         tooltip_meta = if missing_token {
                             "Missing API key for Mercury"
+                        } else if mercury_has_error {
+                            "Mercury free tier limit reached"
                         } else {
                             "Powered by Mercury"
                         };
@@ -414,7 +420,12 @@ impl Render for EditPredictionButton {
                 let show_editor_predictions = self.editor_show_predictions;
                 let user = self.user_store.read(cx).current_user();
 
-                let indicator_color = if missing_token {
+                let mercury_has_error = matches!(provider, EditPredictionProvider::Mercury)
+                    && edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
+                        |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
+                    );
+
+                let indicator_color = if missing_token || mercury_has_error {
                     Some(Color::Error)
                 } else if enabled && (!show_editor_predictions || over_limit) {
                     Some(if over_limit {
@@ -1096,96 +1107,116 @@ impl EditPredictionButton {
                         },
                     )
                     .separator();
-            } else if let Some(usage) = self
-                .edit_prediction_provider
-                .as_ref()
-                .and_then(|provider| provider.usage(cx))
-            {
-                menu = menu.header("Usage");
-                menu = menu
-                    .custom_entry(
-                        move |_window, cx| {
-                            let used_percentage = match usage.limit {
-                                UsageLimit::Limited(limit) => {
-                                    Some((usage.amount as f32 / limit as f32) * 100.)
-                                }
-                                UsageLimit::Unlimited => None,
-                            };
+            } else {
+                let mercury_payment_required = matches!(provider, EditPredictionProvider::Mercury)
+                    && edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
+                        |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
+                    );
+
+                if mercury_payment_required {
+                    menu = menu
+                        .header("Mercury")
+                        .item(ContextMenuEntry::new("Free tier limit reached").disabled(true))
+                        .item(
+                            ContextMenuEntry::new(
+                                "Upgrade to a paid plan to continue using the service",
+                            )
+                            .disabled(true),
+                        )
+                        .separator();
+                }
+
+                if let Some(usage) = self
+                    .edit_prediction_provider
+                    .as_ref()
+                    .and_then(|provider| provider.usage(cx))
+                {
+                    menu = menu.header("Usage");
+                    menu = menu
+                        .custom_entry(
+                            move |_window, cx| {
+                                let used_percentage = match usage.limit {
+                                    UsageLimit::Limited(limit) => {
+                                        Some((usage.amount as f32 / limit as f32) * 100.)
+                                    }
+                                    UsageLimit::Unlimited => None,
+                                };
 
-                            h_flex()
-                                .flex_1()
-                                .gap_1p5()
-                                .children(
-                                    used_percentage.map(|percent| {
+                                h_flex()
+                                    .flex_1()
+                                    .gap_1p5()
+                                    .children(used_percentage.map(|percent| {
                                         ProgressBar::new("usage", percent, 100., cx)
-                                    }),
-                                )
-                                .child(
-                                    Label::new(match usage.limit {
-                                        UsageLimit::Limited(limit) => {
-                                            format!("{} / {limit}", usage.amount)
-                                        }
-                                        UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
-                                    })
+                                    }))
+                                    .child(
+                                        Label::new(match usage.limit {
+                                            UsageLimit::Limited(limit) => {
+                                                format!("{} / {limit}", usage.amount)
+                                            }
+                                            UsageLimit::Unlimited => {
+                                                format!("{} / ∞", usage.amount)
+                                            }
+                                        })
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted),
+                                    )
+                                    .into_any_element()
+                            },
+                            move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
+                        )
+                        .when(usage.over_limit(), |menu| -> ContextMenu {
+                            menu.entry("Subscribe to increase your limit", None, |_window, cx| {
+                                telemetry::event!(
+                                    "Edit Prediction Menu Action",
+                                    action = "upsell_clicked",
+                                    reason = "usage_limit",
+                                );
+                                cx.open_url(&zed_urls::account_url(cx))
+                            })
+                        })
+                        .separator();
+                } else if self.user_store.read(cx).account_too_young() {
+                    menu = menu
+                        .custom_entry(
+                            |_window, _cx| {
+                                Label::new("Your GitHub account is less than 30 days old.")
                                     .size(LabelSize::Small)
-                                    .color(Color::Muted),
-                                )
-                                .into_any_element()
-                        },
-                        move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
-                    )
-                    .when(usage.over_limit(), |menu| -> ContextMenu {
-                        menu.entry("Subscribe to increase your limit", None, |_window, cx| {
+                                    .color(Color::Warning)
+                                    .into_any_element()
+                            },
+                            |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
+                        )
+                        .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
                             telemetry::event!(
                                 "Edit Prediction Menu Action",
                                 action = "upsell_clicked",
-                                reason = "usage_limit",
+                                reason = "account_age",
                             );
                             cx.open_url(&zed_urls::account_url(cx))
                         })
-                    })
-                    .separator();
-            } else if self.user_store.read(cx).account_too_young() {
-                menu = menu
-                    .custom_entry(
-                        |_window, _cx| {
-                            Label::new("Your GitHub account is less than 30 days old.")
-                                .size(LabelSize::Small)
-                                .color(Color::Warning)
-                                .into_any_element()
-                        },
-                        |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
-                    )
-                    .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
-                        telemetry::event!(
-                            "Edit Prediction Menu Action",
-                            action = "upsell_clicked",
-                            reason = "account_age",
-                        );
-                        cx.open_url(&zed_urls::account_url(cx))
-                    })
-                    .separator();
-            } else if self.user_store.read(cx).has_overdue_invoices() {
-                menu = menu
-                    .custom_entry(
-                        |_window, _cx| {
-                            Label::new("You have an outstanding invoice")
-                                .size(LabelSize::Small)
-                                .color(Color::Warning)
-                                .into_any_element()
-                        },
-                        |_window, cx| {
-                            cx.open_url(&zed_urls::account_url(cx))
-                        },
-                    )
-                    .entry(
-                        "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
-                        None,
-                        |_window, cx| {
-                            cx.open_url(&zed_urls::account_url(cx))
-                        },
-                    )
-                    .separator();
+                        .separator();
+                } else if self.user_store.read(cx).has_overdue_invoices() {
+                    menu = menu
+                        .custom_entry(
+                            |_window, _cx| {
+                                Label::new("You have an outstanding invoice")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Warning)
+                                    .into_any_element()
+                            },
+                            |_window, cx| {
+                                cx.open_url(&zed_urls::account_url(cx))
+                            },
+                        )
+                        .entry(
+                            "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
+                            None,
+                            |_window, cx| {
+                                cx.open_url(&zed_urls::account_url(cx))
+                            },
+                        )
+                        .separator();
+                }
             }
 
             if !needs_sign_in {

crates/edit_prediction_ui/src/rate_prediction_modal.rs 🔗

@@ -765,9 +765,7 @@ impl RatePredictionsModal {
                                 .gap_1()
                                 .child(
                                     Button::new("bad", "Bad Prediction")
-                                        .icon(IconName::ThumbsDown)
-                                        .icon_size(IconSize::Small)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(Icon::new(IconName::ThumbsDown).size(IconSize::Small))
                                         .disabled(rated || feedback_empty)
                                         .when(feedback_empty, |this| {
                                             this.tooltip(Tooltip::text(
@@ -791,9 +789,7 @@ impl RatePredictionsModal {
                                 )
                                 .child(
                                     Button::new("good", "Good Prediction")
-                                        .icon(IconName::ThumbsUp)
-                                        .icon_size(IconSize::Small)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(Icon::new(IconName::ThumbsUp).size(IconSize::Small))
                                         .disabled(rated)
                                         .key_binding(KeyBinding::for_action_in(
                                             &ThumbsUpActivePrediction,

crates/editor/src/editor.rs 🔗

@@ -217,7 +217,7 @@ use workspace::{
     CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, NavigationEntry, OpenInTerminal,
     OpenTerminal, Pane, RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection,
     TabBarSettings, Toast, ViewId, Workspace, WorkspaceId, WorkspaceSettings,
-    item::{BreadcrumbText, ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions},
+    item::{ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions},
     notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
     searchable::SearchEvent,
 };
@@ -2142,7 +2142,7 @@ impl Editor {
                         editor.registered_buffers.clear();
                         editor.register_visible_buffers(cx);
                         editor.invalidate_semantic_tokens(None);
-                        editor.refresh_runnables(window, cx);
+                        editor.refresh_runnables(None, window, cx);
                         editor.update_lsp_data(None, window, cx);
                         editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx);
                     }
@@ -2172,7 +2172,7 @@ impl Editor {
                         let buffer_id = *buffer_id;
                         if editor.buffer().read(cx).buffer(buffer_id).is_some() {
                             editor.register_buffer(buffer_id, cx);
-                            editor.refresh_runnables(window, cx);
+                            editor.refresh_runnables(Some(buffer_id), window, cx);
                             editor.update_lsp_data(Some(buffer_id), window, cx);
                             editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
                             refresh_linked_ranges(editor, window, cx);
@@ -2251,7 +2251,7 @@ impl Editor {
                     &task_inventory,
                     window,
                     |editor, _, window, cx| {
-                        editor.refresh_runnables(window, cx);
+                        editor.refresh_runnables(None, window, cx);
                     },
                 ));
             };
@@ -23789,7 +23789,7 @@ impl Editor {
                     .invalidate_buffer(&buffer.read(cx).remote_id());
                 self.update_lsp_data(Some(buffer_id), window, cx);
                 self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
-                self.refresh_runnables(window, cx);
+                self.refresh_runnables(None, window, cx);
                 self.colorize_brackets(false, cx);
                 self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
                 cx.emit(EditorEvent::ExcerptsAdded {
@@ -23850,12 +23850,11 @@ impl Editor {
                 }
                 self.colorize_brackets(false, cx);
                 self.update_lsp_data(None, window, cx);
-                self.refresh_runnables(window, cx);
+                self.refresh_runnables(None, window, cx);
                 cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() })
             }
             multi_buffer::Event::Reparsed(buffer_id) => {
-                self.clear_runnables(Some(*buffer_id));
-                self.refresh_runnables(window, cx);
+                self.refresh_runnables(Some(*buffer_id), window, cx);
                 self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
                 self.colorize_brackets(true, cx);
                 jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
@@ -23863,7 +23862,7 @@ impl Editor {
                 cx.emit(EditorEvent::Reparsed(*buffer_id));
             }
             multi_buffer::Event::DiffHunksToggled => {
-                self.refresh_runnables(window, cx);
+                self.refresh_runnables(None, window, cx);
             }
             multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => {
                 if !is_fresh_language {
@@ -23999,7 +23998,7 @@ impl Editor {
                 .unwrap_or(DiagnosticSeverity::Hint);
             self.set_max_diagnostics_severity(new_severity, cx);
         }
-        self.refresh_runnables(window, cx);
+        self.refresh_runnables(None, window, cx);
         self.update_edit_prediction_settings(cx);
         self.refresh_edit_prediction(true, false, window, cx);
         self.refresh_inline_values(cx);
@@ -25323,14 +25322,13 @@ impl Editor {
         }
     }
 
-    fn breadcrumbs_inner(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs_inner(&self, cx: &App) -> Option<Vec<HighlightedText>> {
         let multibuffer = self.buffer().read(cx);
         let is_singleton = multibuffer.is_singleton();
         let (buffer_id, symbols) = self.outline_symbols_at_cursor.as_ref()?;
         let buffer = multibuffer.buffer(*buffer_id)?;
 
         let buffer = buffer.read(cx);
-        let settings = ThemeSettings::get_global(cx);
         // In a multi-buffer layout, we don't want to include the filename in the breadcrumbs
         let mut breadcrumbs = if is_singleton {
             let text = self.breadcrumb_header.clone().unwrap_or_else(|| {
@@ -25351,19 +25349,17 @@ impl Editor {
                         }
                     })
             });
-            vec![BreadcrumbText {
-                text,
-                highlights: None,
-                font: Some(settings.buffer_font.clone()),
+            vec![HighlightedText {
+                text: text.into(),
+                highlights: vec![],
             }]
         } else {
             vec![]
         };
 
-        breadcrumbs.extend(symbols.iter().map(|symbol| BreadcrumbText {
-            text: symbol.text.clone(),
-            highlights: Some(symbol.highlight_ranges.clone()),
-            font: Some(settings.buffer_font.clone()),
+        breadcrumbs.extend(symbols.iter().map(|symbol| HighlightedText {
+            text: symbol.text.clone().into(),
+            highlights: symbol.highlight_ranges.clone(),
         }));
         Some(breadcrumbs)
     }
@@ -25382,7 +25378,7 @@ impl Editor {
         self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
         if !self.buffer().read(cx).is_singleton() {
             self.update_lsp_data(None, window, cx);
-            self.refresh_runnables(window, cx);
+            self.refresh_runnables(None, window, cx);
         }
     }
 }

crates/editor/src/element.rs 🔗

@@ -41,18 +41,18 @@ use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatu
 use gpui::{
     Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
     Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
-    DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, FontWeight,
-    GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
-    KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent,
-    MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement,
-    Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
-    Size, StatefulInteractiveElement, Style, Styled, StyledText, TextAlign, TextRun,
+    DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, Font, FontId,
+    FontWeight, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement,
+    IsZero, KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton,
+    MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad,
+    ParentElement, Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine,
+    SharedString, Size, StatefulInteractiveElement, Style, Styled, StyledText, TextAlign, TextRun,
     TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop,
     linear_gradient, outline, pattern_slash, point, px, quad, relative, size, solid_background,
     transparent_black,
 };
 use itertools::Itertools;
-use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting};
+use language::{HighlightedText, IndentGuideSettings, language_settings::ShowWhitespaceSetting};
 use markdown::Markdown;
 use multi_buffer::{
     Anchor, ExcerptId, ExcerptInfo, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint,
@@ -98,7 +98,7 @@ use util::{RangeExt, ResultExt, debug_panic};
 use workspace::{
     CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel,
     Workspace,
-    item::{BreadcrumbText, Item, ItemBufferKind},
+    item::{Item, ItemBufferKind},
 };
 
 /// Determines what kinds of highlights should be applied to a lines background.
@@ -7913,7 +7913,8 @@ impl EditorElement {
 }
 
 pub fn render_breadcrumb_text(
-    mut segments: Vec<BreadcrumbText>,
+    mut segments: Vec<HighlightedText>,
+    breadcrumb_font: Option<Font>,
     prefix: Option<gpui::AnyElement>,
     active_item: &dyn ItemHandle,
     multibuffer_header: bool,
@@ -7933,17 +7934,16 @@ pub fn render_breadcrumb_text(
     if suffix_start_ix > prefix_end_ix {
         segments.splice(
             prefix_end_ix..suffix_start_ix,
-            Some(BreadcrumbText {
+            Some(HighlightedText {
                 text: "⋯".into(),
-                highlights: None,
-                font: None,
+                highlights: vec![],
             }),
         );
     }
 
     let highlighted_segments = segments.into_iter().enumerate().map(|(index, segment)| {
         let mut text_style = window.text_style();
-        if let Some(ref font) = segment.font {
+        if let Some(font) = &breadcrumb_font {
             text_style.font_family = font.family.clone();
             text_style.font_features = font.features.clone();
             text_style.font_style = font.style;
@@ -7960,7 +7960,7 @@ pub fn render_breadcrumb_text(
         }
 
         StyledText::new(segment.text.replace('\n', " "))
-            .with_default_highlights(&text_style, segment.highlights.unwrap_or_default())
+            .with_default_highlights(&text_style, segment.highlights)
             .into_any()
     });
 
@@ -8070,13 +8070,13 @@ pub fn render_breadcrumb_text(
 }
 
 fn apply_dirty_filename_style(
-    segment: &BreadcrumbText,
+    segment: &HighlightedText,
     text_style: &gpui::TextStyle,
     cx: &App,
 ) -> Option<gpui::AnyElement> {
     let text = segment.text.replace('\n', " ");
 
-    let filename_position = std::path::Path::new(&segment.text)
+    let filename_position = std::path::Path::new(segment.text.as_ref())
         .file_name()
         .and_then(|f| {
             let filename_str = f.to_string_lossy();
@@ -8446,8 +8446,12 @@ pub(crate) fn render_buffer_header(
                                         el.child(Icon::new(IconName::FileLock).color(Color::Muted))
                                     })
                                     .when_some(breadcrumbs, |then, breadcrumbs| {
+                                        let font = theme::ThemeSettings::get_global(cx)
+                                            .buffer_font
+                                            .clone();
                                         then.child(render_breadcrumb_text(
                                             breadcrumbs,
+                                            Some(font),
                                             None,
                                             editor_handle,
                                             true,

crates/editor/src/hover_popover.rs 🔗

@@ -8,10 +8,10 @@ use crate::{
 };
 use anyhow::Context as _;
 use gpui::{
-    AnyElement, App, AsyncApp, AsyncWindowContext, Bounds, Context, Entity, Focusable as _,
-    FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
-    ScrollHandle, Size, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
-    TextStyleRefinement, WeakEntity, Window, canvas, div, px,
+    AnyElement, App, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla,
+    InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
+    StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement,
+    Window, canvas, div, px,
 };
 use itertools::Itertools;
 use language::{DiagnosticEntry, Language, LanguageRegistry};
@@ -73,18 +73,13 @@ pub fn hover_at(
             }
 
             // If we are moving closer, or if no timer is running at all, start/restart the 300ms timer.
-            let delay = 300u64;
-            let task = cx.spawn(move |this: WeakEntity<Editor>, cx: &mut AsyncApp| {
-                let mut cx = cx.clone();
-                async move {
-                    cx.background_executor()
-                        .timer(Duration::from_millis(delay))
-                        .await;
-                    this.update(&mut cx, |editor, cx| {
-                        hide_hover(editor, cx);
-                    })
-                    .ok();
-                }
+            let delay = Duration::from_millis(300u64);
+            let task = cx.spawn(async move |this, cx| {
+                cx.background_executor().timer(delay).await;
+                this.update(cx, |editor, cx| {
+                    hide_hover(editor, cx);
+                })
+                .ok();
             });
             editor.hover_state.hiding_delay_task = Some(task);
         }

crates/editor/src/items.rs 🔗

@@ -14,12 +14,12 @@ use fs::MTime;
 use futures::future::try_join_all;
 use git::status::GitSummary;
 use gpui::{
-    AnyElement, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, IntoElement,
-    ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point,
+    AnyElement, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, Font,
+    IntoElement, ParentElement, Pixels, SharedString, Styled, Task, WeakEntity, Window, point,
 };
 use language::{
-    Bias, Buffer, BufferRow, CharKind, CharScopeContext, LocalFile, Point, SelectionGoal,
-    proto::serialize_anchor as serialize_text_anchor,
+    Bias, Buffer, BufferRow, CharKind, CharScopeContext, HighlightedText, LocalFile, Point,
+    SelectionGoal, proto::serialize_anchor as serialize_text_anchor,
 };
 use lsp::DiagnosticSeverity;
 use multi_buffer::MultiBufferOffset;
@@ -56,7 +56,7 @@ use workspace::{
 };
 use workspace::{
     OpenVisible, Pane, WorkspaceSettings,
-    item::{BreadcrumbText, FollowEvent, ProjectItemKind},
+    item::{FollowEvent, ProjectItemKind},
     searchable::SearchOptions,
 };
 use zed_actions::preview::{
@@ -981,9 +981,10 @@ impl Item for Editor {
     }
 
     // In a non-singleton case, the breadcrumbs are actually shown on sticky file headers of the multibuffer.
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         if self.buffer.read(cx).is_singleton() {
-            self.breadcrumbs_inner(cx)
+            let font = theme::ThemeSettings::get_global(cx).buffer_font.clone();
+            Some((self.breadcrumbs_inner(cx)?, Some(font)))
         } else {
             None
         }

crates/editor/src/movement.rs 🔗

@@ -408,7 +408,7 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
     let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
 
     find_preceding_boundary_display_point(map, point, FindRange::MultiLine, &mut |left, right| {
-        is_subword_start(left, right, &classifier) || left == '\n'
+        is_subword_start(left, right, &classifier) || left == '\n' || right == '\n'
     })
 }
 
@@ -431,6 +431,7 @@ pub fn is_subword_start(left: char, right: char, classifier: &CharClassifier) ->
     let is_word_start = classifier.kind(left) != classifier.kind(right) && !right.is_whitespace();
     let is_subword_start = classifier.is_word('-') && left == '-' && right != '-'
         || left == '_' && right != '_'
+        || left != '_' && right == '_'
         || left.is_lowercase() && right.is_uppercase();
     is_word_start || is_subword_start
 }
@@ -484,7 +485,7 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
     let classifier = map.buffer_snapshot().char_classifier_at(raw_point);
 
     find_boundary(map, point, FindRange::MultiLine, &mut |left, right| {
-        is_subword_end(left, right, &classifier) || right == '\n'
+        is_subword_end(left, right, &classifier) || left == '\n' || right == '\n'
     })
 }
 
@@ -519,6 +520,7 @@ pub fn is_subword_end(left: char, right: char, classifier: &CharClassifier) -> b
 fn is_subword_boundary_end(left: char, right: char, classifier: &CharClassifier) -> bool {
     classifier.is_word('-') && left != '-' && right == '-'
         || left != '_' && right == '_'
+        || left == '_' && right != '_'
         || left.is_lowercase() && right.is_uppercase()
 }
 
@@ -973,10 +975,10 @@ mod tests {
         }
 
         // Subword boundaries are respected
-        assert("lorem_ˇipˇsum", cx);
+        assert("loremˇ_ˇipsum", cx);
         assert("lorem_ˇipsumˇ", cx);
-        assert("ˇlorem_ˇipsum", cx);
-        assert("lorem_ˇipsum_ˇdolor", cx);
+        assert("ˇloremˇ_ipsum", cx);
+        assert("lorem_ˇipsumˇ_dolor", cx);
         assert("loremˇIpˇsum", cx);
         assert("loremˇIpsumˇ", cx);
 
@@ -1156,10 +1158,10 @@ mod tests {
         }
 
         // Subword boundaries are respected
-        assert("loˇremˇ_ipsum", cx);
+        assert("loremˇ_ˇipsum", cx);
         assert("ˇloremˇ_ipsum", cx);
-        assert("loremˇ_ipsumˇ", cx);
-        assert("loremˇ_ipsumˇ_dolor", cx);
+        assert("loremˇ_ˇipsum", cx);
+        assert("lorem_ˇipsumˇ_dolor", cx);
         assert("loˇremˇIpsum", cx);
         assert("loremˇIpsumˇDolor", cx);
 
@@ -1172,7 +1174,7 @@ mod tests {
         assert("loremˇ    ipsumˇ   ", cx);
         assert("loremˇ-ˇipsum", cx);
         assert("loremˇ#$@-ˇipsum", cx);
-        assert("loremˇ_ipsumˇ", cx);
+        assert("loremˇ_ˇipsum", cx);
         assert(" ˇbcˇΔ", cx);
         assert(" abˇ——ˇcd", cx);
     }

crates/editor/src/runnables.rs 🔗

@@ -1,7 +1,7 @@
 use std::{collections::BTreeMap, mem, ops::Range, sync::Arc};
 
 use clock::Global;
-use collections::HashMap;
+use collections::{HashMap, HashSet};
 use gpui::{
     App, AppContext as _, AsyncWindowContext, ClickEvent, Context, Entity, Focusable as _,
     MouseButton, Task, Window,
@@ -30,6 +30,7 @@ use crate::{
 #[derive(Debug)]
 pub(super) struct RunnableData {
     runnables: HashMap<BufferId, (Global, BTreeMap<BufferRow, RunnableTasks>)>,
+    invalidate_buffer_data: HashSet<BufferId>,
     runnables_update_task: Task<()>,
 }
 
@@ -37,6 +38,7 @@ impl RunnableData {
     pub fn new() -> Self {
         Self {
             runnables: HashMap::default(),
+            invalidate_buffer_data: HashSet::default(),
             runnables_update_task: Task::ready(()),
         }
     }
@@ -108,7 +110,12 @@ pub struct ResolvedTasks {
 }
 
 impl Editor {
-    pub fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+    pub fn refresh_runnables(
+        &mut self,
+        invalidate_buffer_data: Option<BufferId>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
         if !self.mode().is_full()
             || !EditorSettings::get_global(cx).gutter.runnables
             || !self.enable_runnables
@@ -117,13 +124,18 @@ impl Editor {
             return;
         }
         if let Some(buffer) = self.buffer().read(cx).as_singleton() {
-            if self
-                .runnables
-                .has_cached(buffer.read(cx).remote_id(), &buffer.read(cx).version())
+            let buffer_id = buffer.read(cx).remote_id();
+            if invalidate_buffer_data != Some(buffer_id)
+                && self
+                    .runnables
+                    .has_cached(buffer_id, &buffer.read(cx).version())
             {
                 return;
             }
         }
+        if let Some(buffer_id) = invalidate_buffer_data {
+            self.runnables.invalidate_buffer_data.insert(buffer_id);
+        }
 
         let project = self.project().map(Entity::downgrade);
         let lsp_task_sources = self.lsp_task_sources(true, true, cx);
@@ -249,6 +261,10 @@ impl Editor {
             .await;
             editor
                 .update(cx, |editor, cx| {
+                    for buffer_id in std::mem::take(&mut editor.runnables.invalidate_buffer_data) {
+                        editor.clear_runnables(Some(buffer_id));
+                    }
+
                     for ((buffer_id, row), mut new_tasks) in rows {
                         let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else {
                             continue;
@@ -332,6 +348,7 @@ impl Editor {
         } else {
             self.runnables.runnables.clear();
         }
+        self.runnables.invalidate_buffer_data.clear();
         self.runnables.runnables_update_task = Task::ready(());
     }
 
@@ -697,12 +714,17 @@ impl Editor {
 mod tests {
     use std::{sync::Arc, time::Duration};
 
+    use futures::StreamExt as _;
     use gpui::{AppContext as _, Task, TestAppContext};
     use indoc::indoc;
-    use language::ContextProvider;
+    use language::{ContextProvider, FakeLspAdapter};
     use languages::rust_lang;
+    use lsp::LanguageServerName;
     use multi_buffer::{MultiBuffer, PathKey};
-    use project::{FakeFs, Project};
+    use project::{
+        FakeFs, Project,
+        lsp_store::lsp_ext_command::{CargoRunnableArgs, Runnable, RunnableArgs, RunnableKind},
+    };
     use serde_json::json;
     use task::{TaskTemplate, TaskTemplates};
     use text::Point;
@@ -710,8 +732,11 @@ mod tests {
 
     use crate::{
         Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount,
+        test::build_editor_with_project,
     };
 
+    const FAKE_LSP_NAME: &str = "the-fake-language-server";
+
     struct TestRustContextProvider;
 
     impl ContextProvider for TestRustContextProvider {
@@ -739,6 +764,28 @@ mod tests {
         }
     }
 
+    struct TestRustContextProviderWithLsp;
+
+    impl ContextProvider for TestRustContextProviderWithLsp {
+        fn associated_tasks(
+            &self,
+            _: Option<Arc<dyn language::File>>,
+            _: &gpui::App,
+        ) -> Task<Option<TaskTemplates>> {
+            Task::ready(Some(TaskTemplates(vec![TaskTemplate {
+                label: "Run test".into(),
+                command: "cargo".into(),
+                args: vec!["test".into()],
+                tags: vec!["rust-test".into()],
+                ..TaskTemplate::default()
+            }])))
+        }
+
+        fn lsp_task_source(&self) -> Option<LanguageServerName> {
+            Some(LanguageServerName::new_static(FAKE_LSP_NAME))
+        }
+    }
+
     fn rust_lang_with_task_context() -> Arc<language::Language> {
         Arc::new(
             Arc::try_unwrap(rust_lang())
@@ -747,6 +794,14 @@ mod tests {
         )
     }
 
+    fn rust_lang_with_lsp_task_context() -> Arc<language::Language> {
+        Arc::new(
+            Arc::try_unwrap(rust_lang())
+                .unwrap()
+                .with_context_provider(Some(Arc::new(TestRustContextProviderWithLsp))),
+        )
+    }
+
     fn collect_runnable_labels(
         editor: &Editor,
     ) -> Vec<(text::BufferId, language::BufferRow, Vec<String>)> {
@@ -853,7 +908,7 @@ mod tests {
         editor
             .update(cx, |editor, window, cx| {
                 editor.clear_runnables(None);
-                editor.refresh_runnables(window, cx);
+                editor.refresh_runnables(None, window, cx);
             })
             .unwrap();
         cx.executor().advance_clock(UPDATE_DEBOUNCE);
@@ -912,4 +967,127 @@ mod tests {
             "first.rs runnables should survive an edit to second.rs"
         );
     }
+
+    #[gpui::test]
+    async fn test_lsp_runnables_removed_after_edit(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "main.rs": indoc! {"
+                    #[test]
+                    fn test_one() {
+                        assert!(true);
+                    }
+
+                    fn helper() {}
+                "},
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+        language_registry.add(rust_lang_with_lsp_task_context());
+
+        let mut fake_servers = language_registry.register_fake_lsp(
+            "Rust",
+            FakeLspAdapter {
+                name: FAKE_LSP_NAME,
+                ..FakeLspAdapter::default()
+            },
+        );
+
+        let buffer = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer(path!("/project/main.rs"), cx)
+            })
+            .await
+            .unwrap();
+
+        let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
+
+        let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
+        let editor = cx.add_window(|window, cx| {
+            build_editor_with_project(project.clone(), multi_buffer, window, cx)
+        });
+
+        let fake_server = fake_servers.next().await.expect("fake LSP server");
+
+        use project::lsp_store::lsp_ext_command::Runnables;
+        fake_server.set_request_handler::<Runnables, _, _>(move |params, _| async move {
+            let text = params.text_document.uri.path().to_string();
+            if text.contains("main.rs") {
+                let uri = lsp::Uri::from_file_path(path!("/project/main.rs")).expect("valid uri");
+                Ok(vec![Runnable {
+                    label: "LSP test_one".into(),
+                    location: Some(lsp::LocationLink {
+                        origin_selection_range: None,
+                        target_uri: uri,
+                        target_range: lsp::Range::new(
+                            lsp::Position::new(0, 0),
+                            lsp::Position::new(3, 1),
+                        ),
+                        target_selection_range: lsp::Range::new(
+                            lsp::Position::new(0, 0),
+                            lsp::Position::new(3, 1),
+                        ),
+                    }),
+                    kind: RunnableKind::Cargo,
+                    args: RunnableArgs::Cargo(CargoRunnableArgs {
+                        environment: Default::default(),
+                        cwd: path!("/project").into(),
+                        override_cargo: None,
+                        workspace_root: None,
+                        cargo_args: vec!["test".into(), "test_one".into()],
+                        executable_args: Vec::new(),
+                    }),
+                }])
+            } else {
+                Ok(Vec::new())
+            }
+        });
+
+        // Trigger a refresh to pick up both tree-sitter and LSP runnables.
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.refresh_runnables(None, window, cx);
+            })
+            .expect("editor update");
+        cx.executor().advance_clock(UPDATE_DEBOUNCE);
+        cx.executor().run_until_parked();
+
+        let labels = editor
+            .update(cx, |editor, _, _| collect_runnable_labels(editor))
+            .expect("editor update");
+        assert_eq!(
+            labels,
+            vec![(buffer_id, 0, vec!["LSP test_one".to_string()]),],
+            "LSP runnables should appear for #[test] fn"
+        );
+
+        // Remove `#[test]` attribute so the function is no longer a test.
+        buffer.update(cx, |buffer, cx| {
+            let test_attr_end = buffer.text().find("\nfn test_one").expect("find fn");
+            buffer.edit([(0..test_attr_end, "")], None, cx);
+        });
+
+        // Also update the LSP handler to return no runnables.
+        fake_server
+            .set_request_handler::<Runnables, _, _>(move |_, _| async move { Ok(Vec::new()) });
+
+        cx.executor().advance_clock(UPDATE_DEBOUNCE);
+        cx.executor().run_until_parked();
+
+        let labels = editor
+            .update(cx, |editor, _, _| collect_runnable_labels(editor))
+            .expect("editor update");
+        assert_eq!(
+            labels,
+            Vec::<(text::BufferId, language::BufferRow, Vec<String>)>::new(),
+            "Runnables should be removed after #[test] is deleted and LSP returns empty"
+        );
+    }
 }

crates/editor/src/split.rs 🔗

@@ -6,9 +6,11 @@ use std::{
 use buffer_diff::{BufferDiff, BufferDiffSnapshot};
 use collections::HashMap;
 
-use gpui::{Action, AppContext as _, Entity, EventEmitter, Focusable, Subscription, WeakEntity};
+use gpui::{
+    Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Subscription, WeakEntity,
+};
 use itertools::Itertools;
-use language::{Buffer, Capability};
+use language::{Buffer, Capability, HighlightedText};
 use multi_buffer::{
     Anchor, BufferOffset, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer,
     MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey,
@@ -29,7 +31,7 @@ use crate::{
 };
 use workspace::{
     ActivatePaneLeft, ActivatePaneRight, Item, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, ItemBufferKind, ItemEvent, SaveOptions, TabContentParams},
+    item::{ItemBufferKind, ItemEvent, SaveOptions, TabContentParams},
     searchable::{SearchEvent, SearchToken, SearchableItem, SearchableItemHandle},
 };
 
@@ -1853,7 +1855,7 @@ impl Item for SplittableEditor {
         self.rhs_editor.read(cx).breadcrumb_location(cx)
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         self.rhs_editor.read(cx).breadcrumbs(cx)
     }
 

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -1056,10 +1056,11 @@ impl ExtensionsPage {
                     "Install",
                 )
                 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
-                .icon(IconName::Download)
-                .icon_size(IconSize::Small)
-                .icon_color(Color::Muted)
-                .icon_position(IconPosition::Start)
+                .start_icon(
+                    Icon::new(IconName::Download)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
                 .on_click({
                     let extension_id = extension.id.clone();
                     move |_, _, cx| {
@@ -1078,10 +1079,11 @@ impl ExtensionsPage {
                     "Install",
                 )
                 .style(ButtonStyle::Tinted(ui::TintColor::Accent))
-                .icon(IconName::Download)
-                .icon_size(IconSize::Small)
-                .icon_color(Color::Muted)
-                .icon_position(IconPosition::Start)
+                .start_icon(
+                    Icon::new(IconName::Download)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
                 .disabled(true),
                 configure: None,
                 upgrade: None,
@@ -1479,10 +1481,11 @@ impl ExtensionsPage {
                 }
             });
         let open_registry_button = Button::new("open_registry", "Learn More")
-            .icon(IconName::ArrowUpRight)
-            .icon_size(IconSize::Small)
-            .icon_position(IconPosition::End)
-            .icon_color(Color::Muted)
+            .end_icon(
+                Icon::new(IconName::ArrowUpRight)
+                    .size(IconSize::Small)
+                    .color(Color::Muted),
+            )
             .on_click({
                 move |_event, _window, cx| {
                     telemetry::event!(
@@ -1520,9 +1523,7 @@ impl ExtensionsPage {
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
         let docs_url_button = Button::new("open_docs", "View Documentation")
-            .icon(IconName::ArrowUpRight)
-            .icon_size(IconSize::Small)
-            .icon_position(IconPosition::End)
+            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small))
             .on_click({
                 move |_event, _window, cx| {
                     telemetry::event!(

crates/file_finder/Cargo.toml 🔗

@@ -14,6 +14,8 @@ doctest = false
 
 [dependencies]
 anyhow.workspace = true
+channel.workspace = true
+client.workspace = true
 collections.workspace = true
 editor.workspace = true
 file_icons.workspace = true
@@ -45,3 +47,4 @@ serde_json.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 workspace = { workspace = true, features = ["test-support"] }
 zlog.workspace = true
+remote_connection = { workspace = true, features = ["test-support"] }

crates/file_finder/src/file_finder.rs 🔗

@@ -4,10 +4,12 @@ mod file_finder_tests;
 use futures::future::join_all;
 pub use open_path_prompt::OpenPathDelegate;
 
+use channel::ChannelStore;
+use client::ChannelId;
 use collections::HashMap;
 use editor::Editor;
 use file_icons::FileIcons;
-use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
+use fuzzy::{CharBag, PathMatch, PathMatchCandidate, StringMatch, StringMatchCandidate};
 use gpui::{
     Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
     KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
@@ -45,8 +47,8 @@ use util::{
     rel_path::RelPath,
 };
 use workspace::{
-    ModalView, OpenOptions, OpenVisible, SplitDirection, Workspace, item::PreviewTabsSettings,
-    notifications::NotifyResultExt, pane,
+    ModalView, OpenChannelNotesById, OpenOptions, OpenVisible, SplitDirection, Workspace,
+    item::PreviewTabsSettings, notifications::NotifyResultExt, pane,
 };
 use zed_actions::search::ToggleIncludeIgnored;
 
@@ -321,7 +323,7 @@ impl FileFinder {
             if let Some(workspace) = delegate.workspace.upgrade()
                 && let Some(m) = delegate.matches.get(delegate.selected_index())
             {
-                let path = match &m {
+                let path = match m {
                     Match::History { path, .. } => {
                         let worktree_id = path.project.worktree_id;
                         ProjectPath {
@@ -334,6 +336,7 @@ impl FileFinder {
                         path: m.0.path.clone(),
                     },
                     Match::CreateNew(p) => p.clone(),
+                    Match::Channel { .. } => return,
                 };
                 let open_task = workspace.update(cx, move |workspace, cx| {
                     workspace.split_path_preview(path, false, Some(split_direction), window, cx)
@@ -392,6 +395,7 @@ pub struct FileFinderDelegate {
     file_finder: WeakEntity<FileFinder>,
     workspace: WeakEntity<Workspace>,
     project: Entity<Project>,
+    channel_store: Option<Entity<ChannelStore>>,
     search_count: usize,
     latest_search_id: usize,
     latest_search_did_cancel: bool,
@@ -450,13 +454,18 @@ struct Matches {
     matches: Vec<Match>,
 }
 
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
+#[derive(Debug, Clone)]
 enum Match {
     History {
         path: FoundPath,
         panel_match: Option<ProjectPanelOrdMatch>,
     },
     Search(ProjectPanelOrdMatch),
+    Channel {
+        channel_id: ChannelId,
+        channel_name: SharedString,
+        string_match: StringMatch,
+    },
     CreateNew(ProjectPath),
 }
 
@@ -465,7 +474,7 @@ impl Match {
         match self {
             Match::History { path, .. } => Some(&path.project.path),
             Match::Search(panel_match) => Some(&panel_match.0.path),
-            Match::CreateNew(_) => None,
+            Match::Channel { .. } | Match::CreateNew(_) => None,
         }
     }
 
@@ -479,7 +488,7 @@ impl Match {
                     .read(cx)
                     .absolutize(&path_match.path),
             ),
-            Match::CreateNew(_) => None,
+            Match::Channel { .. } | Match::CreateNew(_) => None,
         }
     }
 
@@ -487,7 +496,7 @@ impl Match {
         match self {
             Match::History { panel_match, .. } => panel_match.as_ref(),
             Match::Search(panel_match) => Some(panel_match),
-            Match::CreateNew(_) => None,
+            Match::Channel { .. } | Match::CreateNew(_) => None,
         }
     }
 }
@@ -628,7 +637,6 @@ impl Matches {
             (_, Match::CreateNew(_)) => return cmp::Ordering::Greater,
             _ => {}
         }
-        debug_assert!(a.panel_match().is_some() && b.panel_match().is_some());
 
         match (&a, &b) {
             // bubble currently opened files to the top
@@ -651,32 +659,35 @@ impl Matches {
             }
         }
 
-        let a_panel_match = match a.panel_match() {
-            Some(pm) => pm,
-            None => {
-                return if b.panel_match().is_some() {
-                    cmp::Ordering::Less
-                } else {
-                    cmp::Ordering::Equal
-                };
+        // For file-vs-file matches, use the existing detailed comparison.
+        if let (Some(a_panel), Some(b_panel)) = (a.panel_match(), b.panel_match()) {
+            let a_in_filename = Self::is_filename_match(a_panel);
+            let b_in_filename = Self::is_filename_match(b_panel);
+
+            match (a_in_filename, b_in_filename) {
+                (true, false) => return cmp::Ordering::Greater,
+                (false, true) => return cmp::Ordering::Less,
+                _ => {}
             }
-        };
 
-        let b_panel_match = match b.panel_match() {
-            Some(pm) => pm,
-            None => return cmp::Ordering::Greater,
-        };
+            return a_panel.cmp(b_panel);
+        }
 
-        let a_in_filename = Self::is_filename_match(a_panel_match);
-        let b_in_filename = Self::is_filename_match(b_panel_match);
+        let a_score = Self::match_score(a);
+        let b_score = Self::match_score(b);
+        // When at least one side is a channel, compare by raw score.
+        a_score
+            .partial_cmp(&b_score)
+            .unwrap_or(cmp::Ordering::Equal)
+    }
 
-        match (a_in_filename, b_in_filename) {
-            (true, false) => return cmp::Ordering::Greater,
-            (false, true) => return cmp::Ordering::Less,
-            _ => {} // Both are filename matches or both are path matches
+    fn match_score(m: &Match) -> f64 {
+        match m {
+            Match::History { panel_match, .. } => panel_match.as_ref().map_or(0.0, |pm| pm.0.score),
+            Match::Search(pm) => pm.0.score,
+            Match::Channel { string_match, .. } => string_match.score,
+            Match::CreateNew(_) => 0.0,
         }
-
-        a_panel_match.cmp(b_panel_match)
     }
 
     /// Determines if the match occurred within the filename rather than in the path
@@ -833,10 +844,16 @@ impl FileFinderDelegate {
         cx: &mut Context<FileFinder>,
     ) -> Self {
         Self::subscribe_to_updates(&project, window, cx);
+        let channel_store = if FileFinderSettings::get_global(cx).include_channels {
+            ChannelStore::try_global(cx)
+        } else {
+            None
+        };
         Self {
             file_finder,
             workspace,
             project,
+            channel_store,
             search_count: 0,
             latest_search_id: 0,
             latest_search_did_cancel: false,
@@ -971,6 +988,68 @@ impl FileFinderDelegate {
                 path_style,
             );
 
+            // Add channel matches
+            if let Some(channel_store) = &self.channel_store {
+                let channel_store = channel_store.read(cx);
+                let channels: Vec<_> = channel_store.channels().cloned().collect();
+                if !channels.is_empty() {
+                    let candidates = channels
+                        .iter()
+                        .enumerate()
+                        .map(|(id, channel)| StringMatchCandidate::new(id, &channel.name));
+                    let channel_query = query.path_query();
+                    let query_lower = channel_query.to_lowercase();
+                    let mut channel_matches = Vec::new();
+                    for candidate in candidates {
+                        let channel_name = candidate.string;
+                        let name_lower = channel_name.to_lowercase();
+
+                        let mut positions = Vec::new();
+                        let mut query_idx = 0;
+                        for (name_idx, name_char) in name_lower.char_indices() {
+                            if query_idx < query_lower.len() {
+                                let query_char =
+                                    query_lower[query_idx..].chars().next().unwrap_or_default();
+                                if name_char == query_char {
+                                    positions.push(name_idx);
+                                    query_idx += query_char.len_utf8();
+                                }
+                            }
+                        }
+
+                        if query_idx == query_lower.len() {
+                            let channel = &channels[candidate.id];
+                            let score = if name_lower == query_lower {
+                                1.0
+                            } else if name_lower.starts_with(&query_lower) {
+                                0.8
+                            } else {
+                                0.5 * (query_lower.len() as f64 / name_lower.len() as f64)
+                            };
+                            channel_matches.push(Match::Channel {
+                                channel_id: channel.id,
+                                channel_name: channel.name.clone(),
+                                string_match: StringMatch {
+                                    candidate_id: candidate.id,
+                                    score,
+                                    positions,
+                                    string: channel_name,
+                                },
+                            });
+                        }
+                    }
+                    for channel_match in channel_matches {
+                        match self
+                            .matches
+                            .position(&channel_match, self.currently_opened_path.as_ref())
+                        {
+                            Ok(_duplicate) => {}
+                            Err(ix) => self.matches.matches.insert(ix, channel_match),
+                        }
+                    }
+                }
+            }
+
             let query_path = query.raw_query.as_str();
             if let Ok(mut query_path) = RelPath::new(Path::new(query_path), path_style) {
                 let available_worktree = self
@@ -1095,6 +1174,16 @@ impl FileFinderDelegate {
                     }
                 }
                 Match::Search(path_match) => self.labels_for_path_match(&path_match.0, path_style),
+                Match::Channel {
+                    channel_name,
+                    string_match,
+                    ..
+                } => (
+                    channel_name.to_string(),
+                    string_match.positions.clone(),
+                    "Channel Notes".to_string(),
+                    vec![],
+                ),
                 Match::CreateNew(project_path) => (
                     format!("Create file: {}", project_path.path.display(path_style)),
                     vec![],
@@ -1479,6 +1568,16 @@ impl PickerDelegate for FileFinderDelegate {
         if let Some(m) = self.matches.get(self.selected_index())
             && let Some(workspace) = self.workspace.upgrade()
         {
+            // Channel matches are handled separately since they dispatch an action
+            // rather than directly opening a file path.
+            if let Match::Channel { channel_id, .. } = m {
+                let channel_id = channel_id.0;
+                let finder = self.file_finder.clone();
+                window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx);
+                finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err();
+                return;
+            }
+
             let open_task = workspace.update(cx, |workspace, cx| {
                 let split_or_open =
                     |workspace: &mut Workspace,
@@ -1571,6 +1670,7 @@ impl PickerDelegate for FileFinderDelegate {
                         window,
                         cx,
                     ),
+                    Match::Channel { .. } => unreachable!("handled above"),
                 }
             });
 
@@ -1627,7 +1727,7 @@ impl PickerDelegate for FileFinderDelegate {
 
         let path_match = self.matches.get(ix)?;
 
-        let history_icon = match &path_match {
+        let end_icon = match path_match {
             Match::History { .. } => Icon::new(IconName::HistoryRerun)
                 .color(Color::Muted)
                 .size(IconSize::Small)
@@ -1636,6 +1736,10 @@ impl PickerDelegate for FileFinderDelegate {
                 .flex_none()
                 .size(IconSize::Small.rems())
                 .into_any_element(),
+            Match::Channel { .. } => v_flex()
+                .flex_none()
+                .size(IconSize::Small.rems())
+                .into_any_element(),
             Match::CreateNew(_) => Icon::new(IconName::Plus)
                 .color(Color::Muted)
                 .size(IconSize::Small)
@@ -1643,21 +1747,24 @@ impl PickerDelegate for FileFinderDelegate {
         };
         let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx);
 
-        let file_icon = maybe!({
-            if !settings.file_icons {
-                return None;
-            }
-            let abs_path = path_match.abs_path(&self.project, cx)?;
-            let file_name = abs_path.file_name()?;
-            let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
-            Some(Icon::from_path(icon).color(Color::Muted))
-        });
+        let file_icon = match path_match {
+            Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)),
+            _ => maybe!({
+                if !settings.file_icons {
+                    return None;
+                }
+                let abs_path = path_match.abs_path(&self.project, cx)?;
+                let file_name = abs_path.file_name()?;
+                let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
+                Some(Icon::from_path(icon).color(Color::Muted))
+            }),
+        };
 
         Some(
             ListItem::new(ix)
                 .spacing(ListItemSpacing::Sparse)
                 .start_slot::<Icon>(file_icon)
-                .end_slot::<AnyElement>(history_icon)
+                .end_slot::<AnyElement>(end_icon)
                 .inset(true)
                 .toggle_state(selected)
                 .child(

crates/file_finder/src/file_finder_tests.rs 🔗

@@ -3709,7 +3709,7 @@ impl SearchEntries {
 fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
     let mut search_entries = SearchEntries::default();
     for m in &picker.delegate.matches.matches {
-        match &m {
+        match m {
             Match::History {
                 path: history_path,
                 panel_match: path_match,
@@ -3734,6 +3734,7 @@ fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries
                 search_entries.search_matches.push(path_match.0.clone());
             }
             Match::CreateNew(_) => {}
+            Match::Channel { .. } => {}
         }
     }
     search_entries
@@ -3768,6 +3769,7 @@ fn assert_match_at_position(
         Match::History { path, .. } => path.absolute.file_name().and_then(|s| s.to_str()),
         Match::Search(path_match) => path_match.0.path.file_name(),
         Match::CreateNew(project_path) => project_path.path.file_name(),
+        Match::Channel { channel_name, .. } => Some(channel_name.as_str()),
     }
     .unwrap();
     assert_eq!(match_file_name, expected_file_name);

crates/fs/src/fs.rs 🔗

@@ -147,7 +147,7 @@ pub trait Fs: Send + Sync {
         &self,
         abs_dot_git: &Path,
         system_git_binary_path: Option<&Path>,
-    ) -> Option<Arc<dyn GitRepository>>;
+    ) -> Result<Arc<dyn GitRepository>>;
     async fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String)
     -> Result<()>;
     async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>;
@@ -1149,8 +1149,8 @@ impl Fs for RealFs {
         &self,
         dotgit_path: &Path,
         system_git_binary_path: Option<&Path>,
-    ) -> Option<Arc<dyn GitRepository>> {
-        Some(Arc::new(RealGitRepository::new(
+    ) -> Result<Arc<dyn GitRepository>> {
+        Ok(Arc::new(RealGitRepository::new(
             dotgit_path,
             self.bundled_git_binary_path.clone(),
             system_git_binary_path.map(|path| path.to_path_buf()),
@@ -2866,9 +2866,7 @@ impl Fs for FakeFs {
         &self,
         abs_dot_git: &Path,
         _system_git_binary: Option<&Path>,
-    ) -> Option<Arc<dyn GitRepository>> {
-        use util::ResultExt as _;
-
+    ) -> Result<Arc<dyn GitRepository>> {
         self.with_git_state_and_paths(
             abs_dot_git,
             false,
@@ -2884,7 +2882,6 @@ impl Fs for FakeFs {
                 }) as _
             },
         )
-        .log_err()
     }
 
     async fn git_init(

crates/git/src/blame.rs 🔗

@@ -58,7 +58,7 @@ async fn run_git_blame(
     let mut child = {
         let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str());
         let _enter = span.enter();
-        git.build_command(["blame", "--incremental", "--contents", "-"])
+        git.build_command(&["blame", "--incremental", "--contents", "-"])
             .arg(path.as_unix_str())
             .stdin(Stdio::piped())
             .stdout(Stdio::piped())

crates/git/src/commit.rs 🔗

@@ -81,7 +81,7 @@ pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result<HashMa
 async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result<Vec<String>> {
     const MARKER: &str = "<MARKER>";
     let output = git
-        .build_command(["show"])
+        .build_command(&["show"])
         .arg("-s")
         .arg(format!("--format=%B{}", MARKER))
         .args(shas.iter().map(ToString::to_string))
@@ -91,7 +91,7 @@ async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result<Vec<String>>
     anyhow::ensure!(
         output.status.success(),
         "'git show' failed with error {:?}",
-        output.status
+        String::from_utf8_lossy(&output.stderr)
     );
     Ok(String::from_utf8_lossy(&output.stdout)
         .trim()

crates/git/src/repository.rs 🔗

@@ -1000,11 +1000,18 @@ impl RealGitRepository {
         bundled_git_binary_path: Option<PathBuf>,
         system_git_binary_path: Option<PathBuf>,
         executor: BackgroundExecutor,
-    ) -> Option<Self> {
-        let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?;
-        let workdir_root = dotgit_path.parent()?;
-        let repository = git2::Repository::open(workdir_root).log_err()?;
-        Some(Self {
+    ) -> Result<Self> {
+        let any_git_binary_path = system_git_binary_path
+            .clone()
+            .or(bundled_git_binary_path)
+            .context("no git binary available")?;
+        log::info!(
+            "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}"
+        );
+        let workdir_root = dotgit_path.parent().context(".git has no parent")?;
+        let repository =
+            git2::Repository::open(workdir_root).context("creating libgit2 repository")?;
+        Ok(Self {
             repository: Arc::new(Mutex::new(repository)),
             system_git_binary_path,
             any_git_binary_path,
@@ -1039,7 +1046,7 @@ impl RealGitRepository {
         let git_binary = self.git_binary();
         let output: SharedString = self
             .executor
-            .spawn(async move { git_binary?.run(["help", "-a"]).await })
+            .spawn(async move { git_binary?.run(&["help", "-a"]).await })
             .await
             .unwrap_or_default()
             .into();
@@ -1086,9 +1093,12 @@ pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
     );
 
     cx.background_spawn(async move {
-        let name = git.run(["config", "--global", "user.name"]).await.log_err();
+        let name = git
+            .run(&["config", "--global", "user.name"])
+            .await
+            .log_err();
         let email = git
-            .run(["config", "--global", "user.email"])
+            .run(&["config", "--global", "user.email"])
             .await
             .log_err();
         GitCommitter { name, email }
@@ -1119,7 +1129,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let output = git
-                    .build_command([
+                    .build_command(&[
                         "--no-optional-locks",
                         "show",
                         "--no-patch",
@@ -1157,7 +1167,7 @@ impl GitRepository for RealGitRepository {
         cx.background_spawn(async move {
             let git = git_binary?;
             let show_output = git
-                .build_command([
+                .build_command(&[
                     "--no-optional-locks",
                     "show",
                     "--format=",
@@ -1179,7 +1189,7 @@ impl GitRepository for RealGitRepository {
             let parent_sha = format!("{}^", commit);
 
             let mut cat_file_process = git
-                .build_command(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
+                .build_command(&["--no-optional-locks", "cat-file", "--batch=%(objectsize)"])
                 .stdin(Stdio::piped())
                 .stdout(Stdio::piped())
                 .stderr(Stdio::piped())
@@ -1295,7 +1305,7 @@ impl GitRepository for RealGitRepository {
 
             let git = git_binary?;
             let output = git
-                .build_command(["reset", mode_flag, &commit])
+                .build_command(&["reset", mode_flag, &commit])
                 .envs(env.iter())
                 .output()
                 .await?;
@@ -1323,7 +1333,7 @@ impl GitRepository for RealGitRepository {
 
             let git = git_binary?;
             let output = git
-                .build_command(["checkout", &commit, "--"])
+                .build_command(&["checkout", &commit, "--"])
                 .envs(env.iter())
                 .args(paths.iter().map(|path| path.as_unix_str()))
                 .output()
@@ -1427,7 +1437,7 @@ impl GitRepository for RealGitRepository {
 
                 if let Some(content) = content {
                     let mut child = git
-                        .build_command(["hash-object", "-w", "--stdin"])
+                        .build_command(&["hash-object", "-w", "--stdin"])
                         .envs(env.iter())
                         .stdin(Stdio::piped())
                         .stdout(Stdio::piped())
@@ -1442,7 +1452,7 @@ impl GitRepository for RealGitRepository {
                     log::debug!("indexing SHA: {sha}, path {path:?}");
 
                     let output = git
-                        .build_command(["update-index", "--add", "--cacheinfo", mode, sha])
+                        .build_command(&["update-index", "--add", "--cacheinfo", mode, sha])
                         .envs(env.iter())
                         .arg(path.as_unix_str())
                         .output()
@@ -1456,7 +1466,7 @@ impl GitRepository for RealGitRepository {
                 } else {
                     log::debug!("removing path {path:?} from the index");
                     let output = git
-                        .build_command(["update-index", "--force-remove"])
+                        .build_command(&["update-index", "--force-remove"])
                         .envs(env.iter())
                         .arg(path.as_unix_str())
                         .output()
@@ -1491,7 +1501,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let mut process = git
-                    .build_command([
+                    .build_command(&[
                         "--no-optional-locks",
                         "cat-file",
                         "--batch-check=%(objectname)",
@@ -1551,7 +1561,7 @@ impl GitRepository for RealGitRepository {
         let args = git_status_args(path_prefixes);
         log::debug!("Checking for git status in {path_prefixes:?}");
         self.executor.spawn(async move {
-            let output = git.build_command(args).output().await?;
+            let output = git.build_command(&args).output().await?;
             if output.status.success() {
                 let stdout = String::from_utf8_lossy(&output.stdout);
                 stdout.parse()
@@ -1589,7 +1599,7 @@ impl GitRepository for RealGitRepository {
 
         self.executor
             .spawn(async move {
-                let output = git.build_command(args).output().await?;
+                let output = git.build_command(&args).output().await?;
                 if output.status.success() {
                     let stdout = String::from_utf8_lossy(&output.stdout);
                     stdout.parse()
@@ -1645,7 +1655,7 @@ impl GitRepository for RealGitRepository {
                     &fields,
                 ];
                 let git = git_binary?;
-                let output = git.build_command(args).output().await?;
+                let output = git.build_command(&args).output().await?;
 
                 anyhow::ensure!(
                     output.status.success(),
@@ -1659,7 +1669,7 @@ impl GitRepository for RealGitRepository {
                 if branches.is_empty() {
                     let args = vec!["symbolic-ref", "--quiet", "HEAD"];
 
-                    let output = git.build_command(args).output().await?;
+                    let output = git.build_command(&args).output().await?;
 
                     // git symbolic-ref returns a non-0 exit code if HEAD points
                     // to something other than a branch
@@ -1727,7 +1737,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 std::fs::create_dir_all(final_path.parent().unwrap_or(&final_path))?;
                 let git = git_binary?;
-                let output = git.build_command(args).output().await?;
+                let output = git.build_command(&args).output().await?;
                 if output.status.success() {
                     Ok(())
                 } else {
@@ -1753,7 +1763,7 @@ impl GitRepository for RealGitRepository {
                 }
                 args.push("--".into());
                 args.push(path.as_os_str().into());
-                git_binary?.run(args).await?;
+                git_binary?.run(&args).await?;
                 anyhow::Ok(())
             })
             .boxed()
@@ -1772,7 +1782,7 @@ impl GitRepository for RealGitRepository {
                     old_path.as_os_str().into(),
                     new_path.as_os_str().into(),
                 ];
-                git_binary?.run(args).await?;
+                git_binary?.run(&args).await?;
                 anyhow::Ok(())
             })
             .boxed()
@@ -1975,11 +1985,11 @@ impl GitRepository for RealGitRepository {
                 let git = git_binary?;
                 let output = match diff {
                     DiffType::HeadToIndex => {
-                        git.build_command(["diff", "--staged"]).output().await?
+                        git.build_command(&["diff", "--staged"]).output().await?
                     }
-                    DiffType::HeadToWorktree => git.build_command(["diff"]).output().await?,
+                    DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?,
                     DiffType::MergeBase { base_ref } => {
-                        git.build_command(["diff", "--merge-base", base_ref.as_ref()])
+                        git.build_command(&["diff", "--merge-base", base_ref.as_ref()])
                             .output()
                             .await?
                     }
@@ -2036,7 +2046,7 @@ impl GitRepository for RealGitRepository {
                 if !paths.is_empty() {
                     let git = git_binary?;
                     let output = git
-                        .build_command(["update-index", "--add", "--remove", "--"])
+                        .build_command(&["update-index", "--add", "--remove", "--"])
                         .envs(env.iter())
                         .args(paths.iter().map(|p| p.as_unix_str()))
                         .output()
@@ -2064,7 +2074,7 @@ impl GitRepository for RealGitRepository {
                 if !paths.is_empty() {
                     let git = git_binary?;
                     let output = git
-                        .build_command(["reset", "--quiet", "--"])
+                        .build_command(&["reset", "--quiet", "--"])
                         .envs(env.iter())
                         .args(paths.iter().map(|p| p.as_std_path()))
                         .output()
@@ -2091,7 +2101,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let output = git
-                    .build_command(["stash", "push", "--quiet", "--include-untracked"])
+                    .build_command(&["stash", "push", "--quiet", "--include-untracked"])
                     .envs(env.iter())
                     .args(paths.iter().map(|p| p.as_unix_str()))
                     .output()
@@ -2196,7 +2206,7 @@ impl GitRepository for RealGitRepository {
         // which we want to block on.
         async move {
             let git = git_binary?;
-            let mut cmd = git.build_command(["commit", "--quiet", "-m"]);
+            let mut cmd = git.build_command(&["commit", "--quiet", "-m"]);
             cmd.envs(env.iter())
                 .arg(&message.to_string())
                 .arg("--cleanup=strip")
@@ -2248,7 +2258,7 @@ impl GitRepository for RealGitRepository {
                 executor.clone(),
                 is_trusted,
             );
-            let mut command = git.build_command(["push"]);
+            let mut command = git.build_command(&["push"]);
             command
                 .envs(env.iter())
                 .args(options.map(|option| match option {
@@ -2290,7 +2300,7 @@ impl GitRepository for RealGitRepository {
                 executor.clone(),
                 is_trusted,
             );
-            let mut command = git.build_command(["pull"]);
+            let mut command = git.build_command(&["pull"]);
             command.envs(env.iter());
 
             if rebase {
@@ -2331,7 +2341,7 @@ impl GitRepository for RealGitRepository {
                 executor.clone(),
                 is_trusted,
             );
-            let mut command = git.build_command(["fetch", &remote_name]);
+            let mut command = git.build_command(&["fetch", &remote_name]);
             command
                 .envs(env.iter())
                 .stdout(Stdio::piped())
@@ -2348,7 +2358,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let output = git
-                    .build_command(["rev-parse", "--abbrev-ref"])
+                    .build_command(&["rev-parse", "--abbrev-ref"])
                     .arg(format!("{branch}@{{push}}"))
                     .output()
                     .await?;
@@ -2373,7 +2383,7 @@ impl GitRepository for RealGitRepository {
             .spawn(async move {
                 let git = git_binary?;
                 let output = git
-                    .build_command(["config", "--get"])
+                    .build_command(&["config", "--get"])
                     .arg(format!("branch.{branch}.remote"))
                     .output()
                     .await?;
@@ -2394,7 +2404,7 @@ impl GitRepository for RealGitRepository {
         self.executor
             .spawn(async move {
                 let git = git_binary?;
-                let output = git.build_command(["remote", "-v"]).output().await?;
+                let output = git.build_command(&["remote", "-v"]).output().await?;
 
                 anyhow::ensure!(
                     output.status.success(),
@@ -2725,7 +2735,7 @@ impl GitRepository for RealGitRepository {
         async move {
             let git = git_binary?;
 
-            let mut command = git.build_command([
+            let mut command = git.build_command(&[
                 "log",
                 GRAPH_COMMIT_FORMAT,
                 log_order.as_arg(),
@@ -2808,7 +2818,7 @@ async fn run_commit_data_reader(
     request_rx: smol::channel::Receiver<CommitDataRequest>,
 ) -> Result<()> {
     let mut process = git
-        .build_command(["--no-optional-locks", "cat-file", "--batch"])
+        .build_command(&["--no-optional-locks", "cat-file", "--batch"])
         .stdin(Stdio::piped())
         .stdout(Stdio::piped())
         .stderr(Stdio::piped())
@@ -3075,7 +3085,7 @@ impl GitBinary {
             .join(format!("index-{}.tmp", id))
     }
 
-    pub async fn run<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
+    pub async fn run<S>(&self, args: &[S]) -> Result<String>
     where
         S: AsRef<OsStr>,
     {
@@ -3087,7 +3097,7 @@ impl GitBinary {
     }
 
     /// Returns the result of the command without trimming the trailing newline.
-    pub async fn run_raw<S>(&self, args: impl IntoIterator<Item = S>) -> Result<String>
+    pub async fn run_raw<S>(&self, args: &[S]) -> Result<String>
     where
         S: AsRef<OsStr>,
     {
@@ -3105,10 +3115,7 @@ impl GitBinary {
     }
 
     #[allow(clippy::disallowed_methods)]
-    pub(crate) fn build_command<S>(
-        &self,
-        args: impl IntoIterator<Item = S>,
-    ) -> util::command::Command
+    pub(crate) fn build_command<S>(&self, args: &[S]) -> util::command::Command
     where
         S: AsRef<OsStr>,
     {
@@ -3125,6 +3132,14 @@ impl GitBinary {
             command.args(["-c", "diff.external="]);
         }
         command.args(args);
+
+        // If the `diff` command is being used, we'll want to add the
+        // `--no-ext-diff` flag when working on an untrusted repository,
+        // preventing any external diff programs from being invoked.
+        if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") {
+            command.arg("--no-ext-diff");
+        }
+
         if let Some(index_file_path) = self.index_file_path.as_ref() {
             command.env("GIT_INDEX_FILE", index_file_path);
         }
@@ -3394,7 +3409,7 @@ mod tests {
             false,
         );
         let output = git
-            .build_command(["version"])
+            .build_command(&["version"])
             .output()
             .await
             .expect("git version should succeed");
@@ -3407,7 +3422,7 @@ mod tests {
             false,
         );
         let output = git
-            .build_command(["config", "--get", "core.fsmonitor"])
+            .build_command(&["config", "--get", "core.fsmonitor"])
             .output()
             .await
             .expect("git config should run");
@@ -3426,7 +3441,7 @@ mod tests {
             false,
         );
         let output = git
-            .build_command(["config", "--get", "core.hooksPath"])
+            .build_command(&["config", "--get", "core.hooksPath"])
             .output()
             .await
             .expect("git config should run");
@@ -3451,7 +3466,7 @@ mod tests {
             true,
         );
         let output = git
-            .build_command(["config", "--get", "core.fsmonitor"])
+            .build_command(&["config", "--get", "core.fsmonitor"])
             .output()
             .await
             .expect("git config should run");
@@ -3469,7 +3484,7 @@ mod tests {
             true,
         );
         let output = git
-            .build_command(["config", "--get", "core.hooksPath"])
+            .build_command(&["config", "--get", "core.hooksPath"])
             .output()
             .await
             .expect("git config should run");

crates/git_graph/src/git_graph.rs 🔗

@@ -1494,10 +1494,9 @@ impl GitGraph {
 
                                 this.child(
                                     Button::new("author-email-copy", author_email.clone())
-                                        .icon(icon)
-                                        .icon_size(IconSize::Small)
-                                        .icon_color(icon_color)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(
+                                            Icon::new(icon).size(IconSize::Small).color(icon_color),
+                                        )
                                         .label_size(LabelSize::Small)
                                         .truncate(true)
                                         .color(Color::Muted)
@@ -1542,10 +1541,9 @@ impl GitGraph {
                                 };
 
                                 Button::new("sha-button", &full_sha)
-                                    .icon(icon)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(icon_color)
-                                    .icon_position(IconPosition::Start)
+                                    .start_icon(
+                                        Icon::new(icon).size(IconSize::Small).color(icon_color),
+                                    )
                                     .label_size(LabelSize::Small)
                                     .truncate(true)
                                     .color(Color::Muted)
@@ -1602,10 +1600,9 @@ impl GitGraph {
                                         "view-on-provider",
                                         format!("View on {}", provider_name),
                                     )
-                                    .icon(icon)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(Color::Muted)
-                                    .icon_position(IconPosition::Start)
+                                    .start_icon(
+                                        Icon::new(icon).size(IconSize::Small).color(Color::Muted),
+                                    )
                                     .label_size(LabelSize::Small)
                                     .truncate(true)
                                     .color(Color::Muted)

crates/git_ui/src/blame_ui.rs 🔗

@@ -322,10 +322,11 @@ impl BlameRenderer for GitBlameRenderer {
                                                         format!("#{}", pr.number),
                                                     )
                                                     .color(Color::Muted)
-                                                    .icon(IconName::PullRequest)
-                                                    .icon_color(Color::Muted)
-                                                    .icon_position(IconPosition::Start)
-                                                    .icon_size(IconSize::Small)
+                                                    .start_icon(
+                                                        Icon::new(IconName::PullRequest)
+                                                            .size(IconSize::Small)
+                                                            .color(Color::Muted),
+                                                    )
                                                     .on_click(move |_, _, cx| {
                                                         cx.stop_propagation();
                                                         cx.open_url(pr.url.as_str())
@@ -339,10 +340,11 @@ impl BlameRenderer for GitBlameRenderer {
                                                     short_commit_id.clone(),
                                                 )
                                                 .color(Color::Muted)
-                                                .icon(IconName::FileGit)
-                                                .icon_color(Color::Muted)
-                                                .icon_position(IconPosition::Start)
-                                                .icon_size(IconSize::Small)
+                                                .start_icon(
+                                                    Icon::new(IconName::FileGit)
+                                                        .size(IconSize::Small)
+                                                        .color(Color::Muted),
+                                                )
                                                 .on_click(move |_, window, cx| {
                                                     CommitView::open(
                                                         commit_summary.sha.clone().into(),

crates/git_ui/src/commit_modal.rs 🔗

@@ -366,11 +366,12 @@ impl CommitModal {
             .unwrap_or_else(|| "<no branch>".to_owned());
 
         let branch_picker_button = panel_button(branch)
-            .icon(IconName::GitBranch)
-            .icon_size(IconSize::Small)
-            .icon_color(Color::Placeholder)
+            .start_icon(
+                Icon::new(IconName::GitBranch)
+                    .size(IconSize::Small)
+                    .color(Color::Placeholder),
+            )
             .color(Color::Muted)
-            .icon_position(IconPosition::Start)
             .on_click(cx.listener(|_, _, window, cx| {
                 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
             }))

crates/git_ui/src/commit_tooltip.rs 🔗

@@ -336,9 +336,10 @@ impl Render for CommitTooltip {
                                                     format!("#{}", pr.number),
                                                 )
                                                 .color(Color::Muted)
-                                                .icon(IconName::PullRequest)
-                                                .icon_color(Color::Muted)
-                                                .icon_position(IconPosition::Start)
+                                                .start_icon(
+                                                    Icon::new(IconName::PullRequest)
+                                                        .color(Color::Muted),
+                                                )
                                                 .style(ButtonStyle::Subtle)
                                                 .on_click(move |_, _, cx| {
                                                     cx.stop_propagation();
@@ -354,9 +355,9 @@ impl Render for CommitTooltip {
                                             )
                                             .style(ButtonStyle::Subtle)
                                             .color(Color::Muted)
-                                            .icon(IconName::FileGit)
-                                            .icon_color(Color::Muted)
-                                            .icon_position(IconPosition::Start)
+                                            .start_icon(
+                                                Icon::new(IconName::FileGit).color(Color::Muted),
+                                            )
                                             .on_click(
                                                 move |_, window, cx| {
                                                     CommitView::open(

crates/git_ui/src/commit_view.rs 🔗

@@ -524,10 +524,11 @@ impl CommitView {
             .when(self.stash.is_none(), |this| {
                 this.child(
                     Button::new("sha", "Commit SHA")
-                        .icon(copy_icon)
-                        .icon_color(copy_icon_color)
-                        .icon_position(IconPosition::Start)
-                        .icon_size(IconSize::Small)
+                        .start_icon(
+                            Icon::new(copy_icon)
+                                .size(IconSize::Small)
+                                .color(copy_icon_color),
+                        )
                         .tooltip({
                             let commit_sha = commit_sha.clone();
                             move |_, cx| {

crates/git_ui/src/conflict_view.rs 🔗

@@ -453,10 +453,11 @@ fn render_conflict_buttons(
             this.child(Divider::vertical()).child(
                 Button::new("resolve-with-agent", "Resolve with Agent")
                     .label_size(LabelSize::Small)
-                    .icon(IconName::ZedAssistant)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
+                    .start_icon(
+                        Icon::new(IconName::ZedAssistant)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .on_click({
                         let conflict = conflict.clone();
                         move |_, window, cx| {

crates/git_ui/src/file_diff_view.rs 🔗

@@ -6,9 +6,9 @@ use editor::{Editor, EditorEvent, MultiBuffer};
 use futures::{FutureExt, select_biased};
 use gpui::{
     AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
-    Focusable, IntoElement, Render, Task, WeakEntity, Window,
+    Focusable, Font, IntoElement, Render, Task, WeakEntity, Window,
 };
-use language::{Buffer, LanguageRegistry};
+use language::{Buffer, HighlightedText, LanguageRegistry};
 use project::Project;
 use std::{
     any::{Any, TypeId},
@@ -21,7 +21,7 @@ use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
 use util::paths::PathExt as _;
 use workspace::{
     Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
+    item::{ItemEvent, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -324,7 +324,7 @@ impl Item for FileDiffView {
         ToolbarItemLocation::PrimaryLeft
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         self.editor.breadcrumbs(cx)
     }
 

crates/git_ui/src/file_history_view.rs 🔗

@@ -429,10 +429,11 @@ impl Render for FileHistoryView {
                                     Button::new("load-more", "Load More")
                                         .disabled(self.loading_more)
                                         .label_size(LabelSize::Small)
-                                        .icon(IconName::ArrowCircle)
-                                        .icon_size(IconSize::Small)
-                                        .icon_color(Color::Muted)
-                                        .icon_position(IconPosition::Start)
+                                        .start_icon(
+                                            Icon::new(IconName::ArrowCircle)
+                                                .size(IconSize::Small)
+                                                .color(Color::Muted),
+                                        )
                                         .on_click(cx.listener(|this, _, window, cx| {
                                             this.load_more(window, cx);
                                         })),
@@ -565,7 +566,10 @@ impl Item for FileHistoryView {
         false
     }
 
-    fn breadcrumbs(&self, _cx: &App) -> Option<Vec<workspace::item::BreadcrumbText>> {
+    fn breadcrumbs(
+        &self,
+        _cx: &App,
+    ) -> Option<(Vec<workspace::item::HighlightedText>, Option<gpui::Font>)> {
         None
     }
 

crates/git_ui/src/git_ui.rs 🔗

@@ -872,8 +872,7 @@ impl Render for GitCloneModal {
                     .child(
                         Button::new("learn-more", "Learn More")
                             .label_size(LabelSize::Small)
-                            .icon(IconName::ArrowUpRight)
-                            .icon_size(IconSize::XSmall)
+                            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall))
                             .on_click(|_, _, cx| {
                                 cx.open_url("https://github.com/git-guides/git-clone");
                             }),

crates/git_ui/src/multi_diff_view.rs 🔗

@@ -3,9 +3,9 @@ use buffer_diff::BufferDiff;
 use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines};
 use gpui::{
     AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
-    Focusable, IntoElement, Render, SharedString, Task, Window,
+    Focusable, Font, IntoElement, Render, SharedString, Task, Window,
 };
-use language::{Buffer, Capability, OffsetRangeExt};
+use language::{Buffer, Capability, HighlightedText, OffsetRangeExt};
 use multi_buffer::PathKey;
 use project::Project;
 use std::{
@@ -18,7 +18,7 @@ use util::paths::PathStyle;
 use util::rel_path::RelPath;
 use workspace::{
     Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
+    item::{ItemEvent, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -338,7 +338,7 @@ impl Item for MultiDiffView {
         ToolbarItemLocation::PrimaryLeft
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         self.editor.breadcrumbs(cx)
     }
 

crates/git_ui/src/project_diff.rs 🔗

@@ -1592,8 +1592,11 @@ fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusH
         "send-review",
         format!("Send Review to Agent ({})", review_count),
     )
-    .icon(IconName::ZedAssistant)
-    .icon_position(IconPosition::Start)
+    .start_icon(
+        Icon::new(IconName::ZedAssistant)
+            .size(IconSize::Small)
+            .color(Color::Muted),
+    )
     .tooltip(Tooltip::for_action_title_in(
         "Send all review comments to the Agent panel",
         &SendReviewToAgent,
@@ -1686,10 +1689,11 @@ impl Render for BranchDiffToolbar {
                 let focus_handle = focus_handle.clone();
                 this.child(Divider::vertical()).child(
                     Button::new("review-diff", "Review Diff")
-                        .icon(IconName::ZedAssistant)
-                        .icon_position(IconPosition::Start)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
+                        .start_icon(
+                            Icon::new(IconName::ZedAssistant)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx))
                         .tooltip(move |_, cx| {
                             Tooltip::with_meta_in(

crates/gpui/src/app/headless_app_context.rs 🔗

@@ -186,6 +186,14 @@ impl HeadlessAppContext {
     }
 }
 
+impl Drop for HeadlessAppContext {
+    fn drop(&mut self) {
+        // Shut down the app so windows are closed and entity handles are
+        // released before the LeakDetector runs.
+        self.app.borrow_mut().shutdown();
+    }
+}
+
 impl AppContext for HeadlessAppContext {
     fn new<T: 'static>(&mut self, build_entity: impl FnOnce(&mut Context<T>) -> T) -> Entity<T> {
         let mut app = self.app.borrow_mut();

crates/gpui_linux/src/linux/x11/client.rs 🔗

@@ -602,6 +602,9 @@ impl X11Client {
                     Ok(None) => {
                         break;
                     }
+                    Err(err @ ConnectionError::IoError(..)) => {
+                        return Err(EventHandlerError::from(err));
+                    }
                     Err(err) => {
                         let err = handle_connection_error(err);
                         log::warn!("error while polling for X11 events: {err:?}");

crates/image_viewer/src/image_viewer.rs 🔗

@@ -10,10 +10,10 @@ use file_icons::FileIcons;
 use gpui::PinchEvent;
 use gpui::{
     AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter,
-    FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement,
-    LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels,
-    Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, WeakEntity, Window, actions,
-    checkerboard, div, img, point, px, size,
+    FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement,
+    IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
+    ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task,
+    WeakEntity, Window, actions, checkerboard, div, img, point, px, size,
 };
 use language::File as _;
 use persistence::IMAGE_VIEWER;
@@ -26,7 +26,7 @@ use workspace::{
     ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
     WorkspaceId, delete_unloaded_items,
     invalid_item_view::InvalidItemView,
-    item::{BreadcrumbText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams},
+    item::{HighlightedText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams},
 };
 
 pub use crate::image_info::*;
@@ -530,15 +530,17 @@ impl Item for ImageView {
         }
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx);
-        let settings = ThemeSettings::get_global(cx);
-
-        Some(vec![BreadcrumbText {
-            text,
-            highlights: None,
-            font: Some(settings.buffer_font.clone()),
-        }])
+        let font = ThemeSettings::get_global(cx).buffer_font.clone();
+
+        Some((
+            vec![HighlightedText {
+                text: text.into(),
+                highlights: vec![],
+            }],
+            Some(font),
+        ))
     }
 
     fn can_split(&self) -> bool {

crates/keymap_editor/src/keymap_editor.rs 🔗

@@ -2928,9 +2928,11 @@ impl Render for KeybindingEditorModal {
                                                 .child(
                                                     Button::new("show_matching", "View")
                                                         .label_size(LabelSize::Small)
-                                                        .icon(IconName::ArrowUpRight)
-                                                        .icon_color(Color::Muted)
-                                                        .icon_size(IconSize::Small)
+                                                        .end_icon(
+                                                            Icon::new(IconName::ArrowUpRight)
+                                                                .size(IconSize::Small)
+                                                                .color(Color::Muted),
+                                                        )
                                                         .on_click(cx.listener(
                                                             |this, _, window, cx| {
                                                                 this.show_matching_bindings(

crates/language_models/src/provider/bedrock.rs 🔗

@@ -1574,7 +1574,8 @@ impl Render for ConfigurationView {
         }
 
         v_flex()
-            .size_full()
+            .min_w_0()
+            .w_full()
             .track_focus(&self.focus_handle)
             .on_action(cx.listener(Self::on_tab))
             .on_action(cx.listener(Self::on_tab_prev))

crates/language_models/src/provider/cloud.rs 🔗

@@ -1126,6 +1126,7 @@ impl RenderOnce for ZedAiConfiguration {
         let manage_subscription_buttons = if is_pro {
             Button::new("manage_settings", "Manage Subscription")
                 .full_width()
+                .label_size(LabelSize::Small)
                 .style(ButtonStyle::Tinted(TintColor::Accent))
                 .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx)))
                 .into_any_element()
@@ -1149,10 +1150,7 @@ impl RenderOnce for ZedAiConfiguration {
                 .child(Label::new("Sign in to have access to Zed's complete agentic experience with hosted models."))
                 .child(
                     Button::new("sign_in", "Sign In to use Zed AI")
-                        .icon_color(Color::Muted)
-                        .icon(IconName::Github)
-                        .icon_size(IconSize::Small)
-                        .icon_position(IconPosition::Start)
+                        .start_icon(Icon::new(IconName::Github).size(IconSize::Small).color(Color::Muted))
                         .full_width()
                         .on_click({
                             let callback = self.sign_in_callback.clone();

crates/language_models/src/provider/lmstudio.rs 🔗

@@ -820,9 +820,7 @@ impl ConfigurationView {
                 .child(
                     Button::new("reset-api-url", "Reset API URL")
                         .label_size(LabelSize::Small)
-                        .icon(IconName::Undo)
-                        .icon_size(IconSize::Small)
-                        .icon_position(IconPosition::Start)
+                        .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
                         .layer(ElevationIndex::ModalSurface)
                         .on_click(
                             cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx)),
@@ -918,9 +916,11 @@ impl Render for ConfigurationView {
                                     this.child(
                                         Button::new("lmstudio-site", "LM Studio")
                                             .style(ButtonStyle::Subtle)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::Small)
-                                            .icon_color(Color::Muted)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::Small)
+                                                    .color(Color::Muted),
+                                            )
                                             .on_click(move |_, _window, cx| {
                                                 cx.open_url(LMSTUDIO_SITE)
                                             })
@@ -933,9 +933,11 @@ impl Render for ConfigurationView {
                                             "Download LM Studio",
                                         )
                                         .style(ButtonStyle::Subtle)
-                                        .icon(IconName::ArrowUpRight)
-                                        .icon_size(IconSize::Small)
-                                        .icon_color(Color::Muted)
+                                        .end_icon(
+                                            Icon::new(IconName::ArrowUpRight)
+                                                .size(IconSize::Small)
+                                                .color(Color::Muted),
+                                        )
                                         .on_click(move |_, _window, cx| {
                                             cx.open_url(LMSTUDIO_DOWNLOAD_URL)
                                         })
@@ -946,9 +948,11 @@ impl Render for ConfigurationView {
                             .child(
                                 Button::new("view-models", "Model Catalog")
                                     .style(ButtonStyle::Subtle)
-                                    .icon(IconName::ArrowUpRight)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(Color::Muted)
+                                    .end_icon(
+                                        Icon::new(IconName::ArrowUpRight)
+                                            .size(IconSize::Small)
+                                            .color(Color::Muted),
+                                    )
                                     .on_click(move |_, _window, cx| {
                                         cx.open_url(LMSTUDIO_CATALOG_URL)
                                     }),
@@ -981,9 +985,9 @@ impl Render for ConfigurationView {
                         } else {
                             this.child(
                                 Button::new("retry_lmstudio_models", "Connect")
-                                    .icon_position(IconPosition::Start)
-                                    .icon_size(IconSize::XSmall)
-                                    .icon(IconName::PlayFilled)
+                                    .start_icon(
+                                        Icon::new(IconName::PlayFilled).size(IconSize::XSmall),
+                                    )
                                     .on_click(cx.listener(move |this, _, _window, cx| {
                                         this.retry_connection(_window, cx)
                                     })),

crates/language_models/src/provider/ollama.rs 🔗

@@ -858,9 +858,7 @@ impl ConfigurationView {
                 .child(
                     Button::new("reset-context-window", "Reset")
                         .label_size(LabelSize::Small)
-                        .icon(IconName::Undo)
-                        .icon_size(IconSize::Small)
-                        .icon_position(IconPosition::Start)
+                        .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
                         .layer(ElevationIndex::ModalSurface)
                         .on_click(
                             cx.listener(|this, _, window, cx| {
@@ -905,9 +903,7 @@ impl ConfigurationView {
                 .child(
                     Button::new("reset-api-url", "Reset API URL")
                         .label_size(LabelSize::Small)
-                        .icon(IconName::Undo)
-                        .icon_size(IconSize::Small)
-                        .icon_position(IconPosition::Start)
+                        .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
                         .layer(ElevationIndex::ModalSurface)
                         .on_click(
                             cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)),
@@ -949,9 +945,11 @@ impl Render for ConfigurationView {
                                     this.child(
                                         Button::new("ollama-site", "Ollama")
                                             .style(ButtonStyle::Subtle)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::XSmall)
-                                            .icon_color(Color::Muted)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::XSmall)
+                                                    .color(Color::Muted),
+                                            )
                                             .on_click(move |_, _, cx| cx.open_url(OLLAMA_SITE))
                                             .into_any_element(),
                                     )
@@ -959,9 +957,11 @@ impl Render for ConfigurationView {
                                     this.child(
                                         Button::new("download_ollama_button", "Download Ollama")
                                             .style(ButtonStyle::Subtle)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::XSmall)
-                                            .icon_color(Color::Muted)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::XSmall)
+                                                    .color(Color::Muted),
+                                            )
                                             .on_click(move |_, _, cx| {
                                                 cx.open_url(OLLAMA_DOWNLOAD_URL)
                                             })
@@ -972,9 +972,11 @@ impl Render for ConfigurationView {
                             .child(
                                 Button::new("view-models", "View All Models")
                                     .style(ButtonStyle::Subtle)
-                                    .icon(IconName::ArrowUpRight)
-                                    .icon_size(IconSize::XSmall)
-                                    .icon_color(Color::Muted)
+                                    .end_icon(
+                                        Icon::new(IconName::ArrowUpRight)
+                                            .size(IconSize::XSmall)
+                                            .color(Color::Muted),
+                                    )
                                     .on_click(move |_, _, cx| cx.open_url(OLLAMA_LIBRARY_URL)),
                             ),
                     )
@@ -1005,9 +1007,9 @@ impl Render for ConfigurationView {
                         } else {
                             this.child(
                                 Button::new("retry_ollama_models", "Connect")
-                                    .icon_position(IconPosition::Start)
-                                    .icon_size(IconSize::XSmall)
-                                    .icon(IconName::PlayOutlined)
+                                    .start_icon(
+                                        Icon::new(IconName::PlayOutlined).size(IconSize::XSmall),
+                                    )
                                     .on_click(cx.listener(move |this, _, window, cx| {
                                         this.retry_connection(window, cx)
                                     })),

crates/language_models/src/provider/open_ai.rs 🔗

@@ -1415,9 +1415,11 @@ impl Render for ConfigurationView {
             )
             .child(
                 Button::new("docs", "Learn More")
-                    .icon(IconName::ArrowUpRight)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
+                    .end_icon(
+                        Icon::new(IconName::ArrowUpRight)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .on_click(move |_, _window, cx| {
                         cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible")
                     }),

crates/language_models/src/provider/open_ai_compatible.rs 🔗

@@ -545,9 +545,7 @@ impl Render for ConfigurationView {
                         .child(
                             Button::new("reset-api-key", "Reset API Key")
                                 .label_size(LabelSize::Small)
-                                .icon(IconName::Undo)
-                                .icon_size(IconSize::Small)
-                                .icon_position(IconPosition::Start)
+                                .start_icon(Icon::new(IconName::Undo).size(IconSize::Small))
                                 .layer(ElevationIndex::ModalSurface)
                                 .when(env_var_set, |this| {
                                     this.tooltip(Tooltip::text(format!("To reset your API key, unset the {env_var_name} environment variable.")))

crates/language_onboarding/src/python.rs 🔗

@@ -56,10 +56,8 @@ impl Render for BasedPyrightBanner {
                                 .gap_0p5()
                                 .child(
                                     Button::new("learn-more", "Learn More")
-                                        .icon(IconName::ArrowUpRight)
                                         .label_size(LabelSize::Small)
-                                        .icon_size(IconSize::XSmall)
-                                        .icon_color(Color::Muted)
+                                        .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall).color(Color::Muted))
                                         .on_click(|_, _, cx| {
                                             cx.open_url("https://zed.dev/docs/languages/python")
                                         }),

crates/language_tools/src/lsp_log_view.rs 🔗

@@ -18,7 +18,7 @@ use project::{
 };
 use proto::toggle_lsp_logs::LogType;
 use std::{any::TypeId, borrow::Cow, sync::Arc};
-use ui::{Button, Checkbox, ContextMenu, Label, PopoverMenu, ToggleState, prelude::*};
+use ui::{Checkbox, ContextMenu, PopoverMenu, ToggleState, prelude::*};
 use util::ResultExt as _;
 use workspace::{
     SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
@@ -969,9 +969,11 @@ impl Render for LspLogToolbarItemView {
                         })
                         .unwrap_or_else(|| "No server selected".into()),
                 )
-                .icon(IconName::ChevronDown)
-                .icon_size(IconSize::Small)
-                .icon_color(Color::Muted),
+                .end_icon(
+                    Icon::new(IconName::ChevronDown)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                ),
             )
             .menu({
                 let log_view = log_view.clone();
@@ -1030,10 +1032,11 @@ impl Render for LspLogToolbarItemView {
             PopoverMenu::new("LspViewSelector")
                 .anchor(Corner::TopLeft)
                 .trigger(
-                    Button::new("language_server_menu_header", label)
-                        .icon(IconName::ChevronDown)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted),
+                    Button::new("language_server_menu_header", label).end_icon(
+                        Icon::new(IconName::ChevronDown)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    ),
                 )
                 .menu(move |window, cx| {
                     let log_toolbar_view = log_toolbar_view.upgrade()?;
@@ -1125,9 +1128,11 @@ impl Render for LspLogToolbarItemView {
                                                 "language_server_trace_level_selector",
                                                 "Trace level",
                                             )
-                                            .icon(IconName::ChevronDown)
-                                            .icon_size(IconSize::Small)
-                                            .icon_color(Color::Muted),
+                                            .end_icon(
+                                                Icon::new(IconName::ChevronDown)
+                                                    .size(IconSize::Small)
+                                                    .color(Color::Muted),
+                                            ),
                                         )
                                         .menu({
                                             let log_view = log_view;
@@ -1193,9 +1198,11 @@ impl Render for LspLogToolbarItemView {
                                                 "language_server_log_level_selector",
                                                 "Log level",
                                             )
-                                            .icon(IconName::ChevronDown)
-                                            .icon_size(IconSize::Small)
-                                            .icon_color(Color::Muted),
+                                            .end_icon(
+                                                Icon::new(IconName::ChevronDown)
+                                                    .size(IconSize::Small)
+                                                    .color(Color::Muted),
+                                            ),
                                         )
                                         .menu({
                                             let log_view = log_view;

crates/onboarding/src/basics_page.rs 🔗

@@ -10,9 +10,8 @@ use theme::{
     ThemeSettings,
 };
 use ui::{
-    Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor,
-    ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip,
-    prelude::*, rems_from_px,
+    Divider, StatefulInteractiveElement, SwitchField, TintColor, ToggleButtonGroup,
+    ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, prelude::*,
 };
 use vim_mode_setting::VimModeSetting;
 
@@ -477,8 +476,7 @@ fn render_setting_import_button(
         .toggle_state(imported)
         .tab_index(tab_index)
         .when(imported, |this| {
-            this.icon(IconName::Check)
-                .icon_size(IconSize::Small)
+            this.end_icon(Icon::new(IconName::Check).size(IconSize::Small))
                 .color(Color::Success)
         })
         .on_click(move |_, window, cx| {

crates/onboarding/src/multibuffer_hint.rs 🔗

@@ -158,10 +158,11 @@ impl Render for MultibufferHint {
                     )
                     .child(
                         Button::new("open_docs", "Learn More")
-                            .icon(IconName::ArrowUpRight)
-                            .icon_size(IconSize::Small)
-                            .icon_color(Color::Muted)
-                            .icon_position(IconPosition::End)
+                            .end_icon(
+                                Icon::new(IconName::ArrowUpRight)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .on_click(move |_event, _, cx| {
                                 cx.open_url("https://zed.dev/docs/multibuffers")
                             }),

crates/open_path_prompt/src/file_finder_settings.rs 🔗

@@ -8,6 +8,7 @@ pub struct FileFinderSettings {
     pub modal_max_width: FileFinderWidth,
     pub skip_focus_for_active_in_search: bool,
     pub include_ignored: Option<bool>,
+    pub include_channels: bool,
 }
 
 impl Settings for FileFinderSettings {
@@ -23,6 +24,7 @@ impl Settings for FileFinderSettings {
                 settings::IncludeIgnoredContent::Indexed => Some(false),
                 settings::IncludeIgnoredContent::Smart => None,
             },
+            include_channels: file_finder.include_channels.unwrap(),
         }
     }
 }

crates/panel/src/panel.rs 🔗

@@ -52,7 +52,6 @@ pub fn panel_button(label: impl Into<SharedString>) -> ui::Button {
     let id = ElementId::Name(label.to_lowercase().replace(' ', "_").into());
     ui::Button::new(id, label)
         .label_size(ui::LabelSize::Small)
-        .icon_size(ui::IconSize::Small)
         // TODO: Change this once we use on_surface_bg in button_like
         .layer(ui::ElevationIndex::ModalSurface)
         .size(ui::ButtonSize::Compact)

crates/project/src/lsp_store.rs 🔗

@@ -3963,10 +3963,7 @@ impl BufferLspData {
         self.inlay_hints.remove_server_data(for_server);
 
         if let Some(semantic_tokens) = &mut self.semantic_tokens {
-            semantic_tokens.raw_tokens.servers.remove(&for_server);
-            semantic_tokens
-                .latest_invalidation_requests
-                .remove(&for_server);
+            semantic_tokens.remove_server_data(for_server);
         }
 
         if let Some(folding_ranges) = &mut self.folding_ranges {

crates/project/src/lsp_store/semantic_tokens.rs 🔗

@@ -610,6 +610,14 @@ pub struct SemanticTokensData {
     update: Option<(Global, SemanticTokensTask)>,
 }
 
+impl SemanticTokensData {
+    pub(super) fn remove_server_data(&mut self, server_id: LanguageServerId) {
+        self.raw_tokens.servers.remove(&server_id);
+        self.latest_invalidation_requests.remove(&server_id);
+        self.update = None;
+    }
+}
+
 /// All the semantic token tokens for a buffer.
 ///
 /// This aggregates semantic tokens from multiple language servers in a specific order.

crates/project_panel/src/project_panel.rs 🔗

@@ -6341,6 +6341,7 @@ impl Render for ProjectPanel {
         let panel_settings = ProjectPanelSettings::get_global(cx);
         let indent_size = panel_settings.indent_size;
         let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always;
+        let horizontal_scroll = panel_settings.scrollbar.horizontal_scroll;
         let show_sticky_entries = {
             if panel_settings.sticky_scroll {
                 let is_scrollable = self.scroll_handle.is_scrollable();
@@ -6713,10 +6714,14 @@ impl Render for ProjectPanel {
                                 })
                             })
                             .with_sizing_behavior(ListSizingBehavior::Infer)
-                            .with_horizontal_sizing_behavior(
-                                ListHorizontalSizingBehavior::Unconstrained,
-                            )
-                            .with_width_from_item(self.state.max_width_item_index)
+                            .with_horizontal_sizing_behavior(if horizontal_scroll {
+                                ListHorizontalSizingBehavior::Unconstrained
+                            } else {
+                                ListHorizontalSizingBehavior::FitList
+                            })
+                            .when(horizontal_scroll, |list| {
+                                list.with_width_from_item(self.state.max_width_item_index)
+                            })
                             .track_scroll(&self.scroll_handle),
                         )
                         .child(
@@ -6877,13 +6882,17 @@ impl Render for ProjectPanel {
                         .size_full(),
                 )
                 .custom_scrollbars(
-                    Scrollbars::for_settings::<ProjectPanelSettings>()
-                        .tracked_scroll_handle(&self.scroll_handle)
-                        .with_track_along(
-                            ScrollAxes::Horizontal,
-                            cx.theme().colors().panel_background,
-                        )
-                        .notify_content(),
+                    {
+                        let mut scrollbars = Scrollbars::for_settings::<ProjectPanelSettings>()
+                            .tracked_scroll_handle(&self.scroll_handle);
+                        if horizontal_scroll {
+                            scrollbars = scrollbars.with_track_along(
+                                ScrollAxes::Horizontal,
+                                cx.theme().colors().panel_background,
+                            );
+                        }
+                        scrollbars.notify_content()
+                    },
                     window,
                     cx,
                 )

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -49,6 +49,11 @@ pub struct ScrollbarSettings {
     ///
     /// Default: inherits editor scrollbar settings
     pub show: Option<ShowScrollbar>,
+    /// Whether to allow horizontal scrolling in the project panel.
+    /// When false, the view is locked to the leftmost position and long file names are clipped.
+    ///
+    /// Default: true
+    pub horizontal_scroll: bool,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -111,8 +116,12 @@ impl Settings for ProjectPanelSettings {
             auto_fold_dirs: project_panel.auto_fold_dirs.unwrap(),
             bold_folder_labels: project_panel.bold_folder_labels.unwrap(),
             starts_open: project_panel.starts_open.unwrap(),
-            scrollbar: ScrollbarSettings {
-                show: project_panel.scrollbar.unwrap().show.map(Into::into),
+            scrollbar: {
+                let scrollbar = project_panel.scrollbar.unwrap();
+                ScrollbarSettings {
+                    show: scrollbar.show.map(Into::into),
+                    horizontal_scroll: scrollbar.horizontal_scroll.unwrap(),
+                }
             },
             show_diagnostics: project_panel.show_diagnostics.unwrap(),
             hide_root: project_panel.hide_root.unwrap(),

crates/recent_projects/src/disconnected_overlay.rs 🔗

@@ -2,11 +2,7 @@ use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, Focusable, Rende
 use project::project_settings::ProjectSettings;
 use remote::RemoteConnectionOptions;
 use settings::Settings;
-use ui::{
-    Button, ButtonCommon, ButtonStyle, Clickable, Context, ElevationIndex, FluentBuilder, Headline,
-    HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal,
-    ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, Window, div, h_flex, rems,
-};
+use ui::{ElevationIndex, Modal, ModalFooter, ModalHeader, Section, prelude::*};
 use workspace::{
     ModalView, MultiWorkspace, OpenOptions, Workspace, notifications::DetachAndPromptErr,
 };
@@ -207,8 +203,7 @@ impl Render for DisconnectedOverlay {
                                         Button::new("reconnect", "Reconnect")
                                             .style(ButtonStyle::Filled)
                                             .layer(ElevationIndex::ModalSurface)
-                                            .icon(IconName::ArrowCircle)
-                                            .icon_position(IconPosition::Start)
+                                            .start_icon(Icon::new(IconName::ArrowCircle))
                                             .on_click(cx.listener(Self::handle_reconnect)),
                                     )
                                 }),

crates/recent_projects/src/remote_servers.rs 🔗

@@ -2117,8 +2117,10 @@ impl RemoteServerProjects {
                                     .child(
                                         Button::new("learn-more", "Learn More")
                                             .label_size(LabelSize::Small)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::XSmall)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::XSmall),
+                                            )
                                             .on_click(|_, _, cx| {
                                                 cx.open_url(
                                                     "https://zed.dev/docs/remote-development",

crates/repl/src/components/kernel_options.rs 🔗

@@ -431,10 +431,11 @@ impl PickerDelegate for KernelPickerDelegate {
                 .gap_4()
                 .child(
                     Button::new("kernel-docs", "Kernel Docs")
-                        .icon(IconName::ArrowUpRight)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
-                        .icon_position(IconPosition::End)
+                        .end_icon(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .on_click(move |_, _, cx| cx.open_url(KERNEL_DOCS_URL)),
                 )
                 .into_any(),

crates/repl/src/notebook/notebook_ui.rs 🔗

@@ -1117,10 +1117,11 @@ impl NotebookEditor {
                     worktree_id,
                     Button::new("kernel-selector", kernel_name.clone())
                         .label_size(LabelSize::Small)
-                        .icon(status_icon)
-                        .icon_size(IconSize::Small)
-                        .icon_color(status_color)
-                        .icon_position(IconPosition::Start),
+                        .start_icon(
+                            Icon::new(status_icon)
+                                .size(IconSize::Small)
+                                .color(status_color),
+                        ),
                     Tooltip::text(format!(
                         "Kernel: {} ({}). Click to change.",
                         kernel_name,

crates/rules_library/src/rules_library.rs 🔗

@@ -1170,10 +1170,11 @@ impl RulesLibrary {
                             Button::new("new-rule", "New Rule")
                                 .full_width()
                                 .style(ButtonStyle::Outlined)
-                                .icon(IconName::Plus)
-                                .icon_size(IconSize::Small)
-                                .icon_position(IconPosition::Start)
-                                .icon_color(Color::Muted)
+                                .start_icon(
+                                    Icon::new(IconName::Plus)
+                                        .size(IconSize::Small)
+                                        .color(Color::Muted),
+                                )
                                 .on_click(|_, window, cx| {
                                     window.dispatch_action(Box::new(NewRule), cx);
                                 }),

crates/search/src/project_search.rs 🔗

@@ -1583,9 +1583,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("filter-paths", "Include/exclude specific paths")
-                    .icon(IconName::Filter)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::Filter).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(&ToggleFilters, &focus_handle, cx))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleFilters.boxed_clone(), cx)
@@ -1593,9 +1591,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("find-replace", "Find and replace")
-                    .icon(IconName::Replace)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::Replace).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(&ToggleReplace, &focus_handle, cx))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleReplace.boxed_clone(), cx)
@@ -1603,9 +1599,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("regex", "Match with regex")
-                    .icon(IconName::Regex)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::Regex).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(&ToggleRegex, &focus_handle, cx))
                     .on_click(|_event, window, cx| {
                         window.dispatch_action(ToggleRegex.boxed_clone(), cx)
@@ -1613,9 +1607,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("match-case", "Match case")
-                    .icon(IconName::CaseSensitive)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::CaseSensitive).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(
                         &ToggleCaseSensitive,
                         &focus_handle,
@@ -1627,9 +1619,7 @@ impl ProjectSearchView {
             )
             .child(
                 Button::new("match-whole-words", "Match whole words")
-                    .icon(IconName::WholeWord)
-                    .icon_position(IconPosition::Start)
-                    .icon_size(IconSize::Small)
+                    .start_icon(Icon::new(IconName::WholeWord).size(IconSize::Small))
                     .key_binding(KeyBinding::for_action_in(
                         &ToggleWholeWord,
                         &focus_handle,

crates/settings/src/vscode_import.rs 🔗

@@ -793,7 +793,12 @@ impl VsCodeSettings {
             hide_root: None,
             indent_guides: None,
             indent_size: None,
-            scrollbar: None,
+            scrollbar: self.read_bool("workbench.list.horizontalScrolling").map(
+                |horizontal_scrolling| ProjectPanelScrollbarSettingsContent {
+                    show: None,
+                    horizontal_scroll: Some(horizontal_scrolling),
+                },
+            ),
             show_diagnostics: self
                 .read_bool("problems.decorations.enabled")
                 .and_then(|b| if b { Some(ShowDiagnostics::Off) } else { None }),

crates/settings_content/src/settings_content.rs 🔗

@@ -721,6 +721,10 @@ pub struct FileFinderSettingsContent {
     ///
     /// Default: Smart
     pub include_ignored: Option<IncludeIgnoredContent>,
+    /// Whether to include text channels in file finder results.
+    ///
+    /// Default: false
+    pub include_channels: Option<bool>,
 }
 
 #[derive(

crates/settings_content/src/workspace.rs 🔗

@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
 use settings_macros::{MergeFrom, with_fallible_options};
 
 use crate::{
-    CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity,
-    ScrollbarSettingsContent, ShowIndentGuides, serialize_optional_f32_with_two_decimal_places,
+    CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, ShowIndentGuides,
+    ShowScrollbar, serialize_optional_f32_with_two_decimal_places,
 };
 
 #[with_fallible_options]
@@ -710,7 +710,7 @@ pub struct ProjectPanelSettingsContent {
     /// Default: true
     pub starts_open: Option<bool>,
     /// Scrollbar-related settings
-    pub scrollbar: Option<ScrollbarSettingsContent>,
+    pub scrollbar: Option<ProjectPanelScrollbarSettingsContent>,
     /// Which files containing diagnostic errors/warnings to mark in the project panel.
     ///
     /// Default: all
@@ -793,6 +793,23 @@ pub enum ProjectPanelSortMode {
     FilesFirst,
 }
 
+#[with_fallible_options]
+#[derive(
+    Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default,
+)]
+pub struct ProjectPanelScrollbarSettingsContent {
+    /// When to show the scrollbar in the project panel.
+    ///
+    /// Default: inherits editor scrollbar settings
+    pub show: Option<ShowScrollbar>,
+    /// Whether to allow horizontal scrolling in the project panel.
+    /// When false, the view is locked to the leftmost position and
+    /// long file names are clipped.
+    ///
+    /// Default: true
+    pub horizontal_scroll: Option<bool>,
+}
+
 #[with_fallible_options]
 #[derive(
     Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default,

crates/settings_ui/src/page_data.rs 🔗

@@ -4238,7 +4238,7 @@ fn window_and_layout_page() -> SettingsPage {
 }
 
 fn panels_page() -> SettingsPage {
-    fn project_panel_section() -> [SettingsPageItem; 22] {
+    fn project_panel_section() -> [SettingsPageItem; 23] {
         [
             SettingsPageItem::SectionHeader("Project Panel"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -4516,6 +4516,32 @@ fn panels_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Horizontal Scroll",
+                description: "Whether to allow horizontal scrolling in the project panel. When disabled, the view is always locked to the leftmost position and long file names are clipped.",
+                field: Box::new(SettingField {
+                    json_path: Some("project_panel.scrollbar.horizontal_scroll"),
+                    pick: |settings_content| {
+                        settings_content
+                            .project_panel
+                            .as_ref()?
+                            .scrollbar
+                            .as_ref()?
+                            .horizontal_scroll
+                            .as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .project_panel
+                            .get_or_insert_default()
+                            .scrollbar
+                            .get_or_insert_default()
+                            .horizontal_scroll = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Show Diagnostics",
                 description: "Which files containing diagnostic errors/warnings to mark in the project panel.",

crates/settings_ui/src/pages/tool_permissions_setup.rs 🔗

@@ -275,10 +275,11 @@ fn render_tool_list_item(
                 .tab_index(tool_index as isize)
                 .style(ButtonStyle::OutlinedGhost)
                 .size(ButtonSize::Medium)
-                .icon(IconName::ChevronRight)
-                .icon_position(IconPosition::End)
-                .icon_color(Color::Muted)
-                .icon_size(IconSize::Small)
+                .end_icon(
+                    Icon::new(IconName::ChevronRight)
+                        .size(IconSize::Small)
+                        .color(Color::Muted),
+                )
                 .on_click(cx.listener(move |this, _, window, cx| {
                     this.push_dynamic_sub_page(
                         tool_name,
@@ -1090,9 +1091,7 @@ fn render_global_default_mode_section(current_mode: ToolPermissionMode) -> AnyEl
                         .tab_index(0_isize)
                         .style(ButtonStyle::Outlined)
                         .size(ButtonSize::Medium)
-                        .icon(IconName::ChevronDown)
-                        .icon_position(IconPosition::End)
-                        .icon_size(IconSize::Small),
+                        .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)),
                 )
                 .menu(move |window, cx| {
                     Some(ContextMenu::build(window, cx, move |menu, _, _| {
@@ -1141,9 +1140,7 @@ fn render_default_mode_section(
                         .tab_index(0_isize)
                         .style(ButtonStyle::Outlined)
                         .size(ButtonSize::Medium)
-                        .icon(IconName::ChevronDown)
-                        .icon_position(IconPosition::End)
-                        .icon_size(IconSize::Small),
+                        .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)),
                 )
                 .menu(move |window, cx| {
                     let tool_id = tool_id_owned.clone();

crates/settings_ui/src/settings_ui.rs 🔗

@@ -925,9 +925,7 @@ impl SettingsPageItem {
                         Button::new("error-warning", warning)
                             .style(ButtonStyle::Outlined)
                             .size(ButtonSize::Medium)
-                            .icon(Some(IconName::Debug))
-                            .icon_position(IconPosition::Start)
-                            .icon_color(Color::Error)
+                            .start_icon(Icon::new(IconName::Debug).color(Color::Error))
                             .tab_index(0_isize)
                             .tooltip(Tooltip::text(setting_item.field.type_name()))
                             .into_any_element(),
@@ -992,11 +990,12 @@ impl SettingsPageItem {
                                 ("sub-page".into(), sub_page_link.title.clone()),
                                 "Configure",
                             )
-                            .icon(IconName::ChevronRight)
                             .tab_index(0_isize)
-                            .icon_position(IconPosition::End)
-                            .icon_color(Color::Muted)
-                            .icon_size(IconSize::Small)
+                            .end_icon(
+                                Icon::new(IconName::ChevronRight)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .style(ButtonStyle::OutlinedGhost)
                             .size(ButtonSize::Medium)
                             .on_click({
@@ -1125,11 +1124,12 @@ impl SettingsPageItem {
                                 ("action-link".into(), action_link.title.clone()),
                                 action_link.button_text.clone(),
                             )
-                            .icon(IconName::ArrowUpRight)
                             .tab_index(0_isize)
-                            .icon_position(IconPosition::End)
-                            .icon_color(Color::Muted)
-                            .icon_size(IconSize::Small)
+                            .end_icon(
+                                Icon::new(IconName::ArrowUpRight)
+                                    .size(IconSize::Small)
+                                    .color(Color::Muted),
+                            )
                             .style(ButtonStyle::OutlinedGhost)
                             .size(ButtonSize::Medium)
                             .on_click({
@@ -4174,10 +4174,11 @@ fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button
         .tab_index(0_isize)
         .style(ButtonStyle::Outlined)
         .size(ButtonSize::Medium)
-        .icon(IconName::ChevronUpDown)
-        .icon_color(Color::Muted)
-        .icon_size(IconSize::Small)
-        .icon_position(IconPosition::End)
+        .end_icon(
+            Icon::new(IconName::ChevronUpDown)
+                .size(IconSize::Small)
+                .color(Color::Muted),
+        )
 }
 
 fn render_font_picker(

crates/terminal_view/src/terminal_view.rs 🔗

@@ -9,7 +9,7 @@ use assistant_slash_command::SlashCommandRegistry;
 use editor::{Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager};
 use gpui::{
     Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths,
-    FocusHandle, Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent,
+    FocusHandle, Focusable, Font, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent,
     Pixels, Point, Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions,
     anchored, deferred, div,
 };
@@ -55,7 +55,7 @@ use workspace::{
     CloseActiveItem, DraggedSelection, DraggedTab, NewCenterTerminal, NewTerminal, Pane,
     ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items,
     item::{
-        BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
+        HighlightedText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
     },
     register_serializable_item,
     searchable::{
@@ -1655,12 +1655,14 @@ impl Item for TerminalView {
         }
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        Some(vec![BreadcrumbText {
-            text: self.terminal().read(cx).breadcrumb_text.clone(),
-            highlights: None,
-            font: None,
-        }])
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
+        Some((
+            vec![HighlightedText {
+                text: self.terminal().read(cx).breadcrumb_text.clone().into(),
+                highlights: vec![],
+            }],
+            None,
+        ))
     }
 
     fn added_to_workspace(

crates/theme_selector/src/icon_theme_selector.rs 🔗

@@ -311,10 +311,11 @@ impl PickerDelegate for IconThemeSelectorDelegate {
                 .border_color(cx.theme().colors().border_variant)
                 .child(
                     Button::new("docs", "View Icon Theme Docs")
-                        .icon(IconName::ArrowUpRight)
-                        .icon_position(IconPosition::End)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
+                        .end_icon(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .on_click(|_event, _window, cx| {
                             cx.open_url("https://zed.dev/docs/icon-themes");
                         }),

crates/theme_selector/src/theme_selector.rs 🔗

@@ -497,10 +497,11 @@ impl PickerDelegate for ThemeSelectorDelegate {
                 .border_color(cx.theme().colors().border_variant)
                 .child(
                     Button::new("docs", "View Theme Docs")
-                        .icon(IconName::ArrowUpRight)
-                        .icon_position(IconPosition::End)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
+                        .end_icon(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
                         .on_click(cx.listener(|_, _, _, cx| {
                             cx.open_url("https://zed.dev/docs/themes");
                         })),

crates/title_bar/src/title_bar.rs 🔗

@@ -583,10 +583,11 @@ impl TitleBar {
             .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)
+            .start_icon(
+                Icon::new(IconName::Warning)
+                    .size(IconSize::Small)
+                    .color(Color::Warning),
+            )
             .tooltip(|_, cx| {
                 Tooltip::with_meta(
                     "You're in Restricted Mode",
@@ -697,9 +698,11 @@ impl TitleBar {
                 Button::new("project_name_trigger", display_name)
                     .label_size(LabelSize::Small)
                     .when(self.worktree_count(cx) > 1, |this| {
-                        this.icon(IconName::ChevronDown)
-                            .icon_color(Color::Muted)
-                            .icon_size(IconSize::XSmall)
+                        this.end_icon(
+                            Icon::new(IconName::ChevronDown)
+                                .size(IconSize::XSmall)
+                                .color(Color::Muted),
+                        )
                     })
                     .selected_style(ButtonStyle::Tinted(TintColor::Accent))
                     .when(!is_project_selected, |s| s.color(Color::Muted)),
@@ -779,11 +782,9 @@ impl TitleBar {
                         .color(Color::Muted)
                         .when(settings.show_branch_icon, |branch_button| {
                             let (icon, icon_color) = icon_info;
-                            branch_button
-                                .icon(icon)
-                                .icon_position(IconPosition::Start)
-                                .icon_color(icon_color)
-                                .icon_size(IconSize::Indicator)
+                            branch_button.start_icon(
+                                Icon::new(icon).size(IconSize::Indicator).color(icon_color),
+                            )
                         }),
                     move |_window, cx| {
                         Tooltip::with_meta(

crates/ui/src/components/ai.rs 🔗

@@ -1,5 +1,7 @@
 mod configured_api_card;
 mod thread_item;
+mod thread_sidebar_toggle;
 
 pub use configured_api_card::*;
 pub use thread_item::*;
+pub use thread_sidebar_toggle::*;

crates/ui/src/components/ai/configured_api_card.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{Tooltip, prelude::*};
 use gpui::{ClickEvent, IntoElement, ParentElement, SharedString};
 
-#[derive(IntoElement)]
+#[derive(IntoElement, RegisterComponent)]
 pub struct ConfiguredApiCard {
     label: SharedString,
     button_label: Option<SharedString>,
@@ -52,6 +52,59 @@ impl ConfiguredApiCard {
     }
 }
 
+impl Component for ConfiguredApiCard {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let container = || {
+            v_flex()
+                .w_72()
+                .p_2()
+                .gap_2()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().panel_background)
+        };
+
+        let examples = vec![
+            single_example(
+                "Default",
+                container()
+                    .child(ConfiguredApiCard::new("API key is configured"))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Custom Button Label",
+                container()
+                    .child(
+                        ConfiguredApiCard::new("OpenAI API key configured")
+                            .button_label("Remove Key"),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "With Tooltip",
+                container()
+                    .child(
+                        ConfiguredApiCard::new("Anthropic API key configured")
+                            .tooltip_label("Click to reset your API key"),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Disabled",
+                container()
+                    .child(ConfiguredApiCard::new("API key is configured").disabled(true))
+                    .into_any_element(),
+            ),
+        ];
+
+        Some(example_group(examples).into_any_element())
+    }
+}
+
 impl RenderOnce for ConfiguredApiCard {
     fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
         let button_label = self.button_label.unwrap_or("Reset Key".into());
@@ -80,10 +133,11 @@ impl RenderOnce for ConfiguredApiCard {
                         elem.tab_index(tab_index)
                     })
                     .label_size(LabelSize::Small)
-                    .icon(IconName::Undo)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted)
-                    .icon_position(IconPosition::Start)
+                    .start_icon(
+                        Icon::new(IconName::Undo)
+                            .size(IconSize::Small)
+                            .color(Color::Muted),
+                    )
                     .disabled(self.disabled)
                     .when_some(self.tooltip_label, |this, label| {
                         this.tooltip(Tooltip::text(label))

crates/ui/src/components/ai/thread_item.rs 🔗

@@ -311,11 +311,10 @@ impl RenderOnce for ThreadItem {
                             this.child(dot_separator())
                         })
                         .when(has_diff_stats, |this| {
-                            this.child(DiffStat::new(
-                                diff_stat_id.clone(),
-                                added_count,
-                                removed_count,
-                            ))
+                            this.child(
+                                DiffStat::new(diff_stat_id.clone(), added_count, removed_count)
+                                    .tooltip("Unreviewed changes"),
+                            )
                         })
                         .when(has_diff_stats && has_timestamp, |this| {
                             this.child(dot_separator())
@@ -336,7 +335,10 @@ impl RenderOnce for ThreadItem {
                         .gap_1p5()
                         .child(icon_container()) // Icon Spacing
                         .when(has_diff_stats, |this| {
-                            this.child(DiffStat::new(diff_stat_id, added_count, removed_count))
+                            this.child(
+                                DiffStat::new(diff_stat_id, added_count, removed_count)
+                                    .tooltip("Unreviewed changes"),
+                            )
                         })
                         .when(has_diff_stats && has_timestamp, |this| {
                             this.child(dot_separator())

crates/ui/src/components/ai/thread_sidebar_toggle.rs 🔗

@@ -0,0 +1,177 @@
+use gpui::{AnyView, ClickEvent};
+use ui_macros::RegisterComponent;
+
+use crate::prelude::*;
+use crate::{IconButton, IconName, Tooltip};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct ThreadSidebarToggle {
+    sidebar_selected: bool,
+    thread_selected: bool,
+    flipped: bool,
+    sidebar_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
+    thread_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
+    on_sidebar_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+    on_thread_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+}
+
+impl ThreadSidebarToggle {
+    pub fn new() -> Self {
+        Self {
+            sidebar_selected: false,
+            thread_selected: false,
+            flipped: false,
+            sidebar_tooltip: None,
+            thread_tooltip: None,
+            on_sidebar_click: None,
+            on_thread_click: None,
+        }
+    }
+
+    pub fn sidebar_selected(mut self, selected: bool) -> Self {
+        self.sidebar_selected = selected;
+        self
+    }
+
+    pub fn thread_selected(mut self, selected: bool) -> Self {
+        self.thread_selected = selected;
+        self
+    }
+
+    pub fn flipped(mut self, flipped: bool) -> Self {
+        self.flipped = flipped;
+        self
+    }
+
+    pub fn sidebar_tooltip(
+        mut self,
+        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
+    ) -> Self {
+        self.sidebar_tooltip = Some(Box::new(tooltip));
+        self
+    }
+
+    pub fn thread_tooltip(
+        mut self,
+        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
+    ) -> Self {
+        self.thread_tooltip = Some(Box::new(tooltip));
+        self
+    }
+
+    pub fn on_sidebar_click(
+        mut self,
+        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_sidebar_click = Some(Box::new(handler));
+        self
+    }
+
+    pub fn on_thread_click(
+        mut self,
+        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_thread_click = Some(Box::new(handler));
+        self
+    }
+}
+
+impl RenderOnce for ThreadSidebarToggle {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let sidebar_icon = match (self.sidebar_selected, self.flipped) {
+            (true, false) => IconName::ThreadsSidebarLeftOpen,
+            (false, false) => IconName::ThreadsSidebarLeftClosed,
+            (true, true) => IconName::ThreadsSidebarRightOpen,
+            (false, true) => IconName::ThreadsSidebarRightClosed,
+        };
+
+        h_flex()
+            .min_w_0()
+            .rounded_sm()
+            .gap_px()
+            .border_1()
+            .border_color(cx.theme().colors().border)
+            .when(self.flipped, |this| this.flex_row_reverse())
+            .child(
+                IconButton::new("sidebar-toggle", sidebar_icon)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(self.sidebar_selected)
+                    .when_some(self.sidebar_tooltip, |this, tooltip| this.tooltip(tooltip))
+                    .when_some(self.on_sidebar_click, |this, handler| {
+                        this.on_click(handler)
+                    }),
+            )
+            .child(div().h_4().w_px().bg(cx.theme().colors().border))
+            .child(
+                IconButton::new("thread-toggle", IconName::Thread)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(self.thread_selected)
+                    .when_some(self.thread_tooltip, |this, tooltip| this.tooltip(tooltip))
+                    .when_some(self.on_thread_click, |this, handler| this.on_click(handler)),
+            )
+    }
+}
+
+impl Component for ThreadSidebarToggle {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let container = || div().p_2().bg(cx.theme().colors().status_bar_background);
+
+        let examples = vec![
+            single_example(
+                "Both Unselected",
+                container()
+                    .child(ThreadSidebarToggle::new())
+                    .into_any_element(),
+            ),
+            single_example(
+                "Sidebar Selected",
+                container()
+                    .child(ThreadSidebarToggle::new().sidebar_selected(true))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Thread Selected",
+                container()
+                    .child(ThreadSidebarToggle::new().thread_selected(true))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Both Selected",
+                container()
+                    .child(
+                        ThreadSidebarToggle::new()
+                            .sidebar_selected(true)
+                            .thread_selected(true),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Flipped",
+                container()
+                    .child(
+                        ThreadSidebarToggle::new()
+                            .sidebar_selected(true)
+                            .thread_selected(true)
+                            .flipped(true),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "With Tooltips",
+                container()
+                    .child(
+                        ThreadSidebarToggle::new()
+                            .sidebar_tooltip(Tooltip::text("Toggle Sidebar"))
+                            .thread_tooltip(Tooltip::text("Toggle Thread")),
+                    )
+                    .into_any_element(),
+            ),
+        ];
+
+        Some(example_group(examples).into_any_element())
+    }
+}

crates/ui/src/components/banner.rs 🔗

@@ -8,16 +8,14 @@ use gpui::{AnyElement, IntoElement, ParentElement, Styled};
 ///
 /// ```
 /// use ui::prelude::*;
-/// use ui::{Banner, Button, IconName, IconPosition, IconSize, Label, Severity};
+/// use ui::{Banner, Button, Icon, IconName, IconSize, Label, Severity};
 ///
 /// Banner::new()
 ///     .severity(Severity::Success)
 ///     .children([Label::new("This is a success message")])
 ///     .action_slot(
 ///         Button::new("learn-more", "Learn More")
-///             .icon(IconName::ArrowUpRight)
-///             .icon_size(IconSize::Small)
-///             .icon_position(IconPosition::End)
+///             .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)),
 ///     );
 /// ```
 #[derive(IntoElement, RegisterComponent)]
@@ -151,9 +149,7 @@ impl Component for Banner {
                     .child(Label::new("This is an informational message"))
                     .action_slot(
                         Button::new("learn-more", "Learn More")
-                            .icon(IconName::ArrowUpRight)
-                            .icon_size(IconSize::Small)
-                            .icon_position(IconPosition::End),
+                            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)),
                     )
                     .into_any_element(),
             ),

crates/ui/src/components/button/button.rs 🔗

@@ -2,15 +2,12 @@ use crate::component_prelude::*;
 use gpui::{AnyElement, AnyView, DefiniteLength};
 use ui_macros::RegisterComponent;
 
-use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label};
+use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, Label};
 use crate::{
-    Color, DynamicSpacing, ElevationIndex, IconPosition, KeyBinding, KeybindingPosition, TintColor,
-    prelude::*,
+    Color, DynamicSpacing, ElevationIndex, KeyBinding, KeybindingPosition, TintColor, prelude::*,
 };
 
-use super::button_icon::ButtonIcon;
-
-/// An element that creates a button with a label and an optional icon.
+/// An element that creates a button with a label and optional icons.
 ///
 /// Common buttons:
 /// - Label, Icon + Label: [`Button`] (this component)
@@ -42,7 +39,7 @@ use super::button_icon::ButtonIcon;
 /// use ui::prelude::*;
 ///
 /// Button::new("button_id", "Click me!")
-///     .icon(IconName::Check)
+///     .start_icon(Icon::new(IconName::Check))
 ///     .toggle_state(true)
 ///     .on_click(|event, window, cx| {
 ///         // Handle click event
@@ -85,12 +82,8 @@ pub struct Button {
     label_size: Option<LabelSize>,
     selected_label: Option<SharedString>,
     selected_label_color: Option<Color>,
-    icon: Option<IconName>,
-    icon_position: Option<IconPosition>,
-    icon_size: Option<IconSize>,
-    icon_color: Option<Color>,
-    selected_icon: Option<IconName>,
-    selected_icon_color: Option<Color>,
+    start_icon: Option<Icon>,
+    end_icon: Option<Icon>,
     key_binding: Option<KeyBinding>,
     key_binding_position: KeybindingPosition,
     alpha: Option<f32>,
@@ -112,12 +105,8 @@ impl Button {
             label_size: None,
             selected_label: None,
             selected_label_color: None,
-            icon: None,
-            icon_position: None,
-            icon_size: None,
-            icon_color: None,
-            selected_icon: None,
-            selected_icon_color: None,
+            start_icon: None,
+            end_icon: None,
             key_binding: None,
             key_binding_position: KeybindingPosition::default(),
             alpha: None,
@@ -149,39 +138,19 @@ impl Button {
         self
     }
 
-    /// Assigns an icon to the button.
-    pub fn icon(mut self, icon: impl Into<Option<IconName>>) -> Self {
-        self.icon = icon.into();
-        self
-    }
-
-    /// Sets the position of the icon relative to the label.
-    pub fn icon_position(mut self, icon_position: impl Into<Option<IconPosition>>) -> Self {
-        self.icon_position = icon_position.into();
-        self
-    }
-
-    /// Specifies the size of the button's icon.
-    pub fn icon_size(mut self, icon_size: impl Into<Option<IconSize>>) -> Self {
-        self.icon_size = icon_size.into();
-        self
-    }
-
-    /// Sets the color of the button's icon.
-    pub fn icon_color(mut self, icon_color: impl Into<Option<Color>>) -> Self {
-        self.icon_color = icon_color.into();
-        self
-    }
-
-    /// Chooses an icon to display when the button is in a selected state.
-    pub fn selected_icon(mut self, icon: impl Into<Option<IconName>>) -> Self {
-        self.selected_icon = icon.into();
+    /// Sets an icon to display at the start (left) of the button label.
+    ///
+    /// The icon's color will be overridden to `Color::Disabled` when the button is disabled.
+    pub fn start_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
+        self.start_icon = icon.into();
         self
     }
 
-    /// Sets the icon color used when the button is in a selected state.
-    pub fn selected_icon_color(mut self, color: impl Into<Option<Color>>) -> Self {
-        self.selected_icon_color = color.into();
+    /// Sets an icon to display at the end (right) of the button label.
+    ///
+    /// The icon's color will be overridden to `Color::Disabled` when the button is disabled.
+    pub fn end_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
+        self.end_icon = icon.into();
         self
     }
 
@@ -219,22 +188,24 @@ impl Button {
 impl Toggleable for Button {
     /// Sets the selected state of the button.
     ///
-    /// This method allows the selection state of the button to be specified.
-    /// It modifies the button's appearance to reflect its selected state.
-    ///
     /// # Examples
     ///
+    /// Create a toggleable button that changes appearance when selected:
+    ///
     /// ```
     /// use ui::prelude::*;
+    /// use ui::TintColor;
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .toggle_state(true)
+    /// let selected = true;
+    ///
+    /// Button::new("toggle_button", "Toggle Me")
+    ///     .start_icon(Icon::new(IconName::Check))
+    ///     .toggle_state(selected)
+    ///     .selected_style(ButtonStyle::Tinted(TintColor::Accent))
     ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
+    ///         // Toggle the selected state
     ///     });
     /// ```
-    ///
-    /// Use [`selected_style`](Button::selected_style) to change the style of the button when it is selected.
     fn toggle_state(mut self, selected: bool) -> Self {
         self.base = self.base.toggle_state(selected);
         self
@@ -242,22 +213,20 @@ impl Toggleable for Button {
 }
 
 impl SelectableButton for Button {
-    /// Sets the style for the button when selected.
+    /// Sets the style for the button in a selected state.
     ///
     /// # Examples
     ///
+    /// Customize the selected appearance of a button:
+    ///
     /// ```
     /// use ui::prelude::*;
     /// use ui::TintColor;
     ///
-    /// Button::new("button_id", "Click me!")
+    /// Button::new("styled_button", "Styled Button")
     ///     .toggle_state(true)
-    ///     .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
+    ///     .selected_style(ButtonStyle::Tinted(TintColor::Accent));
     /// ```
-    /// This results in a button with a blue tinted background when selected.
     fn selected_style(mut self, style: ButtonStyle) -> Self {
         self.base = self.base.selected_style(style);
         self
@@ -265,36 +234,27 @@ impl SelectableButton for Button {
 }
 
 impl Disableable for Button {
-    /// Disables the button.
+    /// Disables the button, preventing interaction and changing its appearance.
     ///
-    /// This method allows the button to be disabled. When a button is disabled,
-    /// it doesn't react to user interactions and its appearance is updated to reflect this.
+    /// When disabled, the button's icon and label will use `Color::Disabled`.
     ///
     /// # Examples
     ///
+    /// Create a disabled button:
+    ///
     /// ```
     /// use ui::prelude::*;
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .disabled(true)
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
+    /// Button::new("disabled_button", "Can't Click Me")
+    ///     .disabled(true);
     /// ```
-    ///
-    /// This results in a button that is disabled and does not respond to click events.
     fn disabled(mut self, disabled: bool) -> Self {
         self.base = self.base.disabled(disabled);
-        self.key_binding = self
-            .key_binding
-            .take()
-            .map(|binding| binding.disabled(disabled));
         self
     }
 }
 
 impl Clickable for Button {
-    /// Sets the click event handler for the button.
     fn on_click(
         mut self,
         handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
@@ -310,44 +270,35 @@ impl Clickable for Button {
 }
 
 impl FixedWidth for Button {
-    /// Sets a fixed width for the button.
-    ///
-    /// This function allows a button to have a fixed width instead of automatically growing or shrinking.
     /// Sets a fixed width for the button.
     ///
     /// # Examples
     ///
+    /// Create a button with a fixed width of 100 pixels:
+    ///
     /// ```
     /// use ui::prelude::*;
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .width(px(100.))
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
+    /// Button::new("fixed_width_button", "Fixed Width")
+    ///     .width(px(100.0));
     /// ```
-    ///
-    /// This sets the button's width to be exactly 100 pixels.
     fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
         self.base = self.base.width(width);
         self
     }
 
-    /// Sets the button to occupy the full width of its container.
+    /// Makes the button take up the full width of its container.
     ///
     /// # Examples
     ///
+    /// Create a button that takes up the full width of its container:
+    ///
     /// ```
     /// use ui::prelude::*;
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .full_width()
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
+    /// Button::new("full_width_button", "Full Width")
+    ///     .full_width();
     /// ```
-    ///
-    /// This stretches the button to the full width of its container.
     fn full_width(mut self) -> Self {
         self.base = self.base.full_width();
         self
@@ -355,43 +306,34 @@ impl FixedWidth for Button {
 }
 
 impl ButtonCommon for Button {
-    /// Sets the button's id.
     fn id(&self) -> &ElementId {
         self.base.id()
     }
 
-    /// Sets the visual style of the button using a [`ButtonStyle`].
+    /// Sets the visual style of the button.
     fn style(mut self, style: ButtonStyle) -> Self {
         self.base = self.base.style(style);
         self
     }
 
-    /// Sets the button's size using a [`ButtonSize`].
+    /// Sets the size of the button.
     fn size(mut self, size: ButtonSize) -> Self {
         self.base = self.base.size(size);
         self
     }
 
-    /// Sets a tooltip for the button.
-    ///
-    /// This method allows a tooltip to be set for the button. The tooltip is a function that
-    /// takes a mutable references to [`Window`] and [`App`], and returns an [`AnyView`]. The
-    /// tooltip is displayed when the user hovers over the button.
+    /// Sets a tooltip that appears on hover.
     ///
     /// # Examples
     ///
-    /// ```
-    /// use ui::prelude::*;
-    /// use ui::Tooltip;
+    /// Add a tooltip to a button:
     ///
-    /// Button::new("button_id", "Click me!")
-    ///     .tooltip(Tooltip::text("This is a tooltip"))
-    ///     .on_click(|event, window, cx| {
-    ///         // Handle click event
-    ///     });
     /// ```
+    /// use ui::{Tooltip, prelude::*};
     ///
-    /// This will create a button with a tooltip that displays "This is a tooltip" when hovered over.
+    /// Button::new("tooltip_button", "Hover Me")
+    ///     .tooltip(Tooltip::text("This is a tooltip"));
+    /// ```
     fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
         self.base = self.base.tooltip(tooltip);
         self
@@ -436,16 +378,12 @@ impl RenderOnce for Button {
             h_flex()
                 .when(self.truncate, |this| this.min_w_0().overflow_hidden())
                 .gap(DynamicSpacing::Base04.rems(cx))
-                .when(self.icon_position == Some(IconPosition::Start), |this| {
-                    this.children(self.icon.map(|icon| {
-                        ButtonIcon::new(icon)
-                            .disabled(is_disabled)
-                            .toggle_state(is_selected)
-                            .selected_icon(self.selected_icon)
-                            .selected_icon_color(self.selected_icon_color)
-                            .size(self.icon_size)
-                            .color(self.icon_color)
-                    }))
+                .when_some(self.start_icon, |this, icon| {
+                    this.child(if is_disabled {
+                        icon.color(Color::Disabled)
+                    } else {
+                        icon
+                    })
                 })
                 .child(
                     h_flex()
@@ -465,16 +403,12 @@ impl RenderOnce for Button {
                         )
                         .children(self.key_binding),
                 )
-                .when(self.icon_position != Some(IconPosition::Start), |this| {
-                    this.children(self.icon.map(|icon| {
-                        ButtonIcon::new(icon)
-                            .disabled(is_disabled)
-                            .toggle_state(is_selected)
-                            .selected_icon(self.selected_icon)
-                            .selected_icon_color(self.selected_icon_color)
-                            .size(self.icon_size)
-                            .color(self.icon_color)
-                    }))
+                .when_some(self.end_icon, |this, icon| {
+                    this.child(if is_disabled {
+                        icon.color(Color::Disabled)
+                    } else {
+                        icon
+                    })
                 }),
         )
     }
@@ -585,24 +519,28 @@ impl Component for Button {
                         "Buttons with Icons",
                         vec![
                             single_example(
-                                "Icon Start",
-                                Button::new("icon_start", "Icon Start")
-                                    .icon(IconName::Check)
-                                    .icon_position(IconPosition::Start)
+                                "Start Icon",
+                                Button::new("icon_start", "Start Icon")
+                                    .start_icon(Icon::new(IconName::Check))
+                                    .into_any_element(),
+                            ),
+                            single_example(
+                                "End Icon",
+                                Button::new("icon_end", "End Icon")
+                                    .end_icon(Icon::new(IconName::Check))
                                     .into_any_element(),
                             ),
                             single_example(
-                                "Icon End",
-                                Button::new("icon_end", "Icon End")
-                                    .icon(IconName::Check)
-                                    .icon_position(IconPosition::End)
+                                "Both Icons",
+                                Button::new("both_icons", "Both Icons")
+                                    .start_icon(Icon::new(IconName::Check))
+                                    .end_icon(Icon::new(IconName::ChevronDown))
                                     .into_any_element(),
                             ),
                             single_example(
                                 "Icon Color",
                                 Button::new("icon_color", "Icon Color")
-                                    .icon(IconName::Check)
-                                    .icon_color(Color::Accent)
+                                    .start_icon(Icon::new(IconName::Check).color(Color::Accent))
                                     .into_any_element(),
                             ),
                         ],

crates/ui/src/components/button/button_icon.rs 🔗

@@ -1,199 +0,0 @@
-use crate::{Icon, IconName, IconSize, IconWithIndicator, Indicator, prelude::*};
-use gpui::Hsla;
-
-/// An icon that appears within a button.
-///
-/// Can be used as either an icon alongside a label, like in [`Button`](crate::Button),
-/// or as a standalone icon, like in [`IconButton`](crate::IconButton).
-#[derive(IntoElement, RegisterComponent)]
-pub(super) struct ButtonIcon {
-    icon: IconName,
-    size: IconSize,
-    color: Color,
-    disabled: bool,
-    selected: bool,
-    selected_icon: Option<IconName>,
-    selected_icon_color: Option<Color>,
-    selected_style: Option<ButtonStyle>,
-    indicator: Option<Indicator>,
-    indicator_border_color: Option<Hsla>,
-}
-
-impl ButtonIcon {
-    pub fn new(icon: IconName) -> Self {
-        Self {
-            icon,
-            size: IconSize::default(),
-            color: Color::default(),
-            disabled: false,
-            selected: false,
-            selected_icon: None,
-            selected_icon_color: None,
-            selected_style: None,
-            indicator: None,
-            indicator_border_color: None,
-        }
-    }
-
-    pub fn size(mut self, size: impl Into<Option<IconSize>>) -> Self {
-        if let Some(size) = size.into() {
-            self.size = size;
-        }
-        self
-    }
-
-    pub fn color(mut self, color: impl Into<Option<Color>>) -> Self {
-        if let Some(color) = color.into() {
-            self.color = color;
-        }
-        self
-    }
-
-    pub fn selected_icon(mut self, icon: impl Into<Option<IconName>>) -> Self {
-        self.selected_icon = icon.into();
-        self
-    }
-
-    pub fn selected_icon_color(mut self, color: impl Into<Option<Color>>) -> Self {
-        self.selected_icon_color = color.into();
-        self
-    }
-
-    pub fn indicator(mut self, indicator: Indicator) -> Self {
-        self.indicator = Some(indicator);
-        self
-    }
-
-    pub fn indicator_border_color(mut self, color: Option<Hsla>) -> Self {
-        self.indicator_border_color = color;
-        self
-    }
-}
-
-impl Disableable for ButtonIcon {
-    fn disabled(mut self, disabled: bool) -> Self {
-        self.disabled = disabled;
-        self
-    }
-}
-
-impl Toggleable for ButtonIcon {
-    fn toggle_state(mut self, selected: bool) -> Self {
-        self.selected = selected;
-        self
-    }
-}
-
-impl SelectableButton for ButtonIcon {
-    fn selected_style(mut self, style: ButtonStyle) -> Self {
-        self.selected_style = Some(style);
-        self
-    }
-}
-
-impl RenderOnce for ButtonIcon {
-    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
-        let icon = self
-            .selected_icon
-            .filter(|_| self.selected)
-            .unwrap_or(self.icon);
-
-        let icon_color = if self.disabled {
-            Color::Disabled
-        } else if self.selected_style.is_some() && self.selected {
-            self.selected_style.unwrap().into()
-        } else if self.selected {
-            self.selected_icon_color.unwrap_or(Color::Selected)
-        } else {
-            self.color
-        };
-
-        let icon = Icon::new(icon).size(self.size).color(icon_color);
-
-        match self.indicator {
-            Some(indicator) => IconWithIndicator::new(icon, Some(indicator))
-                .indicator_border_color(self.indicator_border_color)
-                .into_any_element(),
-            None => icon.into_any_element(),
-        }
-    }
-}
-
-impl Component for ButtonIcon {
-    fn scope() -> ComponentScope {
-        ComponentScope::Input
-    }
-
-    fn name() -> &'static str {
-        "ButtonIcon"
-    }
-
-    fn description() -> Option<&'static str> {
-        Some("An icon component specifically designed for use within buttons.")
-    }
-
-    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
-        Some(
-            v_flex()
-                .gap_6()
-                .children(vec![
-                    example_group_with_title(
-                        "Basic Usage",
-                        vec![
-                            single_example(
-                                "Default",
-                                ButtonIcon::new(IconName::Star).into_any_element(),
-                            ),
-                            single_example(
-                                "Custom Size",
-                                ButtonIcon::new(IconName::Star)
-                                    .size(IconSize::Medium)
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Custom Color",
-                                ButtonIcon::new(IconName::Star)
-                                    .color(Color::Accent)
-                                    .into_any_element(),
-                            ),
-                        ],
-                    ),
-                    example_group_with_title(
-                        "States",
-                        vec![
-                            single_example(
-                                "Selected",
-                                ButtonIcon::new(IconName::Star)
-                                    .toggle_state(true)
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Disabled",
-                                ButtonIcon::new(IconName::Star)
-                                    .disabled(true)
-                                    .into_any_element(),
-                            ),
-                        ],
-                    ),
-                    example_group_with_title(
-                        "With Indicator",
-                        vec![
-                            single_example(
-                                "Default Indicator",
-                                ButtonIcon::new(IconName::Star)
-                                    .indicator(Indicator::dot())
-                                    .into_any_element(),
-                            ),
-                            single_example(
-                                "Custom Indicator",
-                                ButtonIcon::new(IconName::Star)
-                                    .indicator(Indicator::dot().color(Color::Error))
-                                    .into_any_element(),
-                            ),
-                        ],
-                    ),
-                ])
-                .into_any_element(),
-        )
-    }
-}

crates/ui/src/components/button/icon_button.rs 🔗

@@ -1,11 +1,11 @@
 use gpui::{AnyView, DefiniteLength, Hsla};
 
 use super::button_like::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle};
-use crate::{ElevationIndex, Indicator, SelectableButton, TintColor, prelude::*};
+use crate::{
+    ElevationIndex, Icon, IconWithIndicator, Indicator, SelectableButton, TintColor, prelude::*,
+};
 use crate::{IconName, IconSize};
 
-use super::button_icon::ButtonIcon;
-
 /// The shape of an [`IconButton`].
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 pub enum IconButtonShape {
@@ -22,6 +22,7 @@ pub struct IconButton {
     icon_color: Color,
     selected_icon: Option<IconName>,
     selected_icon_color: Option<Color>,
+    selected_style: Option<ButtonStyle>,
     indicator: Option<Indicator>,
     indicator_border_color: Option<Hsla>,
     alpha: Option<f32>,
@@ -37,6 +38,7 @@ impl IconButton {
             icon_color: Color::Default,
             selected_icon: None,
             selected_icon_color: None,
+            selected_style: None,
             indicator: None,
             indicator_border_color: None,
             alpha: None,
@@ -112,6 +114,7 @@ impl Toggleable for IconButton {
 
 impl SelectableButton for IconButton {
     fn selected_style(mut self, style: ButtonStyle) -> Self {
+        self.selected_style = Some(style);
         self.base = self.base.selected_style(style);
         self
     }
@@ -192,9 +195,25 @@ impl RenderOnce for IconButton {
     fn render(self, window: &mut Window, cx: &mut App) -> ButtonLike {
         let is_disabled = self.base.disabled;
         let is_selected = self.base.selected;
-        let selected_style = self.base.selected_style;
 
-        let color = self.icon_color.color(cx).opacity(self.alpha.unwrap_or(1.0));
+        let icon = self
+            .selected_icon
+            .filter(|_| is_selected)
+            .unwrap_or(self.icon);
+
+        let icon_color = if is_disabled {
+            Color::Disabled
+        } else if self.selected_style.is_some() && is_selected {
+            self.selected_style.unwrap().into()
+        } else if is_selected {
+            self.selected_icon_color.unwrap_or(Color::Selected)
+        } else {
+            let base_color = self.icon_color.color(cx);
+            Color::Custom(base_color.opacity(self.alpha.unwrap_or(1.0)))
+        };
+
+        let icon_element = Icon::new(icon).size(self.icon_size).color(icon_color);
+
         self.base
             .map(|this| match self.shape {
                 IconButtonShape::Square => {
@@ -203,20 +222,12 @@ impl RenderOnce for IconButton {
                 }
                 IconButtonShape::Wide => this,
             })
-            .child(
-                ButtonIcon::new(self.icon)
-                    .disabled(is_disabled)
-                    .toggle_state(is_selected)
-                    .selected_icon(self.selected_icon)
-                    .selected_icon_color(self.selected_icon_color)
-                    .when_some(selected_style, |this, style| this.selected_style(style))
-                    .when_some(self.indicator, |this, indicator| {
-                        this.indicator(indicator)
-                            .indicator_border_color(self.indicator_border_color)
-                    })
-                    .size(self.icon_size)
-                    .color(Color::Custom(color)),
-            )
+            .child(match self.indicator {
+                Some(indicator) => IconWithIndicator::new(icon_element, Some(indicator))
+                    .indicator_border_color(self.indicator_border_color)
+                    .into_any_element(),
+                None => icon_element.into_any_element(),
+            })
     }
 }
 

crates/ui/src/components/diff_stat.rs 🔗

@@ -1,3 +1,4 @@
+use crate::Tooltip;
 use crate::prelude::*;
 
 #[derive(IntoElement, RegisterComponent)]
@@ -6,6 +7,7 @@ pub struct DiffStat {
     added: usize,
     removed: usize,
     label_size: LabelSize,
+    tooltip: Option<SharedString>,
 }
 
 impl DiffStat {
@@ -15,6 +17,7 @@ impl DiffStat {
             added,
             removed,
             label_size: LabelSize::Small,
+            tooltip: None,
         }
     }
 
@@ -22,10 +25,16 @@ impl DiffStat {
         self.label_size = label_size;
         self
     }
+
+    pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
+        self.tooltip = Some(tooltip.into());
+        self
+    }
 }
 
 impl RenderOnce for DiffStat {
     fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let tooltip = self.tooltip;
         h_flex()
             .id(self.id)
             .gap_1()
@@ -39,6 +48,9 @@ impl RenderOnce for DiffStat {
                     .color(Color::Error)
                     .size(self.label_size),
             )
+            .when_some(tooltip, |this, tooltip| {
+                this.tooltip(Tooltip::text(tooltip))
+            })
     }
 }
 

crates/ui/src/components/dropdown_menu.rs 🔗

@@ -163,11 +163,10 @@ impl RenderOnce for DropdownMenu {
                 Some(
                     Button::new(self.id.clone(), text)
                         .style(button_style)
-                        .when(self.chevron, |this| {
-                            this.icon(self.trigger_icon)
-                                .icon_position(IconPosition::End)
-                                .icon_size(IconSize::XSmall)
-                                .icon_color(Color::Muted)
+                        .when_some(self.trigger_icon.filter(|_| self.chevron), |this, icon| {
+                            this.end_icon(
+                                Icon::new(icon).size(IconSize::XSmall).color(Color::Muted),
+                            )
                         })
                         .when(full_width, |this| this.full_width())
                         .size(trigger_size)

crates/workspace/src/item.rs 🔗

@@ -12,10 +12,11 @@ use client::{Client, proto};
 use futures::{StreamExt, channel::mpsc};
 use gpui::{
     Action, AnyElement, AnyEntity, AnyView, App, AppContext, Context, Entity, EntityId,
-    EventEmitter, FocusHandle, Focusable, Font, HighlightStyle, Pixels, Point, Render,
-    SharedString, Task, WeakEntity, Window,
+    EventEmitter, FocusHandle, Focusable, Font, Pixels, Point, Render, SharedString, Task,
+    WeakEntity, Window,
 };
 use language::Capability;
+pub use language::HighlightedText;
 use project::{Project, ProjectEntryId, ProjectPath};
 pub use settings::{
     ActivateOnClose, ClosePosition, RegisterSetting, Settings, SettingsLocation, ShowCloseButton,
@@ -25,7 +26,6 @@ use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
     cell::RefCell,
-    ops::Range,
     path::Path,
     rc::Rc,
     sync::Arc,
@@ -124,14 +124,6 @@ pub enum ItemEvent {
     Edit,
 }
 
-// TODO: Combine this with existing HighlightedText struct?
-#[derive(Debug)]
-pub struct BreadcrumbText {
-    pub text: String,
-    pub highlights: Option<Vec<(Range<usize>, HighlightStyle)>>,
-    pub font: Option<Font>,
-}
-
 #[derive(Clone, Copy, Default, Debug)]
 pub struct TabContentParams {
     pub detail: Option<usize>,
@@ -329,7 +321,7 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
         ToolbarItemLocation::Hidden
     }
 
-    fn breadcrumbs(&self, _cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, _cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         None
     }
 
@@ -548,7 +540,7 @@ pub trait ItemHandle: 'static + Send {
     ) -> gpui::Subscription;
     fn to_searchable_item_handle(&self, cx: &App) -> Option<Box<dyn SearchableItemHandle>>;
     fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation;
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>>;
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)>;
     fn breadcrumb_prefix(&self, window: &mut Window, cx: &mut App) -> Option<gpui::AnyElement>;
     fn show_toolbar(&self, cx: &App) -> bool;
     fn pixel_position_of_cursor(&self, cx: &App) -> Option<Point<Pixels>>;
@@ -1090,7 +1082,7 @@ impl<T: Item> ItemHandle for Entity<T> {
         self.read(cx).breadcrumb_location(cx)
     }
 
-    fn breadcrumbs(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
+    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
         self.read(cx).breadcrumbs(cx)
     }
 

crates/workspace/src/notifications.rs 🔗

@@ -917,11 +917,11 @@ pub mod simple_message_notification {
                                 }));
 
                             if let Some(icon) = self.primary_icon {
-                                button = button
-                                    .icon(icon)
-                                    .icon_color(self.primary_icon_color.unwrap_or(Color::Muted))
-                                    .icon_position(IconPosition::Start)
-                                    .icon_size(IconSize::Small);
+                                button = button.start_icon(
+                                    Icon::new(icon)
+                                        .size(IconSize::Small)
+                                        .color(self.primary_icon_color.unwrap_or(Color::Muted)),
+                                );
                             }
 
                             button
@@ -937,11 +937,11 @@ pub mod simple_message_notification {
                                 }));
 
                             if let Some(icon) = self.secondary_icon {
-                                button = button
-                                    .icon(icon)
-                                    .icon_position(IconPosition::Start)
-                                    .icon_size(IconSize::Small)
-                                    .icon_color(self.secondary_icon_color.unwrap_or(Color::Muted));
+                                button = button.start_icon(
+                                    Icon::new(icon)
+                                        .size(IconSize::Small)
+                                        .color(self.secondary_icon_color.unwrap_or(Color::Muted)),
+                                );
                             }
 
                             button
@@ -955,9 +955,11 @@ pub mod simple_message_notification {
                                         let url = url.clone();
                                         Button::new(message.clone(), message.clone())
                                             .label_size(LabelSize::Small)
-                                            .icon(IconName::ArrowUpRight)
-                                            .icon_size(IconSize::Indicator)
-                                            .icon_color(Color::Muted)
+                                            .end_icon(
+                                                Icon::new(IconName::ArrowUpRight)
+                                                    .size(IconSize::Indicator)
+                                                    .color(Color::Muted),
+                                            )
                                             .on_click(cx.listener(move |_, _, _, cx| {
                                                 cx.open_url(&url);
                                             }))

crates/workspace/src/persistence.rs 🔗

@@ -1784,11 +1784,17 @@ impl WorkspaceDb {
         }
     }
 
-    async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool {
+    async fn all_paths_exist_with_a_directory(
+        paths: &[PathBuf],
+        fs: &dyn Fs,
+        timestamp: Option<DateTime<Utc>>,
+    ) -> bool {
         let mut any_dir = false;
         for path in paths {
             match fs.metadata(path).await.ok().flatten() {
-                None => return false,
+                None => {
+                    return timestamp.is_some_and(|t| Utc::now() - t < chrono::Duration::days(7));
+                }
                 Some(meta) => {
                     if meta.is_dir {
                         any_dir = true;
@@ -1844,7 +1850,9 @@ impl WorkspaceDb {
             // If a local workspace points to WSL, this check will cause us to wait for the
             // WSL VM and file server to boot up. This can block for many seconds.
             // Supported scenarios use remote workspaces.
-            if !has_wsl_path && Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
+            if !has_wsl_path
+                && Self::all_paths_exist_with_a_directory(paths.paths(), fs, Some(timestamp)).await
+            {
                 result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp));
             } else {
                 delete_tasks.push(self.delete_workspace_by_id(id));
@@ -1904,7 +1912,7 @@ impl WorkspaceDb {
                     window_id,
                 });
             } else {
-                if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
+                if Self::all_paths_exist_with_a_directory(paths.paths(), fs, None).await {
                     workspaces.push(SessionWorkspace {
                         workspace_id,
                         location: SerializedWorkspaceLocation::Local,

crates/workspace/src/workspace.rs 🔗

@@ -8524,6 +8524,15 @@ actions!(
         CopyRoomId,
     ]
 );
+
+/// Opens the channel notes for a specific channel by its ID.
+#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = collab)]
+#[serde(deny_unknown_fields)]
+pub struct OpenChannelNotesById {
+    pub channel_id: u64,
+}
+
 actions!(
     zed,
     [

docs/src/SUMMARY.md 🔗

@@ -161,6 +161,7 @@
 - [Debugger Extensions](./extensions/debugger-extensions.md)
 - [Theme Extensions](./extensions/themes.md)
 - [Icon Theme Extensions](./extensions/icon-themes.md)
+- [Snippets Extensions](./extensions/snippets.md)
 - [Slash Command Extensions](./extensions/slash-commands.md)
 - [Agent Server Extensions](./extensions/agent-servers.md)
 - [MCP Server Extensions](./extensions/mcp-extensions.md)

docs/src/extensions.md 🔗

@@ -14,6 +14,7 @@ Zed lets you add new functionality using user-defined extensions.
   - [Developing Debugger Extensions](./extensions/debugger-extensions.md)
   - [Developing Themes](./extensions/themes.md)
   - [Developing Icon Themes](./extensions/icon-themes.md)
+  - [Developing Snippets](./extensions/snippets.md)
   - [Developing Slash Commands](./extensions/slash-commands.md)
   - [Developing Agent Servers](./extensions/agent-servers.md)
   - [Developing MCP Servers](./extensions/mcp-extensions.md)

docs/src/extensions/developing-extensions.md 🔗

@@ -5,7 +5,7 @@ description: "Create Zed extensions: languages, themes, debuggers, slash command
 
 # Developing Extensions {#developing-extensions}
 
-Zed extensions are Git repositories containing an `extension.toml` manifest. They can provide languages, themes, debuggers, slash commands, and MCP servers.
+Zed extensions are Git repositories containing an `extension.toml` manifest. They can provide languages, themes, debuggers, snippets, slash commands, and MCP servers.
 
 ## Extension Features {#extension-features}
 
@@ -15,6 +15,7 @@ Extensions can provide:
 - [Debuggers](./debugger-extensions.md)
 - [Themes](./themes.md)
 - [Icon Themes](./icon-themes.md)
+- [Snippets](./snippets.md)
 - [Slash Commands](./slash-commands.md)
 - [MCP Servers](./mcp-extensions.md)
 
@@ -63,6 +64,9 @@ my-extension/
       highlights.scm
   themes/
     my-theme.json
+  snippets/
+    snippets.json
+    rust.json
 ```
 
 ## WebAssembly

docs/src/extensions/snippets.md 🔗

@@ -0,0 +1,27 @@
+---
+title: Snippets
+description: "Snippets for Zed extensions."
+---
+
+# Snippets
+
+Extensions may provide snippets for one or more languages.
+
+Each file containing snippets can be specified in the `snippets` field of the `extensions.toml` file.
+
+The referenced path must be relative to the `extension.toml`.
+
+## Defining Snippets
+
+A given extension may provide one or more snippets. Each snippet must be registered in the `extension.toml`.
+
+Zed matches snippet files based on the lowercase name of the language (e.g. `rust.json` for Rust).
+You can use `snippets.json` as a file name to define snippets that will be available regardless of the current buffer language.
+
+For example, here is an extension that provides snippets for Rust and TypeScript:
+
+```toml
+snippets = ["./snippets/rust.json", "./snippets/typescript.json"]
+```
+
+For more information on how to create snippets, see the [Snippets documentation](../snippets.md).

docs/src/reference/all-settings.md 🔗

@@ -4695,7 +4695,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
     "bold_folder_labels": false,
     "drag_and_drop": true,
     "scrollbar": {
-      "show": null
+      "show": null,
+      "horizontal_scroll": true
     },
     "sticky_scroll": true,
     "show_diagnostics": "all",
@@ -4941,9 +4942,9 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
 }
 ```
 
-### Scrollbar: Show
+### Scrollbar
 
-- Description: Whether to show a scrollbar in the project panel. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details.
+- Description: Scrollbar-related settings for the project panel.
 - Setting: `scrollbar`
 - Default:
 
@@ -4951,7 +4952,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
 {
   "project_panel": {
     "scrollbar": {
-      "show": null
+      "show": null,
+      "horizontal_scroll": true
     }
   }
 }
@@ -4959,29 +4961,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
 
 **Options**
 
-1. Show scrollbar in the project panel
-
-```json [settings]
-{
-  "project_panel": {
-    "scrollbar": {
-      "show": "always"
-    }
-  }
-}
-```
-
-2. Hide scrollbar in the project panel
-
-```json [settings]
-{
-  "project_panel": {
-    "scrollbar": {
-      "show": "never"
-    }
-  }
-}
-```
+- `show`: Whether to show a scrollbar in the project panel. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details.
+- `horizontal_scroll`: Whether to allow horizontal scrolling in the project panel. When `false`, the view is locked to the leftmost position and long file names are clipped.
 
 ### Sort Mode
 

extensions/glsl/languages/glsl/config.toml 🔗

@@ -5,6 +5,8 @@ path_suffixes = [
     "vert", "frag", "tesc", "tese", "geom",
     # Compute shaders
     "comp",
+    # Mesh pipeline shaders
+    "task", "mesh",
     # Ray tracing pipeline shaders
     "rgen", "rint", "rahit", "rchit", "rmiss", "rcall",
     # Other

tooling/xtask/src/tasks/workflows.rs 🔗

@@ -13,6 +13,7 @@ mod cherry_pick;
 mod compare_perf;
 mod danger;
 mod deploy_collab;
+mod extension_auto_bump;
 mod extension_bump;
 mod extension_tests;
 mod extension_workflow_rollout;
@@ -199,6 +200,7 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> {
         WorkflowFile::zed(danger::danger),
         WorkflowFile::zed(deploy_collab::deploy_collab),
         WorkflowFile::zed(extension_bump::extension_bump),
+        WorkflowFile::zed(extension_auto_bump::extension_auto_bump),
         WorkflowFile::zed(extension_tests::extension_tests),
         WorkflowFile::zed(extension_workflow_rollout::extension_workflow_rollout),
         WorkflowFile::zed(publish_extension_cli::publish_extension_cli),

tooling/xtask/src/tasks/workflows/extension_auto_bump.rs 🔗

@@ -0,0 +1,113 @@
+use gh_workflow::{
+    Event, Expression, Input, Job, Level, Permissions, Push, Strategy, UsesJob, Workflow,
+};
+use indoc::indoc;
+use serde_json::json;
+
+use crate::tasks::workflows::{
+    extensions::WithAppSecrets,
+    run_tests::DETECT_CHANGED_EXTENSIONS_SCRIPT,
+    runners,
+    steps::{self, CommonJobConditions, NamedJob, named},
+    vars::{StepOutput, one_workflow_per_non_main_branch},
+};
+
+/// Generates a workflow that triggers on push to main, detects changed extensions
+/// in the `extensions/` directory, and invokes the `extension_bump` reusable workflow
+/// for each changed extension via a matrix strategy.
+pub(crate) fn extension_auto_bump() -> Workflow {
+    let detect = detect_changed_extensions();
+    let bump = bump_extension_versions(&detect);
+
+    named::workflow()
+        .add_event(
+            Event::default().push(
+                Push::default()
+                    .add_branch("main")
+                    .add_path("extensions/**")
+                    .add_path("!extensions/workflows/**")
+                    .add_path("!extensions/*.md"),
+            ),
+        )
+        .concurrency(one_workflow_per_non_main_branch())
+        .add_job(detect.name, detect.job)
+        .add_job(bump.name, bump.job)
+}
+
+fn detect_changed_extensions() -> NamedJob {
+    let preamble = indoc! {r#"
+        COMPARE_REV="$(git rev-parse HEAD~1)"
+        CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
+    "#};
+
+    let filter_new_and_removed = indoc! {r#"
+        # Filter out newly added or entirely removed extensions
+        FILTERED="[]"
+        for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do
+            if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \
+               [ -f "$ext/extension.toml" ]; then
+                FILTERED=$(echo "$FILTERED" | jq -c --arg e "$ext" '. + [$e]')
+            fi
+        done
+        echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT"
+    "#};
+
+    let script = format!(
+        "{preamble}{detect}{filter}",
+        preamble = preamble,
+        detect = DETECT_CHANGED_EXTENSIONS_SCRIPT,
+        filter = filter_new_and_removed,
+    );
+
+    let step = named::bash(script).id("detect");
+
+    let output = StepOutput::new(&step, "changed_extensions");
+
+    let job = Job::default()
+        .with_repository_owner_guard()
+        .runs_on(runners::LINUX_SMALL)
+        .timeout_minutes(5u32)
+        .add_step(steps::checkout_repo().with_custom_fetch_depth(2))
+        .add_step(step)
+        .outputs([("changed_extensions".to_owned(), output.to_string())]);
+
+    named::job(job)
+}
+
+fn bump_extension_versions(detect_job: &NamedJob) -> NamedJob<UsesJob> {
+    let job = Job::default()
+        .needs(vec![detect_job.name.clone()])
+        .cond(Expression::new(format!(
+            "needs.{}.outputs.changed_extensions != '[]'",
+            detect_job.name
+        )))
+        .permissions(
+            Permissions::default()
+                .contents(Level::Write)
+                .issues(Level::Write)
+                .pull_requests(Level::Write)
+                .actions(Level::Write),
+        )
+        .strategy(
+            Strategy::default()
+                .fail_fast(false)
+                // TODO: Remove the limit. We currently need this to workaround the concurrency group issue
+                // where different matrix jobs would be placed in the same concurrency group and thus cancelled.
+                .max_parallel(1u32)
+                .matrix(json!({
+                    "extension": format!(
+                        "${{{{ fromJson(needs.{}.outputs.changed_extensions) }}}}",
+                        detect_job.name
+                    )
+                })),
+        )
+        .uses_local(".github/workflows/extension_bump.yml")
+        .with(
+            Input::default()
+                .add("working-directory", "${{ matrix.extension }}")
+                .add("force-bump", false),
+        )
+        .with_app_secrets();
+
+    named::job(job)
+}

tooling/xtask/src/tasks/workflows/extension_bump.rs 🔗

@@ -5,11 +5,12 @@ use crate::tasks::workflows::{
     extension_tests::{self},
     runners,
     steps::{
-        self, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, NamedJob,
-        checkout_repo, dependant_job, named,
+        self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder,
+        NamedJob, checkout_repo, dependant_job, named,
     },
     vars::{
-        JobOutput, StepOutput, WorkflowInput, WorkflowSecret, one_workflow_per_non_main_branch,
+        JobOutput, StepOutput, WorkflowInput, WorkflowSecret,
+        one_workflow_per_non_main_branch_and_token,
     },
 };
 
@@ -22,6 +23,7 @@ pub(crate) fn extension_bump() -> Workflow {
     // TODO: Ideally, this would have a default of `false`, but this is currently not
     // supported in gh-workflows
     let force_bump = WorkflowInput::bool("force-bump", None);
+    let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned()));
 
     let (app_id, app_secret) = extension_workflow_secrets();
     let (check_version_changed, version_changed, current_version) = check_version_changed();
@@ -59,6 +61,7 @@ pub(crate) fn extension_bump() -> Workflow {
                 WorkflowCall::default()
                     .add_input(bump_type.name, bump_type.call_input())
                     .add_input(force_bump.name, force_bump.call_input())
+                    .add_input(working_directory.name, working_directory.call_input())
                     .secrets([
                         (app_id.name.to_owned(), app_id.secret_configuration()),
                         (
@@ -68,7 +71,7 @@ pub(crate) fn extension_bump() -> Workflow {
                     ]),
             ),
         )
-        .concurrency(one_workflow_per_non_main_branch())
+        .concurrency(one_workflow_per_non_main_branch_and_token("extension-bump"))
         .add_env(("CARGO_TERM_COLOR", "always"))
         .add_env(("RUST_BACKTRACE", 1))
         .add_env(("CARGO_INCREMENTAL", 0))
@@ -82,10 +85,19 @@ pub(crate) fn extension_bump() -> Workflow {
         .add_job(trigger_release.name, trigger_release.job)
 }
 
+fn extension_job_defaults() -> Defaults {
+    Defaults::default().run(
+        RunDefaults::default()
+            .shell(BASH_SHELL)
+            .working_directory("${{ inputs.working-directory }}"),
+    )
+}
+
 fn check_version_changed() -> (NamedJob, StepOutput, StepOutput) {
     let (compare_versions, version_changed, current_version) = compare_versions();
 
     let job = Job::default()
+        .defaults(extension_job_defaults())
         .with_repository_owner_guard()
         .outputs([
             (version_changed.name.to_owned(), version_changed.to_string()),
@@ -112,6 +124,7 @@ fn create_version_label(
     let (generate_token, generated_token) =
         generate_token(&app_id.to_string(), &app_secret.to_string(), None);
     let job = steps::dependant_job(dependencies)
+        .defaults(extension_job_defaults())
         .cond(Expression::new(format!(
             "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && \
             github.ref == 'refs/heads/main' && {version_changed} == 'true'",
@@ -153,8 +166,6 @@ pub(crate) fn compare_versions() -> (Step<Run>, StepOutput, StepOutput) {
         if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
             PR_FORK_POINT="$(git merge-base origin/main HEAD)"
             git checkout "$PR_FORK_POINT"
-        elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then
-            git checkout "$BRANCH_PARENT_SHA"
         else
             git checkout "$(git log -1 --format=%H)"~1
         fi
@@ -187,9 +198,11 @@ fn bump_extension_version(
 ) -> NamedJob {
     let (generate_token, generated_token) =
         generate_token(&app_id.to_string(), &app_secret.to_string(), None);
-    let (bump_version, new_version) = bump_version(current_version, bump_type);
+    let (bump_version, _new_version, title, body, branch_name) =
+        bump_version(current_version, bump_type);
 
     let job = steps::dependant_job(dependencies)
+        .defaults(extension_job_defaults())
         .cond(Expression::new(format!(
             "{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({force_bump} == true || {version_changed} == 'false')",
             force_bump = force_bump_output.expr(),
@@ -201,7 +214,12 @@ fn bump_extension_version(
         .add_step(steps::checkout_repo())
         .add_step(install_bump_2_version())
         .add_step(bump_version)
-        .add_step(create_pull_request(new_version, generated_token));
+        .add_step(create_pull_request(
+            title,
+            body,
+            generated_token,
+            branch_name,
+        ));
 
     named::job(job)
 }
@@ -256,7 +274,10 @@ fn install_bump_2_version() -> Step<Run> {
     )
 }
 
-fn bump_version(current_version: &JobOutput, bump_type: &WorkflowInput) -> (Step<Run>, StepOutput) {
+fn bump_version(
+    current_version: &JobOutput,
+    bump_type: &WorkflowInput,
+) -> (Step<Run>, StepOutput, StepOutput, StepOutput, StepOutput) {
     let step = named::bash(formatdoc! {r#"
         BUMP_FILES=("extension.toml")
         if [[ -f "Cargo.toml" ]]; then
@@ -274,33 +295,50 @@ fn bump_version(current_version: &JobOutput, bump_type: &WorkflowInput) -> (Step
         fi
 
         NEW_VERSION="$({VERSION_CHECK})"
+        EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
+        EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
+
+        if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
+            {{
+                echo "title=Bump version to ${{NEW_VERSION}}";
+                echo "body=This PR bumps the version of this extension to v${{NEW_VERSION}}";
+                echo "branch_name=zed-zippy-autobump";
+            }} >> "$GITHUB_OUTPUT"
+        else
+            {{
+                echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}";
+                echo "body=This PR bumps the version of the ${{EXTENSION_NAME}} extension to v${{NEW_VERSION}}";
+                echo "branch_name=zed-zippy-${{EXTENSION_ID}}-autobump";
+            }} >> "$GITHUB_OUTPUT"
+        fi
 
         echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT"
         "#
     })
     .id("bump-version")
     .add_env(("OLD_VERSION", current_version.to_string()))
-    .add_env(("BUMP_TYPE", bump_type.to_string()));
+    .add_env(("BUMP_TYPE", bump_type.to_string()))
+    .add_env(("WORKING_DIR", "${{ inputs.working-directory }}"));
 
     let new_version = StepOutput::new(&step, "new_version");
-    (step, new_version)
+    let title = StepOutput::new(&step, "title");
+    let body = StepOutput::new(&step, "body");
+    let branch_name = StepOutput::new(&step, "branch_name");
+    (step, new_version, title, body, branch_name)
 }
 
-fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) -> Step<Use> {
-    let formatted_version = format!("v{new_version}");
-
+fn create_pull_request(
+    title: StepOutput,
+    body: StepOutput,
+    generated_token: StepOutput,
+    branch_name: StepOutput,
+) -> Step<Use> {
     named::uses("peter-evans", "create-pull-request", "v7").with(
         Input::default()
-            .add("title", format!("Bump version to {new_version}"))
-            .add(
-                "body",
-                format!("This PR bumps the version of this extension to {formatted_version}",),
-            )
-            .add(
-                "commit-message",
-                format!("Bump version to {formatted_version}"),
-            )
-            .add("branch", "zed-zippy-autobump")
+            .add("title", title.to_string())
+            .add("body", body.to_string())
+            .add("commit-message", title.to_string())
+            .add("branch", branch_name.to_string())
             .add(
                 "committer",
                 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
@@ -328,6 +366,7 @@ fn trigger_release(
     let (get_extension_id, extension_id) = get_extension_id();
 
     let job = dependant_job(dependencies)
+        .defaults(extension_job_defaults())
         .with_repository_owner_guard()
         .runs_on(runners::LINUX_SMALL)
         .add_step(generate_token)

tooling/xtask/src/tasks/workflows/extension_tests.rs 🔗

@@ -3,15 +3,13 @@ use indoc::indoc;
 
 use crate::tasks::workflows::{
     extension_bump::compare_versions,
-    run_tests::{
-        fetch_ts_query_ls, orchestrate_without_package_filter, run_ts_query_ls, tests_pass,
-    },
+    run_tests::{fetch_ts_query_ls, orchestrate_for_extension, run_ts_query_ls, tests_pass},
     runners,
     steps::{
-        self, CommonJobConditions, FluentBuilder, NamedJob, cache_rust_dependencies_namespace,
-        named,
+        self, BASH_SHELL, CommonJobConditions, FluentBuilder, NamedJob,
+        cache_rust_dependencies_namespace, named,
     },
-    vars::{PathCondition, StepOutput, one_workflow_per_non_main_branch},
+    vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token},
 };
 
 pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667";
@@ -25,8 +23,10 @@ pub(crate) fn extension_tests() -> Workflow {
     let should_check_extension =
         PathCondition::new("check_extension", r"^(extension\.toml|.*\.scm)$");
 
-    let orchestrate =
-        orchestrate_without_package_filter(&[&should_check_rust, &should_check_extension]);
+    let orchestrate = with_extension_defaults(orchestrate_for_extension(&[
+        &should_check_rust,
+        &should_check_extension,
+    ]));
 
     let jobs = [
         orchestrate,
@@ -34,11 +34,20 @@ pub(crate) fn extension_tests() -> Workflow {
         should_check_extension.guard(check_extension()),
     ];
 
-    let tests_pass = tests_pass(&jobs);
+    let tests_pass = tests_pass(&jobs, &[]);
+
+    let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned()));
 
     named::workflow()
-        .add_event(Event::default().workflow_call(WorkflowCall::default()))
-        .concurrency(one_workflow_per_non_main_branch())
+        .add_event(
+            Event::default().workflow_call(
+                WorkflowCall::default()
+                    .add_input(working_directory.name, working_directory.call_input()),
+            ),
+        )
+        .concurrency(one_workflow_per_non_main_branch_and_token(
+            "extension-tests",
+        ))
         .add_env(("CARGO_TERM_COLOR", "always"))
         .add_env(("RUST_BACKTRACE", 1))
         .add_env(("CARGO_INCREMENTAL", 0))
@@ -58,27 +67,66 @@ fn install_rust_target() -> Step<Run> {
     named::bash(format!("rustup target add {EXTENSION_RUST_TARGET}",))
 }
 
-fn run_clippy() -> Step<Run> {
-    named::bash("cargo clippy --release --all-features -- --deny warnings")
+fn get_package_name() -> (Step<Run>, StepOutput) {
+    let step = named::bash(indoc! {r#"
+        PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < Cargo.toml | head -1 | tr -d '[:space:]')"
+        echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
+    "#})
+    .id("get-package-name");
+
+    let output = StepOutput::new(&step, "package_name");
+    (step, output)
+}
+
+fn cargo_fmt_package(package_name: &StepOutput) -> Step<Run> {
+    named::bash(r#"cargo fmt -p "$PACKAGE_NAME" -- --check"#)
+        .add_env(("PACKAGE_NAME", package_name.to_string()))
+}
+
+fn run_clippy(package_name: &StepOutput) -> Step<Run> {
+    named::bash(r#"cargo clippy -p "$PACKAGE_NAME" --release --all-features -- --deny warnings"#)
+        .add_env(("PACKAGE_NAME", package_name.to_string()))
+}
+
+fn run_nextest(package_name: &StepOutput) -> Step<Run> {
+    named::bash(
+        r#"cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n 's|host: ||p')""#,
+    )
+    .add_env(("PACKAGE_NAME", package_name.to_string()))
+    .add_env(("NEXTEST_NO_TESTS", "warn"))
+}
+
+fn extension_job_defaults() -> Defaults {
+    Defaults::default().run(
+        RunDefaults::default()
+            .shell(BASH_SHELL)
+            .working_directory("${{ inputs.working-directory }}"),
+    )
+}
+
+fn with_extension_defaults(named_job: NamedJob) -> NamedJob {
+    NamedJob {
+        name: named_job.name,
+        job: named_job.job.defaults(extension_job_defaults()),
+    }
 }
 
 fn check_rust() -> NamedJob {
+    let (get_package, package_name) = get_package_name();
+
     let job = Job::default()
+        .defaults(extension_job_defaults())
         .with_repository_owner_guard()
         .runs_on(runners::LINUX_LARGE_RAM)
         .timeout_minutes(6u32)
         .add_step(steps::checkout_repo())
         .add_step(steps::cache_rust_dependencies_namespace())
         .add_step(install_rust_target())
-        .add_step(steps::cargo_fmt())
-        .add_step(run_clippy())
+        .add_step(get_package)
+        .add_step(cargo_fmt_package(&package_name))
+        .add_step(run_clippy(&package_name))
         .add_step(steps::cargo_install_nextest())
-        .add_step(
-            steps::cargo_nextest(runners::Platform::Linux)
-                // Set the target to the current platform again
-                .with_target("$(rustc -vV | sed -n 's|host: ||p')")
-                .add_env(("NEXTEST_NO_TESTS", "warn")),
-        );
+        .add_step(run_nextest(&package_name));
 
     named::job(job)
 }
@@ -88,6 +136,7 @@ pub(crate) fn check_extension() -> NamedJob {
     let (check_version_job, version_changed, _) = compare_versions();
 
     let job = Job::default()
+        .defaults(extension_job_defaults())
         .with_repository_owner_guard()
         .runs_on(runners::LINUX_LARGE_RAM)
         .timeout_minutes(6u32)
@@ -124,8 +173,8 @@ pub fn download_zed_extension_cli(cache_hit: StepOutput) -> Step<Run> {
     named::bash(
     indoc! {
         r#"
-        wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension"
-        chmod +x zed-extension
+        wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension"
+        chmod +x "$GITHUB_WORKSPACE/zed-extension"
         "#,
     }
     ).if_condition(Expression::new(format!("{} != 'true'", cache_hit.expr())))
@@ -136,7 +185,7 @@ pub fn check() -> Step<Run> {
         r#"
         mkdir -p /tmp/ext-scratch
         mkdir -p /tmp/ext-output
-        ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
+        "$GITHUB_WORKSPACE/zed-extension" --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
         "#
     })
 }

tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs 🔗

@@ -127,8 +127,9 @@ fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOu
         .id("calc-changes")
         .add_env(("PREV_COMMIT", prev_commit.to_string()));
 
-        let removed_ci = StepOutput::new(&step, "removed_ci");
-        let removed_shared = StepOutput::new(&step, "removed_shared");
+        // These are created in the for-loop above and thus do exist
+        let removed_ci = StepOutput::new_unchecked(&step, "removed_ci");
+        let removed_shared = StepOutput::new_unchecked(&step, "removed_shared");
 
         (step, removed_ci, removed_shared)
     }

tooling/xtask/src/tasks/workflows/run_tests.rs 🔗

@@ -1,9 +1,10 @@
 use gh_workflow::{
-    Concurrency, Container, Event, Expression, Job, Port, PullRequest, Push, Run, Step, Use,
-    Workflow,
+    Concurrency, Container, Event, Expression, Input, Job, Level, Permissions, Port, PullRequest,
+    Push, Run, Step, Strategy, Use, UsesJob, Workflow,
 };
 use indexmap::IndexMap;
 use indoc::formatdoc;
+use serde_json::json;
 
 use crate::tasks::workflows::{
     steps::{
@@ -24,9 +25,10 @@ pub(crate) fn run_tests() -> Workflow {
     // - script/update_top_ranking_issues/
     // - .github/ISSUE_TEMPLATE/
     // - .github/workflows/  (except .github/workflows/ci.yml)
+    // - extensions/  (these have their own test workflow)
     let should_run_tests = PathCondition::inverted(
         "run_tests",
-        r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))",
+        r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)",
     );
     let should_check_docs = PathCondition::new("run_docs", r"^(docs/|crates/.*\.rs)");
     let should_check_scripts = PathCondition::new(
@@ -60,7 +62,8 @@ pub(crate) fn run_tests() -> Workflow {
         should_check_licences.guard(check_licenses()),
         should_check_scripts.guard(check_scripts()),
     ];
-    let tests_pass = tests_pass(&jobs);
+    let ext_tests = extension_tests();
+    let tests_pass = tests_pass(&jobs, &[&ext_tests.name]);
 
     jobs.push(should_run_tests.guard(check_postgres_and_protobuf_migrations())); // could be more specific here?
 
@@ -91,20 +94,32 @@ pub(crate) fn run_tests() -> Workflow {
             }
             workflow
         })
+        .add_job(ext_tests.name, ext_tests.job)
         .add_job(tests_pass.name, tests_pass.job)
 }
 
+/// Controls which features `orchestrate_impl` includes in the generated script.
+#[derive(PartialEq, Eq)]
+enum OrchestrateTarget {
+    /// For the main Zed repo: includes the cargo package filter and extension
+    /// change detection, but no working-directory scoping.
+    ZedRepo,
+    /// For individual extension repos: scopes changed-file detection to the
+    /// working directory, with no package filter or extension detection.
+    Extension,
+}
+
 // Generates a bash script that checks changed files against regex patterns
 // and sets GitHub output variables accordingly
 pub fn orchestrate(rules: &[&PathCondition]) -> NamedJob {
-    orchestrate_impl(rules, true)
+    orchestrate_impl(rules, OrchestrateTarget::ZedRepo)
 }
 
-pub fn orchestrate_without_package_filter(rules: &[&PathCondition]) -> NamedJob {
-    orchestrate_impl(rules, false)
+pub fn orchestrate_for_extension(rules: &[&PathCondition]) -> NamedJob {
+    orchestrate_impl(rules, OrchestrateTarget::Extension)
 }
 
-fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> NamedJob {
+fn orchestrate_impl(rules: &[&PathCondition], target: OrchestrateTarget) -> NamedJob {
     let name = "orchestrate".to_owned();
     let step_name = "filter".to_owned();
     let mut script = String::new();
@@ -121,6 +136,22 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N
         fi
         CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
 
+    "#});
+
+    if target == OrchestrateTarget::Extension {
+        script.push_str(indoc::indoc! {r#"
+        # When running from a subdirectory, git diff returns repo-root-relative paths.
+        # Filter to only files within the current working directory and strip the prefix.
+        REPO_SUBDIR="$(git rev-parse --show-prefix)"
+        REPO_SUBDIR="${REPO_SUBDIR%/}"
+        if [ -n "$REPO_SUBDIR" ]; then
+            CHANGED_FILES="$(echo "$CHANGED_FILES" | grep "^${REPO_SUBDIR}/" | sed "s|^${REPO_SUBDIR}/||" || true)"
+        fi
+
+    "#});
+    }
+
+    script.push_str(indoc::indoc! {r#"
         check_pattern() {
           local output_name="$1"
           local pattern="$2"
@@ -135,7 +166,7 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N
 
     let mut outputs = IndexMap::new();
 
-    if include_package_filter {
+    if target == OrchestrateTarget::ZedRepo {
         script.push_str(indoc::indoc! {r#"
         # Check for changes that require full rebuild (no filter)
         # Direct pushes to main/stable/preview always run full suite
@@ -221,6 +252,16 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N
         ));
     }
 
+    if target == OrchestrateTarget::ZedRepo {
+        script.push_str(DETECT_CHANGED_EXTENSIONS_SCRIPT);
+        script.push_str("echo \"changed_extensions=$EXTENSIONS_JSON\" >> \"$GITHUB_OUTPUT\"\n");
+
+        outputs.insert(
+            "changed_extensions".to_owned(),
+            format!("${{{{ steps.{}.outputs.changed_extensions }}}}", step_name),
+        );
+    }
+
     let job = Job::default()
         .runs_on(runners::LINUX_SMALL)
         .with_repository_owner_guard()
@@ -231,7 +272,7 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N
     NamedJob { name, job }
 }
 
-pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
+pub fn tests_pass(jobs: &[NamedJob], extra_job_names: &[&str]) -> NamedJob {
     let mut script = String::from(indoc::indoc! {r#"
         set +x
         EXIT_CODE=0
@@ -243,20 +284,26 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
 
     "#});
 
-    let env_entries: Vec<_> = jobs
+    let all_names: Vec<&str> = jobs
         .iter()
-        .map(|job| {
-            let env_name = format!("RESULT_{}", job.name.to_uppercase());
-            let env_value = format!("${{{{ needs.{}.result }}}}", job.name);
+        .map(|job| job.name.as_str())
+        .chain(extra_job_names.iter().copied())
+        .collect();
+
+    let env_entries: Vec<_> = all_names
+        .iter()
+        .map(|name| {
+            let env_name = format!("RESULT_{}", name.to_uppercase());
+            let env_value = format!("${{{{ needs.{}.result }}}}", name);
             (env_name, env_value)
         })
         .collect();
 
     script.push_str(
-        &jobs
+        &all_names
             .iter()
             .zip(env_entries.iter())
-            .map(|(job, (env_name, _))| format!("check_result \"{}\" \"${}\"", job.name, env_name))
+            .map(|(name, (env_name, _))| format!("check_result \"{}\" \"${}\"", name, env_name))
             .collect::<Vec<_>>()
             .join("\n"),
     );
@@ -266,8 +313,9 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
     let job = Job::default()
         .runs_on(runners::LINUX_SMALL)
         .needs(
-            jobs.iter()
-                .map(|j| j.name.to_string())
+            all_names
+                .iter()
+                .map(|name| name.to_string())
                 .collect::<Vec<String>>(),
         )
         .cond(repository_owner_guard_expression(true))
@@ -282,6 +330,19 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob {
     named::job(job)
 }
 
+/// Bash script snippet that detects changed extension directories from `$CHANGED_FILES`.
+/// Assumes `$CHANGED_FILES` is already set. Sets `$EXTENSIONS_JSON` to a JSON array of
+/// changed extension paths. Callers are responsible for writing the result to `$GITHUB_OUTPUT`.
+pub(crate) const DETECT_CHANGED_EXTENSIONS_SCRIPT: &str = indoc::indoc! {r#"
+    # Detect changed extension directories (excluding extensions/workflows)
+    CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true)
+    if [ -n "$CHANGED_EXTENSIONS" ]; then
+        EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))')
+    else
+        EXTENSIONS_JSON="[]"
+    fi
+"#};
+
 const TS_QUERY_LS_FILE: &str = "ts_query_ls-x86_64-unknown-linux-gnu.tar.gz";
 const CI_TS_QUERY_RELEASE: &str = "tags/v3.15.1";
 
@@ -298,8 +359,8 @@ pub(crate) fn fetch_ts_query_ls() -> Step<Use> {
 
 pub(crate) fn run_ts_query_ls() -> Step<Run> {
     named::bash(formatdoc!(
-        r#"tar -xf {TS_QUERY_LS_FILE}
-        ./ts_query_ls format --check . || {{
+        r#"tar -xf "$GITHUB_WORKSPACE/{TS_QUERY_LS_FILE}" -C "$GITHUB_WORKSPACE"
+        "$GITHUB_WORKSPACE/ts_query_ls" format --check . || {{
             echo "Found unformatted queries, please format them with ts_query_ls."
             echo "For easy use, install the Tree-sitter query extension:"
             echo "zed://extension/tree-sitter-query"
@@ -692,3 +753,26 @@ pub(crate) fn check_scripts() -> NamedJob {
             .add_step(check_xtask_workflows()),
     )
 }
+
+fn extension_tests() -> NamedJob<UsesJob> {
+    let job = Job::default()
+        .needs(vec!["orchestrate".to_owned()])
+        .cond(Expression::new(
+            "needs.orchestrate.outputs.changed_extensions != '[]'",
+        ))
+        .permissions(Permissions::default().contents(Level::Read))
+        .strategy(
+            Strategy::default()
+                .fail_fast(false)
+                // TODO: Remove the limit. We currently need this to workaround the concurrency group issue
+                // where different matrix jobs would be placed in the same concurrency group and thus cancelled.
+                .max_parallel(1u32)
+                .matrix(json!({
+                    "extension": "${{ fromJson(needs.orchestrate.outputs.changed_extensions) }}"
+                })),
+        )
+        .uses_local(".github/workflows/extension_tests.yml")
+        .with(Input::default().add("working-directory", "${{ matrix.extension }}"));
+
+    named::job(job)
+}

tooling/xtask/src/tasks/workflows/steps.rs 🔗

@@ -10,7 +10,7 @@ pub(crate) fn use_clang(job: Job) -> Job {
 
 const SCCACHE_R2_BUCKET: &str = "sccache-zed";
 
-const BASH_SHELL: &str = "bash -euxo pipefail {0}";
+pub(crate) const BASH_SHELL: &str = "bash -euxo pipefail {0}";
 // https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell
 pub const PWSH_SHELL: &str = "pwsh";
 
@@ -24,13 +24,6 @@ pub(crate) fn cargo_nextest(platform: Platform) -> Nextest {
 }
 
 impl Nextest {
-    pub(crate) fn with_target(mut self, target: &str) -> Step<Run> {
-        if let Some(nextest_command) = self.0.value.run.as_mut() {
-            nextest_command.push_str(&format!(r#" --target "{target}""#));
-        }
-        self.into()
-    }
-
     #[allow(dead_code)]
     pub(crate) fn with_filter_expr(mut self, filter_expr: &str) -> Self {
         if let Some(nextest_command) = self.0.value.run.as_mut() {

tooling/xtask/src/tasks/workflows/vars.rs 🔗

@@ -156,14 +156,31 @@ pub(crate) struct StepOutput {
 
 impl StepOutput {
     pub fn new<T>(step: &Step<T>, name: &'static str) -> Self {
-        Self {
-            name,
-            step_id: step
-                .value
-                .id
-                .clone()
-                .expect("Steps that produce outputs must have an ID"),
-        }
+        let step_id = step
+            .value
+            .id
+            .clone()
+            .expect("Steps that produce outputs must have an ID");
+
+        assert!(
+            step.value
+                .run
+                .as_ref()
+                .is_none_or(|run_command| run_command.contains(name)),
+            "Step Output name {name} must occur at least once in run command with ID {step_id}!"
+        );
+
+        Self { name, step_id }
+    }
+
+    pub fn new_unchecked<T>(step: &Step<T>, name: &'static str) -> Self {
+        let step_id = step
+            .value
+            .id
+            .clone()
+            .expect("Steps that produce outputs must have an ID");
+
+        Self { name, step_id }
     }
 
     pub fn expr(&self) -> String {