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> { + Task::ready(Some(TaskTemplates(vec![TaskTemplate { + label: "Run test".into(), + command: "cargo".into(), + args: vec!["test".into()], + tags: vec!["rust-test".into()], + ..TaskTemplate::default() + }]))) + } + + fn lsp_task_source(&self) -> Option { + Some(LanguageServerName::new_static(FAKE_LSP_NAME)) + } + } + fn rust_lang_with_task_context() -> Arc { Arc::new( Arc::try_unwrap(rust_lang()) @@ -747,6 +794,14 @@ mod tests { ) } + fn rust_lang_with_lsp_task_context() -> Arc { + Arc::new( + Arc::try_unwrap(rust_lang()) + .unwrap() + .with_context_provider(Some(Arc::new(TestRustContextProviderWithLsp))), + ) + } + fn collect_runnable_labels( editor: &Editor, ) -> Vec<(text::BufferId, language::BufferRow, Vec)> { @@ -853,7 +908,7 @@ mod tests { editor .update(cx, |editor, window, cx| { editor.clear_runnables(None); - editor.refresh_runnables(window, cx); + editor.refresh_runnables(None, window, cx); }) .unwrap(); cx.executor().advance_clock(UPDATE_DEBOUNCE); @@ -912,4 +967,127 @@ mod tests { "first.rs runnables should survive an edit to second.rs" ); } + + #[gpui::test] + async fn test_lsp_runnables_removed_after_edit(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "main.rs": indoc! {" + #[test] + fn test_one() { + assert!(true); + } + + fn helper() {} + "}, + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang_with_lsp_task_context()); + + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + name: FAKE_LSP_NAME, + ..FakeLspAdapter::default() + }, + ); + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/main.rs"), cx) + }) + .await + .unwrap(); + + let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); + + let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let editor = cx.add_window(|window, cx| { + build_editor_with_project(project.clone(), multi_buffer, window, cx) + }); + + let fake_server = fake_servers.next().await.expect("fake LSP server"); + + use project::lsp_store::lsp_ext_command::Runnables; + fake_server.set_request_handler::(move |params, _| async move { + let text = params.text_document.uri.path().to_string(); + if text.contains("main.rs") { + let uri = lsp::Uri::from_file_path(path!("/project/main.rs")).expect("valid uri"); + Ok(vec![Runnable { + label: "LSP test_one".into(), + location: Some(lsp::LocationLink { + origin_selection_range: None, + target_uri: uri, + target_range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(3, 1), + ), + target_selection_range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(3, 1), + ), + }), + kind: RunnableKind::Cargo, + args: RunnableArgs::Cargo(CargoRunnableArgs { + environment: Default::default(), + cwd: path!("/project").into(), + override_cargo: None, + workspace_root: None, + cargo_args: vec!["test".into(), "test_one".into()], + executable_args: Vec::new(), + }), + }]) + } else { + Ok(Vec::new()) + } + }); + + // Trigger a refresh to pick up both tree-sitter and LSP runnables. + editor + .update(cx, |editor, window, cx| { + editor.refresh_runnables(None, window, cx); + }) + .expect("editor update"); + cx.executor().advance_clock(UPDATE_DEBOUNCE); + cx.executor().run_until_parked(); + + let labels = editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .expect("editor update"); + assert_eq!( + labels, + vec![(buffer_id, 0, vec!["LSP test_one".to_string()]),], + "LSP runnables should appear for #[test] fn" + ); + + // Remove `#[test]` attribute so the function is no longer a test. + buffer.update(cx, |buffer, cx| { + let test_attr_end = buffer.text().find("\nfn test_one").expect("find fn"); + buffer.edit([(0..test_attr_end, "")], None, cx); + }); + + // Also update the LSP handler to return no runnables. + fake_server + .set_request_handler::(move |_, _| async move { Ok(Vec::new()) }); + + cx.executor().advance_clock(UPDATE_DEBOUNCE); + cx.executor().run_until_parked(); + + let labels = editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .expect("editor update"); + assert_eq!( + labels, + Vec::<(text::BufferId, language::BufferRow, Vec)>::new(), + "Runnables should be removed after #[test] is deleted and LSP returns empty" + ); + } } diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 877f388fc3b783202cb29f8ca063446635e4277a..c9668bc35655dfcda62e71884a782b4edecae093 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -6,9 +6,11 @@ use std::{ use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use collections::HashMap; -use gpui::{Action, AppContext as _, Entity, EventEmitter, Focusable, Subscription, WeakEntity}; +use gpui::{ + Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Subscription, WeakEntity, +}; use itertools::Itertools; -use language::{Buffer, Capability}; +use language::{Buffer, Capability, HighlightedText}; use multi_buffer::{ Anchor, BufferOffset, ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey, @@ -29,7 +31,7 @@ use crate::{ }; use workspace::{ ActivatePaneLeft, ActivatePaneRight, Item, ToolbarItemLocation, Workspace, - item::{BreadcrumbText, ItemBufferKind, ItemEvent, SaveOptions, TabContentParams}, + item::{ItemBufferKind, ItemEvent, SaveOptions, TabContentParams}, searchable::{SearchEvent, SearchToken, SearchableItem, SearchableItemHandle}, }; @@ -1853,7 +1855,7 @@ impl Item for SplittableEditor { self.rhs_editor.read(cx).breadcrumb_location(cx) } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { self.rhs_editor.read(cx).breadcrumbs(cx) } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 7343edcdef3851bfeb7a3aa80f3449ff06f55d9f..2d0b151a107000e913ba4772d7d3d2bf50474fc1 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -1056,10 +1056,11 @@ impl ExtensionsPage { "Install", ) .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Download) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click({ let extension_id = extension.id.clone(); move |_, _, cx| { @@ -1078,10 +1079,11 @@ impl ExtensionsPage { "Install", ) .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Download) + .size(IconSize::Small) + .color(Color::Muted), + ) .disabled(true), configure: None, upgrade: None, @@ -1479,10 +1481,11 @@ impl ExtensionsPage { } }); let open_registry_button = Button::new("open_registry", "Learn More") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click({ move |_event, _window, cx| { telemetry::event!( @@ -1520,9 +1523,7 @@ impl ExtensionsPage { cx: &mut Context, ) -> impl IntoElement { let docs_url_button = Button::new("open_docs", "View Documentation") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) + .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)) .on_click({ move |_event, _window, cx| { telemetry::event!( diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 113bf68d34f778f8fba9fdc62b586c31e689a380..0876d95a7b044d2a4ce5bf8be964c4057725f827 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -14,6 +14,8 @@ doctest = false [dependencies] anyhow.workspace = true +channel.workspace = true +client.workspace = true collections.workspace = true editor.workspace = true file_icons.workspace = true @@ -45,3 +47,4 @@ serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true +remote_connection = { workspace = true, features = ["test-support"] } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index a1e64964ff578ed263e9e89a610997423f33f7c0..7e0c584c739caa9c71f87be9673a04bd9b9b840f 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -4,10 +4,12 @@ mod file_finder_tests; use futures::future::join_all; pub use open_path_prompt::OpenPathDelegate; +use channel::ChannelStore; +use client::ChannelId; use collections::HashMap; use editor::Editor; use file_icons::FileIcons; -use fuzzy::{CharBag, PathMatch, PathMatchCandidate}; +use fuzzy::{CharBag, PathMatch, PathMatchCandidate, StringMatch, StringMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity, @@ -45,8 +47,8 @@ use util::{ rel_path::RelPath, }; use workspace::{ - ModalView, OpenOptions, OpenVisible, SplitDirection, Workspace, item::PreviewTabsSettings, - notifications::NotifyResultExt, pane, + ModalView, OpenChannelNotesById, OpenOptions, OpenVisible, SplitDirection, Workspace, + item::PreviewTabsSettings, notifications::NotifyResultExt, pane, }; use zed_actions::search::ToggleIncludeIgnored; @@ -321,7 +323,7 @@ impl FileFinder { if let Some(workspace) = delegate.workspace.upgrade() && let Some(m) = delegate.matches.get(delegate.selected_index()) { - let path = match &m { + let path = match m { Match::History { path, .. } => { let worktree_id = path.project.worktree_id; ProjectPath { @@ -334,6 +336,7 @@ impl FileFinder { path: m.0.path.clone(), }, Match::CreateNew(p) => p.clone(), + Match::Channel { .. } => return, }; let open_task = workspace.update(cx, move |workspace, cx| { workspace.split_path_preview(path, false, Some(split_direction), window, cx) @@ -392,6 +395,7 @@ pub struct FileFinderDelegate { file_finder: WeakEntity, workspace: WeakEntity, project: Entity, + channel_store: Option>, search_count: usize, latest_search_id: usize, latest_search_did_cancel: bool, @@ -450,13 +454,18 @@ struct Matches { matches: Vec, } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[derive(Debug, Clone)] enum Match { History { path: FoundPath, panel_match: Option, }, Search(ProjectPanelOrdMatch), + Channel { + channel_id: ChannelId, + channel_name: SharedString, + string_match: StringMatch, + }, CreateNew(ProjectPath), } @@ -465,7 +474,7 @@ impl Match { match self { Match::History { path, .. } => Some(&path.project.path), Match::Search(panel_match) => Some(&panel_match.0.path), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } @@ -479,7 +488,7 @@ impl Match { .read(cx) .absolutize(&path_match.path), ), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } @@ -487,7 +496,7 @@ impl Match { match self { Match::History { panel_match, .. } => panel_match.as_ref(), Match::Search(panel_match) => Some(panel_match), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } } @@ -628,7 +637,6 @@ impl Matches { (_, Match::CreateNew(_)) => return cmp::Ordering::Greater, _ => {} } - debug_assert!(a.panel_match().is_some() && b.panel_match().is_some()); match (&a, &b) { // bubble currently opened files to the top @@ -651,32 +659,35 @@ impl Matches { } } - let a_panel_match = match a.panel_match() { - Some(pm) => pm, - None => { - return if b.panel_match().is_some() { - cmp::Ordering::Less - } else { - cmp::Ordering::Equal - }; + // For file-vs-file matches, use the existing detailed comparison. + if let (Some(a_panel), Some(b_panel)) = (a.panel_match(), b.panel_match()) { + let a_in_filename = Self::is_filename_match(a_panel); + let b_in_filename = Self::is_filename_match(b_panel); + + match (a_in_filename, b_in_filename) { + (true, false) => return cmp::Ordering::Greater, + (false, true) => return cmp::Ordering::Less, + _ => {} } - }; - let b_panel_match = match b.panel_match() { - Some(pm) => pm, - None => return cmp::Ordering::Greater, - }; + return a_panel.cmp(b_panel); + } - let a_in_filename = Self::is_filename_match(a_panel_match); - let b_in_filename = Self::is_filename_match(b_panel_match); + let a_score = Self::match_score(a); + let b_score = Self::match_score(b); + // When at least one side is a channel, compare by raw score. + a_score + .partial_cmp(&b_score) + .unwrap_or(cmp::Ordering::Equal) + } - match (a_in_filename, b_in_filename) { - (true, false) => return cmp::Ordering::Greater, - (false, true) => return cmp::Ordering::Less, - _ => {} // Both are filename matches or both are path matches + fn match_score(m: &Match) -> f64 { + match m { + Match::History { panel_match, .. } => panel_match.as_ref().map_or(0.0, |pm| pm.0.score), + Match::Search(pm) => pm.0.score, + Match::Channel { string_match, .. } => string_match.score, + Match::CreateNew(_) => 0.0, } - - a_panel_match.cmp(b_panel_match) } /// Determines if the match occurred within the filename rather than in the path @@ -833,10 +844,16 @@ impl FileFinderDelegate { cx: &mut Context, ) -> Self { Self::subscribe_to_updates(&project, window, cx); + let channel_store = if FileFinderSettings::get_global(cx).include_channels { + ChannelStore::try_global(cx) + } else { + None + }; Self { file_finder, workspace, project, + channel_store, search_count: 0, latest_search_id: 0, latest_search_did_cancel: false, @@ -971,6 +988,68 @@ impl FileFinderDelegate { path_style, ); + // Add channel matches + if let Some(channel_store) = &self.channel_store { + let channel_store = channel_store.read(cx); + let channels: Vec<_> = channel_store.channels().cloned().collect(); + if !channels.is_empty() { + let candidates = channels + .iter() + .enumerate() + .map(|(id, channel)| StringMatchCandidate::new(id, &channel.name)); + let channel_query = query.path_query(); + let query_lower = channel_query.to_lowercase(); + let mut channel_matches = Vec::new(); + for candidate in candidates { + let channel_name = candidate.string; + let name_lower = channel_name.to_lowercase(); + + let mut positions = Vec::new(); + let mut query_idx = 0; + for (name_idx, name_char) in name_lower.char_indices() { + if query_idx < query_lower.len() { + let query_char = + query_lower[query_idx..].chars().next().unwrap_or_default(); + if name_char == query_char { + positions.push(name_idx); + query_idx += query_char.len_utf8(); + } + } + } + + if query_idx == query_lower.len() { + let channel = &channels[candidate.id]; + let score = if name_lower == query_lower { + 1.0 + } else if name_lower.starts_with(&query_lower) { + 0.8 + } else { + 0.5 * (query_lower.len() as f64 / name_lower.len() as f64) + }; + channel_matches.push(Match::Channel { + channel_id: channel.id, + channel_name: channel.name.clone(), + string_match: StringMatch { + candidate_id: candidate.id, + score, + positions, + string: channel_name, + }, + }); + } + } + for channel_match in channel_matches { + match self + .matches + .position(&channel_match, self.currently_opened_path.as_ref()) + { + Ok(_duplicate) => {} + Err(ix) => self.matches.matches.insert(ix, channel_match), + } + } + } + } + let query_path = query.raw_query.as_str(); if let Ok(mut query_path) = RelPath::new(Path::new(query_path), path_style) { let available_worktree = self @@ -1095,6 +1174,16 @@ impl FileFinderDelegate { } } Match::Search(path_match) => self.labels_for_path_match(&path_match.0, path_style), + Match::Channel { + channel_name, + string_match, + .. + } => ( + channel_name.to_string(), + string_match.positions.clone(), + "Channel Notes".to_string(), + vec![], + ), Match::CreateNew(project_path) => ( format!("Create file: {}", project_path.path.display(path_style)), vec![], @@ -1479,6 +1568,16 @@ impl PickerDelegate for FileFinderDelegate { if let Some(m) = self.matches.get(self.selected_index()) && let Some(workspace) = self.workspace.upgrade() { + // Channel matches are handled separately since they dispatch an action + // rather than directly opening a file path. + if let Match::Channel { channel_id, .. } = m { + let channel_id = channel_id.0; + let finder = self.file_finder.clone(); + window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx); + finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err(); + return; + } + let open_task = workspace.update(cx, |workspace, cx| { let split_or_open = |workspace: &mut Workspace, @@ -1571,6 +1670,7 @@ impl PickerDelegate for FileFinderDelegate { window, cx, ), + Match::Channel { .. } => unreachable!("handled above"), } }); @@ -1627,7 +1727,7 @@ impl PickerDelegate for FileFinderDelegate { let path_match = self.matches.get(ix)?; - let history_icon = match &path_match { + let end_icon = match path_match { Match::History { .. } => Icon::new(IconName::HistoryRerun) .color(Color::Muted) .size(IconSize::Small) @@ -1636,6 +1736,10 @@ impl PickerDelegate for FileFinderDelegate { .flex_none() .size(IconSize::Small.rems()) .into_any_element(), + Match::Channel { .. } => v_flex() + .flex_none() + .size(IconSize::Small.rems()) + .into_any_element(), Match::CreateNew(_) => Icon::new(IconName::Plus) .color(Color::Muted) .size(IconSize::Small) @@ -1643,21 +1747,24 @@ impl PickerDelegate for FileFinderDelegate { }; let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx); - let file_icon = maybe!({ - if !settings.file_icons { - return None; - } - let abs_path = path_match.abs_path(&self.project, cx)?; - let file_name = abs_path.file_name()?; - let icon = FileIcons::get_icon(file_name.as_ref(), cx)?; - Some(Icon::from_path(icon).color(Color::Muted)) - }); + let file_icon = match path_match { + Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)), + _ => maybe!({ + if !settings.file_icons { + return None; + } + let abs_path = path_match.abs_path(&self.project, cx)?; + let file_name = abs_path.file_name()?; + let icon = FileIcons::get_icon(file_name.as_ref(), cx)?; + Some(Icon::from_path(icon).color(Color::Muted)) + }), + }; Some( ListItem::new(ix) .spacing(ListItemSpacing::Sparse) .start_slot::(file_icon) - .end_slot::(history_icon) + .end_slot::(end_icon) .inset(true) .toggle_state(selected) .child( diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index c81d13420b179cc7ce0d8afd2aee26673673f09e..da9fd4b87b045a6321a291cb7128a051d977815b 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -3709,7 +3709,7 @@ impl SearchEntries { fn collect_search_matches(picker: &Picker) -> SearchEntries { let mut search_entries = SearchEntries::default(); for m in &picker.delegate.matches.matches { - match &m { + match m { Match::History { path: history_path, panel_match: path_match, @@ -3734,6 +3734,7 @@ fn collect_search_matches(picker: &Picker) -> SearchEntries search_entries.search_matches.push(path_match.0.clone()); } Match::CreateNew(_) => {} + Match::Channel { .. } => {} } } search_entries @@ -3768,6 +3769,7 @@ fn assert_match_at_position( Match::History { path, .. } => path.absolute.file_name().and_then(|s| s.to_str()), Match::Search(path_match) => path_match.0.path.file_name(), Match::CreateNew(project_path) => project_path.path.file_name(), + Match::Channel { channel_name, .. } => Some(channel_name.as_str()), } .unwrap(); assert_eq!(match_file_name, expected_file_name); diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 6c7074d2139068d2ea581ea6343de4d4c1f09030..311992d20d9947d189ff5026a73620090a8579c4 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -147,7 +147,7 @@ pub trait Fs: Send + Sync { &self, abs_dot_git: &Path, system_git_binary_path: Option<&Path>, - ) -> Option>; + ) -> Result>; async fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>; async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>; @@ -1149,8 +1149,8 @@ impl Fs for RealFs { &self, dotgit_path: &Path, system_git_binary_path: Option<&Path>, - ) -> Option> { - Some(Arc::new(RealGitRepository::new( + ) -> Result> { + Ok(Arc::new(RealGitRepository::new( dotgit_path, self.bundled_git_binary_path.clone(), system_git_binary_path.map(|path| path.to_path_buf()), @@ -2866,9 +2866,7 @@ impl Fs for FakeFs { &self, abs_dot_git: &Path, _system_git_binary: Option<&Path>, - ) -> Option> { - use util::ResultExt as _; - + ) -> Result> { self.with_git_state_and_paths( abs_dot_git, false, @@ -2884,7 +2882,6 @@ impl Fs for FakeFs { }) as _ }, ) - .log_err() } async fn git_init( diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index c44aea74051bb7c190a091703d6c60807fc4e27e..76e622fd6d7ae490c2c869c5ed02f02a48b45cab 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -58,7 +58,7 @@ async fn run_git_blame( let mut child = { let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str()); let _enter = span.enter(); - git.build_command(["blame", "--incremental", "--contents", "-"]) + git.build_command(&["blame", "--incremental", "--contents", "-"]) .arg(path.as_unix_str()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index 46e050ce155fc049a670fdfa26101eb729b34352..50b62fa506bc31c0f4e2b3bedefc46cef415143b 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -81,7 +81,7 @@ pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result Result> { const MARKER: &str = ""; let output = git - .build_command(["show"]) + .build_command(&["show"]) .arg("-s") .arg(format!("--format=%B{}", MARKER)) .args(shas.iter().map(ToString::to_string)) @@ -91,7 +91,7 @@ async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result> anyhow::ensure!( output.status.success(), "'git show' failed with error {:?}", - output.status + String::from_utf8_lossy(&output.stderr) ); Ok(String::from_utf8_lossy(&output.stdout) .trim() diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 45e719fb6d5a586074de523b5974ee11bf225453..094e634c7ff9265ef60ad0a3b892ef1eebdbad4e 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1000,11 +1000,18 @@ impl RealGitRepository { bundled_git_binary_path: Option, system_git_binary_path: Option, executor: BackgroundExecutor, - ) -> Option { - let any_git_binary_path = system_git_binary_path.clone().or(bundled_git_binary_path)?; - let workdir_root = dotgit_path.parent()?; - let repository = git2::Repository::open(workdir_root).log_err()?; - Some(Self { + ) -> Result { + let any_git_binary_path = system_git_binary_path + .clone() + .or(bundled_git_binary_path) + .context("no git binary available")?; + log::info!( + "opening git repository at {dotgit_path:?} using git binary {any_git_binary_path:?}" + ); + let workdir_root = dotgit_path.parent().context(".git has no parent")?; + let repository = + git2::Repository::open(workdir_root).context("creating libgit2 repository")?; + Ok(Self { repository: Arc::new(Mutex::new(repository)), system_git_binary_path, any_git_binary_path, @@ -1039,7 +1046,7 @@ impl RealGitRepository { let git_binary = self.git_binary(); let output: SharedString = self .executor - .spawn(async move { git_binary?.run(["help", "-a"]).await }) + .spawn(async move { git_binary?.run(&["help", "-a"]).await }) .await .unwrap_or_default() .into(); @@ -1086,9 +1093,12 @@ pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter { ); cx.background_spawn(async move { - let name = git.run(["config", "--global", "user.name"]).await.log_err(); + let name = git + .run(&["config", "--global", "user.name"]) + .await + .log_err(); let email = git - .run(["config", "--global", "user.email"]) + .run(&["config", "--global", "user.email"]) .await .log_err(); GitCommitter { name, email } @@ -1119,7 +1129,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command([ + .build_command(&[ "--no-optional-locks", "show", "--no-patch", @@ -1157,7 +1167,7 @@ impl GitRepository for RealGitRepository { cx.background_spawn(async move { let git = git_binary?; let show_output = git - .build_command([ + .build_command(&[ "--no-optional-locks", "show", "--format=", @@ -1179,7 +1189,7 @@ impl GitRepository for RealGitRepository { let parent_sha = format!("{}^", commit); let mut cat_file_process = git - .build_command(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) + .build_command(&["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1295,7 +1305,7 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let output = git - .build_command(["reset", mode_flag, &commit]) + .build_command(&["reset", mode_flag, &commit]) .envs(env.iter()) .output() .await?; @@ -1323,7 +1333,7 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let output = git - .build_command(["checkout", &commit, "--"]) + .build_command(&["checkout", &commit, "--"]) .envs(env.iter()) .args(paths.iter().map(|path| path.as_unix_str())) .output() @@ -1427,7 +1437,7 @@ impl GitRepository for RealGitRepository { if let Some(content) = content { let mut child = git - .build_command(["hash-object", "-w", "--stdin"]) + .build_command(&["hash-object", "-w", "--stdin"]) .envs(env.iter()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -1442,7 +1452,7 @@ impl GitRepository for RealGitRepository { log::debug!("indexing SHA: {sha}, path {path:?}"); let output = git - .build_command(["update-index", "--add", "--cacheinfo", mode, sha]) + .build_command(&["update-index", "--add", "--cacheinfo", mode, sha]) .envs(env.iter()) .arg(path.as_unix_str()) .output() @@ -1456,7 +1466,7 @@ impl GitRepository for RealGitRepository { } else { log::debug!("removing path {path:?} from the index"); let output = git - .build_command(["update-index", "--force-remove"]) + .build_command(&["update-index", "--force-remove"]) .envs(env.iter()) .arg(path.as_unix_str()) .output() @@ -1491,7 +1501,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let mut process = git - .build_command([ + .build_command(&[ "--no-optional-locks", "cat-file", "--batch-check=%(objectname)", @@ -1551,7 +1561,7 @@ impl GitRepository for RealGitRepository { let args = git_status_args(path_prefixes); log::debug!("Checking for git status in {path_prefixes:?}"); self.executor.spawn(async move { - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() @@ -1589,7 +1599,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() @@ -1645,7 +1655,7 @@ impl GitRepository for RealGitRepository { &fields, ]; let git = git_binary?; - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; anyhow::ensure!( output.status.success(), @@ -1659,7 +1669,7 @@ impl GitRepository for RealGitRepository { if branches.is_empty() { let args = vec!["symbolic-ref", "--quiet", "HEAD"]; - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; // git symbolic-ref returns a non-0 exit code if HEAD points // to something other than a branch @@ -1727,7 +1737,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { std::fs::create_dir_all(final_path.parent().unwrap_or(&final_path))?; let git = git_binary?; - let output = git.build_command(args).output().await?; + let output = git.build_command(&args).output().await?; if output.status.success() { Ok(()) } else { @@ -1753,7 +1763,7 @@ impl GitRepository for RealGitRepository { } args.push("--".into()); args.push(path.as_os_str().into()); - git_binary?.run(args).await?; + git_binary?.run(&args).await?; anyhow::Ok(()) }) .boxed() @@ -1772,7 +1782,7 @@ impl GitRepository for RealGitRepository { old_path.as_os_str().into(), new_path.as_os_str().into(), ]; - git_binary?.run(args).await?; + git_binary?.run(&args).await?; anyhow::Ok(()) }) .boxed() @@ -1975,11 +1985,11 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let output = match diff { DiffType::HeadToIndex => { - git.build_command(["diff", "--staged"]).output().await? + git.build_command(&["diff", "--staged"]).output().await? } - DiffType::HeadToWorktree => git.build_command(["diff"]).output().await?, + DiffType::HeadToWorktree => git.build_command(&["diff"]).output().await?, DiffType::MergeBase { base_ref } => { - git.build_command(["diff", "--merge-base", base_ref.as_ref()]) + git.build_command(&["diff", "--merge-base", base_ref.as_ref()]) .output() .await? } @@ -2036,7 +2046,7 @@ impl GitRepository for RealGitRepository { if !paths.is_empty() { let git = git_binary?; let output = git - .build_command(["update-index", "--add", "--remove", "--"]) + .build_command(&["update-index", "--add", "--remove", "--"]) .envs(env.iter()) .args(paths.iter().map(|p| p.as_unix_str())) .output() @@ -2064,7 +2074,7 @@ impl GitRepository for RealGitRepository { if !paths.is_empty() { let git = git_binary?; let output = git - .build_command(["reset", "--quiet", "--"]) + .build_command(&["reset", "--quiet", "--"]) .envs(env.iter()) .args(paths.iter().map(|p| p.as_std_path())) .output() @@ -2091,7 +2101,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command(["stash", "push", "--quiet", "--include-untracked"]) + .build_command(&["stash", "push", "--quiet", "--include-untracked"]) .envs(env.iter()) .args(paths.iter().map(|p| p.as_unix_str())) .output() @@ -2196,7 +2206,7 @@ impl GitRepository for RealGitRepository { // which we want to block on. async move { let git = git_binary?; - let mut cmd = git.build_command(["commit", "--quiet", "-m"]); + let mut cmd = git.build_command(&["commit", "--quiet", "-m"]); cmd.envs(env.iter()) .arg(&message.to_string()) .arg("--cleanup=strip") @@ -2248,7 +2258,7 @@ impl GitRepository for RealGitRepository { executor.clone(), is_trusted, ); - let mut command = git.build_command(["push"]); + let mut command = git.build_command(&["push"]); command .envs(env.iter()) .args(options.map(|option| match option { @@ -2290,7 +2300,7 @@ impl GitRepository for RealGitRepository { executor.clone(), is_trusted, ); - let mut command = git.build_command(["pull"]); + let mut command = git.build_command(&["pull"]); command.envs(env.iter()); if rebase { @@ -2331,7 +2341,7 @@ impl GitRepository for RealGitRepository { executor.clone(), is_trusted, ); - let mut command = git.build_command(["fetch", &remote_name]); + let mut command = git.build_command(&["fetch", &remote_name]); command .envs(env.iter()) .stdout(Stdio::piped()) @@ -2348,7 +2358,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command(["rev-parse", "--abbrev-ref"]) + .build_command(&["rev-parse", "--abbrev-ref"]) .arg(format!("{branch}@{{push}}")) .output() .await?; @@ -2373,7 +2383,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command(["config", "--get"]) + .build_command(&["config", "--get"]) .arg(format!("branch.{branch}.remote")) .output() .await?; @@ -2394,7 +2404,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let git = git_binary?; - let output = git.build_command(["remote", "-v"]).output().await?; + let output = git.build_command(&["remote", "-v"]).output().await?; anyhow::ensure!( output.status.success(), @@ -2725,7 +2735,7 @@ impl GitRepository for RealGitRepository { async move { let git = git_binary?; - let mut command = git.build_command([ + let mut command = git.build_command(&[ "log", GRAPH_COMMIT_FORMAT, log_order.as_arg(), @@ -2808,7 +2818,7 @@ async fn run_commit_data_reader( request_rx: smol::channel::Receiver, ) -> Result<()> { let mut process = git - .build_command(["--no-optional-locks", "cat-file", "--batch"]) + .build_command(&["--no-optional-locks", "cat-file", "--batch"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -3075,7 +3085,7 @@ impl GitBinary { .join(format!("index-{}.tmp", id)) } - pub async fn run(&self, args: impl IntoIterator) -> Result + pub async fn run(&self, args: &[S]) -> Result where S: AsRef, { @@ -3087,7 +3097,7 @@ impl GitBinary { } /// Returns the result of the command without trimming the trailing newline. - pub async fn run_raw(&self, args: impl IntoIterator) -> Result + pub async fn run_raw(&self, args: &[S]) -> Result where S: AsRef, { @@ -3105,10 +3115,7 @@ impl GitBinary { } #[allow(clippy::disallowed_methods)] - pub(crate) fn build_command( - &self, - args: impl IntoIterator, - ) -> util::command::Command + pub(crate) fn build_command(&self, args: &[S]) -> util::command::Command where S: AsRef, { @@ -3125,6 +3132,14 @@ impl GitBinary { command.args(["-c", "diff.external="]); } command.args(args); + + // If the `diff` command is being used, we'll want to add the + // `--no-ext-diff` flag when working on an untrusted repository, + // preventing any external diff programs from being invoked. + if !self.is_trusted && args.iter().any(|arg| arg.as_ref() == "diff") { + command.arg("--no-ext-diff"); + } + if let Some(index_file_path) = self.index_file_path.as_ref() { command.env("GIT_INDEX_FILE", index_file_path); } @@ -3394,7 +3409,7 @@ mod tests { false, ); let output = git - .build_command(["version"]) + .build_command(&["version"]) .output() .await .expect("git version should succeed"); @@ -3407,7 +3422,7 @@ mod tests { false, ); let output = git - .build_command(["config", "--get", "core.fsmonitor"]) + .build_command(&["config", "--get", "core.fsmonitor"]) .output() .await .expect("git config should run"); @@ -3426,7 +3441,7 @@ mod tests { false, ); let output = git - .build_command(["config", "--get", "core.hooksPath"]) + .build_command(&["config", "--get", "core.hooksPath"]) .output() .await .expect("git config should run"); @@ -3451,7 +3466,7 @@ mod tests { true, ); let output = git - .build_command(["config", "--get", "core.fsmonitor"]) + .build_command(&["config", "--get", "core.fsmonitor"]) .output() .await .expect("git config should run"); @@ -3469,7 +3484,7 @@ mod tests { true, ); let output = git - .build_command(["config", "--get", "core.hooksPath"]) + .build_command(&["config", "--get", "core.hooksPath"]) .output() .await .expect("git config should run"); diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 12ed44cd7ec2de0e68d56642b756e1be824e19fe..b0a4701cd25021e2725ff28b7cc45d1b4f203c8d 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1494,10 +1494,9 @@ impl GitGraph { this.child( Button::new("author-email-copy", author_email.clone()) - .icon(icon) - .icon_size(IconSize::Small) - .icon_color(icon_color) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(icon).size(IconSize::Small).color(icon_color), + ) .label_size(LabelSize::Small) .truncate(true) .color(Color::Muted) @@ -1542,10 +1541,9 @@ impl GitGraph { }; Button::new("sha-button", &full_sha) - .icon(icon) - .icon_size(IconSize::Small) - .icon_color(icon_color) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(icon).size(IconSize::Small).color(icon_color), + ) .label_size(LabelSize::Small) .truncate(true) .color(Color::Muted) @@ -1602,10 +1600,9 @@ impl GitGraph { "view-on-provider", format!("View on {}", provider_name), ) - .icon(icon) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(icon).size(IconSize::Small).color(Color::Muted), + ) .label_size(LabelSize::Small) .truncate(true) .color(Color::Muted) diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index e91d98038818224594c1f139f70d7c3d11f2a78b..c2d7333484224bbfbc248e25fb2ac51a19f428e2 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -322,10 +322,11 @@ impl BlameRenderer for GitBlameRenderer { format!("#{}", pr.number), ) .color(Color::Muted) - .icon(IconName::PullRequest) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon( + Icon::new(IconName::PullRequest) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _, cx| { cx.stop_propagation(); cx.open_url(pr.url.as_str()) @@ -339,10 +340,11 @@ impl BlameRenderer for GitBlameRenderer { short_commit_id.clone(), ) .color(Color::Muted) - .icon(IconName::FileGit) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon( + Icon::new(IconName::FileGit) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, window, cx| { CommitView::open( commit_summary.sha.clone().into(), diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 57c25681439f9bb8ea7e5761c01d4c1a9defd427..432da803e6eedfec304836198f6111f5418084cc 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -366,11 +366,12 @@ impl CommitModal { .unwrap_or_else(|| "".to_owned()); let branch_picker_button = panel_button(branch) - .icon(IconName::GitBranch) - .icon_size(IconSize::Small) - .icon_color(Color::Placeholder) + .start_icon( + Icon::new(IconName::GitBranch) + .size(IconSize::Small) + .color(Color::Placeholder), + ) .color(Color::Muted) - .icon_position(IconPosition::Start) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx); })) diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 21e7d8a5d1f8e3f5c5b124fe8b276028df91b752..4740e148099980a7510a1f551d0d3f51c08892a1 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -336,9 +336,10 @@ impl Render for CommitTooltip { format!("#{}", pr.number), ) .color(Color::Muted) - .icon(IconName::PullRequest) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::PullRequest) + .color(Color::Muted), + ) .style(ButtonStyle::Subtle) .on_click(move |_, _, cx| { cx.stop_propagation(); @@ -354,9 +355,9 @@ impl Render for CommitTooltip { ) .style(ButtonStyle::Subtle) .color(Color::Muted) - .icon(IconName::FileGit) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::FileGit).color(Color::Muted), + ) .on_click( move |_, window, cx| { CommitView::open( diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 8f2a019fddf0513c100a53956c81012d11c2ca30..b7f7b526ca16ed6686965f82180d0dcbb63f994a 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -524,10 +524,11 @@ impl CommitView { .when(self.stash.is_none(), |this| { this.child( Button::new("sha", "Commit SHA") - .icon(copy_icon) - .icon_color(copy_icon_color) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon( + Icon::new(copy_icon) + .size(IconSize::Small) + .color(copy_icon_color), + ) .tooltip({ let commit_sha = commit_sha.clone(); move |_, cx| { diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 7bb880abe6d1209aaf6b15d78979cc388bf37a36..d3bb5213a5c5c94171d48d324c7ce05e6399399f 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -453,10 +453,11 @@ fn render_conflict_buttons( this.child(Divider::vertical()).child( Button::new("resolve-with-agent", "Resolve with Agent") .label_size(LabelSize::Small) - .icon(IconName::ZedAssistant) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click({ let conflict = conflict.clone(); move |_, window, cx| { diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index c684c230cf54cdbe89f13d9126c142e2dece3558..bdd5dee36e2d54888d081cfefed21602ecb8fa1b 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -6,9 +6,9 @@ use editor::{Editor, EditorEvent, MultiBuffer}; use futures::{FutureExt, select_biased}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, Render, Task, WeakEntity, Window, + Focusable, Font, IntoElement, Render, Task, WeakEntity, Window, }; -use language::{Buffer, LanguageRegistry}; +use language::{Buffer, HighlightedText, LanguageRegistry}; use project::Project; use std::{ any::{Any, TypeId}, @@ -21,7 +21,7 @@ use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString}; use util::paths::PathExt as _; use workspace::{ Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace, - item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams}, + item::{ItemEvent, SaveOptions, TabContentParams}, searchable::SearchableItemHandle, }; @@ -324,7 +324,7 @@ impl Item for FileDiffView { ToolbarItemLocation::PrimaryLeft } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { self.editor.breadcrumbs(cx) } diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs index ffd600c32af5be8fe9f390b93b6f96911bfecb07..e0cee4ef1d66b7c09ff249d2323fc9fa72abbd7c 100644 --- a/crates/git_ui/src/file_history_view.rs +++ b/crates/git_ui/src/file_history_view.rs @@ -429,10 +429,11 @@ impl Render for FileHistoryView { Button::new("load-more", "Load More") .disabled(self.loading_more) .label_size(LabelSize::Small) - .icon(IconName::ArrowCircle) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(cx.listener(|this, _, window, cx| { this.load_more(window, cx); })), @@ -565,7 +566,10 @@ impl Item for FileHistoryView { false } - fn breadcrumbs(&self, _cx: &App) -> Option> { + fn breadcrumbs( + &self, + _cx: &App, + ) -> Option<(Vec, Option)> { None } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 1a9866fcc6e7ef420742620dab3faa2f38bfa5f5..01375e600392d2b18b34ec3241aff45c5fad6e67 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -872,8 +872,7 @@ impl Render for GitCloneModal { .child( Button::new("learn-more", "Learn More") .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall)) .on_click(|_, _, cx| { cx.open_url("https://github.com/git-guides/git-clone"); }), diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index 6c4c236da869e479cd042e4ed4cf12c98d861a84..c5e456a1e43584fd6ec5da98b9f5134e9801ef5c 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -3,9 +3,9 @@ use buffer_diff::BufferDiff; use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, Render, SharedString, Task, Window, + Focusable, Font, IntoElement, Render, SharedString, Task, Window, }; -use language::{Buffer, Capability, OffsetRangeExt}; +use language::{Buffer, Capability, HighlightedText, OffsetRangeExt}; use multi_buffer::PathKey; use project::Project; use std::{ @@ -18,7 +18,7 @@ use util::paths::PathStyle; use util::rel_path::RelPath; use workspace::{ Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace, - item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams}, + item::{ItemEvent, SaveOptions, TabContentParams}, searchable::SearchableItemHandle, }; @@ -338,7 +338,7 @@ impl Item for MultiDiffView { ToolbarItemLocation::PrimaryLeft } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { self.editor.breadcrumbs(cx) } diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 3af77b8fb680abbca2688410b783007af573578d..41eff7b23a95ca2d4112d4b95aef67ff7d4a765f 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1592,8 +1592,11 @@ fn render_send_review_to_agent_button(review_count: usize, focus_handle: &FocusH "send-review", format!("Send Review to Agent ({})", review_count), ) - .icon(IconName::ZedAssistant) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) .tooltip(Tooltip::for_action_title_in( "Send all review comments to the Agent panel", &SendReviewToAgent, @@ -1686,10 +1689,11 @@ impl Render for BranchDiffToolbar { let focus_handle = focus_handle.clone(); this.child(Divider::vertical()).child( Button::new("review-diff", "Review Diff") - .icon(IconName::ZedAssistant) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) .key_binding(KeyBinding::for_action_in(&ReviewDiff, &focus_handle, cx)) .tooltip(move |_, cx| { Tooltip::with_meta_in( diff --git a/crates/gpui/src/app/headless_app_context.rs b/crates/gpui/src/app/headless_app_context.rs index bebade89d9a8417769147e5f64923953e4bc3694..90dc8c8f0c0994e3f118916b2d004f7d90566ea7 100644 --- a/crates/gpui/src/app/headless_app_context.rs +++ b/crates/gpui/src/app/headless_app_context.rs @@ -186,6 +186,14 @@ impl HeadlessAppContext { } } +impl Drop for HeadlessAppContext { + fn drop(&mut self) { + // Shut down the app so windows are closed and entity handles are + // released before the LeakDetector runs. + self.app.borrow_mut().shutdown(); + } +} + impl AppContext for HeadlessAppContext { fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { let mut app = self.app.borrow_mut(); diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 1f8db390029d67d8cdc17da7800a0f8e1d5e1af9..77f154201d3af6bb7504349e579a5be6b4edcbb5 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -602,6 +602,9 @@ impl X11Client { Ok(None) => { break; } + Err(err @ ConnectionError::IoError(..)) => { + return Err(EventHandlerError::from(err)); + } Err(err) => { let err = handle_connection_error(err); log::warn!("error while polling for X11 events: {err:?}"); diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 291603b2b3f1544f6c60f9c3bdbbb87d3f77c424..729a2d9ce31cbe2165f0f66c15921e566d6878b4 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -10,10 +10,10 @@ use file_icons::FileIcons; use gpui::PinchEvent; use gpui::{ AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, - FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, - LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, - Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, WeakEntity, Window, actions, - checkerboard, div, img, point, px, size, + FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement, + IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, + WeakEntity, Window, actions, checkerboard, div, img, point, px, size, }; use language::File as _; use persistence::IMAGE_VIEWER; @@ -26,7 +26,7 @@ use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, delete_unloaded_items, invalid_item_view::InvalidItemView, - item::{BreadcrumbText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams}, + item::{HighlightedText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams}, }; pub use crate::image_info::*; @@ -530,15 +530,17 @@ impl Item for ImageView { } } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx); - let settings = ThemeSettings::get_global(cx); - - Some(vec![BreadcrumbText { - text, - highlights: None, - font: Some(settings.buffer_font.clone()), - }]) + let font = ThemeSettings::get_global(cx).buffer_font.clone(); + + Some(( + vec![HighlightedText { + text: text.into(), + highlights: vec![], + }], + Some(font), + )) } fn can_split(&self) -> bool { diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index ff3389a4d4a10bc8472d0931d18ffa5be839c631..c8df5c1d8cf60ed07d6013cfb088bf8d362cf330 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -2928,9 +2928,11 @@ impl Render for KeybindingEditorModal { .child( Button::new("show_matching", "View") .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(cx.listener( |this, _, window, cx| { this.show_matching_bindings( diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 5b493fdf1087911372d8796cc88f4ad14eef8df0..0df2f0856c36053367172dd3a0412a0cb6cf4e6f 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -1574,7 +1574,8 @@ impl Render for ConfigurationView { } v_flex() - .size_full() + .min_w_0() + .w_full() .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_tab)) .on_action(cx.listener(Self::on_tab_prev)) diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 4fdf06cc959ccc853f92f4e150978cd15c8e70d3..b871015826f36fb3dc9b727fb8b194c46c0ec05c 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1126,6 +1126,7 @@ impl RenderOnce for ZedAiConfiguration { let manage_subscription_buttons = if is_pro { Button::new("manage_settings", "Manage Subscription") .full_width() + .label_size(LabelSize::Small) .style(ButtonStyle::Tinted(TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) .into_any_element() @@ -1149,10 +1150,7 @@ impl RenderOnce for ZedAiConfiguration { .child(Label::new("Sign in to have access to Zed's complete agentic experience with hosted models.")) .child( Button::new("sign_in", "Sign In to use Zed AI") - .icon_color(Color::Muted) - .icon(IconName::Github) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Github).size(IconSize::Small).color(Color::Muted)) .full_width() .on_click({ let callback = self.sign_in_callback.clone(); diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index ee08f1689aeea9cfa18346108cd2d314b2259583..6c8d3c6e1c50185a4b09e9afc80c688f4c8d1381 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -820,9 +820,7 @@ impl ConfigurationView { .child( Button::new("reset-api-url", "Reset API URL") .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx)), @@ -918,9 +916,11 @@ impl Render for ConfigurationView { this.child( Button::new("lmstudio-site", "LM Studio") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _window, cx| { cx.open_url(LMSTUDIO_SITE) }) @@ -933,9 +933,11 @@ impl Render for ConfigurationView { "Download LM Studio", ) .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _window, cx| { cx.open_url(LMSTUDIO_DOWNLOAD_URL) }) @@ -946,9 +948,11 @@ impl Render for ConfigurationView { .child( Button::new("view-models", "Model Catalog") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _window, cx| { cx.open_url(LMSTUDIO_CATALOG_URL) }), @@ -981,9 +985,9 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_lmstudio_models", "Connect") - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon(IconName::PlayFilled) + .start_icon( + Icon::new(IconName::PlayFilled).size(IconSize::XSmall), + ) .on_click(cx.listener(move |this, _, _window, cx| { this.retry_connection(_window, cx) })), diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 96343ec060e13ff4e63bbdf96db3b2501e32a461..30234687633215ec6a1da6f9d63ea136d08254b8 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -858,9 +858,7 @@ impl ConfigurationView { .child( Button::new("reset-context-window", "Reset") .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| { @@ -905,9 +903,7 @@ impl ConfigurationView { .child( Button::new("reset-api-url", "Reset API URL") .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)), @@ -949,9 +945,11 @@ impl Render for ConfigurationView { this.child( Button::new("ollama-site", "Ollama") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .on_click(move |_, _, cx| cx.open_url(OLLAMA_SITE)) .into_any_element(), ) @@ -959,9 +957,11 @@ impl Render for ConfigurationView { this.child( Button::new("download_ollama_button", "Download Ollama") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .on_click(move |_, _, cx| { cx.open_url(OLLAMA_DOWNLOAD_URL) }) @@ -972,9 +972,11 @@ impl Render for ConfigurationView { .child( Button::new("view-models", "View All Models") .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall) + .color(Color::Muted), + ) .on_click(move |_, _, cx| cx.open_url(OLLAMA_LIBRARY_URL)), ), ) @@ -1005,9 +1007,9 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_ollama_models", "Connect") - .icon_position(IconPosition::Start) - .icon_size(IconSize::XSmall) - .icon(IconName::PlayOutlined) + .start_icon( + Icon::new(IconName::PlayOutlined).size(IconSize::XSmall), + ) .on_click(cx.listener(move |this, _, window, cx| { this.retry_connection(window, cx) })), diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index ce79de7cb2df22847a2666d7b4847e2c696fb12e..c1ebf76e0b0678d35a5e013e87f9efd9488a4e8d 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -1415,9 +1415,11 @@ impl Render for ConfigurationView { ) .child( Button::new("docs", "Learn More") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _window, cx| { cx.open_url("https://zed.dev/docs/ai/llm-providers#openai-api-compatible") }), diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index b478bc843c05e01d428561d9c255ef0d2ca97148..87a08097782198238a5d2467af32cc66b3183664 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -545,9 +545,7 @@ impl Render for ConfigurationView { .child( Button::new("reset-api-key", "Reset API Key") .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) .layer(ElevationIndex::ModalSurface) .when(env_var_set, |this| { this.tooltip(Tooltip::text(format!("To reset your API key, unset the {env_var_name} environment variable."))) diff --git a/crates/language_onboarding/src/python.rs b/crates/language_onboarding/src/python.rs index e715cb7c806f417980a93a62210c72ca8529fcb5..751980fd57af5d2bd28ca17f38b88aa09741e482 100644 --- a/crates/language_onboarding/src/python.rs +++ b/crates/language_onboarding/src/python.rs @@ -56,10 +56,8 @@ impl Render for BasedPyrightBanner { .gap_0p5() .child( Button::new("learn-more", "Learn More") - .icon(IconName::ArrowUpRight) .label_size(LabelSize::Small) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall).color(Color::Muted)) .on_click(|_, _, cx| { cx.open_url("https://zed.dev/docs/languages/python") }), diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index a4b8977da7661b09b85fff3cbb86c2a3ff1647aa..47c840ea4e2f22e1b64cfc5b78bb7f983255dcba 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -18,7 +18,7 @@ use project::{ }; use proto::toggle_lsp_logs::LogType; use std::{any::TypeId, borrow::Cow, sync::Arc}; -use ui::{Button, Checkbox, ContextMenu, Label, PopoverMenu, ToggleState, prelude::*}; +use ui::{Checkbox, ContextMenu, PopoverMenu, ToggleState, prelude::*}; use util::ResultExt as _; use workspace::{ SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, @@ -969,9 +969,11 @@ impl Render for LspLogToolbarItemView { }) .unwrap_or_else(|| "No server selected".into()), ) - .icon(IconName::ChevronDown) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + .end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .menu({ let log_view = log_view.clone(); @@ -1030,10 +1032,11 @@ impl Render for LspLogToolbarItemView { PopoverMenu::new("LspViewSelector") .anchor(Corner::TopLeft) .trigger( - Button::new("language_server_menu_header", label) - .icon(IconName::ChevronDown) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + Button::new("language_server_menu_header", label).end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .menu(move |window, cx| { let log_toolbar_view = log_toolbar_view.upgrade()?; @@ -1125,9 +1128,11 @@ impl Render for LspLogToolbarItemView { "language_server_trace_level_selector", "Trace level", ) - .icon(IconName::ChevronDown) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + .end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .menu({ let log_view = log_view; @@ -1193,9 +1198,11 @@ impl Render for LspLogToolbarItemView { "language_server_log_level_selector", "Log level", ) - .icon(IconName::ChevronDown) - .icon_size(IconSize::Small) - .icon_color(Color::Muted), + .end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::Small) + .color(Color::Muted), + ), ) .menu({ let log_view = log_view; diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index b683b13743819bbba692a99a7c559cfd9823a4b4..7221d8104cbff2e1e0a8ebe265b419b1c725472d 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -10,9 +10,8 @@ use theme::{ ThemeSettings, }; use ui::{ - Divider, ParentElement as _, StatefulInteractiveElement, SwitchField, TintColor, - ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, - prelude::*, rems_from_px, + Divider, StatefulInteractiveElement, SwitchField, TintColor, ToggleButtonGroup, + ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, prelude::*, }; use vim_mode_setting::VimModeSetting; @@ -477,8 +476,7 @@ fn render_setting_import_button( .toggle_state(imported) .tab_index(tab_index) .when(imported, |this| { - this.icon(IconName::Check) - .icon_size(IconSize::Small) + this.end_icon(Icon::new(IconName::Check).size(IconSize::Small)) .color(Color::Success) }) .on_click(move |_, window, cx| { diff --git a/crates/onboarding/src/multibuffer_hint.rs b/crates/onboarding/src/multibuffer_hint.rs index 26ab409fbad6333f2e56ee4a274a43806adce676..1f710318a64760faeecb31c8a6a368a0e11537a4 100644 --- a/crates/onboarding/src/multibuffer_hint.rs +++ b/crates/onboarding/src/multibuffer_hint.rs @@ -158,10 +158,11 @@ impl Render for MultibufferHint { ) .child( Button::new("open_docs", "Learn More") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_event, _, cx| { cx.open_url("https://zed.dev/docs/multibuffers") }), diff --git a/crates/open_path_prompt/src/file_finder_settings.rs b/crates/open_path_prompt/src/file_finder_settings.rs index 36f05e89bd7a1c73d849e3d72f05a092d0c8ec34..56ea60c20864fc620b43d2e445a1dd7b92edfa65 100644 --- a/crates/open_path_prompt/src/file_finder_settings.rs +++ b/crates/open_path_prompt/src/file_finder_settings.rs @@ -8,6 +8,7 @@ pub struct FileFinderSettings { pub modal_max_width: FileFinderWidth, pub skip_focus_for_active_in_search: bool, pub include_ignored: Option, + pub include_channels: bool, } impl Settings for FileFinderSettings { @@ -23,6 +24,7 @@ impl Settings for FileFinderSettings { settings::IncludeIgnoredContent::Indexed => Some(false), settings::IncludeIgnoredContent::Smart => None, }, + include_channels: file_finder.include_channels.unwrap(), } } } diff --git a/crates/panel/src/panel.rs b/crates/panel/src/panel.rs index 133efa9cb61c122af79a228cdfb74f86e22792b4..cf6465f3f5973bf24429f010dadf369346123b8f 100644 --- a/crates/panel/src/panel.rs +++ b/crates/panel/src/panel.rs @@ -52,7 +52,6 @@ pub fn panel_button(label: impl Into) -> ui::Button { let id = ElementId::Name(label.to_lowercase().replace(' ', "_").into()); ui::Button::new(id, label) .label_size(ui::LabelSize::Small) - .icon_size(ui::IconSize::Small) // TODO: Change this once we use on_surface_bg in button_like .layer(ui::ElevationIndex::ModalSurface) .size(ui::ButtonSize::Compact) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index ff272cb10a662f7e69d1789d9afd719cb9e73005..8b4f3d7e8e1a6f68a1263fc11dc2e61c4a4890aa 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -3963,10 +3963,7 @@ impl BufferLspData { self.inlay_hints.remove_server_data(for_server); if let Some(semantic_tokens) = &mut self.semantic_tokens { - semantic_tokens.raw_tokens.servers.remove(&for_server); - semantic_tokens - .latest_invalidation_requests - .remove(&for_server); + semantic_tokens.remove_server_data(for_server); } if let Some(folding_ranges) = &mut self.folding_ranges { diff --git a/crates/project/src/lsp_store/semantic_tokens.rs b/crates/project/src/lsp_store/semantic_tokens.rs index 2927e5c0af77c50420462e95c271e61828b020e5..7865e8f20ca0e4dbc9d06c2ffd808fe4090634ed 100644 --- a/crates/project/src/lsp_store/semantic_tokens.rs +++ b/crates/project/src/lsp_store/semantic_tokens.rs @@ -610,6 +610,14 @@ pub struct SemanticTokensData { update: Option<(Global, SemanticTokensTask)>, } +impl SemanticTokensData { + pub(super) fn remove_server_data(&mut self, server_id: LanguageServerId) { + self.raw_tokens.servers.remove(&server_id); + self.latest_invalidation_requests.remove(&server_id); + self.update = None; + } +} + /// All the semantic token tokens for a buffer. /// /// This aggregates semantic tokens from multiple language servers in a specific order. diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 2984bb49c6a961c77adc1b82c806f7ec57d54a3e..96e680c0d1648bd4cf337cbc55e321e3948c217a 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -6341,6 +6341,7 @@ impl Render for ProjectPanel { let panel_settings = ProjectPanelSettings::get_global(cx); let indent_size = panel_settings.indent_size; let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always; + let horizontal_scroll = panel_settings.scrollbar.horizontal_scroll; let show_sticky_entries = { if panel_settings.sticky_scroll { let is_scrollable = self.scroll_handle.is_scrollable(); @@ -6713,10 +6714,14 @@ impl Render for ProjectPanel { }) }) .with_sizing_behavior(ListSizingBehavior::Infer) - .with_horizontal_sizing_behavior( - ListHorizontalSizingBehavior::Unconstrained, - ) - .with_width_from_item(self.state.max_width_item_index) + .with_horizontal_sizing_behavior(if horizontal_scroll { + ListHorizontalSizingBehavior::Unconstrained + } else { + ListHorizontalSizingBehavior::FitList + }) + .when(horizontal_scroll, |list| { + list.with_width_from_item(self.state.max_width_item_index) + }) .track_scroll(&self.scroll_handle), ) .child( @@ -6877,13 +6882,17 @@ impl Render for ProjectPanel { .size_full(), ) .custom_scrollbars( - Scrollbars::for_settings::() - .tracked_scroll_handle(&self.scroll_handle) - .with_track_along( - ScrollAxes::Horizontal, - cx.theme().colors().panel_background, - ) - .notify_content(), + { + let mut scrollbars = Scrollbars::for_settings::() + .tracked_scroll_handle(&self.scroll_handle); + if horizontal_scroll { + scrollbars = scrollbars.with_track_along( + ScrollAxes::Horizontal, + cx.theme().colors().panel_background, + ); + } + scrollbars.notify_content() + }, window, cx, ) diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 0d703c55c06dfff2976fe59f6e030ad9eb1d758b..de2ff8e0087b8e7dbe4fcc533e3eea0470553b50 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -49,6 +49,11 @@ pub struct ScrollbarSettings { /// /// Default: inherits editor scrollbar settings pub show: Option, + /// Whether to allow horizontal scrolling in the project panel. + /// When false, the view is locked to the leftmost position and long file names are clipped. + /// + /// Default: true + pub horizontal_scroll: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -111,8 +116,12 @@ impl Settings for ProjectPanelSettings { auto_fold_dirs: project_panel.auto_fold_dirs.unwrap(), bold_folder_labels: project_panel.bold_folder_labels.unwrap(), starts_open: project_panel.starts_open.unwrap(), - scrollbar: ScrollbarSettings { - show: project_panel.scrollbar.unwrap().show.map(Into::into), + scrollbar: { + let scrollbar = project_panel.scrollbar.unwrap(); + ScrollbarSettings { + show: scrollbar.show.map(Into::into), + horizontal_scroll: scrollbar.horizontal_scroll.unwrap(), + } }, show_diagnostics: project_panel.show_diagnostics.unwrap(), hide_root: project_panel.hide_root.unwrap(), diff --git a/crates/recent_projects/src/disconnected_overlay.rs b/crates/recent_projects/src/disconnected_overlay.rs index 82ff0699054e5614b8078d3223d5e9282e5034b5..732b50c123d9d61750781df81ce00b392997af3c 100644 --- a/crates/recent_projects/src/disconnected_overlay.rs +++ b/crates/recent_projects/src/disconnected_overlay.rs @@ -2,11 +2,7 @@ use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, Focusable, Rende use project::project_settings::ProjectSettings; use remote::RemoteConnectionOptions; use settings::Settings; -use ui::{ - Button, ButtonCommon, ButtonStyle, Clickable, Context, ElevationIndex, FluentBuilder, Headline, - HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal, - ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, Window, div, h_flex, rems, -}; +use ui::{ElevationIndex, Modal, ModalFooter, ModalHeader, Section, prelude::*}; use workspace::{ ModalView, MultiWorkspace, OpenOptions, Workspace, notifications::DetachAndPromptErr, }; @@ -207,8 +203,7 @@ impl Render for DisconnectedOverlay { Button::new("reconnect", "Reconnect") .style(ButtonStyle::Filled) .layer(ElevationIndex::ModalSurface) - .icon(IconName::ArrowCircle) - .icon_position(IconPosition::Start) + .start_icon(Icon::new(IconName::ArrowCircle)) .on_click(cx.listener(Self::handle_reconnect)), ) }), diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 60ebf85dd23460a8a0ce0c70da2d7b69761690db..d4cfb6520e6f73592ede5abcacb558967d10dbc7 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -2117,8 +2117,10 @@ impl RemoteServerProjects { .child( Button::new("learn-more", "Learn More") .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::XSmall), + ) .on_click(|_, _, cx| { cx.open_url( "https://zed.dev/docs/remote-development", diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index b6d4f39c0ccb75619a7e4efd6a532202893c8722..ce68a4d30285fe04427c54aa8d5fbdc3aa059648 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -431,10 +431,11 @@ impl PickerDelegate for KernelPickerDelegate { .gap_4() .child( Button::new("kernel-docs", "Kernel Docs") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(move |_, _, cx| cx.open_url(KERNEL_DOCS_URL)), ) .into_any(), diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index 87f18708a1988c70d66dc4cef5355d4cbcb11dba..76a0d2a47037f0ccd48fcfe9cb088ceb9e37aeaa 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -1117,10 +1117,11 @@ impl NotebookEditor { worktree_id, Button::new("kernel-selector", kernel_name.clone()) .label_size(LabelSize::Small) - .icon(status_icon) - .icon_size(IconSize::Small) - .icon_color(status_color) - .icon_position(IconPosition::Start), + .start_icon( + Icon::new(status_icon) + .size(IconSize::Small) + .color(status_color), + ), Tooltip::text(format!( "Kernel: {} ({}). Click to change.", kernel_name, diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index dd4bbcfaeb7a14ea4bda8c546f5cf2539734eb73..cb568c95627bd6a32dcd89b7cfd645f4dac65f59 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -1170,10 +1170,11 @@ impl RulesLibrary { Button::new("new-rule", "New Rule") .full_width() .style(ButtonStyle::Outlined) - .icon(IconName::Plus) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .icon_color(Color::Muted) + .start_icon( + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(|_, window, cx| { window.dispatch_action(Box::new(NewRule), cx); }), diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 9b23c96259e4933bc1660af960b508c0678fe767..292dfd7e5fad4174ecd7dbe51bb28f3a1df98827 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1583,9 +1583,7 @@ impl ProjectSearchView { ) .child( Button::new("filter-paths", "Include/exclude specific paths") - .icon(IconName::Filter) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::Filter).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in(&ToggleFilters, &focus_handle, cx)) .on_click(|_event, window, cx| { window.dispatch_action(ToggleFilters.boxed_clone(), cx) @@ -1593,9 +1591,7 @@ impl ProjectSearchView { ) .child( Button::new("find-replace", "Find and replace") - .icon(IconName::Replace) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::Replace).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in(&ToggleReplace, &focus_handle, cx)) .on_click(|_event, window, cx| { window.dispatch_action(ToggleReplace.boxed_clone(), cx) @@ -1603,9 +1599,7 @@ impl ProjectSearchView { ) .child( Button::new("regex", "Match with regex") - .icon(IconName::Regex) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::Regex).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in(&ToggleRegex, &focus_handle, cx)) .on_click(|_event, window, cx| { window.dispatch_action(ToggleRegex.boxed_clone(), cx) @@ -1613,9 +1607,7 @@ impl ProjectSearchView { ) .child( Button::new("match-case", "Match case") - .icon(IconName::CaseSensitive) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::CaseSensitive).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in( &ToggleCaseSensitive, &focus_handle, @@ -1627,9 +1619,7 @@ impl ProjectSearchView { ) .child( Button::new("match-whole-words", "Match whole words") - .icon(IconName::WholeWord) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) + .start_icon(Icon::new(IconName::WholeWord).size(IconSize::Small)) .key_binding(KeyBinding::for_action_in( &ToggleWholeWord, &focus_handle, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 8a5a497d265c02787d6944915c0dba56e2381a79..bcc579984bda0268a7405cbd1ea184cafc493aab 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -793,7 +793,12 @@ impl VsCodeSettings { hide_root: None, indent_guides: None, indent_size: None, - scrollbar: None, + scrollbar: self.read_bool("workbench.list.horizontalScrolling").map( + |horizontal_scrolling| ProjectPanelScrollbarSettingsContent { + show: None, + horizontal_scroll: Some(horizontal_scrolling), + }, + ), show_diagnostics: self .read_bool("problems.decorations.enabled") .and_then(|b| if b { Some(ShowDiagnostics::Off) } else { None }), diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 5b573a0f01dc7980abadeba5576b6e8e3553bfb4..023f4954388c0a5e163fe61aba8d970364d84f43 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -721,6 +721,10 @@ pub struct FileFinderSettingsContent { /// /// Default: Smart pub include_ignored: Option, + /// Whether to include text channels in file finder results. + /// + /// Default: false + pub include_channels: Option, } #[derive( diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 7262a83b384665b0bcd868bf14dbfaa2928a35c1..92dc6679e60fc5d54b24afafa4daa00600c066f2 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; use settings_macros::{MergeFrom, with_fallible_options}; use crate::{ - CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, - ScrollbarSettingsContent, ShowIndentGuides, serialize_optional_f32_with_two_decimal_places, + CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, ShowIndentGuides, + ShowScrollbar, serialize_optional_f32_with_two_decimal_places, }; #[with_fallible_options] @@ -710,7 +710,7 @@ pub struct ProjectPanelSettingsContent { /// Default: true pub starts_open: Option, /// Scrollbar-related settings - pub scrollbar: Option, + pub scrollbar: Option, /// Which files containing diagnostic errors/warnings to mark in the project panel. /// /// Default: all @@ -793,6 +793,23 @@ pub enum ProjectPanelSortMode { FilesFirst, } +#[with_fallible_options] +#[derive( + Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default, +)] +pub struct ProjectPanelScrollbarSettingsContent { + /// When to show the scrollbar in the project panel. + /// + /// Default: inherits editor scrollbar settings + pub show: Option, + /// Whether to allow horizontal scrolling in the project panel. + /// When false, the view is locked to the leftmost position and + /// long file names are clipped. + /// + /// Default: true + pub horizontal_scroll: Option, +} + #[with_fallible_options] #[derive( Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq, Default, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 708840668d7502ae0c34e9f1751fd7b76da2ca07..9243e14521010c5b3aa2a9092c6e0a687a989306 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4238,7 +4238,7 @@ fn window_and_layout_page() -> SettingsPage { } fn panels_page() -> SettingsPage { - fn project_panel_section() -> [SettingsPageItem; 22] { + fn project_panel_section() -> [SettingsPageItem; 23] { [ SettingsPageItem::SectionHeader("Project Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -4516,6 +4516,32 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Horizontal Scroll", + description: "Whether to allow horizontal scrolling in the project panel. When disabled, the view is always locked to the leftmost position and long file names are clipped.", + field: Box::new(SettingField { + json_path: Some("project_panel.scrollbar.horizontal_scroll"), + pick: |settings_content| { + settings_content + .project_panel + .as_ref()? + .scrollbar + .as_ref()? + .horizontal_scroll + .as_ref() + }, + write: |settings_content, value| { + settings_content + .project_panel + .get_or_insert_default() + .scrollbar + .get_or_insert_default() + .horizontal_scroll = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Show Diagnostics", description: "Which files containing diagnostic errors/warnings to mark in the project panel.", diff --git a/crates/settings_ui/src/pages/tool_permissions_setup.rs b/crates/settings_ui/src/pages/tool_permissions_setup.rs index c1c978efbb3da5dc57c8d40a45370a908698bd40..f5f1f0ea7eb71c7af41ba2c60a30b2ec5cb01a4d 100644 --- a/crates/settings_ui/src/pages/tool_permissions_setup.rs +++ b/crates/settings_ui/src/pages/tool_permissions_setup.rs @@ -275,10 +275,11 @@ fn render_tool_list_item( .tab_index(tool_index as isize) .style(ButtonStyle::OutlinedGhost) .size(ButtonSize::Medium) - .icon(IconName::ChevronRight) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ChevronRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(cx.listener(move |this, _, window, cx| { this.push_dynamic_sub_page( tool_name, @@ -1090,9 +1091,7 @@ fn render_global_default_mode_section(current_mode: ToolPermissionMode) -> AnyEl .tab_index(0_isize) .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(IconName::ChevronDown) - .icon_position(IconPosition::End) - .icon_size(IconSize::Small), + .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)), ) .menu(move |window, cx| { Some(ContextMenu::build(window, cx, move |menu, _, _| { @@ -1141,9 +1140,7 @@ fn render_default_mode_section( .tab_index(0_isize) .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(IconName::ChevronDown) - .icon_position(IconPosition::End) - .icon_size(IconSize::Small), + .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)), ) .menu(move |window, cx| { let tool_id = tool_id_owned.clone(); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 9d7fe83736be8d1d9ed79d85708c5ed0574b7e3a..26417a5469955cd89a12564248e36be288004a15 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -925,9 +925,7 @@ impl SettingsPageItem { Button::new("error-warning", warning) .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(Some(IconName::Debug)) - .icon_position(IconPosition::Start) - .icon_color(Color::Error) + .start_icon(Icon::new(IconName::Debug).color(Color::Error)) .tab_index(0_isize) .tooltip(Tooltip::text(setting_item.field.type_name())) .into_any_element(), @@ -992,11 +990,12 @@ impl SettingsPageItem { ("sub-page".into(), sub_page_link.title.clone()), "Configure", ) - .icon(IconName::ChevronRight) .tab_index(0_isize) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ChevronRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .style(ButtonStyle::OutlinedGhost) .size(ButtonSize::Medium) .on_click({ @@ -1125,11 +1124,12 @@ impl SettingsPageItem { ("action-link".into(), action_link.title.clone()), action_link.button_text.clone(), ) - .icon(IconName::ArrowUpRight) .tab_index(0_isize) - .icon_position(IconPosition::End) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .style(ButtonStyle::OutlinedGhost) .size(ButtonSize::Medium) .on_click({ @@ -4174,10 +4174,11 @@ fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button .tab_index(0_isize) .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) - .icon(IconName::ChevronUpDown) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End) + .end_icon( + Icon::new(IconName::ChevronUpDown) + .size(IconSize::Small) + .color(Color::Muted), + ) } fn render_font_picker( diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index e4ed410ef79897770d2a27aaef10017b1d284390..c1a6542fbc17526eed4914815738212cf74eca8f 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -9,7 +9,7 @@ use assistant_slash_command::SlashCommandRegistry; use editor::{Editor, EditorSettings, actions::SelectAll, blink_manager::BlinkManager}; use gpui::{ Action, AnyElement, App, ClipboardEntry, DismissEvent, Entity, EventEmitter, ExternalPaths, - FocusHandle, Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, + FocusHandle, Focusable, Font, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Point, Render, ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div, }; @@ -55,7 +55,7 @@ use workspace::{ CloseActiveItem, DraggedSelection, DraggedTab, NewCenterTerminal, NewTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items, item::{ - BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent, + HighlightedText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent, }, register_serializable_item, searchable::{ @@ -1655,12 +1655,14 @@ impl Item for TerminalView { } } - fn breadcrumbs(&self, cx: &App) -> Option> { - Some(vec![BreadcrumbText { - text: self.terminal().read(cx).breadcrumb_text.clone(), - highlights: None, - font: None, - }]) + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { + Some(( + vec![HighlightedText { + text: self.terminal().read(cx).breadcrumb_text.clone().into(), + highlights: vec![], + }], + None, + )) } fn added_to_workspace( diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 2ea3436d43cd2d2a4bda392384ff51f962824143..1ddd6879405ad69a75e038da608d034f58bb5eff 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -311,10 +311,11 @@ impl PickerDelegate for IconThemeSelectorDelegate { .border_color(cx.theme().colors().border_variant) .child( Button::new("docs", "View Icon Theme Docs") - .icon(IconName::ArrowUpRight) - .icon_position(IconPosition::End) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(|_event, _window, cx| { cx.open_url("https://zed.dev/docs/icon-themes"); }), diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 74b242dd0b7c3a3ddbe6ca76d34a59f03560f14a..f3c32c8f2f50cbec820e043a701f382e6ac22d0a 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -497,10 +497,11 @@ impl PickerDelegate for ThemeSelectorDelegate { .border_color(cx.theme().colors().border_variant) .child( Button::new("docs", "View Theme Docs") - .icon(IconName::ArrowUpRight) - .icon_position(IconPosition::End) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) .on_click(cx.listener(|_, _, _, cx| { cx.open_url("https://zed.dev/docs/themes"); })), diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 916d58426b76f020bce8a9bf69971f34bc3803a4..7fc86706a3eb0971b1f8539d76b8daf3b709537e 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -583,10 +583,11 @@ impl TitleBar { .style(ButtonStyle::Tinted(TintColor::Warning)) .label_size(LabelSize::Small) .color(Color::Warning) - .icon(IconName::Warning) - .icon_color(Color::Warning) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) .tooltip(|_, cx| { Tooltip::with_meta( "You're in Restricted Mode", @@ -697,9 +698,11 @@ impl TitleBar { Button::new("project_name_trigger", display_name) .label_size(LabelSize::Small) .when(self.worktree_count(cx) > 1, |this| { - this.icon(IconName::ChevronDown) - .icon_color(Color::Muted) - .icon_size(IconSize::XSmall) + this.end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ) }) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when(!is_project_selected, |s| s.color(Color::Muted)), @@ -779,11 +782,9 @@ impl TitleBar { .color(Color::Muted) .when(settings.show_branch_icon, |branch_button| { let (icon, icon_color) = icon_info; - branch_button - .icon(icon) - .icon_position(IconPosition::Start) - .icon_color(icon_color) - .icon_size(IconSize::Indicator) + branch_button.start_icon( + Icon::new(icon).size(IconSize::Indicator).color(icon_color), + ) }), move |_window, cx| { Tooltip::with_meta( diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs index a31db264e985b3adbca26b9e8d3fb2bdca306dcb..de6b74afb02e23d5fa87a01ae448d63979815870 100644 --- a/crates/ui/src/components/ai.rs +++ b/crates/ui/src/components/ai.rs @@ -1,5 +1,7 @@ mod configured_api_card; mod thread_item; +mod thread_sidebar_toggle; pub use configured_api_card::*; pub use thread_item::*; +pub use thread_sidebar_toggle::*; diff --git a/crates/ui/src/components/ai/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs index 37f9ac7602d676906565a911f1bbca6d2b40f755..c9fd129a678d008d2ff0d6833e1497f61c73d989 100644 --- a/crates/ui/src/components/ai/configured_api_card.rs +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -1,7 +1,7 @@ use crate::{Tooltip, prelude::*}; use gpui::{ClickEvent, IntoElement, ParentElement, SharedString}; -#[derive(IntoElement)] +#[derive(IntoElement, RegisterComponent)] pub struct ConfiguredApiCard { label: SharedString, button_label: Option, @@ -52,6 +52,59 @@ impl ConfiguredApiCard { } } +impl Component for ConfiguredApiCard { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || { + v_flex() + .w_72() + .p_2() + .gap_2() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + }; + + let examples = vec![ + single_example( + "Default", + container() + .child(ConfiguredApiCard::new("API key is configured")) + .into_any_element(), + ), + single_example( + "Custom Button Label", + container() + .child( + ConfiguredApiCard::new("OpenAI API key configured") + .button_label("Remove Key"), + ) + .into_any_element(), + ), + single_example( + "With Tooltip", + container() + .child( + ConfiguredApiCard::new("Anthropic API key configured") + .tooltip_label("Click to reset your API key"), + ) + .into_any_element(), + ), + single_example( + "Disabled", + container() + .child(ConfiguredApiCard::new("API key is configured").disabled(true)) + .into_any_element(), + ), + ]; + + Some(example_group(examples).into_any_element()) + } +} + impl RenderOnce for ConfiguredApiCard { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let button_label = self.button_label.unwrap_or("Reset Key".into()); @@ -80,10 +133,11 @@ impl RenderOnce for ConfiguredApiCard { elem.tab_index(tab_index) }) .label_size(LabelSize::Small) - .icon(IconName::Undo) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) + .start_icon( + Icon::new(IconName::Undo) + .size(IconSize::Small) + .color(Color::Muted), + ) .disabled(self.disabled) .when_some(self.tooltip_label, |this, label| { this.tooltip(Tooltip::text(label)) diff --git a/crates/ui/src/components/ai/copilot_configuration_callout.rs b/crates/ui/src/components/ai/copilot_configuration_callout.rs deleted file mode 100644 index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000 --- a/crates/ui/src/components/ai/copilot_configuration_callout.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 13e1db8f483ea251a6f65b61054c205d040a0d53..35aa3487a39c69795545b646666840743cfd8526 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -311,11 +311,10 @@ impl RenderOnce for ThreadItem { this.child(dot_separator()) }) .when(has_diff_stats, |this| { - this.child(DiffStat::new( - diff_stat_id.clone(), - added_count, - removed_count, - )) + this.child( + DiffStat::new(diff_stat_id.clone(), added_count, removed_count) + .tooltip("Unreviewed changes"), + ) }) .when(has_diff_stats && has_timestamp, |this| { this.child(dot_separator()) @@ -336,7 +335,10 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .when(has_diff_stats, |this| { - this.child(DiffStat::new(diff_stat_id, added_count, removed_count)) + this.child( + DiffStat::new(diff_stat_id, added_count, removed_count) + .tooltip("Unreviewed changes"), + ) }) .when(has_diff_stats && has_timestamp, |this| { this.child(dot_separator()) diff --git a/crates/ui/src/components/ai/thread_sidebar_toggle.rs b/crates/ui/src/components/ai/thread_sidebar_toggle.rs new file mode 100644 index 0000000000000000000000000000000000000000..606d7f1eed6852f677b7167e0b868c1c1e3847c2 --- /dev/null +++ b/crates/ui/src/components/ai/thread_sidebar_toggle.rs @@ -0,0 +1,177 @@ +use gpui::{AnyView, ClickEvent}; +use ui_macros::RegisterComponent; + +use crate::prelude::*; +use crate::{IconButton, IconName, Tooltip}; + +#[derive(IntoElement, RegisterComponent)] +pub struct ThreadSidebarToggle { + sidebar_selected: bool, + thread_selected: bool, + flipped: bool, + sidebar_tooltip: Option AnyView + 'static>>, + thread_tooltip: Option AnyView + 'static>>, + on_sidebar_click: Option>, + on_thread_click: Option>, +} + +impl ThreadSidebarToggle { + pub fn new() -> Self { + Self { + sidebar_selected: false, + thread_selected: false, + flipped: false, + sidebar_tooltip: None, + thread_tooltip: None, + on_sidebar_click: None, + on_thread_click: None, + } + } + + pub fn sidebar_selected(mut self, selected: bool) -> Self { + self.sidebar_selected = selected; + self + } + + pub fn thread_selected(mut self, selected: bool) -> Self { + self.thread_selected = selected; + self + } + + pub fn flipped(mut self, flipped: bool) -> Self { + self.flipped = flipped; + self + } + + pub fn sidebar_tooltip( + mut self, + tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.sidebar_tooltip = Some(Box::new(tooltip)); + self + } + + pub fn thread_tooltip( + mut self, + tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static, + ) -> Self { + self.thread_tooltip = Some(Box::new(tooltip)); + self + } + + pub fn on_sidebar_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_sidebar_click = Some(Box::new(handler)); + self + } + + pub fn on_thread_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_thread_click = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for ThreadSidebarToggle { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let sidebar_icon = match (self.sidebar_selected, self.flipped) { + (true, false) => IconName::ThreadsSidebarLeftOpen, + (false, false) => IconName::ThreadsSidebarLeftClosed, + (true, true) => IconName::ThreadsSidebarRightOpen, + (false, true) => IconName::ThreadsSidebarRightClosed, + }; + + h_flex() + .min_w_0() + .rounded_sm() + .gap_px() + .border_1() + .border_color(cx.theme().colors().border) + .when(self.flipped, |this| this.flex_row_reverse()) + .child( + IconButton::new("sidebar-toggle", sidebar_icon) + .icon_size(IconSize::Small) + .toggle_state(self.sidebar_selected) + .when_some(self.sidebar_tooltip, |this, tooltip| this.tooltip(tooltip)) + .when_some(self.on_sidebar_click, |this, handler| { + this.on_click(handler) + }), + ) + .child(div().h_4().w_px().bg(cx.theme().colors().border)) + .child( + IconButton::new("thread-toggle", IconName::Thread) + .icon_size(IconSize::Small) + .toggle_state(self.thread_selected) + .when_some(self.thread_tooltip, |this, tooltip| this.tooltip(tooltip)) + .when_some(self.on_thread_click, |this, handler| this.on_click(handler)), + ) + } +} + +impl Component for ThreadSidebarToggle { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || div().p_2().bg(cx.theme().colors().status_bar_background); + + let examples = vec![ + single_example( + "Both Unselected", + container() + .child(ThreadSidebarToggle::new()) + .into_any_element(), + ), + single_example( + "Sidebar Selected", + container() + .child(ThreadSidebarToggle::new().sidebar_selected(true)) + .into_any_element(), + ), + single_example( + "Thread Selected", + container() + .child(ThreadSidebarToggle::new().thread_selected(true)) + .into_any_element(), + ), + single_example( + "Both Selected", + container() + .child( + ThreadSidebarToggle::new() + .sidebar_selected(true) + .thread_selected(true), + ) + .into_any_element(), + ), + single_example( + "Flipped", + container() + .child( + ThreadSidebarToggle::new() + .sidebar_selected(true) + .thread_selected(true) + .flipped(true), + ) + .into_any_element(), + ), + single_example( + "With Tooltips", + container() + .child( + ThreadSidebarToggle::new() + .sidebar_tooltip(Tooltip::text("Toggle Sidebar")) + .thread_tooltip(Tooltip::text("Toggle Thread")), + ) + .into_any_element(), + ), + ]; + + Some(example_group(examples).into_any_element()) + } +} diff --git a/crates/ui/src/components/banner.rs b/crates/ui/src/components/banner.rs index 199c72113afae37ab97c96932f5b9e805c5628bd..19795c2c7c86045572ac4a031276a6552a1d68ee 100644 --- a/crates/ui/src/components/banner.rs +++ b/crates/ui/src/components/banner.rs @@ -8,16 +8,14 @@ use gpui::{AnyElement, IntoElement, ParentElement, Styled}; /// /// ``` /// use ui::prelude::*; -/// use ui::{Banner, Button, IconName, IconPosition, IconSize, Label, Severity}; +/// use ui::{Banner, Button, Icon, IconName, IconSize, Label, Severity}; /// /// Banner::new() /// .severity(Severity::Success) /// .children([Label::new("This is a success message")]) /// .action_slot( /// Button::new("learn-more", "Learn More") -/// .icon(IconName::ArrowUpRight) -/// .icon_size(IconSize::Small) -/// .icon_position(IconPosition::End) +/// .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)), /// ); /// ``` #[derive(IntoElement, RegisterComponent)] @@ -151,9 +149,7 @@ impl Component for Banner { .child(Label::new("This is an informational message")) .action_slot( Button::new("learn-more", "Learn More") - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_position(IconPosition::End), + .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)), ) .into_any_element(), ), diff --git a/crates/ui/src/components/button.rs b/crates/ui/src/components/button.rs index 17c216ec7b000bd9b563b3e00d4ee9979ca5287f..bcec46e59ce66a242cbd96d840e4323751541f92 100644 --- a/crates/ui/src/components/button.rs +++ b/crates/ui/src/components/button.rs @@ -1,5 +1,4 @@ mod button; -mod button_icon; mod button_like; mod button_link; mod copy_button; diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 2ac3b9ca13123a0d9330d71e8b73d034d65faf89..52ea9df14293e5aa25ab8de4487975019a6481ff 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -2,15 +2,12 @@ use crate::component_prelude::*; use gpui::{AnyElement, AnyView, DefiniteLength}; use ui_macros::RegisterComponent; -use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, IconName, IconSize, Label}; +use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, Label}; use crate::{ - Color, DynamicSpacing, ElevationIndex, IconPosition, KeyBinding, KeybindingPosition, TintColor, - prelude::*, + Color, DynamicSpacing, ElevationIndex, KeyBinding, KeybindingPosition, TintColor, prelude::*, }; -use super::button_icon::ButtonIcon; - -/// An element that creates a button with a label and an optional icon. +/// An element that creates a button with a label and optional icons. /// /// Common buttons: /// - Label, Icon + Label: [`Button`] (this component) @@ -42,7 +39,7 @@ use super::button_icon::ButtonIcon; /// use ui::prelude::*; /// /// Button::new("button_id", "Click me!") -/// .icon(IconName::Check) +/// .start_icon(Icon::new(IconName::Check)) /// .toggle_state(true) /// .on_click(|event, window, cx| { /// // Handle click event @@ -85,12 +82,8 @@ pub struct Button { label_size: Option, selected_label: Option, selected_label_color: Option, - icon: Option, - icon_position: Option, - icon_size: Option, - icon_color: Option, - selected_icon: Option, - selected_icon_color: Option, + start_icon: Option, + end_icon: Option, key_binding: Option, key_binding_position: KeybindingPosition, alpha: Option, @@ -112,12 +105,8 @@ impl Button { label_size: None, selected_label: None, selected_label_color: None, - icon: None, - icon_position: None, - icon_size: None, - icon_color: None, - selected_icon: None, - selected_icon_color: None, + start_icon: None, + end_icon: None, key_binding: None, key_binding_position: KeybindingPosition::default(), alpha: None, @@ -149,39 +138,19 @@ impl Button { self } - /// Assigns an icon to the button. - pub fn icon(mut self, icon: impl Into>) -> Self { - self.icon = icon.into(); - self - } - - /// Sets the position of the icon relative to the label. - pub fn icon_position(mut self, icon_position: impl Into>) -> Self { - self.icon_position = icon_position.into(); - self - } - - /// Specifies the size of the button's icon. - pub fn icon_size(mut self, icon_size: impl Into>) -> Self { - self.icon_size = icon_size.into(); - self - } - - /// Sets the color of the button's icon. - pub fn icon_color(mut self, icon_color: impl Into>) -> Self { - self.icon_color = icon_color.into(); - self - } - - /// Chooses an icon to display when the button is in a selected state. - pub fn selected_icon(mut self, icon: impl Into>) -> Self { - self.selected_icon = icon.into(); + /// Sets an icon to display at the start (left) of the button label. + /// + /// The icon's color will be overridden to `Color::Disabled` when the button is disabled. + pub fn start_icon(mut self, icon: impl Into>) -> Self { + self.start_icon = icon.into(); self } - /// Sets the icon color used when the button is in a selected state. - pub fn selected_icon_color(mut self, color: impl Into>) -> Self { - self.selected_icon_color = color.into(); + /// Sets an icon to display at the end (right) of the button label. + /// + /// The icon's color will be overridden to `Color::Disabled` when the button is disabled. + pub fn end_icon(mut self, icon: impl Into>) -> Self { + self.end_icon = icon.into(); self } @@ -219,22 +188,24 @@ impl Button { impl Toggleable for Button { /// Sets the selected state of the button. /// - /// This method allows the selection state of the button to be specified. - /// It modifies the button's appearance to reflect its selected state. - /// /// # Examples /// + /// Create a toggleable button that changes appearance when selected: + /// /// ``` /// use ui::prelude::*; + /// use ui::TintColor; /// - /// Button::new("button_id", "Click me!") - /// .toggle_state(true) + /// let selected = true; + /// + /// Button::new("toggle_button", "Toggle Me") + /// .start_icon(Icon::new(IconName::Check)) + /// .toggle_state(selected) + /// .selected_style(ButtonStyle::Tinted(TintColor::Accent)) /// .on_click(|event, window, cx| { - /// // Handle click event + /// // Toggle the selected state /// }); /// ``` - /// - /// Use [`selected_style`](Button::selected_style) to change the style of the button when it is selected. fn toggle_state(mut self, selected: bool) -> Self { self.base = self.base.toggle_state(selected); self @@ -242,22 +213,20 @@ impl Toggleable for Button { } impl SelectableButton for Button { - /// Sets the style for the button when selected. + /// Sets the style for the button in a selected state. /// /// # Examples /// + /// Customize the selected appearance of a button: + /// /// ``` /// use ui::prelude::*; /// use ui::TintColor; /// - /// Button::new("button_id", "Click me!") + /// Button::new("styled_button", "Styled Button") /// .toggle_state(true) - /// .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); + /// .selected_style(ButtonStyle::Tinted(TintColor::Accent)); /// ``` - /// This results in a button with a blue tinted background when selected. fn selected_style(mut self, style: ButtonStyle) -> Self { self.base = self.base.selected_style(style); self @@ -265,36 +234,27 @@ impl SelectableButton for Button { } impl Disableable for Button { - /// Disables the button. + /// Disables the button, preventing interaction and changing its appearance. /// - /// This method allows the button to be disabled. When a button is disabled, - /// it doesn't react to user interactions and its appearance is updated to reflect this. + /// When disabled, the button's icon and label will use `Color::Disabled`. /// /// # Examples /// + /// Create a disabled button: + /// /// ``` /// use ui::prelude::*; /// - /// Button::new("button_id", "Click me!") - /// .disabled(true) - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); + /// Button::new("disabled_button", "Can't Click Me") + /// .disabled(true); /// ``` - /// - /// This results in a button that is disabled and does not respond to click events. fn disabled(mut self, disabled: bool) -> Self { self.base = self.base.disabled(disabled); - self.key_binding = self - .key_binding - .take() - .map(|binding| binding.disabled(disabled)); self } } impl Clickable for Button { - /// Sets the click event handler for the button. fn on_click( mut self, handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, @@ -310,44 +270,35 @@ impl Clickable for Button { } impl FixedWidth for Button { - /// Sets a fixed width for the button. - /// - /// This function allows a button to have a fixed width instead of automatically growing or shrinking. /// Sets a fixed width for the button. /// /// # Examples /// + /// Create a button with a fixed width of 100 pixels: + /// /// ``` /// use ui::prelude::*; /// - /// Button::new("button_id", "Click me!") - /// .width(px(100.)) - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); + /// Button::new("fixed_width_button", "Fixed Width") + /// .width(px(100.0)); /// ``` - /// - /// This sets the button's width to be exactly 100 pixels. fn width(mut self, width: impl Into) -> Self { self.base = self.base.width(width); self } - /// Sets the button to occupy the full width of its container. + /// Makes the button take up the full width of its container. /// /// # Examples /// + /// Create a button that takes up the full width of its container: + /// /// ``` /// use ui::prelude::*; /// - /// Button::new("button_id", "Click me!") - /// .full_width() - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); + /// Button::new("full_width_button", "Full Width") + /// .full_width(); /// ``` - /// - /// This stretches the button to the full width of its container. fn full_width(mut self) -> Self { self.base = self.base.full_width(); self @@ -355,43 +306,34 @@ impl FixedWidth for Button { } impl ButtonCommon for Button { - /// Sets the button's id. fn id(&self) -> &ElementId { self.base.id() } - /// Sets the visual style of the button using a [`ButtonStyle`]. + /// Sets the visual style of the button. fn style(mut self, style: ButtonStyle) -> Self { self.base = self.base.style(style); self } - /// Sets the button's size using a [`ButtonSize`]. + /// Sets the size of the button. fn size(mut self, size: ButtonSize) -> Self { self.base = self.base.size(size); self } - /// Sets a tooltip for the button. - /// - /// This method allows a tooltip to be set for the button. The tooltip is a function that - /// takes a mutable references to [`Window`] and [`App`], and returns an [`AnyView`]. The - /// tooltip is displayed when the user hovers over the button. + /// Sets a tooltip that appears on hover. /// /// # Examples /// - /// ``` - /// use ui::prelude::*; - /// use ui::Tooltip; + /// Add a tooltip to a button: /// - /// Button::new("button_id", "Click me!") - /// .tooltip(Tooltip::text("This is a tooltip")) - /// .on_click(|event, window, cx| { - /// // Handle click event - /// }); /// ``` + /// use ui::{Tooltip, prelude::*}; /// - /// This will create a button with a tooltip that displays "This is a tooltip" when hovered over. + /// Button::new("tooltip_button", "Hover Me") + /// .tooltip(Tooltip::text("This is a tooltip")); + /// ``` fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { self.base = self.base.tooltip(tooltip); self @@ -436,16 +378,12 @@ impl RenderOnce for Button { h_flex() .when(self.truncate, |this| this.min_w_0().overflow_hidden()) .gap(DynamicSpacing::Base04.rems(cx)) - .when(self.icon_position == Some(IconPosition::Start), |this| { - this.children(self.icon.map(|icon| { - ButtonIcon::new(icon) - .disabled(is_disabled) - .toggle_state(is_selected) - .selected_icon(self.selected_icon) - .selected_icon_color(self.selected_icon_color) - .size(self.icon_size) - .color(self.icon_color) - })) + .when_some(self.start_icon, |this, icon| { + this.child(if is_disabled { + icon.color(Color::Disabled) + } else { + icon + }) }) .child( h_flex() @@ -465,16 +403,12 @@ impl RenderOnce for Button { ) .children(self.key_binding), ) - .when(self.icon_position != Some(IconPosition::Start), |this| { - this.children(self.icon.map(|icon| { - ButtonIcon::new(icon) - .disabled(is_disabled) - .toggle_state(is_selected) - .selected_icon(self.selected_icon) - .selected_icon_color(self.selected_icon_color) - .size(self.icon_size) - .color(self.icon_color) - })) + .when_some(self.end_icon, |this, icon| { + this.child(if is_disabled { + icon.color(Color::Disabled) + } else { + icon + }) }), ) } @@ -585,24 +519,28 @@ impl Component for Button { "Buttons with Icons", vec![ single_example( - "Icon Start", - Button::new("icon_start", "Icon Start") - .icon(IconName::Check) - .icon_position(IconPosition::Start) + "Start Icon", + Button::new("icon_start", "Start Icon") + .start_icon(Icon::new(IconName::Check)) + .into_any_element(), + ), + single_example( + "End Icon", + Button::new("icon_end", "End Icon") + .end_icon(Icon::new(IconName::Check)) .into_any_element(), ), single_example( - "Icon End", - Button::new("icon_end", "Icon End") - .icon(IconName::Check) - .icon_position(IconPosition::End) + "Both Icons", + Button::new("both_icons", "Both Icons") + .start_icon(Icon::new(IconName::Check)) + .end_icon(Icon::new(IconName::ChevronDown)) .into_any_element(), ), single_example( "Icon Color", Button::new("icon_color", "Icon Color") - .icon(IconName::Check) - .icon_color(Color::Accent) + .start_icon(Icon::new(IconName::Check).color(Color::Accent)) .into_any_element(), ), ], diff --git a/crates/ui/src/components/button/button_icon.rs b/crates/ui/src/components/button/button_icon.rs deleted file mode 100644 index 510c418714575112070e64e945da3e185f37ee3e..0000000000000000000000000000000000000000 --- a/crates/ui/src/components/button/button_icon.rs +++ /dev/null @@ -1,199 +0,0 @@ -use crate::{Icon, IconName, IconSize, IconWithIndicator, Indicator, prelude::*}; -use gpui::Hsla; - -/// An icon that appears within a button. -/// -/// Can be used as either an icon alongside a label, like in [`Button`](crate::Button), -/// or as a standalone icon, like in [`IconButton`](crate::IconButton). -#[derive(IntoElement, RegisterComponent)] -pub(super) struct ButtonIcon { - icon: IconName, - size: IconSize, - color: Color, - disabled: bool, - selected: bool, - selected_icon: Option, - selected_icon_color: Option, - selected_style: Option, - indicator: Option, - indicator_border_color: Option, -} - -impl ButtonIcon { - pub fn new(icon: IconName) -> Self { - Self { - icon, - size: IconSize::default(), - color: Color::default(), - disabled: false, - selected: false, - selected_icon: None, - selected_icon_color: None, - selected_style: None, - indicator: None, - indicator_border_color: None, - } - } - - pub fn size(mut self, size: impl Into>) -> Self { - if let Some(size) = size.into() { - self.size = size; - } - self - } - - pub fn color(mut self, color: impl Into>) -> Self { - if let Some(color) = color.into() { - self.color = color; - } - self - } - - pub fn selected_icon(mut self, icon: impl Into>) -> Self { - self.selected_icon = icon.into(); - self - } - - pub fn selected_icon_color(mut self, color: impl Into>) -> Self { - self.selected_icon_color = color.into(); - self - } - - pub fn indicator(mut self, indicator: Indicator) -> Self { - self.indicator = Some(indicator); - self - } - - pub fn indicator_border_color(mut self, color: Option) -> Self { - self.indicator_border_color = color; - self - } -} - -impl Disableable for ButtonIcon { - fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } -} - -impl Toggleable for ButtonIcon { - fn toggle_state(mut self, selected: bool) -> Self { - self.selected = selected; - self - } -} - -impl SelectableButton for ButtonIcon { - fn selected_style(mut self, style: ButtonStyle) -> Self { - self.selected_style = Some(style); - self - } -} - -impl RenderOnce for ButtonIcon { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let icon = self - .selected_icon - .filter(|_| self.selected) - .unwrap_or(self.icon); - - let icon_color = if self.disabled { - Color::Disabled - } else if self.selected_style.is_some() && self.selected { - self.selected_style.unwrap().into() - } else if self.selected { - self.selected_icon_color.unwrap_or(Color::Selected) - } else { - self.color - }; - - let icon = Icon::new(icon).size(self.size).color(icon_color); - - match self.indicator { - Some(indicator) => IconWithIndicator::new(icon, Some(indicator)) - .indicator_border_color(self.indicator_border_color) - .into_any_element(), - None => icon.into_any_element(), - } - } -} - -impl Component for ButtonIcon { - fn scope() -> ComponentScope { - ComponentScope::Input - } - - fn name() -> &'static str { - "ButtonIcon" - } - - fn description() -> Option<&'static str> { - Some("An icon component specifically designed for use within buttons.") - } - - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Usage", - vec![ - single_example( - "Default", - ButtonIcon::new(IconName::Star).into_any_element(), - ), - single_example( - "Custom Size", - ButtonIcon::new(IconName::Star) - .size(IconSize::Medium) - .into_any_element(), - ), - single_example( - "Custom Color", - ButtonIcon::new(IconName::Star) - .color(Color::Accent) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "States", - vec![ - single_example( - "Selected", - ButtonIcon::new(IconName::Star) - .toggle_state(true) - .into_any_element(), - ), - single_example( - "Disabled", - ButtonIcon::new(IconName::Star) - .disabled(true) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "With Indicator", - vec![ - single_example( - "Default Indicator", - ButtonIcon::new(IconName::Star) - .indicator(Indicator::dot()) - .into_any_element(), - ), - single_example( - "Custom Indicator", - ButtonIcon::new(IconName::Star) - .indicator(Indicator::dot().color(Color::Error)) - .into_any_element(), - ), - ], - ), - ]) - .into_any_element(), - ) - } -} diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index 961176ed6cee7e55c7a51cd52719c0eef8a8f181..a103ddf169a8ba3ed9d1b6bf6055ff84858aef7d 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -1,11 +1,11 @@ use gpui::{AnyView, DefiniteLength, Hsla}; use super::button_like::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle}; -use crate::{ElevationIndex, Indicator, SelectableButton, TintColor, prelude::*}; +use crate::{ + ElevationIndex, Icon, IconWithIndicator, Indicator, SelectableButton, TintColor, prelude::*, +}; use crate::{IconName, IconSize}; -use super::button_icon::ButtonIcon; - /// The shape of an [`IconButton`]. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] pub enum IconButtonShape { @@ -22,6 +22,7 @@ pub struct IconButton { icon_color: Color, selected_icon: Option, selected_icon_color: Option, + selected_style: Option, indicator: Option, indicator_border_color: Option, alpha: Option, @@ -37,6 +38,7 @@ impl IconButton { icon_color: Color::Default, selected_icon: None, selected_icon_color: None, + selected_style: None, indicator: None, indicator_border_color: None, alpha: None, @@ -112,6 +114,7 @@ impl Toggleable for IconButton { impl SelectableButton for IconButton { fn selected_style(mut self, style: ButtonStyle) -> Self { + self.selected_style = Some(style); self.base = self.base.selected_style(style); self } @@ -192,9 +195,25 @@ impl RenderOnce for IconButton { fn render(self, window: &mut Window, cx: &mut App) -> ButtonLike { let is_disabled = self.base.disabled; let is_selected = self.base.selected; - let selected_style = self.base.selected_style; - let color = self.icon_color.color(cx).opacity(self.alpha.unwrap_or(1.0)); + let icon = self + .selected_icon + .filter(|_| is_selected) + .unwrap_or(self.icon); + + let icon_color = if is_disabled { + Color::Disabled + } else if self.selected_style.is_some() && is_selected { + self.selected_style.unwrap().into() + } else if is_selected { + self.selected_icon_color.unwrap_or(Color::Selected) + } else { + let base_color = self.icon_color.color(cx); + Color::Custom(base_color.opacity(self.alpha.unwrap_or(1.0))) + }; + + let icon_element = Icon::new(icon).size(self.icon_size).color(icon_color); + self.base .map(|this| match self.shape { IconButtonShape::Square => { @@ -203,20 +222,12 @@ impl RenderOnce for IconButton { } IconButtonShape::Wide => this, }) - .child( - ButtonIcon::new(self.icon) - .disabled(is_disabled) - .toggle_state(is_selected) - .selected_icon(self.selected_icon) - .selected_icon_color(self.selected_icon_color) - .when_some(selected_style, |this, style| this.selected_style(style)) - .when_some(self.indicator, |this, indicator| { - this.indicator(indicator) - .indicator_border_color(self.indicator_border_color) - }) - .size(self.icon_size) - .color(Color::Custom(color)), - ) + .child(match self.indicator { + Some(indicator) => IconWithIndicator::new(icon_element, Some(indicator)) + .indicator_border_color(self.indicator_border_color) + .into_any_element(), + None => icon_element.into_any_element(), + }) } } diff --git a/crates/ui/src/components/diff_stat.rs b/crates/ui/src/components/diff_stat.rs index 45539c62869b8c23cb76671d2a7a862c9592a181..c2e76b171e7e28cc5cb2e2b0c4d776b5bc7e2bfc 100644 --- a/crates/ui/src/components/diff_stat.rs +++ b/crates/ui/src/components/diff_stat.rs @@ -1,3 +1,4 @@ +use crate::Tooltip; use crate::prelude::*; #[derive(IntoElement, RegisterComponent)] @@ -6,6 +7,7 @@ pub struct DiffStat { added: usize, removed: usize, label_size: LabelSize, + tooltip: Option, } impl DiffStat { @@ -15,6 +17,7 @@ impl DiffStat { added, removed, label_size: LabelSize::Small, + tooltip: None, } } @@ -22,10 +25,16 @@ impl DiffStat { self.label_size = label_size; self } + + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(tooltip.into()); + self + } } impl RenderOnce for DiffStat { fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement { + let tooltip = self.tooltip; h_flex() .id(self.id) .gap_1() @@ -39,6 +48,9 @@ impl RenderOnce for DiffStat { .color(Color::Error) .size(self.label_size), ) + .when_some(tooltip, |this, tooltip| { + this.tooltip(Tooltip::text(tooltip)) + }) } } diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index 7a1d3c7dfd77306b2d7b3b6786dae04d6eaee6b2..961608461c04971cda81cfdd64d9eb62577f07ed 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -163,11 +163,10 @@ impl RenderOnce for DropdownMenu { Some( Button::new(self.id.clone(), text) .style(button_style) - .when(self.chevron, |this| { - this.icon(self.trigger_icon) - .icon_position(IconPosition::End) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .when_some(self.trigger_icon.filter(|_| self.chevron), |this, icon| { + this.end_icon( + Icon::new(icon).size(IconSize::XSmall).color(Color::Muted), + ) }) .when(full_width, |this| this.full_width()) .size(trigger_size) diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 09c99c230a0c7a9710e2976ac0673b639d8e36c4..d4d31739779e7872e29005b180f2e4682ef808af 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -12,10 +12,11 @@ use client::{Client, proto}; use futures::{StreamExt, channel::mpsc}; use gpui::{ Action, AnyElement, AnyEntity, AnyView, App, AppContext, Context, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, Font, HighlightStyle, Pixels, Point, Render, - SharedString, Task, WeakEntity, Window, + EventEmitter, FocusHandle, Focusable, Font, Pixels, Point, Render, SharedString, Task, + WeakEntity, Window, }; use language::Capability; +pub use language::HighlightedText; use project::{Project, ProjectEntryId, ProjectPath}; pub use settings::{ ActivateOnClose, ClosePosition, RegisterSetting, Settings, SettingsLocation, ShowCloseButton, @@ -25,7 +26,6 @@ use smallvec::SmallVec; use std::{ any::{Any, TypeId}, cell::RefCell, - ops::Range, path::Path, rc::Rc, sync::Arc, @@ -124,14 +124,6 @@ pub enum ItemEvent { Edit, } -// TODO: Combine this with existing HighlightedText struct? -#[derive(Debug)] -pub struct BreadcrumbText { - pub text: String, - pub highlights: Option, HighlightStyle)>>, - pub font: Option, -} - #[derive(Clone, Copy, Default, Debug)] pub struct TabContentParams { pub detail: Option, @@ -329,7 +321,7 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { ToolbarItemLocation::Hidden } - fn breadcrumbs(&self, _cx: &App) -> Option> { + fn breadcrumbs(&self, _cx: &App) -> Option<(Vec, Option)> { None } @@ -548,7 +540,7 @@ pub trait ItemHandle: 'static + Send { ) -> gpui::Subscription; fn to_searchable_item_handle(&self, cx: &App) -> Option>; fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation; - fn breadcrumbs(&self, cx: &App) -> Option>; + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)>; fn breadcrumb_prefix(&self, window: &mut Window, cx: &mut App) -> Option; fn show_toolbar(&self, cx: &App) -> bool; fn pixel_position_of_cursor(&self, cx: &App) -> Option>; @@ -1090,7 +1082,7 @@ impl ItemHandle for Entity { self.read(cx).breadcrumb_location(cx) } - fn breadcrumbs(&self, cx: &App) -> Option> { + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { self.read(cx).breadcrumbs(cx) } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 9f4b5538ed67bde3f32969467828296485b7810f..29bb9d7b063ff6e4b9f472d708f354fb50f7a2e8 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -917,11 +917,11 @@ pub mod simple_message_notification { })); if let Some(icon) = self.primary_icon { - button = button - .icon(icon) - .icon_color(self.primary_icon_color.unwrap_or(Color::Muted)) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small); + button = button.start_icon( + Icon::new(icon) + .size(IconSize::Small) + .color(self.primary_icon_color.unwrap_or(Color::Muted)), + ); } button @@ -937,11 +937,11 @@ pub mod simple_message_notification { })); if let Some(icon) = self.secondary_icon { - button = button - .icon(icon) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .icon_color(self.secondary_icon_color.unwrap_or(Color::Muted)); + button = button.start_icon( + Icon::new(icon) + .size(IconSize::Small) + .color(self.secondary_icon_color.unwrap_or(Color::Muted)), + ); } button @@ -955,9 +955,11 @@ pub mod simple_message_notification { let url = url.clone(); Button::new(message.clone(), message.clone()) .label_size(LabelSize::Small) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Indicator) - .icon_color(Color::Muted) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Indicator) + .color(Color::Muted), + ) .on_click(cx.listener(move |_, _, _, cx| { cx.open_url(&url); })) diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 9f0b035049ebb5bfbeef7211acee9ced5288bb47..89ce7dade6e17d5b422dceb46cd9b0a6107eaa46 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1784,11 +1784,17 @@ impl WorkspaceDb { } } - async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool { + async fn all_paths_exist_with_a_directory( + paths: &[PathBuf], + fs: &dyn Fs, + timestamp: Option>, + ) -> bool { let mut any_dir = false; for path in paths { match fs.metadata(path).await.ok().flatten() { - None => return false, + None => { + return timestamp.is_some_and(|t| Utc::now() - t < chrono::Duration::days(7)); + } Some(meta) => { if meta.is_dir { any_dir = true; @@ -1844,7 +1850,9 @@ impl WorkspaceDb { // If a local workspace points to WSL, this check will cause us to wait for the // WSL VM and file server to boot up. This can block for many seconds. // Supported scenarios use remote workspaces. - if !has_wsl_path && Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { + if !has_wsl_path + && Self::all_paths_exist_with_a_directory(paths.paths(), fs, Some(timestamp)).await + { result.push((id, SerializedWorkspaceLocation::Local, paths, timestamp)); } else { delete_tasks.push(self.delete_workspace_by_id(id)); @@ -1904,7 +1912,7 @@ impl WorkspaceDb { window_id, }); } else { - if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { + if Self::all_paths_exist_with_a_directory(paths.paths(), fs, None).await { workspaces.push(SessionWorkspace { workspace_id, location: SerializedWorkspaceLocation::Local, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index f9df225bdf3c5cd3c7038a551738de8f6c85b299..a2594e116441ef583ef7e3d54965f835cd712d64 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -8524,6 +8524,15 @@ actions!( CopyRoomId, ] ); + +/// Opens the channel notes for a specific channel by its ID. +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] +#[action(namespace = collab)] +#[serde(deny_unknown_fields)] +pub struct OpenChannelNotesById { + pub channel_id: u64, +} + actions!( zed, [ diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2b45c581685e9ecb63888edd256ec14b0da94a30..7fae303160702216a8c75095597293c375751c82 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -161,6 +161,7 @@ - [Debugger Extensions](./extensions/debugger-extensions.md) - [Theme Extensions](./extensions/themes.md) - [Icon Theme Extensions](./extensions/icon-themes.md) +- [Snippets Extensions](./extensions/snippets.md) - [Slash Command Extensions](./extensions/slash-commands.md) - [Agent Server Extensions](./extensions/agent-servers.md) - [MCP Server Extensions](./extensions/mcp-extensions.md) diff --git a/docs/src/extensions.md b/docs/src/extensions.md index 01636894a11781717a837a0f0784d6221ded1c3c..af44d981fd9e911235d5a70a1b0266037ed30ddc 100644 --- a/docs/src/extensions.md +++ b/docs/src/extensions.md @@ -14,6 +14,7 @@ Zed lets you add new functionality using user-defined extensions. - [Developing Debugger Extensions](./extensions/debugger-extensions.md) - [Developing Themes](./extensions/themes.md) - [Developing Icon Themes](./extensions/icon-themes.md) + - [Developing Snippets](./extensions/snippets.md) - [Developing Slash Commands](./extensions/slash-commands.md) - [Developing Agent Servers](./extensions/agent-servers.md) - [Developing MCP Servers](./extensions/mcp-extensions.md) diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index c5b4b1079066ba3f7b5e4149778c8e369d03d9cd..c1d593628d9e1b7775aa5ce743351c59ad0ce70e 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -5,7 +5,7 @@ description: "Create Zed extensions: languages, themes, debuggers, slash command # Developing Extensions {#developing-extensions} -Zed extensions are Git repositories containing an `extension.toml` manifest. They can provide languages, themes, debuggers, slash commands, and MCP servers. +Zed extensions are Git repositories containing an `extension.toml` manifest. They can provide languages, themes, debuggers, snippets, slash commands, and MCP servers. ## Extension Features {#extension-features} @@ -15,6 +15,7 @@ Extensions can provide: - [Debuggers](./debugger-extensions.md) - [Themes](./themes.md) - [Icon Themes](./icon-themes.md) +- [Snippets](./snippets.md) - [Slash Commands](./slash-commands.md) - [MCP Servers](./mcp-extensions.md) @@ -63,6 +64,9 @@ my-extension/ highlights.scm themes/ my-theme.json + snippets/ + snippets.json + rust.json ``` ## WebAssembly diff --git a/docs/src/extensions/snippets.md b/docs/src/extensions/snippets.md new file mode 100644 index 0000000000000000000000000000000000000000..1fa83b07b78403346608494b3932b58e37f8688e --- /dev/null +++ b/docs/src/extensions/snippets.md @@ -0,0 +1,27 @@ +--- +title: Snippets +description: "Snippets for Zed extensions." +--- + +# Snippets + +Extensions may provide snippets for one or more languages. + +Each file containing snippets can be specified in the `snippets` field of the `extensions.toml` file. + +The referenced path must be relative to the `extension.toml`. + +## Defining Snippets + +A given extension may provide one or more snippets. Each snippet must be registered in the `extension.toml`. + +Zed matches snippet files based on the lowercase name of the language (e.g. `rust.json` for Rust). +You can use `snippets.json` as a file name to define snippets that will be available regardless of the current buffer language. + +For example, here is an extension that provides snippets for Rust and TypeScript: + +```toml +snippets = ["./snippets/rust.json", "./snippets/typescript.json"] +``` + +For more information on how to create snippets, see the [Snippets documentation](../snippets.md). diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 32fec4a84d56cf996dc85cf112e4daec7893311b..7248a5636a29339ec2ca93481cfa4056b2527d30 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -4695,7 +4695,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a "bold_folder_labels": false, "drag_and_drop": true, "scrollbar": { - "show": null + "show": null, + "horizontal_scroll": true }, "sticky_scroll": true, "show_diagnostics": "all", @@ -4941,9 +4942,9 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a } ``` -### Scrollbar: Show +### Scrollbar -- Description: Whether to show a scrollbar in the project panel. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details. +- Description: Scrollbar-related settings for the project panel. - Setting: `scrollbar` - Default: @@ -4951,7 +4952,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a { "project_panel": { "scrollbar": { - "show": null + "show": null, + "horizontal_scroll": true } } } @@ -4959,29 +4961,8 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a **Options** -1. Show scrollbar in the project panel - -```json [settings] -{ - "project_panel": { - "scrollbar": { - "show": "always" - } - } -} -``` - -2. Hide scrollbar in the project panel - -```json [settings] -{ - "project_panel": { - "scrollbar": { - "show": "never" - } - } -} -``` +- `show`: Whether to show a scrollbar in the project panel. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details. +- `horizontal_scroll`: Whether to allow horizontal scrolling in the project panel. When `false`, the view is locked to the leftmost position and long file names are clipped. ### Sort Mode diff --git a/extensions/glsl/languages/glsl/config.toml b/extensions/glsl/languages/glsl/config.toml index 0c71419c91e40f4b5fc65c10c882ac5c542a080c..ecb1a43f6803e40cd7e2bf003be5c32066dae3fd 100644 --- a/extensions/glsl/languages/glsl/config.toml +++ b/extensions/glsl/languages/glsl/config.toml @@ -5,6 +5,8 @@ path_suffixes = [ "vert", "frag", "tesc", "tese", "geom", # Compute shaders "comp", + # Mesh pipeline shaders + "task", "mesh", # Ray tracing pipeline shaders "rgen", "rint", "rahit", "rchit", "rmiss", "rcall", # Other diff --git a/extensions/html/languages/html/brackets.scm b/extensions/html/languages/html/brackets.scm index adc11a1d7408ae33b80f0daa78a03d8f3352b745..02619c109f3ff2d830948e8e8c4889e1e733fae9 100644 --- a/extensions/html/languages/html/brackets.scm +++ b/extensions/html/languages/html/brackets.scm @@ -2,11 +2,11 @@ "/>" @close) (#set! rainbow.exclude)) -(("" @close) (#set! rainbow.exclude)) -(("<" @open +(("" @close) (#set! rainbow.exclude)) diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 26596c9401c1d3c500a8c1cb18083d525c934e20..35f053f46666a4d5e81bffe27bc80490c20c166d 100644 --- a/tooling/xtask/src/tasks/workflows.rs +++ b/tooling/xtask/src/tasks/workflows.rs @@ -13,6 +13,7 @@ mod cherry_pick; mod compare_perf; mod danger; mod deploy_collab; +mod extension_auto_bump; mod extension_bump; mod extension_tests; mod extension_workflow_rollout; @@ -199,6 +200,7 @@ pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { WorkflowFile::zed(danger::danger), WorkflowFile::zed(deploy_collab::deploy_collab), WorkflowFile::zed(extension_bump::extension_bump), + WorkflowFile::zed(extension_auto_bump::extension_auto_bump), WorkflowFile::zed(extension_tests::extension_tests), WorkflowFile::zed(extension_workflow_rollout::extension_workflow_rollout), WorkflowFile::zed(publish_extension_cli::publish_extension_cli), diff --git a/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs new file mode 100644 index 0000000000000000000000000000000000000000..14c15f39ad76b48402609023c604e17ea49bc432 --- /dev/null +++ b/tooling/xtask/src/tasks/workflows/extension_auto_bump.rs @@ -0,0 +1,113 @@ +use gh_workflow::{ + Event, Expression, Input, Job, Level, Permissions, Push, Strategy, UsesJob, Workflow, +}; +use indoc::indoc; +use serde_json::json; + +use crate::tasks::workflows::{ + extensions::WithAppSecrets, + run_tests::DETECT_CHANGED_EXTENSIONS_SCRIPT, + runners, + steps::{self, CommonJobConditions, NamedJob, named}, + vars::{StepOutput, one_workflow_per_non_main_branch}, +}; + +/// Generates a workflow that triggers on push to main, detects changed extensions +/// in the `extensions/` directory, and invokes the `extension_bump` reusable workflow +/// for each changed extension via a matrix strategy. +pub(crate) fn extension_auto_bump() -> Workflow { + let detect = detect_changed_extensions(); + let bump = bump_extension_versions(&detect); + + named::workflow() + .add_event( + Event::default().push( + Push::default() + .add_branch("main") + .add_path("extensions/**") + .add_path("!extensions/workflows/**") + .add_path("!extensions/*.md"), + ), + ) + .concurrency(one_workflow_per_non_main_branch()) + .add_job(detect.name, detect.job) + .add_job(bump.name, bump.job) +} + +fn detect_changed_extensions() -> NamedJob { + let preamble = indoc! {r#" + COMPARE_REV="$(git rev-parse HEAD~1)" + CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")" + "#}; + + let filter_new_and_removed = indoc! {r#" + # Filter out newly added or entirely removed extensions + FILTERED="[]" + for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do + if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \ + [ -f "$ext/extension.toml" ]; then + FILTERED=$(echo "$FILTERED" | jq -c --arg e "$ext" '. + [$e]') + fi + done + echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT" + "#}; + + let script = format!( + "{preamble}{detect}{filter}", + preamble = preamble, + detect = DETECT_CHANGED_EXTENSIONS_SCRIPT, + filter = filter_new_and_removed, + ); + + let step = named::bash(script).id("detect"); + + let output = StepOutput::new(&step, "changed_extensions"); + + let job = Job::default() + .with_repository_owner_guard() + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(5u32) + .add_step(steps::checkout_repo().with_custom_fetch_depth(2)) + .add_step(step) + .outputs([("changed_extensions".to_owned(), output.to_string())]); + + named::job(job) +} + +fn bump_extension_versions(detect_job: &NamedJob) -> NamedJob { + let job = Job::default() + .needs(vec![detect_job.name.clone()]) + .cond(Expression::new(format!( + "needs.{}.outputs.changed_extensions != '[]'", + detect_job.name + ))) + .permissions( + Permissions::default() + .contents(Level::Write) + .issues(Level::Write) + .pull_requests(Level::Write) + .actions(Level::Write), + ) + .strategy( + Strategy::default() + .fail_fast(false) + // TODO: Remove the limit. We currently need this to workaround the concurrency group issue + // where different matrix jobs would be placed in the same concurrency group and thus cancelled. + .max_parallel(1u32) + .matrix(json!({ + "extension": format!( + "${{{{ fromJson(needs.{}.outputs.changed_extensions) }}}}", + detect_job.name + ) + })), + ) + .uses_local(".github/workflows/extension_bump.yml") + .with( + Input::default() + .add("working-directory", "${{ matrix.extension }}") + .add("force-bump", false), + ) + .with_app_secrets(); + + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 8c31de202ee7ac81b5f5e95fb26ec89452fd077c..91d2e5645f9f5e9fd24dbceaf5e2ad6886e41cb6 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -5,11 +5,12 @@ use crate::tasks::workflows::{ extension_tests::{self}, runners, steps::{ - self, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, NamedJob, - checkout_repo, dependant_job, named, + self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, + NamedJob, checkout_repo, dependant_job, named, }, vars::{ - JobOutput, StepOutput, WorkflowInput, WorkflowSecret, one_workflow_per_non_main_branch, + JobOutput, StepOutput, WorkflowInput, WorkflowSecret, + one_workflow_per_non_main_branch_and_token, }, }; @@ -22,6 +23,7 @@ pub(crate) fn extension_bump() -> Workflow { // TODO: Ideally, this would have a default of `false`, but this is currently not // supported in gh-workflows let force_bump = WorkflowInput::bool("force-bump", None); + let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned())); let (app_id, app_secret) = extension_workflow_secrets(); let (check_version_changed, version_changed, current_version) = check_version_changed(); @@ -59,6 +61,7 @@ pub(crate) fn extension_bump() -> Workflow { WorkflowCall::default() .add_input(bump_type.name, bump_type.call_input()) .add_input(force_bump.name, force_bump.call_input()) + .add_input(working_directory.name, working_directory.call_input()) .secrets([ (app_id.name.to_owned(), app_id.secret_configuration()), ( @@ -68,7 +71,7 @@ pub(crate) fn extension_bump() -> Workflow { ]), ), ) - .concurrency(one_workflow_per_non_main_branch()) + .concurrency(one_workflow_per_non_main_branch_and_token("extension-bump")) .add_env(("CARGO_TERM_COLOR", "always")) .add_env(("RUST_BACKTRACE", 1)) .add_env(("CARGO_INCREMENTAL", 0)) @@ -82,10 +85,19 @@ pub(crate) fn extension_bump() -> Workflow { .add_job(trigger_release.name, trigger_release.job) } +fn extension_job_defaults() -> Defaults { + Defaults::default().run( + RunDefaults::default() + .shell(BASH_SHELL) + .working_directory("${{ inputs.working-directory }}"), + ) +} + fn check_version_changed() -> (NamedJob, StepOutput, StepOutput) { let (compare_versions, version_changed, current_version) = compare_versions(); let job = Job::default() + .defaults(extension_job_defaults()) .with_repository_owner_guard() .outputs([ (version_changed.name.to_owned(), version_changed.to_string()), @@ -112,6 +124,7 @@ fn create_version_label( let (generate_token, generated_token) = generate_token(&app_id.to_string(), &app_secret.to_string(), None); let job = steps::dependant_job(dependencies) + .defaults(extension_job_defaults()) .cond(Expression::new(format!( "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && \ github.ref == 'refs/heads/main' && {version_changed} == 'true'", @@ -153,8 +166,6 @@ pub(crate) fn compare_versions() -> (Step, StepOutput, StepOutput) { if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then PR_FORK_POINT="$(git merge-base origin/main HEAD)" git checkout "$PR_FORK_POINT" - elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then - git checkout "$BRANCH_PARENT_SHA" else git checkout "$(git log -1 --format=%H)"~1 fi @@ -187,9 +198,11 @@ fn bump_extension_version( ) -> NamedJob { let (generate_token, generated_token) = generate_token(&app_id.to_string(), &app_secret.to_string(), None); - let (bump_version, new_version) = bump_version(current_version, bump_type); + let (bump_version, _new_version, title, body, branch_name) = + bump_version(current_version, bump_type); let job = steps::dependant_job(dependencies) + .defaults(extension_job_defaults()) .cond(Expression::new(format!( "{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({force_bump} == true || {version_changed} == 'false')", force_bump = force_bump_output.expr(), @@ -201,7 +214,12 @@ fn bump_extension_version( .add_step(steps::checkout_repo()) .add_step(install_bump_2_version()) .add_step(bump_version) - .add_step(create_pull_request(new_version, generated_token)); + .add_step(create_pull_request( + title, + body, + generated_token, + branch_name, + )); named::job(job) } @@ -256,7 +274,10 @@ fn install_bump_2_version() -> Step { ) } -fn bump_version(current_version: &JobOutput, bump_type: &WorkflowInput) -> (Step, StepOutput) { +fn bump_version( + current_version: &JobOutput, + bump_type: &WorkflowInput, +) -> (Step, StepOutput, StepOutput, StepOutput, StepOutput) { let step = named::bash(formatdoc! {r#" BUMP_FILES=("extension.toml") if [[ -f "Cargo.toml" ]]; then @@ -274,33 +295,50 @@ fn bump_version(current_version: &JobOutput, bump_type: &WorkflowInput) -> (Step fi NEW_VERSION="$({VERSION_CHECK})" + EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + + if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then + {{ + echo "title=Bump version to ${{NEW_VERSION}}"; + echo "body=This PR bumps the version of this extension to v${{NEW_VERSION}}"; + echo "branch_name=zed-zippy-autobump"; + }} >> "$GITHUB_OUTPUT" + else + {{ + echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}"; + echo "body=This PR bumps the version of the ${{EXTENSION_NAME}} extension to v${{NEW_VERSION}}"; + echo "branch_name=zed-zippy-${{EXTENSION_ID}}-autobump"; + }} >> "$GITHUB_OUTPUT" + fi echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT" "# }) .id("bump-version") .add_env(("OLD_VERSION", current_version.to_string())) - .add_env(("BUMP_TYPE", bump_type.to_string())); + .add_env(("BUMP_TYPE", bump_type.to_string())) + .add_env(("WORKING_DIR", "${{ inputs.working-directory }}")); let new_version = StepOutput::new(&step, "new_version"); - (step, new_version) + let title = StepOutput::new(&step, "title"); + let body = StepOutput::new(&step, "body"); + let branch_name = StepOutput::new(&step, "branch_name"); + (step, new_version, title, body, branch_name) } -fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) -> Step { - let formatted_version = format!("v{new_version}"); - +fn create_pull_request( + title: StepOutput, + body: StepOutput, + generated_token: StepOutput, + branch_name: StepOutput, +) -> Step { named::uses("peter-evans", "create-pull-request", "v7").with( Input::default() - .add("title", format!("Bump version to {new_version}")) - .add( - "body", - format!("This PR bumps the version of this extension to {formatted_version}",), - ) - .add( - "commit-message", - format!("Bump version to {formatted_version}"), - ) - .add("branch", "zed-zippy-autobump") + .add("title", title.to_string()) + .add("body", body.to_string()) + .add("commit-message", title.to_string()) + .add("branch", branch_name.to_string()) .add( "committer", "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", @@ -328,6 +366,7 @@ fn trigger_release( let (get_extension_id, extension_id) = get_extension_id(); let job = dependant_job(dependencies) + .defaults(extension_job_defaults()) .with_repository_owner_guard() .runs_on(runners::LINUX_SMALL) .add_step(generate_token) diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index 09f0cadf1c8731f8eed4ef1197a7edd05e0d1558..caf57ce130f7d7e9f0018ef20d4cf4892823f4ab 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -3,15 +3,13 @@ use indoc::indoc; use crate::tasks::workflows::{ extension_bump::compare_versions, - run_tests::{ - fetch_ts_query_ls, orchestrate_without_package_filter, run_ts_query_ls, tests_pass, - }, + run_tests::{fetch_ts_query_ls, orchestrate_for_extension, run_ts_query_ls, tests_pass}, runners, steps::{ - self, CommonJobConditions, FluentBuilder, NamedJob, cache_rust_dependencies_namespace, - named, + self, BASH_SHELL, CommonJobConditions, FluentBuilder, NamedJob, + cache_rust_dependencies_namespace, named, }, - vars::{PathCondition, StepOutput, one_workflow_per_non_main_branch}, + vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token}, }; pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667"; @@ -25,8 +23,10 @@ pub(crate) fn extension_tests() -> Workflow { let should_check_extension = PathCondition::new("check_extension", r"^(extension\.toml|.*\.scm)$"); - let orchestrate = - orchestrate_without_package_filter(&[&should_check_rust, &should_check_extension]); + let orchestrate = with_extension_defaults(orchestrate_for_extension(&[ + &should_check_rust, + &should_check_extension, + ])); let jobs = [ orchestrate, @@ -34,11 +34,20 @@ pub(crate) fn extension_tests() -> Workflow { should_check_extension.guard(check_extension()), ]; - let tests_pass = tests_pass(&jobs); + let tests_pass = tests_pass(&jobs, &[]); + + let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned())); named::workflow() - .add_event(Event::default().workflow_call(WorkflowCall::default())) - .concurrency(one_workflow_per_non_main_branch()) + .add_event( + Event::default().workflow_call( + WorkflowCall::default() + .add_input(working_directory.name, working_directory.call_input()), + ), + ) + .concurrency(one_workflow_per_non_main_branch_and_token( + "extension-tests", + )) .add_env(("CARGO_TERM_COLOR", "always")) .add_env(("RUST_BACKTRACE", 1)) .add_env(("CARGO_INCREMENTAL", 0)) @@ -58,27 +67,66 @@ fn install_rust_target() -> Step { named::bash(format!("rustup target add {EXTENSION_RUST_TARGET}",)) } -fn run_clippy() -> Step { - named::bash("cargo clippy --release --all-features -- --deny warnings") +fn get_package_name() -> (Step, StepOutput) { + let step = named::bash(indoc! {r#" + PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < Cargo.toml | head -1 | tr -d '[:space:]')" + echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT" + "#}) + .id("get-package-name"); + + let output = StepOutput::new(&step, "package_name"); + (step, output) +} + +fn cargo_fmt_package(package_name: &StepOutput) -> Step { + named::bash(r#"cargo fmt -p "$PACKAGE_NAME" -- --check"#) + .add_env(("PACKAGE_NAME", package_name.to_string())) +} + +fn run_clippy(package_name: &StepOutput) -> Step { + named::bash(r#"cargo clippy -p "$PACKAGE_NAME" --release --all-features -- --deny warnings"#) + .add_env(("PACKAGE_NAME", package_name.to_string())) +} + +fn run_nextest(package_name: &StepOutput) -> Step { + named::bash( + r#"cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n 's|host: ||p')""#, + ) + .add_env(("PACKAGE_NAME", package_name.to_string())) + .add_env(("NEXTEST_NO_TESTS", "warn")) +} + +fn extension_job_defaults() -> Defaults { + Defaults::default().run( + RunDefaults::default() + .shell(BASH_SHELL) + .working_directory("${{ inputs.working-directory }}"), + ) +} + +fn with_extension_defaults(named_job: NamedJob) -> NamedJob { + NamedJob { + name: named_job.name, + job: named_job.job.defaults(extension_job_defaults()), + } } fn check_rust() -> NamedJob { + let (get_package, package_name) = get_package_name(); + let job = Job::default() + .defaults(extension_job_defaults()) .with_repository_owner_guard() .runs_on(runners::LINUX_LARGE_RAM) .timeout_minutes(6u32) .add_step(steps::checkout_repo()) .add_step(steps::cache_rust_dependencies_namespace()) .add_step(install_rust_target()) - .add_step(steps::cargo_fmt()) - .add_step(run_clippy()) + .add_step(get_package) + .add_step(cargo_fmt_package(&package_name)) + .add_step(run_clippy(&package_name)) .add_step(steps::cargo_install_nextest()) - .add_step( - steps::cargo_nextest(runners::Platform::Linux) - // Set the target to the current platform again - .with_target("$(rustc -vV | sed -n 's|host: ||p')") - .add_env(("NEXTEST_NO_TESTS", "warn")), - ); + .add_step(run_nextest(&package_name)); named::job(job) } @@ -88,6 +136,7 @@ pub(crate) fn check_extension() -> NamedJob { let (check_version_job, version_changed, _) = compare_versions(); let job = Job::default() + .defaults(extension_job_defaults()) .with_repository_owner_guard() .runs_on(runners::LINUX_LARGE_RAM) .timeout_minutes(6u32) @@ -124,8 +173,8 @@ pub fn download_zed_extension_cli(cache_hit: StepOutput) -> Step { named::bash( indoc! { r#" - wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" - chmod +x zed-extension + wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension" + chmod +x "$GITHUB_WORKSPACE/zed-extension" "#, } ).if_condition(Expression::new(format!("{} != 'true'", cache_hit.expr()))) @@ -136,7 +185,7 @@ pub fn check() -> Step { r#" mkdir -p /tmp/ext-scratch mkdir -p /tmp/ext-output - ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output + "$GITHUB_WORKSPACE/zed-extension" --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output "# }) } diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 4e247fe16ca7b97638488c218684889c39cfcfa8..a62bb107da5228cd3ba620e47ab77dc673974696 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -127,8 +127,9 @@ fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOu .id("calc-changes") .add_env(("PREV_COMMIT", prev_commit.to_string())); - let removed_ci = StepOutput::new(&step, "removed_ci"); - let removed_shared = StepOutput::new(&step, "removed_shared"); + // These are created in the for-loop above and thus do exist + let removed_ci = StepOutput::new_unchecked(&step, "removed_ci"); + let removed_shared = StepOutput::new_unchecked(&step, "removed_shared"); (step, removed_ci, removed_shared) } diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index 38ba1bd32945f9ba8ee1e08ebc994a1132fb07f2..3ca8e456346dc5b1bbea89ca40993456e4f1354c 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -1,9 +1,10 @@ use gh_workflow::{ - Concurrency, Container, Event, Expression, Job, Port, PullRequest, Push, Run, Step, Use, - Workflow, + Concurrency, Container, Event, Expression, Input, Job, Level, Permissions, Port, PullRequest, + Push, Run, Step, Strategy, Use, UsesJob, Workflow, }; use indexmap::IndexMap; use indoc::formatdoc; +use serde_json::json; use crate::tasks::workflows::{ steps::{ @@ -24,9 +25,10 @@ pub(crate) fn run_tests() -> Workflow { // - script/update_top_ranking_issues/ // - .github/ISSUE_TEMPLATE/ // - .github/workflows/ (except .github/workflows/ci.yml) + // - extensions/ (these have their own test workflow) let should_run_tests = PathCondition::inverted( "run_tests", - r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests)))", + r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)", ); let should_check_docs = PathCondition::new("run_docs", r"^(docs/|crates/.*\.rs)"); let should_check_scripts = PathCondition::new( @@ -60,7 +62,8 @@ pub(crate) fn run_tests() -> Workflow { should_check_licences.guard(check_licenses()), should_check_scripts.guard(check_scripts()), ]; - let tests_pass = tests_pass(&jobs); + let ext_tests = extension_tests(); + let tests_pass = tests_pass(&jobs, &[&ext_tests.name]); jobs.push(should_run_tests.guard(check_postgres_and_protobuf_migrations())); // could be more specific here? @@ -91,20 +94,32 @@ pub(crate) fn run_tests() -> Workflow { } workflow }) + .add_job(ext_tests.name, ext_tests.job) .add_job(tests_pass.name, tests_pass.job) } +/// Controls which features `orchestrate_impl` includes in the generated script. +#[derive(PartialEq, Eq)] +enum OrchestrateTarget { + /// For the main Zed repo: includes the cargo package filter and extension + /// change detection, but no working-directory scoping. + ZedRepo, + /// For individual extension repos: scopes changed-file detection to the + /// working directory, with no package filter or extension detection. + Extension, +} + // Generates a bash script that checks changed files against regex patterns // and sets GitHub output variables accordingly pub fn orchestrate(rules: &[&PathCondition]) -> NamedJob { - orchestrate_impl(rules, true) + orchestrate_impl(rules, OrchestrateTarget::ZedRepo) } -pub fn orchestrate_without_package_filter(rules: &[&PathCondition]) -> NamedJob { - orchestrate_impl(rules, false) +pub fn orchestrate_for_extension(rules: &[&PathCondition]) -> NamedJob { + orchestrate_impl(rules, OrchestrateTarget::Extension) } -fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> NamedJob { +fn orchestrate_impl(rules: &[&PathCondition], target: OrchestrateTarget) -> NamedJob { let name = "orchestrate".to_owned(); let step_name = "filter".to_owned(); let mut script = String::new(); @@ -121,6 +136,22 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N fi CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")" + "#}); + + if target == OrchestrateTarget::Extension { + script.push_str(indoc::indoc! {r#" + # When running from a subdirectory, git diff returns repo-root-relative paths. + # Filter to only files within the current working directory and strip the prefix. + REPO_SUBDIR="$(git rev-parse --show-prefix)" + REPO_SUBDIR="${REPO_SUBDIR%/}" + if [ -n "$REPO_SUBDIR" ]; then + CHANGED_FILES="$(echo "$CHANGED_FILES" | grep "^${REPO_SUBDIR}/" | sed "s|^${REPO_SUBDIR}/||" || true)" + fi + + "#}); + } + + script.push_str(indoc::indoc! {r#" check_pattern() { local output_name="$1" local pattern="$2" @@ -135,7 +166,7 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N let mut outputs = IndexMap::new(); - if include_package_filter { + if target == OrchestrateTarget::ZedRepo { script.push_str(indoc::indoc! {r#" # Check for changes that require full rebuild (no filter) # Direct pushes to main/stable/preview always run full suite @@ -221,6 +252,16 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N )); } + if target == OrchestrateTarget::ZedRepo { + script.push_str(DETECT_CHANGED_EXTENSIONS_SCRIPT); + script.push_str("echo \"changed_extensions=$EXTENSIONS_JSON\" >> \"$GITHUB_OUTPUT\"\n"); + + outputs.insert( + "changed_extensions".to_owned(), + format!("${{{{ steps.{}.outputs.changed_extensions }}}}", step_name), + ); + } + let job = Job::default() .runs_on(runners::LINUX_SMALL) .with_repository_owner_guard() @@ -231,7 +272,7 @@ fn orchestrate_impl(rules: &[&PathCondition], include_package_filter: bool) -> N NamedJob { name, job } } -pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { +pub fn tests_pass(jobs: &[NamedJob], extra_job_names: &[&str]) -> NamedJob { let mut script = String::from(indoc::indoc! {r#" set +x EXIT_CODE=0 @@ -243,20 +284,26 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { "#}); - let env_entries: Vec<_> = jobs + let all_names: Vec<&str> = jobs .iter() - .map(|job| { - let env_name = format!("RESULT_{}", job.name.to_uppercase()); - let env_value = format!("${{{{ needs.{}.result }}}}", job.name); + .map(|job| job.name.as_str()) + .chain(extra_job_names.iter().copied()) + .collect(); + + let env_entries: Vec<_> = all_names + .iter() + .map(|name| { + let env_name = format!("RESULT_{}", name.to_uppercase()); + let env_value = format!("${{{{ needs.{}.result }}}}", name); (env_name, env_value) }) .collect(); script.push_str( - &jobs + &all_names .iter() .zip(env_entries.iter()) - .map(|(job, (env_name, _))| format!("check_result \"{}\" \"${}\"", job.name, env_name)) + .map(|(name, (env_name, _))| format!("check_result \"{}\" \"${}\"", name, env_name)) .collect::>() .join("\n"), ); @@ -266,8 +313,9 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { let job = Job::default() .runs_on(runners::LINUX_SMALL) .needs( - jobs.iter() - .map(|j| j.name.to_string()) + all_names + .iter() + .map(|name| name.to_string()) .collect::>(), ) .cond(repository_owner_guard_expression(true)) @@ -282,6 +330,19 @@ pub fn tests_pass(jobs: &[NamedJob]) -> NamedJob { named::job(job) } +/// Bash script snippet that detects changed extension directories from `$CHANGED_FILES`. +/// Assumes `$CHANGED_FILES` is already set. Sets `$EXTENSIONS_JSON` to a JSON array of +/// changed extension paths. Callers are responsible for writing the result to `$GITHUB_OUTPUT`. +pub(crate) const DETECT_CHANGED_EXTENSIONS_SCRIPT: &str = indoc::indoc! {r#" + # Detect changed extension directories (excluding extensions/workflows) + CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true) + if [ -n "$CHANGED_EXTENSIONS" ]; then + EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))') + else + EXTENSIONS_JSON="[]" + fi +"#}; + const TS_QUERY_LS_FILE: &str = "ts_query_ls-x86_64-unknown-linux-gnu.tar.gz"; const CI_TS_QUERY_RELEASE: &str = "tags/v3.15.1"; @@ -298,8 +359,8 @@ pub(crate) fn fetch_ts_query_ls() -> Step { pub(crate) fn run_ts_query_ls() -> Step { named::bash(formatdoc!( - r#"tar -xf {TS_QUERY_LS_FILE} - ./ts_query_ls format --check . || {{ + r#"tar -xf "$GITHUB_WORKSPACE/{TS_QUERY_LS_FILE}" -C "$GITHUB_WORKSPACE" + "$GITHUB_WORKSPACE/ts_query_ls" format --check . || {{ echo "Found unformatted queries, please format them with ts_query_ls." echo "For easy use, install the Tree-sitter query extension:" echo "zed://extension/tree-sitter-query" @@ -692,3 +753,26 @@ pub(crate) fn check_scripts() -> NamedJob { .add_step(check_xtask_workflows()), ) } + +fn extension_tests() -> NamedJob { + let job = Job::default() + .needs(vec!["orchestrate".to_owned()]) + .cond(Expression::new( + "needs.orchestrate.outputs.changed_extensions != '[]'", + )) + .permissions(Permissions::default().contents(Level::Read)) + .strategy( + Strategy::default() + .fail_fast(false) + // TODO: Remove the limit. We currently need this to workaround the concurrency group issue + // where different matrix jobs would be placed in the same concurrency group and thus cancelled. + .max_parallel(1u32) + .matrix(json!({ + "extension": "${{ fromJson(needs.orchestrate.outputs.changed_extensions) }}" + })), + ) + .uses_local(".github/workflows/extension_tests.yml") + .with(Input::default().add("working-directory", "${{ matrix.extension }}")); + + named::job(job) +} diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 6bede217b74a1172db712b92ed3d50cd2af603b2..fbe7ef66a331e2e7b84c1b4be7af3482f2b1ce95 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -10,7 +10,7 @@ pub(crate) fn use_clang(job: Job) -> Job { const SCCACHE_R2_BUCKET: &str = "sccache-zed"; -const BASH_SHELL: &str = "bash -euxo pipefail {0}"; +pub(crate) const BASH_SHELL: &str = "bash -euxo pipefail {0}"; // https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell pub const PWSH_SHELL: &str = "pwsh"; @@ -24,13 +24,6 @@ pub(crate) fn cargo_nextest(platform: Platform) -> Nextest { } impl Nextest { - pub(crate) fn with_target(mut self, target: &str) -> Step { - if let Some(nextest_command) = self.0.value.run.as_mut() { - nextest_command.push_str(&format!(r#" --target "{target}""#)); - } - self.into() - } - #[allow(dead_code)] pub(crate) fn with_filter_expr(mut self, filter_expr: &str) -> Self { if let Some(nextest_command) = self.0.value.run.as_mut() { diff --git a/tooling/xtask/src/tasks/workflows/vars.rs b/tooling/xtask/src/tasks/workflows/vars.rs index aa8fb0a4056a53807cd4b2f12f331cb9d4d0a235..b3f8bdf56e9bb0f93f81992fbc61dab2b9754e63 100644 --- a/tooling/xtask/src/tasks/workflows/vars.rs +++ b/tooling/xtask/src/tasks/workflows/vars.rs @@ -156,14 +156,31 @@ pub(crate) struct StepOutput { impl StepOutput { pub fn new(step: &Step, name: &'static str) -> Self { - Self { - name, - step_id: step - .value - .id - .clone() - .expect("Steps that produce outputs must have an ID"), - } + let step_id = step + .value + .id + .clone() + .expect("Steps that produce outputs must have an ID"); + + assert!( + step.value + .run + .as_ref() + .is_none_or(|run_command| run_command.contains(name)), + "Step Output name {name} must occur at least once in run command with ID {step_id}!" + ); + + Self { name, step_id } + } + + pub fn new_unchecked(step: &Step, name: &'static str) -> Self { + let step_id = step + .value + .id + .clone() + .expect("Steps that produce outputs must have an ID"); + + Self { name, step_id } } pub fn expr(&self) -> String {