diff --git a/.github/workflows/extension_auto_bump.yml b/.github/workflows/extension_auto_bump.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f5203800958c51ee0c6bc0f0ee0fb76da826def5
--- /dev/null
+++ b/.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}
diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml
index 9cc53741e8007a1b3ddd02ad07b191b3ce171cc8..31f34c9299cee8b464162d501aecaa2bb70035d6 100644
--- a/.github/workflows/extension_bump.yml
+++ b/.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:
diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml
index 53de373c1b79dc3ca9a3637642e10998c781580a..89668c028a6d1fa4baddd417687226dd55a52426 100644
--- a/.github/workflows/extension_tests.yml
+++ b/.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:
diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml
index 00d69639a53868386157e67aeab5ce7383d32426..fed05e00459b3c688c4244ddb9ea29ec1dbfd564 100644
--- a/.github/workflows/run_tests.yml
+++ b/.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
diff --git a/Cargo.lock b/Cargo.lock
index 6570398f5b22f2248a9cd59f84d2cf70080c3591..65d7f7ccb5ae148e337257d52f71ac2cc4aeebc0 100644
--- a/Cargo.lock
+++ b/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",
diff --git a/Cargo.toml b/Cargo.toml
index 36e7ca8cc7129af0ed7ab29dc5db338cdf33f7d4..754860cc43f5b841e45316a0434b37886e901a0f 100644
--- a/Cargo.toml
+++ b/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"
diff --git a/assets/icons/thread.svg b/assets/icons/thread.svg
index 496cf42e3a3ee1439f36b8e2479d05564362e628..569a6f3aec7e3b8742d3d7d23fe11db5aea199ba 100644
--- a/assets/icons/thread.svg
+++ b/assets/icons/thread.svg
@@ -1,3 +1,4 @@
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index ba2afc46317462e1873370b8669a0a326d80b242..cf9c7d1db1da8a1aceb246240ca99890f9a24dfb 100644
--- a/assets/keymaps/default-linux.json
+++ b/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",
diff --git a/assets/settings/default.json b/assets/settings/default.json
index d812673d9dac997df570625be3ea07cf1cb831dc..7af6ce7e44d9abde7b29c80bb170cd13f3c2e786 100644
--- a/assets/settings/default.json
+++ b/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.
diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs
index a661289f6221818c6f63c799b0593907bb665eb9..ba0851565e4ee84e1eb4360a6391a1ad442602cf 100644
--- a/crates/agent_servers/src/acp.rs
+++ b/crates/agent_servers/src/acp.rs
@@ -753,7 +753,7 @@ impl AgentConnection for AcpConnection {
session_id: &acp::SessionId,
cx: &mut App,
) -> Task> {
- 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()
))));
diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs
index ef3f3fdacc3d155554f3e2576ed1ed27c1d9ff0d..6b7f46d87f2db1e9262eadf9e7064c06245b1e3c 100644
--- a/crates/agent_ui/src/agent_configuration.rs
+++ b/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({
diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs
index a3a389ac0a068d92112ee98caacb2986c499ad86..3d18d734af4890ef06a67dccec0c0e884a219a79 100644
--- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs
+++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs
@@ -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()
diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
index 38805f2c26693f168c7273afddf5aceea44f83e3..857a084b720e732b218f0060f1fbee312f712540 100644
--- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
+++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs
@@ -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| {
diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs
index 13e62eb502de1d4bf454b47b216374a0abf2bc79..bb1367b7da31d7975ab271ec821fb43a5da70605 100644
--- a/crates/agent_ui/src/agent_diff.rs
+++ b/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,
diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs
index 465eb808404dd5521ef056b62c813a9566bb7a47..93984121c261034a5cc6198621e79d87d2de1ff4 100644
--- a/crates/agent_ui/src/agent_model_selector.rs
+++ b/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,
@@ -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),
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 632f06111b49d45abd44c6299721fd2fa74862e3..b3edba3a09fb71ad7bae6d8b3ba444c445cb6712 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/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>>,
onboarding: Entity,
- selected_agent: AgentType,
+ selected_agent_type: AgentType,
start_thread_in: StartThreadIn,
worktree_creation_status: Option,
_thread_view_subscription: Option,
@@ -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,
) -> Option {
- 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,
window: &mut Window,
cx: &mut Context,
) -> Entity {
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,
window: &mut Window,
@@ -2328,8 +2344,8 @@ impl AgentPanel {
}
}
- fn selected_external_agent(&self) -> Option {
- match &self.selected_agent {
+ pub(crate) fn selected_agent(&self) -> Option {
+ 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,
- title: Option,
- window: &mut Window,
- cx: &mut Context,
- ) {
- 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,
title: Option,
@@ -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,
resume_session_id: Option,
@@ -2503,8 +2506,8 @@ impl AgentPanel {
cx: &mut Context,
) {
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>,
> = {
- 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.
diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs
index d003ba958276c8c2370011d83028eda2e9121440..cb99077697a59b4f0c1a50277172ef1eaf0b77aa 100644
--- a/crates/agent_ui/src/agent_registry_ui.rs
+++ b/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))
}),
diff --git a/crates/agent_ui/src/config_options.rs b/crates/agent_ui/src/config_options.rs
index 6ec2595202490ca7474717f8985b6e4f6d7ca0b9..b8cf7e5d57921c7710392911829fc2b5045a0f90 100644
--- a/crates/agent_ui/src/config_options.rs
+++ b/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)
}
}
diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs
index ddba63be9863a466ab70fa0f51802246605f89d1..bea9c93ced96e79122b5fdf1afd4bb6e2f52407e 100644
--- a/crates/agent_ui/src/connection_view.rs
+++ b/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::()
+ .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>>,
+ }
+
+ 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,
+ project: Entity,
+ cwd: &Path,
+ cx: &mut gpui::App,
+ ) -> Task>> {
+ 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,
+ session_id: &acp::SessionId,
+ _cx: &mut App,
+ ) -> Task> {
+ 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> {
+ Task::ready(Ok(()))
+ }
+
+ fn prompt(
+ &self,
+ _id: Option,
+ _params: acp::PromptRequest,
+ _cx: &mut App,
+ ) -> Task> {
+ Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
+ }
+
+ fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {}
+
+ fn into_any(self: Rc) -> Rc {
+ self
+ }
+ }
}
diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs
index 030f6c5431eb79258be60f9d0139b8757611aa71..35df60b567de86762a9af330013df0fab35f3f01 100644
--- a/crates/agent_ui/src/connection_view/thread_view.rs
+++ b/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)
diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs
index 0450efc4b7ebf466d0b9b13f516249a2cba0ecfa..fa68c86fe9fa39319b7d6adb1c7ae50544ae4f00 100644
--- a/crates/agent_ui/src/inline_prompt_editor.rs
+++ b/crates/agent_ui/src/inline_prompt_editor.rs
@@ -796,9 +796,11 @@ impl PromptEditor {
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)),
)
diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs
index 1cb22af6a3fd15df5eeedc5018deaeff77a1dbff..782d2b353c8f3599ba38486a4cf558f448b31bcf 100644
--- a/crates/agent_ui/src/mention_set.rs
+++ b/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,
diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs
index c9067d4ec261261e66c7718b36ebcb96b2099fed..4170417df0c5fdfcdb86f2e4c0478c0ef59cefa9 100644
--- a/crates/agent_ui/src/message_editor.rs
+++ b/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) {
+ 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()
}
}),
diff --git a/crates/agent_ui/src/mode_selector.rs b/crates/agent_ui/src/mode_selector.rs
index 9ec25d6d2a1e11a12ef8f05061f143fec5fe53bb..60c9b8787092388ad2b3e2d5817834018dc7ea25 100644
--- a/crates/agent_ui/src/mode_selector.rs
+++ b/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")
diff --git a/crates/agent_ui/src/model_selector_popover.rs b/crates/agent_ui/src/model_selector_popover.rs
index 7a4e9dbf8633680fe9c6ee3bda4acdb0ff5b1478..74ebd78ba61681325cc4905be8d577b225e50e92 100644
--- a/crates/agent_ui/src/model_selector_popover.rs
+++ b/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 {
diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs
index f785c936a643f4280121d083831eba4c909bc0f5..661f887b53116094b5a8694bf93b21389bd9f58b 100644
--- a/crates/agent_ui/src/profile_selector.rs
+++ b/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;
diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs
index f33d2b11e3fbd2348556ad46a14fa876847660a2..cb9da19d0c7182370a0af08447360a3a266cb042 100644
--- a/crates/agent_ui/src/sidebar.rs
+++ b/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,
@@ -196,7 +197,7 @@ fn root_repository_snapshots(
workspace: &Entity,
cx: &App,
) -> Vec {
- 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,
- 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, 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> = 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,
window: &mut Window,
@@ -1446,18 +1447,23 @@ impl Sidebar {
if let Some(agent_panel) = workspace.read(cx).panel::(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> {
+ 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,
+ ) {
+ 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| ::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| ::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| ::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| ::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"
+ );
+ }
}
diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs
index 13764bd655c23176b3aa016f36eae193e16f92de..118de80af215d5ede10b125af1fe154461c3f80d 100644
--- a/crates/agent_ui/src/text_thread_editor.rs
+++ b/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,
diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs
index 4e43748911ba0559485e7a4d991e5dc9d2d4c524..092169efbf57f2947f2532e4a599e7b4935dc539 100644
--- a/crates/agent_ui/src/thread_history_view.rs
+++ b/crates/agent_ui/src/thread_history_view.rs
@@ -751,13 +751,17 @@ impl RenderOnce for HistoryEntryElement {
{
if let Some(panel) = workspace.read(cx).panel::(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,
+ );
+ }
});
}
}
diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs
index 3d7dba591dfa60f7408f9710561863791bcd802b..e1fd44b4d81280037404fa3f2415b39bdc2aade7 100644
--- a/crates/agent_ui/src/threads_archive_view.rs
+++ b/crates/agent_ui/src/threads_archive_view.rs
@@ -89,7 +89,10 @@ fn fuzzy_match_positions(query: &str, text: &str) -> Option> {
pub enum ThreadsArchiveViewEvent {
Close,
- OpenThread(AgentSessionInfo),
+ OpenThread {
+ agent: Agent,
+ session_info: AgentSessionInfo,
+ },
}
impl EventEmitter 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()
diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs
index 23f3eadc4b259aa854f6c2cbb6bb3a68ec46deb5..ee214e07ffb526f1c4ef89cc9301b4ea7e8d6ebf 100644
--- a/crates/agent_ui/src/ui/acp_onboarding_modal.rs
+++ b/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));
diff --git a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs
index 9e499690efcb797e28f32ca8b3bd0f2c2f0da9db..3a9010b0a155873e658946b4155f09f8867e498a 100644
--- a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs
+++ b/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));
diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs
index 0f0b8ecc1d7d66a6025bcfed772c7ead7061fe20..b70b77e6ca603aba8fd55706918ffb3543e2a734 100644
--- a/crates/agent_ui/src/ui/mention_crease.rs
+++ b/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,
+ )
});
}
diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs
index 54a5e40337dc4b41ddd668783656498e9be841b9..a63a332e4a0e38e4b65020bf77f94f78600594d3 100644
--- a/crates/breadcrumbs/src/breadcrumbs.rs
+++ b/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,
+ Vec,
+ Option,
Option,
&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::() {
(render_fn.0)(
segments,
+ breadcrumb_font,
prefix_element,
active_item.as_ref(),
false,
diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs
index a9357a765a75443e18efb1e6f31cdfab313ebcce..f8d28ac96d7c140141ac520b1c38a10c82dd75a9 100644
--- a/crates/channel/src/channel_store.rs
+++ b/crates/channel/src/channel_store.rs
@@ -156,6 +156,10 @@ impl ChannelStore {
cx.global::().0.clone()
}
+ pub fn try_global(cx: &App) -> Option> {
+ cx.try_global::().map(|g| g.0.clone())
+ }
+
pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self {
let rpc_subscriptions = [
client.add_message_handler(cx.weak_entity(), Self::handle_update_channels),
diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs
index 6b23780156e03d62543cf597e82959083685f0c0..1590f498308c74125c7672595cb7510b6653e9b1 100644
--- a/crates/collab/tests/integration/editor_tests.rs
+++ b/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::>(),
diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs
index 0ec5d03a478ba42d438f57ae2f4fdea9f34d1b50..9aeeeeb4233a7e5486ef49da8b0aeaaddd846d17 100644
--- a/crates/collab_ui/src/collab_panel.rs
+++ b/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")
diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs
index f9ce68a6afe8497c50096b153847070b3eca35a2..fd70163896113f0a20b66c5181749d58385b4c34 100644
--- a/crates/collab_ui/src/notification_panel.rs
+++ b/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({
diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs
index 24b1218305474a29ac2d2e7c8e0a212d6d757522..033effd230d65fee7594d0241b2828a41908a432 100644
--- a/crates/copilot_ui/src/sign_in.rs
+++ b/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)
diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs
index cac96918e32cde4770bedac69fb92a08825e3b25..7e11fe4e19f9acafdb9e2d0be30069f3d5457e5c 100644
--- a/crates/debugger_ui/src/debugger_panel.rs
+++ b/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 {
diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs
index 2347a731cb5b5f3590dafcf0a57dc0bab88c380c..0dd387e627a29fcd48b0523dd72990bbc05a5311 100644
--- a/crates/edit_prediction/src/edit_prediction.rs
+++ b/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();
diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs
index 0a952f0869b46f626c231e11f8a61370c50490fa..b80498c4ddccfffab02e77ceb20e6e9cf68851f4 100644
--- a/crates/edit_prediction/src/mercury.rs
+++ b/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,
+ 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,
) -> Task>> {
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::()
+ .is_some()
+}
+
+fn mercury_payment_required_message(body: &[u8]) -> SharedString {
+ serde_json::from_slice::(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 = env_var!("MERCURY_AI_TOKEN");
struct GlobalMercuryApiKey(Entity);
diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs
index dac4c812f8ac1377423f7044c1c250b5a5333f64..1a5e60ca8b27f31d26c6389bbd39a516164f3bf6 100644
--- a/crates/edit_prediction_ui/src/edit_prediction_button.rs
+++ b/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 {
diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs
index 1c4328d8a1d301b7cc01aa520c166bda4b40e32d..b2e7209c1a7e9dd403ed0ee70336119ef0f1bdc9 100644
--- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs
+++ b/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,
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index 20d976ad6c0e0a9c82fbaa681efea80f2873d375..8c2e03722c345a0f093572c336029a0eaa355537 100644
--- a/crates/editor/src/editor.rs
+++ b/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> {
+ fn breadcrumbs_inner(&self, cx: &App) -> Option> {
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);
}
}
}
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index dcbd00ef8c89de8c4a3e3334ae1804ebe9e7b042..ab00de0df25ca209604c7052367f0ac6ce2142ae 100644
--- a/crates/editor/src/element.rs
+++ b/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,
+ mut segments: Vec,
+ breadcrumb_font: Option,
prefix: Option,
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 {
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,
diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs
index ad54d6105ca3896d21857d548d80f991a1a76ecc..99069cac6ceeec3983d6713777007876c74c8d19 100644
--- a/crates/editor/src/hover_popover.rs
+++ b/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, 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);
}
diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs
index 1a79414ddc3aa57397d964d4e0af0d87bedc9c3b..e0502e4d9987bef512506ef927ff5384be5f0c30 100644
--- a/crates/editor/src/items.rs
+++ b/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> {
+ fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> {
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
}
diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs
index 01f7d0064e6f5ecd0d4d9c1760386102e9ce16e0..6bf6449506f1c1eb2a71270546ad3b063f7e9022 100644
--- a/crates/editor/src/movement.rs
+++ b/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);
}
diff --git a/crates/editor/src/runnables.rs b/crates/editor/src/runnables.rs
index 9fa6b89ec130e74f388c5e82b9b346197bb13abb..e36658cf0b160dc2e340f11abe76efa5e895b4ee 100644
--- a/crates/editor/src/runnables.rs
+++ b/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)>,
+ invalidate_buffer_data: HashSet,
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) {
+ pub fn refresh_runnables(
+ &mut self,
+ invalidate_buffer_data: Option,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
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>,
+ _: &gpui::App,
+ ) -> Task