diff --git a/.cargo/ci-config.toml b/.cargo/ci-config.toml index b31b79a59b262a5cc18cf1d2b32124a97bab4fc7..6a5feece648a39be39e99fa3eb5807713b911348 100644 --- a/.cargo/ci-config.toml +++ b/.cargo/ci-config.toml @@ -15,14 +15,4 @@ rustflags = ["-D", "warnings"] [profile.dev] debug = "limited" -# Use Mold on Linux, because it's faster than GNU ld and LLD. -# -# We no longer set this in the default `config.toml` so that developers can opt in to Wild, which -# is faster than Mold, in their own ~/.cargo/config.toml. -[target.x86_64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] -[target.aarch64-unknown-linux-gnu] -linker = "clang" -rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.cargo/config.toml b/.cargo/config.toml index 9b2e6f51c96e3ae98a54bbb11524210911d0e262..a9bf1f9cc975cf812605e88379def0ab334f76ad 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -16,5 +16,9 @@ rustflags = [ "target-feature=+crt-static", # This fixes the linking issue when compiling livekit on Windows ] +# We need lld to link libwebrtc.a successfully on aarch64-linux +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-fuse-ld=lld"] + [env] MACOSX_DEPLOYMENT_TARGET = "10.15.7" diff --git a/.github/DISCUSSION_TEMPLATE/feature-requests.yml b/.github/DISCUSSION_TEMPLATE/feature-requests.yml index 183a3de934eccc8baa8428e822176e31d1d11782..e8a695063c34771ac6120b1e477b7494a17aa3c9 100644 --- a/.github/DISCUSSION_TEMPLATE/feature-requests.yml +++ b/.github/DISCUSSION_TEMPLATE/feature-requests.yml @@ -40,4 +40,4 @@ body: attributes: value: | Learn more about how feature requests work in our - [Feature Request Guidelines](https://github.com/zed-industries/zed/discussions/47963). + [Feature Request Guidelines](https://github.com/zed-industries/zed/discussions/51422). diff --git a/.github/workflows/assign-reviewers.yml b/.github/workflows/assign-reviewers.yml new file mode 100644 index 0000000000000000000000000000000000000000..a77f1812d06330b4635fe173583f0f1ce93e4e17 --- /dev/null +++ b/.github/workflows/assign-reviewers.yml @@ -0,0 +1,81 @@ +# Assign Reviewers — Smart team assignment based on diff weight +# +# Triggers on PR open and ready_for_review events. Checks out the coordinator +# repo (zed-industries/codeowner-coordinator) to access the assignment script and rules, +# then assigns the 1-2 most relevant teams as reviewers. +# +# NOTE: This file is stored in the codeowner-coordinator repo but must be deployed to +# the zed repo at .github/workflows/assign-reviewers.yml. See INSTALL.md. +# +# AUTH NOTE: Uses a GitHub App (COORDINATOR_APP_ID + COORDINATOR_APP_PRIVATE_KEY) +# for all API operations: cloning the private coordinator repo, requesting team +# reviewers, and setting PR assignees. GITHUB_TOKEN is not used. + +name: Assign Reviewers + +on: + pull_request: + types: [opened, ready_for_review] + +# GITHUB_TOKEN is not used — all operations use the GitHub App token. +# Declare minimal permissions so the default token has no write access. +permissions: {} + +# Only run for PRs from within the org (not forks) — fork PRs don't have +# write access to request team reviewers. +jobs: + assign-reviewers: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.draft == false && + contains(fromJSON('["MEMBER", "OWNER"]'), github.event.pull_request.author_association) + runs-on: ubuntu-latest + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ vars.COORDINATOR_APP_ID }} + private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }} + repositories: codeowner-coordinator,zed + + - name: Checkout coordinator repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + repository: zed-industries/codeowner-coordinator + ref: main + path: codeowner-coordinator + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + + - name: Install dependencies + run: pip install pyyaml==6.0.3 + + - name: Assign reviewers + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_URL: ${{ github.event.pull_request.html_url }} + TARGET_REPO: ${{ github.repository }} + run: | + cd codeowner-coordinator + python .github/scripts/assign-reviewers.py \ + --pr "$PR_URL" \ + --apply \ + --rules-file team-membership-rules.yml \ + --repo "$TARGET_REPO" \ + --org zed-industries \ + --min-association member \ + 2>&1 | tee /tmp/assign-reviewers-output.txt + + - name: Upload output + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: assign-reviewers-output + path: /tmp/assign-reviewers-output.txt + retention-days: 30 diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 1fa271d168a8c3d1744439647ff50b793a854d1d..1f9e6320700d14cab69662e317c30fa7206eb655 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -37,8 +37,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_pnpm diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 480d8b0ada98e859d2e72b49a39805ffe8f72b25..62540321ed755f2fd3879a7ddfc3a37237d8e7de 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -23,8 +23,8 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false - token: ${{ steps.get-app-token.outputs.token }} ref: ${{ inputs.branch }} + token: ${{ steps.get-app-token.outputs.token }} - name: bump_patch_version::run_bump_patch_version::bump_patch_version run: | channel="$(cat crates/zed/RELEASE_CHANNEL)" diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml index f7d78dbbf6a6d04bc47212b6842f894850288fcc..03113f2aa0be4dc794f8f5edec18df22fb0daa31 100644 --- a/.github/workflows/compare_perf.yml +++ b/.github/workflows/compare_perf.yml @@ -30,8 +30,6 @@ jobs: cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: compare_perf::run_perf::install_hyperfine diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 89fb6980b65f2d09a6571f140ab016a710be230f..7fe06460f752599513c79b71bb01636d69d20e6c 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -12,6 +12,9 @@ jobs: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') name: Check formatting and Clippy lints runs-on: namespace-profile-16x32-ubuntu-2204 + env: + CC: clang + CXX: clang++ steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 @@ -29,8 +32,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_fmt @@ -42,6 +43,9 @@ jobs: - style name: Run tests runs-on: namespace-profile-16x32-ubuntu-2204 + env: + CC: clang + CXX: clang++ steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 @@ -59,8 +63,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest 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..c971bc2ab096cd54089558a6a19875cb66f03918 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 @@ -77,6 +83,11 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + path: ~/.rustup - name: extension_bump::install_bump_2_version run: pip install bump2version --break-system-packages - id: bump-version @@ -98,25 +109,52 @@ 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<> "$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 token: ${{ steps.generate-token.outputs.token }} sign-commits: true assignees: ${{ github.actor }} - timeout-minutes: 3 + timeout-minutes: 5 + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} create_version_label: needs: - check_version_changed @@ -133,6 +171,21 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false + - id: determine-tag + name: extension_bump::determine_tag + run: | + EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + + if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then + TAG="v${CURRENT_VERSION}" + else + TAG="${EXTENSION_ID}-v${CURRENT_VERSION}" + fi + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + env: + CURRENT_VERSION: ${{ needs.check_version_changed.outputs.current_version }} + WORKING_DIR: ${{ inputs.working-directory }} - name: extension_bump::create_version_tag uses: actions/github-script@v7 with: @@ -140,11 +193,17 @@ jobs: github.rest.git.createRef({ owner: context.repo.owner, repo: context.repo.repo, - ref: 'refs/tags/v${{ needs.check_version_changed.outputs.current_version }}', + ref: 'refs/tags/${{ steps.determine-tag.outputs.tag }}', sha: context.sha }) github-token: ${{ steps.generate-token.outputs.token }} + outputs: + tag: ${{ steps.determine-tag.outputs.tag }} timeout-minutes: 1 + defaults: + run: + shell: bash -euxo pipefail {0} + working-directory: ${{ inputs.working-directory }} trigger_release: needs: - check_version_changed @@ -171,15 +230,19 @@ jobs: echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT" - name: extension_bump::release_action - uses: huacnlee/zed-extension-action@v2 + uses: zed-extensions/update-action@1ef53b23be40fe2549be0baffaa98e9f51838fef with: extension-name: ${{ steps.get-extension-id.outputs.extension_id }} push-to: zed-industries/extensions - tag: v${{ needs.check_version_changed.outputs.current_version }} + tag: ${{ needs.create_version_label.outputs.tag }} 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/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index 9bfac06d4527985553ba3d04e64c656ee5bf85e4..f695b43ecac47a221bbc795d03e6ddd6259d7014 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -4,12 +4,57 @@ name: extension_workflow_rollout env: CARGO_TERM_COLOR: always on: - workflow_dispatch: {} + workflow_dispatch: + inputs: + filter-repos: + description: Comma-separated list of repository names to rollout to. Leave empty for all repos. + type: string + default: '' + change-description: + description: Description for the changes to be expected with this rollout + type: string + default: '' jobs: fetch_extension_repos: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' runs-on: namespace-profile-2x4-ubuntu-2404 steps: + - name: checkout_zed_repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + clean: false + fetch-depth: 0 + - id: prev-tag + name: extension_workflow_rollout::fetch_extension_repos::get_previous_tag_commit + run: | + PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "") + if [ -z "$PREV_COMMIT" ]; then + echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes." + exit 1 + fi + echo "Found previous rollout at commit: $PREV_COMMIT" + echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" + - id: calc-changes + name: extension_workflow_rollout::fetch_extension_repos::get_removed_files + run: | + for workflow_type in "ci" "shared"; do + if [ "$workflow_type" = "ci" ]; then + WORKFLOW_DIR="extensions/workflows" + else + WORKFLOW_DIR="extensions/workflows/shared" + fi + + REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ + awk '/^D/ { print $2 } /^R/ { print $2 }' | \ + xargs -I{} basename {} 2>/dev/null | \ + tr '\n' ' ' || echo "") + REMOVED=$(echo "$REMOVED" | xargs) + + echo "Removed files for $workflow_type: $REMOVED" + echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT" + done + env: + PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }} - id: list-repos name: extension_workflow_rollout::fetch_extension_repos::get_repositories uses: actions/github-script@v7 @@ -21,16 +66,42 @@ jobs: per_page: 100, }); - const filteredRepos = repos + let filteredRepos = repos .filter(repo => !repo.archived) .map(repo => repo.name); + const filterInput = `${{ inputs.filter-repos }}`.trim(); + if (filterInput.length > 0) { + const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0); + filteredRepos = filteredRepos.filter(name => allowedNames.includes(name)); + console.log(`Filter applied. Matched ${filteredRepos.length} repos from ${allowedNames.length} requested.`); + } + console.log(`Found ${filteredRepos.length} extension repos`); return filteredRepos; result-encoding: json + - name: steps::cache_rust_dependencies_namespace + uses: namespacelabs/nscloud-cache-action@v1 + with: + cache: rust + path: ~/.rustup + - name: extension_workflow_rollout::fetch_extension_repos::generate_workflow_files + run: | + cargo xtask workflows "$COMMIT_SHA" + env: + COMMIT_SHA: ${{ github.sha }} + - name: extension_workflow_rollout::fetch_extension_repos::upload_workflow_files + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 + with: + name: extension-workflow-files + path: extensions/workflows/**/*.yml + if-no-files-found: error outputs: repos: ${{ steps.list-repos.outputs.result }} - timeout-minutes: 5 + prev_commit: ${{ steps.prev-tag.outputs.prev_commit }} + removed_ci: ${{ steps.calc-changes.outputs.removed_ci }} + removed_shared: ${{ steps.calc-changes.outputs.removed_shared }} + timeout-minutes: 10 rollout_workflows_to_extension: needs: - fetch_extension_repos @@ -53,59 +124,28 @@ jobs: permission-pull-requests: write permission-contents: write permission-workflows: write - - name: checkout_zed_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - with: - clean: false - fetch-depth: 0 - path: zed - name: checkout_extension_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false - token: ${{ steps.generate-token.outputs.token }} path: extension repository: zed-extensions/${{ matrix.repo }} - - id: prev-tag - name: extension_workflow_rollout::rollout_workflows_to_extension::get_previous_tag_commit - run: | - PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "") - if [ -z "$PREV_COMMIT" ]; then - echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes." - exit 1 - fi - echo "Found previous rollout at commit: $PREV_COMMIT" - echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" - working-directory: zed - - id: calc-changes - name: extension_workflow_rollout::rollout_workflows_to_extension::get_removed_files + token: ${{ steps.generate-token.outputs.token }} + - name: extension_workflow_rollout::rollout_workflows_to_extension::download_workflow_files + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 + with: + name: extension-workflow-files + path: workflow-files + - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files run: | + mkdir -p extension/.github/workflows + if [ "$MATRIX_REPO" = "workflows" ]; then - WORKFLOW_DIR="extensions/workflows" + REMOVED_FILES="$REMOVED_CI" else - WORKFLOW_DIR="extensions/workflows/shared" + REMOVED_FILES="$REMOVED_SHARED" fi - echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR" - - # Get deleted files (status D) and renamed files (status R - old name needs removal) - # Using -M to detect renames, then extracting files that are gone from their original location - REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ - awk '/^D/ { print $2 } /^R/ { print $2 }' | \ - xargs -I{} basename {} 2>/dev/null | \ - tr '\n' ' ' || echo "") - - REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs) - - echo "Files to remove: $REMOVED_FILES" - echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT" - env: - PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }} - MATRIX_REPO: ${{ matrix.repo }} - working-directory: zed - - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files - run: | - mkdir -p extension/.github/workflows cd extension/.github/workflows if [ -n "$REMOVED_FILES" ]; then @@ -119,18 +159,18 @@ jobs: cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp zed/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/*.yml extension/.github/workflows/ else - cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/shared/*.yml extension/.github/workflows/ fi env: - REMOVED_FILES: ${{ steps.calc-changes.outputs.removed_files }} + REMOVED_CI: ${{ needs.fetch_extension_repos.outputs.removed_ci }} + REMOVED_SHARED: ${{ needs.fetch_extension_repos.outputs.removed_shared }} MATRIX_REPO: ${{ matrix.repo }} - id: short-sha name: extension_workflow_rollout::rollout_workflows_to_extension::get_short_sha run: | - echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" - working-directory: zed + echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" - id: create-pr name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request uses: peter-evans/create-pull-request@v7 @@ -140,6 +180,8 @@ jobs: body: | This PR updates the CI workflow files from the main Zed repository based on the commit zed-industries/zed@${{ github.sha }} + + ${{ inputs.change-description }} commit-message: Update CI workflows to `${{ steps.short-sha.outputs.sha_short }}` branch: update-workflows committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> @@ -151,16 +193,17 @@ jobs: - name: extension_workflow_rollout::rollout_workflows_to_extension::enable_auto_merge run: | if [ -n "$PR_NUMBER" ]; then - cd extension gh pr merge "$PR_NUMBER" --auto --squash fi env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }} + working-directory: extension timeout-minutes: 10 create_rollout_tag: needs: - rollout_workflows_to_extension + if: inputs.filter-repos == '' runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token diff --git a/.github/workflows/pr_labeler.yml b/.github/workflows/pr_labeler.yml index cc9c4a9eefd4aa75ba69fb18b353efa6a32778c5..ce5b5f39e6769de8f793b2effd0dee73b2c7d2b8 100644 --- a/.github/workflows/pr_labeler.yml +++ b/.github/workflows/pr_labeler.yml @@ -1,5 +1,6 @@ # Labels pull requests by author: 'bot' for bot accounts, 'staff' for -# staff team members, 'first contribution' for first-time external contributors. +# staff team members, 'guild' for guild members, 'first contribution' for +# first-time external contributors. name: PR Labeler on: @@ -29,8 +30,47 @@ jobs: script: | const BOT_LABEL = 'bot'; const STAFF_LABEL = 'staff'; + const GUILD_LABEL = 'guild'; const FIRST_CONTRIBUTION_LABEL = 'first contribution'; const STAFF_TEAM_SLUG = 'staff'; + const GUILD_MEMBERS = [ + '11happy', + 'AidanV', + 'AmaanBilwar', + 'OmChillure', + 'Palanikannan1437', + 'Shivansh-25', + 'SkandaBhat', + 'TwistingTwists', + 'YEDASAVG', + 'Ziqi-Yang', + 'alanpjohn', + 'arjunkomath', + 'austincummings', + 'ayushk-1801', + 'claiwe', + 'criticic', + 'dongdong867', + 'emamulandalib', + 'eureka928', + 'iam-liam', + 'iksuddle', + 'ishaksebsib', + 'lingyaochu', + 'marcocondrache', + 'mchisolm0', + 'nairadithya', + 'nihalxkumar', + 'notJoon', + 'polyesterswing', + 'prayanshchh', + 'razeghi71', + 'sarmadgulzar', + 'seanstrom', + 'th0jensen', + 'tommyming', + 'virajbhartiya', + ]; const pr = context.payload.pull_request; const author = pr.user.login; @@ -71,6 +111,17 @@ jobs: return; } + if (GUILD_MEMBERS.includes(author)) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [GUILD_LABEL] + }); + console.log(`PR #${pr.number} by ${author}: labeled '${GUILD_LABEL}' (guild member)`); + // No early return: guild members can also get 'first contribution' + } + // We use inverted logic here due to a suspected GitHub bug where first-time contributors // get 'NONE' instead of 'FIRST_TIME_CONTRIBUTOR' or 'FIRST_TIMER'. // https://github.com/orgs/community/discussions/78038 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8adad5cfba278dc68dd227b86455510278c7a1ae..07a0a6d672a0a66c9c1609e82a22af9034dc936e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,8 +72,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_node @@ -199,8 +197,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_sccache @@ -318,8 +314,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux @@ -360,8 +354,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 46d8732b08ea658275e1fb21117a09b9e0668933..093a17e8760e52fc4278d56dd6144b6a0432f3c5 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -122,8 +122,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux @@ -170,8 +168,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux diff --git a/.github/workflows/run_agent_evals.yml b/.github/workflows/run_agent_evals.yml index c506039ce7c1863bd3c60091beb78d5239110bbd..56cbd17a197200a6764ed1e28c87e90740cd7deb 100644 --- a/.github/workflows/run_agent_evals.yml +++ b/.github/workflows/run_agent_evals.yml @@ -34,8 +34,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_cargo_config diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index 7cb1665f9d0bd4fe3b0f3c05527bf39aab5f610a..5a93cf074e2a2d7f2f3cf8418ed508c5ad359d9e 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -32,8 +32,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux @@ -73,8 +71,6 @@ jobs: token: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/bundle-linux diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index 2a204a9d40d78bf52f38825b4db060216e348a87..6af46e678d3d629cc2f7973b8b31ee99477dfefc 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -35,8 +35,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 00d69639a53868386157e67aeab5ce7383d32426..fd7fecb4eb0309b7cc53c6efe0d2f2ece5f2a228 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" @@ -209,8 +218,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_sccache @@ -322,8 +329,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_node @@ -421,8 +426,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_cargo_config @@ -471,8 +474,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::setup_sccache @@ -597,8 +598,6 @@ jobs: jobSummary: false - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: ./script/generate-action-metadata @@ -711,6 +710,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 +741,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 +770,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 +789,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/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml index 2259d2498b76f3627e6784f55023e2fbfe855cbb..44f12a1886bdac2fa1da8c870d223dd358285658 100644 --- a/.github/workflows/run_unit_evals.yml +++ b/.github/workflows/run_unit_evals.yml @@ -38,8 +38,6 @@ jobs: path: ~/.rustup - name: steps::setup_linux run: ./script/linux - - name: steps::install_mold - run: ./script/install-mold - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest diff --git a/Cargo.lock b/Cargo.lock index 9c1cb134fea8a3518a28527de5a811aaa7a33155..a02c4f9f8cf944e0a2c5e2082f9e6ec942e50afa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,7 +86,6 @@ dependencies = [ "smol", "task", "telemetry", - "tempfile", "terminal", "text", "ui", @@ -95,7 +94,6 @@ dependencies = [ "util", "uuid", "watch", - "zlog", ] [[package]] @@ -129,7 +127,6 @@ dependencies = [ "fs", "futures 0.3.31", "gpui", - "indoc", "language", "log", "pretty_assertions", @@ -158,7 +155,6 @@ dependencies = [ "language", "project", "proto", - "release_channel", "smallvec", "ui", "util", @@ -264,11 +260,9 @@ dependencies = [ "task", "telemetry", "tempfile", - "terminal", "text", "theme", "thiserror 2.0.17", - "tree-sitter-rust", "ui", "unindent", "url", @@ -276,7 +270,6 @@ dependencies = [ "uuid", "watch", "web_search", - "worktree", "zed_env_vars", "zlog", "zstd", @@ -284,9 +277,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2659b1089101b15db31137710159421cb44785ecdb5ba784be3b4a6f8cb8a475" +checksum = "9c56a59cf6315e99f874d2c1f96c69d2da5ffe0087d211297fc4a41f849770a2" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -301,16 +294,16 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.10.8" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1" +checksum = "e0497b9a95a404e35799904835c57c6f8c69b9d08ccfd3cb5b7d746425cd6789" dependencies = [ "anyhow", "derive_more", "schemars", "serde", "serde_json", - "strum 0.27.2", + "strum 0.28.0", ] [[package]] @@ -335,7 +328,6 @@ dependencies = [ "gpui_tokio", "http_client", "indoc", - "language", "language_model", "libc", "log", @@ -369,7 +361,6 @@ dependencies = [ "gpui", "language_model", "log", - "paths", "project", "regex", "schemars", @@ -402,7 +393,6 @@ dependencies = [ "buffer_diff", "chrono", "client", - "clock", "cloud_api_types", "cloud_llm_client", "collections", @@ -448,9 +438,7 @@ dependencies = [ "prompt_store", "proto", "rand 0.9.2", - "recent_projects", "release_channel", - "remote_connection", "reqwest_client", "rope", "rules_library", @@ -465,14 +453,12 @@ dependencies = [ "streaming_diff", "task", "telemetry", - "tempfile", "terminal", "terminal_view", "text", "theme", "time", "time_format", - "title_bar", "tree-sitter-md", "ui", "ui_input", @@ -721,17 +707,13 @@ dependencies = [ "anyhow", "chrono", "futures 0.3.31", - "gpui", - "gpui_tokio", "http_client", - "reqwest_client", "schemars", "serde", "serde_json", "settings", "strum 0.27.2", "thiserror 2.0.17", - "tokio", ] [[package]] @@ -943,7 +925,6 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", - "indoc", "itertools 0.14.0", "language", "language_model", @@ -2299,7 +2280,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", @@ -2407,7 +2388,6 @@ dependencies = [ "pretty_assertions", "rand 0.9.2", "rope", - "serde_json", "settings", "sum_tree", "text", @@ -2566,7 +2546,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", @@ -2591,7 +2571,6 @@ dependencies = [ "futures 0.3.31", "gpui", "gpui_tokio", - "http_client", "language", "livekit_client", "log", @@ -3186,8 +3165,6 @@ name = "cloud_llm_client" version = "0.1.0" dependencies = [ "anyhow", - "indoc", - "pretty_assertions", "serde", "serde_json", "strum 0.27.2", @@ -3312,6 +3289,7 @@ dependencies = [ "serde", "serde_json", "text", + "zeta_prompt", ] [[package]] @@ -3319,15 +3297,11 @@ name = "collab" version = "0.44.0" dependencies = [ "agent", - "agent-client-protocol", - "agent_settings", - "agent_ui", "anyhow", "assistant_slash_command", "assistant_text_thread", "async-trait", "async-tungstenite", - "audio", "aws-config", "aws-sdk-kinesis", "aws-sdk-s3", @@ -3343,10 +3317,8 @@ dependencies = [ "collab_ui", "collections", "command_palette_hooks", - "context_server", "ctor", "dap", - "dap-types", "dap_adapters", "dashmap", "debugger_ui", @@ -3363,7 +3335,6 @@ dependencies = [ "gpui_tokio", "hex", "http_client", - "hyper 0.14.32", "indoc", "language", "language_model", @@ -3405,7 +3376,6 @@ dependencies = [ "text", "theme", "time", - "title_bar", "tokio", "toml 0.8.23", "tower 0.4.13", @@ -3436,12 +3406,10 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", - "http_client", "log", "menu", "notifications", "picker", - "pretty_assertions", "project", "release_channel", "rpc", @@ -3454,7 +3422,6 @@ dependencies = [ "time", "time_format", "title_bar", - "tree-sitter-md", "ui", "util", "workspace", @@ -3508,10 +3475,8 @@ dependencies = [ "client", "collections", "command_palette_hooks", - "ctor", "db", "editor", - "env_logger 0.11.8", "fuzzy", "go_to_line", "gpui", @@ -3522,7 +3487,6 @@ dependencies = [ "postage", "project", "serde", - "serde_json", "settings", "telemetry", "theme", @@ -3745,18 +3709,14 @@ version = "0.1.0" dependencies = [ "anyhow", "async-std", - "client", - "clock", "collections", "command_palette_hooks", "copilot_chat", - "ctor", "edit_prediction_types", "editor", "fs", "futures 0.3.31", "gpui", - "http_client", "icons", "indoc", "language", @@ -4594,8 +4554,6 @@ dependencies = [ "smol", "task", "telemetry", - "tree-sitter", - "tree-sitter-go", "util", "zlog", ] @@ -4642,8 +4600,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]] @@ -4660,13 +4628,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", ] @@ -4937,11 +4930,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", @@ -4966,7 +4959,6 @@ dependencies = [ "serde_json", "settings", "smol", - "theme", "ui", "util", "workspace", @@ -4978,7 +4970,6 @@ name = "diagnostics" version = "0.1.0" dependencies = [ "anyhow", - "client", "collections", "component", "ctor", @@ -5371,7 +5362,6 @@ dependencies = [ "thiserror 2.0.17", "time", "toml 0.8.23", - "tree-sitter-rust", "ui", "util", "uuid", @@ -5469,7 +5459,6 @@ dependencies = [ "tree-sitter", "util", "zeta_prompt", - "zlog", ] [[package]] @@ -5490,7 +5479,6 @@ dependencies = [ "anyhow", "buffer_diff", "client", - "clock", "cloud_llm_client", "codestral", "collections", @@ -5507,18 +5495,12 @@ dependencies = [ "gpui", "indoc", "language", - "language_model", - "lsp", "markdown", "menu", "multi_buffer", "paths", - "pretty_assertions", "project", "regex", - "release_channel", - "semver", - "serde_json", "settings", "telemetry", "text", @@ -5529,7 +5511,6 @@ dependencies = [ "workspace", "zed_actions", "zeta_prompt", - "zlog", ] [[package]] @@ -5558,7 +5539,6 @@ dependencies = [ "fuzzy", "git", "gpui", - "http_client", "indoc", "itertools 0.14.0", "language", @@ -5591,7 +5571,6 @@ dependencies = [ "sum_tree", "task", "telemetry", - "tempfile", "text", "theme", "time", @@ -6208,7 +6187,6 @@ dependencies = [ "parking_lot", "paths", "project", - "rand 0.9.2", "release_channel", "remote", "reqwest_client", @@ -6356,7 +6334,6 @@ dependencies = [ name = "feature_flags" version = "0.1.0" dependencies = [ - "futures 0.3.31", "gpui", ] @@ -6364,7 +6341,6 @@ dependencies = [ name = "feedback" version = "0.1.0" dependencies = [ - "editor", "gpui", "system_specs", "urlencoding", @@ -6388,6 +6364,8 @@ name = "file_finder" version = "0.1.0" dependencies = [ "anyhow", + "channel", + "client", "collections", "ctor", "editor", @@ -6395,17 +6373,16 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", - "language", "menu", "open_path_prompt", "picker", "pretty_assertions", "project", "project_panel", + "remote_connection", "serde", "serde_json", "settings", - "text", "theme", "ui", "util", @@ -6692,6 +6669,7 @@ dependencies = [ "async-trait", "cocoa 0.26.0", "collections", + "dunce", "fs", "futures 0.3.31", "git", @@ -7287,7 +7265,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", @@ -7298,13 +7276,13 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "strum_macros", + "strum_macros 0.27.2", ] [[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", @@ -7381,7 +7359,6 @@ dependencies = [ "text", "thiserror 2.0.17", "time", - "unindent", "url", "urlencoding", "util", @@ -7418,7 +7395,6 @@ dependencies = [ "menu", "project", "rand 0.9.2", - "recent_projects", "serde_json", "settings", "smallvec", @@ -7466,10 +7442,10 @@ dependencies = [ "db", "editor", "feature_flags", + "file_icons", "futures 0.3.31", "fuzzy", "git", - "git_hosting_providers", "gpui", "indoc", "itertools 0.14.0", @@ -7631,6 +7607,7 @@ dependencies = [ "indoc", "language", "menu", + "multi_buffer", "project", "rope", "serde", @@ -7638,8 +7615,6 @@ dependencies = [ "settings", "text", "theme", - "tree-sitter-rust", - "tree-sitter-typescript", "ui", "util", "workspace", @@ -7771,7 +7746,6 @@ dependencies = [ "pin-project", "pollster 0.4.0", "postage", - "pretty_assertions", "profiling", "proptest", "rand 0.9.2", @@ -9562,7 +9536,6 @@ dependencies = [ "aws_http_client", "base64 0.22.1", "bedrock", - "chrono", "client", "cloud_api_types", "cloud_llm_client", @@ -9574,7 +9547,6 @@ dependencies = [ "copilot_ui", "credentials_provider", "deepseek", - "editor", "extension", "extension_host", "fs", @@ -9594,7 +9566,6 @@ dependencies = [ "open_router", "partial-json-fixer", "pretty_assertions", - "project", "release_channel", "schemars", "semver", @@ -9722,7 +9693,6 @@ dependencies = [ "snippet", "task", "terminal", - "text", "theme", "toml 0.8.23", "tree-sitter", @@ -9746,7 +9716,6 @@ dependencies = [ "unindent", "url", "util", - "workspace", ] [[package]] @@ -9880,7 +9849,7 @@ dependencies = [ [[package]] name = "libwebrtc" version = "0.3.26" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "cxx", "glib", @@ -9978,7 +9947,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "livekit" version = "0.7.32" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "base64 0.22.1", "bmrng", @@ -10004,7 +9973,7 @@ dependencies = [ [[package]] name = "livekit-api" version = "0.4.14" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "base64 0.21.7", "futures-util", @@ -10031,7 +10000,7 @@ dependencies = [ [[package]] name = "livekit-protocol" version = "0.7.1" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "futures-util", "livekit-runtime", @@ -10047,7 +10016,7 @@ dependencies = [ [[package]] name = "livekit-runtime" version = "0.4.0" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "tokio", "tokio-stream", @@ -10100,7 +10069,6 @@ dependencies = [ "serde_json", "serde_urlencoded", "settings", - "sha2", "simplelog", "smallvec", "ui", @@ -10370,6 +10338,7 @@ dependencies = [ "pretty_assertions", "pulldown-cmark 0.13.0", "settings", + "stacksafe", "theme", "ui", "urlencoding", @@ -10845,7 +10814,6 @@ dependencies = [ "log", "parking_lot", "pretty_assertions", - "project", "rand 0.9.2", "rope", "serde", @@ -11123,12 +11091,10 @@ dependencies = [ "anyhow", "channel", "client", - "collections", "component", "db", "gpui", "rpc", - "settings", "sum_tree", "time", "ui", @@ -11879,8 +11845,6 @@ dependencies = [ "settings", "smol", "theme", - "tree-sitter-rust", - "tree-sitter-typescript", "ui", "util", "workspace", @@ -13243,8 +13207,6 @@ dependencies = [ "collections", "context_server", "dap", - "dap_adapters", - "db", "encoding_rs", "extension", "fancy-regex", @@ -13351,7 +13313,6 @@ dependencies = [ "pretty_assertions", "project", "rayon", - "remote_connection", "schemars", "search", "serde", @@ -13585,11 +13546,9 @@ name = "proto" version = "0.1.0" dependencies = [ "anyhow", - "collections", "prost 0.9.0", "prost-build 0.9.0", "serde", - "typed-path", ] [[package]] @@ -14143,7 +14102,6 @@ dependencies = [ "anyhow", "askpass", "chrono", - "dap", "db", "dev_container", "editor", @@ -14392,7 +14350,6 @@ dependencies = [ "collections", "crash-handler", "crashes", - "dap", "dap_adapters", "debug_adapter_extension", "editor", @@ -14424,7 +14381,6 @@ dependencies = [ "paths", "pretty_assertions", "project", - "prompt_store", "proto", "rayon", "release_channel", @@ -14448,7 +14404,6 @@ dependencies = [ "uuid", "watch", "windows 0.61.3", - "workspace", "worktree", "zlog", ] @@ -14482,7 +14437,6 @@ dependencies = [ "collections", "command_palette_hooks", "editor", - "env_logger 0.11.8", "feature_flags", "file_icons", "futures 0.3.31", @@ -14610,7 +14564,6 @@ dependencies = [ "anyhow", "bytes 1.11.1", "futures 0.3.31", - "gpui", "gpui_util", "http_client", "http_client_tls", @@ -14655,20 +14608,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "rich_text" -version = "0.1.0" -dependencies = [ - "futures 0.3.31", - "gpui", - "language", - "linkify", - "pulldown-cmark 0.13.0", - "theme", - "ui", - "util", -] - [[package]] name = "ring" version = "0.17.14" @@ -15484,7 +15423,6 @@ dependencies = [ "any_vec", "anyhow", "bitflags 2.10.0", - "client", "collections", "editor", "fs", @@ -15836,11 +15774,9 @@ dependencies = [ name = "settings_profile_selector" version = "0.1.0" dependencies = [ - "client", "editor", "fuzzy", "gpui", - "language", "menu", "picker", "project", @@ -15859,9 +15795,7 @@ dependencies = [ "agent", "agent_settings", "anyhow", - "assets", "audio", - "client", "codestral", "component", "copilot", @@ -15870,6 +15804,7 @@ dependencies = [ "edit_prediction", "edit_prediction_ui", "editor", + "feature_flags", "fs", "futures 0.3.31", "fuzzy", @@ -15879,13 +15814,11 @@ dependencies = [ "language", "log", "menu", - "node_runtime", "paths", "picker", "platform_title_bar", "pretty_assertions", "project", - "recent_projects", "regex", "release_channel", "rodio", @@ -15893,7 +15826,6 @@ dependencies = [ "search", "serde", "serde_json", - "session", "settings", "shell_command_parser", "strum 0.27.2", @@ -15904,7 +15836,6 @@ dependencies = [ "util", "workspace", "zed_actions", - "zlog", ] [[package]] @@ -16008,32 +15939,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "sidebar" -version = "0.1.0" -dependencies = [ - "acp_thread", - "agent", - "agent-client-protocol", - "agent_ui", - "assistant_text_thread", - "chrono", - "editor", - "feature_flags", - "fs", - "gpui", - "language_model", - "menu", - "project", - "serde_json", - "settings", - "theme", - "ui", - "util", - "workspace", - "zed_actions", -] - [[package]] name = "signal-hook" version = "0.3.18" @@ -16771,7 +16676,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -16786,6 +16700,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -17297,13 +17223,11 @@ dependencies = [ name = "tab_switcher" version = "0.1.0" dependencies = [ - "anyhow", "collections", "ctor", "editor", "fuzzy", "gpui", - "language", "menu", "picker", "project", @@ -17492,7 +17416,6 @@ dependencies = [ "release_channel", "schemars", "serde", - "serde_json", "settings", "smol", "sysinfo 0.37.2", @@ -17524,7 +17447,6 @@ dependencies = [ "assistant_slash_command", "async-recursion", "breadcrumbs", - "client", "collections", "db", "dirs 4.0.0", @@ -17537,7 +17459,6 @@ dependencies = [ "menu", "pretty_assertions", "project", - "rand 0.9.2", "regex", "schemars", "serde", @@ -17562,11 +17483,9 @@ dependencies = [ "collections", "ctor", "gpui", - "http_client", "log", "parking_lot", "postage", - "proptest", "rand 0.9.2", "regex", "rope", @@ -17866,15 +17785,11 @@ dependencies = [ "chrono", "client", "cloud_api_types", - "collections", "db", - "feature_flags", "git_ui", "gpui", - "http_client", "notifications", "platform_title_bar", - "pretty_assertions", "project", "recent_projects", "release_channel", @@ -17888,7 +17803,6 @@ dependencies = [ "story", "telemetry", "theme", - "tree-sitter-md", "ui", "util", "windows 0.61.3", @@ -18718,12 +18632,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "typed-path" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c462d18470a2857aa657d338af5fa67170bb48bcc80a296710ce3b0802a32566" - [[package]] name = "typeid" version = "1.0.3" @@ -19051,7 +18959,6 @@ dependencies = [ "git2", "globset", "gpui_util", - "indoc", "itertools 0.14.0", "libc", "log", @@ -19196,7 +19103,6 @@ name = "vim" version = "0.1.0" dependencies = [ "anyhow", - "assets", "async-compat", "async-trait", "collections", @@ -19236,7 +19142,6 @@ dependencies = [ "task", "text", "theme", - "title_bar", "tokio", "ui", "util", @@ -19944,7 +19849,6 @@ dependencies = [ "futures 0.3.31", "gpui", "parking_lot", - "rand 0.9.2", "zlog", ] @@ -20142,7 +20046,7 @@ dependencies = [ [[package]] name = "webrtc-sys" version = "0.3.23" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "cc", "cxx", @@ -20156,7 +20060,7 @@ dependencies = [ [[package]] name = "webrtc-sys-build" version = "0.3.13" -source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=37835f840d0070d45ac8b31cce6a6ae7aca3f459#37835f840d0070d45ac8b31cce6a6ae7aca3f459" +source = "git+https://github.com/zed-industries/livekit-rust-sdks?rev=cf4375b244ebb51702968df7fc36e192d0f45ad5#cf4375b244ebb51702968df7fc36e192d0f45ad5" dependencies = [ "anyhow", "fs2", @@ -21536,7 +21440,6 @@ dependencies = [ "clock", "collections", "component", - "dap", "db", "feature_flags", "fs", @@ -21589,9 +21492,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "git", - "git2", "gpui", - "http_client", "ignore", "language", "log", @@ -22024,7 +21925,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.228.0" +version = "0.229.0" dependencies = [ "accesskit_unix", "acp_thread", @@ -22062,7 +21963,6 @@ dependencies = [ "copilot_ui", "crashes", "csv_preview", - "dap", "dap_adapters", "db", "debug_adapter_extension", @@ -22150,7 +22050,6 @@ dependencies = [ "settings_profile_selector", "settings_ui", "shellexpand 2.1.2", - "sidebar", "smol", "snippet_provider", "snippets_ui", @@ -22172,8 +22071,6 @@ dependencies = [ "title_bar", "toolchain_selector", "tracing", - "tree-sitter-md", - "tree-sitter-rust", "ui", "ui_prompt", "url", @@ -22355,14 +22252,14 @@ dependencies = [ [[package]] name = "zed_glsl" -version = "0.2.0" +version = "0.2.2" dependencies = [ "zed_extension_api 0.1.0", ] [[package]] name = "zed_html" -version = "0.3.0" +version = "0.3.1" dependencies = [ "zed_extension_api 0.7.0", ] diff --git a/Cargo.toml b/Cargo.toml index e4addac47939b6b60bb96f32e680827975df60e9..abf1f50ae6e43c1cd6a89a2b9666aed4c4a15fbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,7 +159,6 @@ members = [ "crates/remote_server", "crates/repl", "crates/reqwest_client", - "crates/rich_text", "crates/rope", "crates/rpc", "crates/rules_library", @@ -174,7 +173,6 @@ members = [ "crates/settings_profile_selector", "crates/settings_ui", "crates/shell_command_parser", - "crates/sidebar", "crates/snippet", "crates/snippet_provider", "crates/snippets_ui", @@ -413,7 +411,6 @@ rules_library = { path = "crates/rules_library" } scheduler = { path = "crates/scheduler" } search = { path = "crates/search" } session = { path = "crates/session" } -sidebar = { path = "crates/sidebar" } settings = { path = "crates/settings" } settings_content = { path = "crates/settings_content" } settings_json = { path = "crates/settings_json" } @@ -478,7 +475,7 @@ ztracing_macro = { path = "crates/ztracing_macro" } accesskit = "0.24.0" accesskit_unix = "0.20.0" # todo! feature flag -agent-client-protocol = { version = "=0.9.4", features = ["unstable"] } +agent-client-protocol = { version = "=0.10.2", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" } any_vec = "0.14" @@ -516,7 +513,6 @@ aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" -bincode = "1.2.1" bitflags = "2.6.0" brotli = "8.0.2" bytes = "1.0" @@ -554,6 +550,7 @@ derive_more = { version = "2.1.1", features = [ dirs = "4.0" documented = "0.9.1" dotenvy = "0.15.0" +dunce = "1.0" ec4rs = "1.1" emojis = "0.6.1" env_logger = "0.11" @@ -564,7 +561,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" @@ -575,7 +572,6 @@ human_bytes = "0.4.1" html5ever = "0.27.0" http = "1.1" http-body = "1.0" -hyper = "0.14" ignore = "0.4.22" image = "0.25.1" imara-diff = "0.1.8" @@ -693,7 +689,6 @@ serde_json_lenient = { version = "0.2", features = [ "raw_value", ] } serde_path_to_error = "0.1.17" -serde_repr = "0.1" serde_urlencoded = "0.7" sha2 = "0.10" shellexpand = "2.1.0" @@ -724,7 +719,6 @@ time = { version = "0.3", features = [ ] } tiny_http = "0.8" tokio = { version = "1" } -tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] } tokio-socks = { version = "0.5.2", default-features = false, features = [ "futures-io", "tokio", @@ -853,8 +847,8 @@ notify = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24c notify-types = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24cad542c28e04ced02e20325a4ec28a31d" } windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } calloop = { git = "https://github.com/zed-industries/calloop" } -livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "37835f840d0070d45ac8b31cce6a6ae7aca3f459" } -libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "37835f840d0070d45ac8b31cce6a6ae7aca3f459" } +livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "cf4375b244ebb51702968df7fc36e192d0f45ad5" } +libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "cf4375b244ebb51702968df7fc36e192d0f45ad5" } [profile.dev] split-debuginfo = "unpacked" @@ -910,7 +904,6 @@ refineable = { codegen-units = 1 } release_channel = { codegen-units = 1 } reqwest_client = { codegen-units = 1 } session = { codegen-units = 1 } -sidebar = { codegen-units = 1 } snippet = { codegen-units = 1 } snippets_ui = { codegen-units = 1 } story = { codegen-units = 1 } diff --git a/Dockerfile-collab b/Dockerfile-collab index 63359334906b58c560c0ed6acc6378259ccbd5c5..50af874200a6ef3bc3c882b7d08257ec41f944de 100644 --- a/Dockerfile-collab +++ b/Dockerfile-collab @@ -14,8 +14,12 @@ ARG GITHUB_SHA ENV GITHUB_SHA=$GITHUB_SHA # Also add `cmake`, since we need it to build `wasmtime`. +# clang is needed because `webrtc-sys` uses Clang-specific compiler flags. RUN apt-get update; \ - apt-get install -y --no-install-recommends cmake + apt-get install -y --no-install-recommends cmake clang + +ENV CC=clang +ENV CXX=clang++ RUN --mount=type=cache,target=./script/node_modules \ --mount=type=cache,target=/usr/local/cargo/registry \ diff --git a/assets/icons/archive.svg b/assets/icons/archive.svg new file mode 100644 index 0000000000000000000000000000000000000000..9ffe3f39d27c7fe5cbb532a4f263c8800398e96f --- /dev/null +++ b/assets/icons/archive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/eye_off.svg b/assets/icons/eye_off.svg new file mode 100644 index 0000000000000000000000000000000000000000..3057c3050c36c72be314f9b0646d44932c52e4ee --- /dev/null +++ b/assets/icons/eye_off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/git_merge_conflict.svg b/assets/icons/git_merge_conflict.svg new file mode 100644 index 0000000000000000000000000000000000000000..10bc2c04fc9877112723273b0d60351c3a4c56bc --- /dev/null +++ b/assets/icons/git_merge_conflict.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/list_collapse.svg b/assets/icons/list_collapse.svg index f18bc550b90228c2f689848b86cfc5bea3d6ff50..dbdb2aaa4537c25ba1867d4957c23819af425835 100644 --- a/assets/icons/list_collapse.svg +++ b/assets/icons/list_collapse.svg @@ -1 +1,7 @@ - + + + + + + + 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/icons/threads_sidebar_left_closed.svg b/assets/icons/threads_sidebar_left_closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..feb1015254635ef65f90f2c9ea38efab74d01d60 --- /dev/null +++ b/assets/icons/threads_sidebar_left_closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_left_open.svg b/assets/icons/threads_sidebar_left_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..8057b060a84d7d7ffcf29aff1c0c79a8764edc22 --- /dev/null +++ b/assets/icons/threads_sidebar_left_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_right_closed.svg b/assets/icons/threads_sidebar_right_closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..10fa4b792fd65b5875dcf2cadab1fc12a123ab47 --- /dev/null +++ b/assets/icons/threads_sidebar_right_closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_right_open.svg b/assets/icons/threads_sidebar_right_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..23a01eb3f82a5866157220172c868ed9ded46033 --- /dev/null +++ b/assets/icons/threads_sidebar_right_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/workspace_nav_closed.svg b/assets/icons/workspace_nav_closed.svg deleted file mode 100644 index ed1fce52d6826a4d10299f331358ff84e4caa973..0000000000000000000000000000000000000000 --- a/assets/icons/workspace_nav_closed.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/icons/workspace_nav_open.svg b/assets/icons/workspace_nav_open.svg deleted file mode 100644 index 464b6aac73c2aeaa9463a805aabc4559377bbfd3..0000000000000000000000000000000000000000 --- a/assets/icons/workspace_nav_open.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 55903cdd1532a4b8a1f5a28b97b650367cd44603..a79384ad0139b804f0ba7721e6f42260733c8e0c 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", @@ -258,7 +258,7 @@ "ctrl-shift-j": "agent::ToggleNavigationMenu", "ctrl-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", - "ctrl-alt-shift-t": "agent::ToggleStartThreadInSelector", + "ctrl-shift-t": "agent::CycleStartThreadIn", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl->": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", @@ -624,6 +624,7 @@ "ctrl-shift-t": "pane::ReopenClosedItem", "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", + "ctrl-k ctrl-shift-t": "theme::ToggleMode", "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", @@ -670,13 +671,14 @@ }, }, { - "context": "WorkspaceSidebar", + "context": "ThreadsSidebar", "use_key_equivalents": true, "bindings": { "ctrl-n": "multi_workspace::NewWorkspaceInWindow", "left": "agents_sidebar::CollapseSelectedEntry", "right": "agents_sidebar::ExpandSelectedEntry", "enter": "menu::Confirm", + "shift-backspace": "agent::RemoveSelectedThread", }, }, { @@ -819,7 +821,7 @@ }, }, { - "context": "!ContextEditor > Editor && mode == full", + "context": "!ContextEditor && !AcpThread > Editor && mode == full", "bindings": { "alt-enter": "editor::OpenExcerpts", "shift-enter": "editor::ExpandExcerpts", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f023c0dee408d58e50853e5d1ad27637c870bbb4..14804998a08de962b1849d7b1a728d1d9d6f9778 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -297,7 +297,7 @@ "cmd-shift-j": "agent::ToggleNavigationMenu", "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", - "cmd-alt-shift-t": "agent::ToggleStartThreadInSelector", + "cmd-shift-t": "agent::CycleStartThreadIn", "shift-alt-escape": "agent::ExpandMessageEditor", "cmd->": "agent::AddSelectionToThread", "cmd-shift-e": "project_panel::ToggleFocus", @@ -691,6 +691,7 @@ "cmd-shift-t": "pane::ReopenClosedItem", "cmd-k cmd-s": "zed::OpenKeymap", "cmd-k cmd-t": "theme_selector::Toggle", + "cmd-k cmd-shift-t": "theme::ToggleMode", "ctrl-alt-cmd-p": "settings_profile_selector::Toggle", "cmd-t": "project_symbols::Toggle", "cmd-p": "file_finder::Toggle", @@ -738,13 +739,14 @@ }, }, { - "context": "WorkspaceSidebar", + "context": "ThreadsSidebar", "use_key_equivalents": true, "bindings": { "cmd-n": "multi_workspace::NewWorkspaceInWindow", "left": "agents_sidebar::CollapseSelectedEntry", "right": "agents_sidebar::ExpandSelectedEntry", "enter": "menu::Confirm", + "shift-backspace": "agent::RemoveSelectedThread", }, }, { @@ -882,7 +884,7 @@ }, }, { - "context": "!ContextEditor > Editor && mode == full", + "context": "!ContextEditor && !AcpThread > Editor && mode == full", "use_key_equivalents": true, "bindings": { "alt-enter": "editor::OpenExcerpts", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 83fda88f398aba1d72d2c93bbe77239dbbad360b..66896f43984dac73d7c098bfb46fb1a19568c14a 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -259,7 +259,7 @@ "shift-alt-j": "agent::ToggleNavigationMenu", "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", - "ctrl-shift-alt-t": "agent::ToggleStartThreadInSelector", + "ctrl-shift-t": "agent::CycleStartThreadIn", "shift-alt-escape": "agent::ExpandMessageEditor", "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", @@ -616,6 +616,7 @@ "ctrl-shift-t": "pane::ReopenClosedItem", "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", + "ctrl-k ctrl-shift-t": "theme::ToggleMode", "ctrl-alt-super-p": "settings_profile_selector::Toggle", "ctrl-t": "project_symbols::Toggle", "ctrl-p": "file_finder::Toggle", @@ -674,13 +675,14 @@ }, }, { - "context": "WorkspaceSidebar", + "context": "ThreadsSidebar", "use_key_equivalents": true, "bindings": { "ctrl-n": "multi_workspace::NewWorkspaceInWindow", "left": "agents_sidebar::CollapseSelectedEntry", "right": "agents_sidebar::ExpandSelectedEntry", "enter": "menu::Confirm", + "shift-backspace": "agent::RemoveSelectedThread", }, }, { @@ -821,7 +823,7 @@ }, }, { - "context": "!ContextEditor > Editor && mode == full", + "context": "!ContextEditor && !AcpThread > Editor && mode == full", "use_key_equivalents": true, "bindings": { "alt-enter": "editor::OpenExcerpts", diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 8612e07c4719dfdbf67762c89505cc2da0cfa000..304ffb86e8c2fd08fb756b015490f8c4ac424f58 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -33,6 +33,7 @@ "cmd-+": "editor::UnfoldLines", "alt-shift-g": "editor::SplitSelectionIntoLines", "ctrl-g": ["editor::SelectNext", { "replace_newest": false }], + "ctrl-shift-g": "editor::UndoSelection", "ctrl-cmd-g": ["editor::SelectPrevious", { "replace_newest": false }], "cmd-/": ["editor::ToggleComments", { "advance_downwards": true }], "alt-up": "editor::SelectLargerSyntaxNode", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 1f2742f982bc2165181a797e577b350f5630def9..66693ab0a153a73af1dccb101e0ed36259b774fa 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -427,6 +427,7 @@ "escape": "vim::SwitchToHelixNormalMode", "i": "vim::HelixInsert", "a": "vim::HelixAppend", + "shift-a": "vim::HelixInsertEndOfLine", "ctrl-[": "editor::Cancel", }, }, diff --git a/assets/settings/default.json b/assets/settings/default.json index 0a824bbe93a0d68a23d934a63eb1fdab1e2f1b02..74ba9e6d52158ea63bab9c6200567b27286aa1da 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: @@ -895,6 +898,14 @@ // Choices: label_color, icon // Default: icon "status_style": "icon", + // Whether to show file icons in the git panel. + // + // Default: false + "file_icons": false, + // Whether to show folder icons or chevrons for directories in the git panel. + // + // Default: true + "folder_icons": true, // What branch name to use if `init.defaultBranch` is not set // // Default: main @@ -911,6 +922,10 @@ /// /// Default: false "tree_view": false, + // Whether to show a badge on the git panel icon with the count of uncommitted changes. + // + // Default: false + "show_count_badge": false, "scrollbar": { // When to show the scrollbar in the git panel. // @@ -920,8 +935,8 @@ }, // Whether to show the addition/deletion change count next to each file in the Git panel. // - // Default: false - "diff_stats": false, + // Default: true + "diff_stats": true, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. @@ -935,6 +950,8 @@ "dock": "right", // Default width of the notification panel. "default_width": 380, + // Whether to show a badge on the notification panel icon with the count of unread notifications. + "show_count_badge": false, }, "agent": { // Whether the inline assistant should use streaming tools, when available @@ -1080,6 +1097,10 @@ "tools": {}, }, }, + // Whether to start a new thread in the current local project or in a new Git worktree. + // + // Default: local_project + "new_thread_location": "local_project", // Where to show notifications when the agent has either completed // its response, or else needs confirmation before it can run a // tool action. @@ -1282,6 +1303,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. @@ -1850,6 +1873,8 @@ // Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. Specifying a // timeout of `0` will disable path hyperlinking in terminal. "path_hyperlink_timeout_ms": 1, + // Whether to show a badge on the terminal panel icon with the count of open terminals. + "show_count_badge": false, }, "code_actions_on_format": {}, // Settings related to running tasks. diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index e2b7c3c91fca46ab0e4064719bea5c8793faaccc..3450e35bf62d780bdaf0cff2c6bc9f8bdfea7c1e 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -71,31 +71,31 @@ "terminal.background": "#0d1016ff", "terminal.foreground": "#bfbdb6ff", "terminal.bright_foreground": "#bfbdb6ff", - "terminal.dim_foreground": "#0d1016ff", + "terminal.dim_foreground": "#85847fff", "terminal.ansi.black": "#0d1016ff", "terminal.ansi.bright_black": "#545557ff", - "terminal.ansi.dim_black": "#bfbdb6ff", + "terminal.ansi.dim_black": "#3a3b3cff", "terminal.ansi.red": "#ef7177ff", "terminal.ansi.bright_red": "#83353bff", - "terminal.ansi.dim_red": "#febab9ff", + "terminal.ansi.dim_red": "#a74f53ff", "terminal.ansi.green": "#aad84cff", "terminal.ansi.bright_green": "#567627ff", - "terminal.ansi.dim_green": "#d8eca8ff", + "terminal.ansi.dim_green": "#769735ff", "terminal.ansi.yellow": "#feb454ff", "terminal.ansi.bright_yellow": "#92582bff", - "terminal.ansi.dim_yellow": "#ffd9aaff", + "terminal.ansi.dim_yellow": "#b17d3aff", "terminal.ansi.blue": "#5ac1feff", "terminal.ansi.bright_blue": "#27618cff", - "terminal.ansi.dim_blue": "#b7dffeff", + "terminal.ansi.dim_blue": "#3e87b1ff", "terminal.ansi.magenta": "#39bae5ff", "terminal.ansi.bright_magenta": "#205a78ff", - "terminal.ansi.dim_magenta": "#addcf3ff", + "terminal.ansi.dim_magenta": "#2782a0ff", "terminal.ansi.cyan": "#95e5cbff", "terminal.ansi.bright_cyan": "#4c806fff", - "terminal.ansi.dim_cyan": "#cbf2e4ff", + "terminal.ansi.dim_cyan": "#68a08eff", "terminal.ansi.white": "#bfbdb6ff", "terminal.ansi.bright_white": "#fafafaff", - "terminal.ansi.dim_white": "#787876ff", + "terminal.ansi.dim_white": "#85847fff", "link_text.hover": "#5ac1feff", "conflict": "#feb454ff", "conflict.background": "#572815ff", @@ -855,31 +855,31 @@ "terminal.background": "#242835ff", "terminal.foreground": "#cccac2ff", "terminal.bright_foreground": "#cccac2ff", - "terminal.dim_foreground": "#242835ff", + "terminal.dim_foreground": "#8e8d87ff", "terminal.ansi.black": "#242835ff", "terminal.ansi.bright_black": "#67696eff", - "terminal.ansi.dim_black": "#cccac2ff", + "terminal.ansi.dim_black": "#48494dff", "terminal.ansi.red": "#f18779ff", "terminal.ansi.bright_red": "#833f3cff", - "terminal.ansi.dim_red": "#fec4baff", + "terminal.ansi.dim_red": "#a85e54ff", "terminal.ansi.green": "#d5fe80ff", "terminal.ansi.bright_green": "#75993cff", - "terminal.ansi.dim_green": "#ecffc1ff", + "terminal.ansi.dim_green": "#95b159ff", "terminal.ansi.yellow": "#fecf72ff", "terminal.ansi.bright_yellow": "#937237ff", - "terminal.ansi.dim_yellow": "#ffe7b9ff", + "terminal.ansi.dim_yellow": "#b1904fff", "terminal.ansi.blue": "#72cffeff", "terminal.ansi.bright_blue": "#336d8dff", - "terminal.ansi.dim_blue": "#c1e7ffff", + "terminal.ansi.dim_blue": "#4f90b1ff", "terminal.ansi.magenta": "#5bcde5ff", "terminal.ansi.bright_magenta": "#2b6c7bff", - "terminal.ansi.dim_magenta": "#b7e7f2ff", + "terminal.ansi.dim_magenta": "#3f8fa0ff", "terminal.ansi.cyan": "#95e5cbff", "terminal.ansi.bright_cyan": "#4c806fff", - "terminal.ansi.dim_cyan": "#cbf2e4ff", + "terminal.ansi.dim_cyan": "#68a08eff", "terminal.ansi.white": "#cccac2ff", "terminal.ansi.bright_white": "#fafafaff", - "terminal.ansi.dim_white": "#898a8aff", + "terminal.ansi.dim_white": "#8e8d87ff", "link_text.hover": "#72cffeff", "conflict": "#fecf72ff", "conflict.background": "#574018ff", diff --git a/crates/acp_thread/Cargo.toml b/crates/acp_thread/Cargo.toml index 83cf86bfafc33e4d1b520ca5af04da626831aed7..7ef53bc522708680e64cfcc9ce2860990bfd7d13 100644 --- a/crates/acp_thread/Cargo.toml +++ b/crates/acp_thread/Cargo.toml @@ -59,7 +59,5 @@ indoc.workspace = true parking_lot.workspace = true project = { workspace = true, "features" = ["test-support"] } rand.workspace = true -tempfile.workspace = true util.workspace = true settings.workspace = true -zlog.workspace = true diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 58252eaddca553eb1da4c960a829a88afb9eb497..1a5764eca1b1861aa4c928aa5ede12e18c49e64b 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -31,6 +31,7 @@ use task::{Shell, ShellBuilder}; pub use terminal::*; use text::Bias; use ui::App; +use util::path_list::PathList; use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle}; use uuid::Uuid; @@ -953,7 +954,7 @@ struct RunningTurn { pub struct AcpThread { session_id: acp::SessionId, - cwd: Option, + work_dirs: Option, parent_session_id: Option, title: SharedString, provisional_title: Option, @@ -976,6 +977,30 @@ pub struct AcpThread { draft_prompt: Option>, /// The initial scroll position for the thread view, set during session registration. ui_scroll_position: Option, + /// Buffer for smooth text streaming. Holds text that has been received from + /// the model but not yet revealed in the UI. A timer task drains this buffer + /// gradually to create a fluid typing effect instead of choppy chunk-at-a-time + /// updates. + streaming_text_buffer: Option, +} + +struct StreamingTextBuffer { + /// Text received from the model but not yet appended to the Markdown source. + pending: String, + /// The number of bytes to reveal per timer turn. + bytes_to_reveal_per_tick: usize, + /// The Markdown entity being streamed into. + target: Entity, + /// Timer task that periodically moves text from `pending` into `source`. + _reveal_task: Task<()>, +} + +impl StreamingTextBuffer { + /// The number of milliseconds between each timer tick, controlling how quickly + /// text is revealed. + const TASK_UPDATE_MS: u64 = 16; + /// The time in milliseconds to reveal the entire pending text. + const REVEAL_TARGET: f32 = 200.0; } impl From<&AcpThread> for ActionLogTelemetry { @@ -1095,7 +1120,7 @@ impl AcpThread { pub fn new( parent_session_id: Option, title: impl Into, - cwd: Option, + work_dirs: Option, connection: Rc, project: Entity, action_log: Entity, @@ -1116,7 +1141,7 @@ impl AcpThread { Self { parent_session_id, - cwd, + work_dirs, action_log, shared_buffers: Default::default(), entries: Default::default(), @@ -1137,6 +1162,7 @@ impl AcpThread { had_error: false, draft_prompt: None, ui_scroll_position: None, + streaming_text_buffer: None, } } @@ -1182,6 +1208,10 @@ impl AcpThread { .unwrap_or_else(|| self.title.clone()) } + pub fn has_provisional_title(&self) -> bool { + self.provisional_title.is_some() + } + pub fn entries(&self) -> &[AgentThreadEntry] { &self.entries } @@ -1190,8 +1220,8 @@ impl AcpThread { &self.session_id } - pub fn cwd(&self) -> Option<&PathBuf> { - self.cwd.as_ref() + pub fn work_dirs(&self) -> Option<&PathList> { + self.work_dirs.as_ref() } pub fn status(&self) -> ThreadStatus { @@ -1343,6 +1373,7 @@ impl AcpThread { }) = last_entry && *existing_indented == indented { + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); *id = message_id.or(id.take()); content.append(chunk.clone(), &language_registry, path_style, cx); chunks.push(chunk); @@ -1379,8 +1410,20 @@ impl AcpThread { indented: bool, cx: &mut Context, ) { - let language_registry = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); + + // For text chunks going to an existing Markdown block, buffer for smooth + // streaming instead of appending all at once which may feel more choppy. + if let acp::ContentBlock::Text(text_content) = &chunk { + if let Some(markdown) = self.streaming_markdown_target(is_thought, indented) { + let entries_len = self.entries.len(); + cx.emit(AcpThreadEvent::EntryUpdated(entries_len - 1)); + self.buffer_streaming_text(&markdown, text_content.text.clone(), cx); + return; + } + } + + let language_registry = self.project.read(cx).languages().clone(); let entries_len = self.entries.len(); if let Some(last_entry) = self.entries.last_mut() && let AgentThreadEntry::AssistantMessage(AssistantMessage { @@ -1391,6 +1434,7 @@ impl AcpThread { && *existing_indented == indented { let idx = entries_len - 1; + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); cx.emit(AcpThreadEvent::EntryUpdated(idx)); match (chunks.last_mut(), is_thought) { (Some(AssistantMessageChunk::Message { block }), false) @@ -1425,7 +1469,134 @@ impl AcpThread { } } + fn streaming_markdown_target( + &self, + is_thought: bool, + indented: bool, + ) -> Option> { + let last_entry = self.entries.last()?; + if let AgentThreadEntry::AssistantMessage(AssistantMessage { + chunks, + indented: existing_indented, + .. + }) = last_entry + && *existing_indented == indented + && let [.., chunk] = chunks.as_slice() + { + match (chunk, is_thought) { + ( + AssistantMessageChunk::Message { + block: ContentBlock::Markdown { markdown }, + }, + false, + ) + | ( + AssistantMessageChunk::Thought { + block: ContentBlock::Markdown { markdown }, + }, + true, + ) => Some(markdown.clone()), + _ => None, + } + } else { + None + } + } + + /// Add text to the streaming buffer. If the target changed (e.g. switching + /// from thoughts to message text), flush the old buffer first. + fn buffer_streaming_text( + &mut self, + markdown: &Entity, + text: String, + cx: &mut Context, + ) { + if let Some(buffer) = &mut self.streaming_text_buffer { + if buffer.target.entity_id() == markdown.entity_id() { + buffer.pending.push_str(&text); + + buffer.bytes_to_reveal_per_tick = (buffer.pending.len() as f32 + / StreamingTextBuffer::REVEAL_TARGET + * StreamingTextBuffer::TASK_UPDATE_MS as f32) + .ceil() as usize; + return; + } + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); + } + + let target = markdown.clone(); + let _reveal_task = self.start_streaming_reveal(cx); + let pending_len = text.len(); + let bytes_to_reveal = (pending_len as f32 / StreamingTextBuffer::REVEAL_TARGET + * StreamingTextBuffer::TASK_UPDATE_MS as f32) + .ceil() as usize; + self.streaming_text_buffer = Some(StreamingTextBuffer { + pending: text, + bytes_to_reveal_per_tick: bytes_to_reveal, + target, + _reveal_task, + }); + } + + /// Flush all buffered streaming text into the Markdown entity immediately. + fn flush_streaming_text( + streaming_text_buffer: &mut Option, + cx: &mut Context, + ) { + if let Some(buffer) = streaming_text_buffer.take() { + if !buffer.pending.is_empty() { + buffer + .target + .update(cx, |markdown, cx| markdown.append(&buffer.pending, cx)); + } + } + } + + /// Spawns a foreground task that periodically drains + /// `streaming_text_buffer.pending` into the target `Markdown` entity, + /// producing smooth, continuous text output. + fn start_streaming_reveal(&self, cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(StreamingTextBuffer::TASK_UPDATE_MS)) + .await; + + let should_continue = this + .update(cx, |this, cx| { + let Some(buffer) = &mut this.streaming_text_buffer else { + return false; + }; + + if buffer.pending.is_empty() { + return true; + } + + let pending_len = buffer.pending.len(); + + let byte_boundary = buffer + .pending + .ceil_char_boundary(buffer.bytes_to_reveal_per_tick) + .min(pending_len); + + buffer.target.update(cx, |markdown: &mut Markdown, cx| { + markdown.append(&buffer.pending[..byte_boundary], cx); + buffer.pending.drain(..byte_boundary); + }); + + true + }) + .unwrap_or(false); + + if !should_continue { + break; + } + } + }) + } + fn push_entry(&mut self, entry: AgentThreadEntry, cx: &mut Context) { + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); self.entries.push(entry); cx.emit(AcpThreadEvent::NewEntry); } @@ -1970,6 +2141,8 @@ impl AcpThread { match response { Ok(r) => { + Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); + if r.stop_reason == acp::StopReason::MaxTokens { this.had_error = true; cx.emit(AcpThreadEvent::Error); @@ -2022,6 +2195,8 @@ impl AcpThread { Ok(Some(r)) } Err(e) => { + Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); + this.had_error = true; cx.emit(AcpThreadEvent::Error); log::error!("Error in run turn: {:?}", e); @@ -2039,6 +2214,7 @@ impl AcpThread { }; self.connection.cancel(&self.session_id, cx); + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); self.mark_pending_tools_as_canceled(); // Wait for the send task to complete @@ -2103,6 +2279,7 @@ impl AcpThread { return Task::ready(Err(anyhow!("not supported"))); }; + Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); let telemetry = ActionLogTelemetry::from(&*self); cx.spawn(async move |this, cx| { cx.update(|cx| truncate.run(id.clone(), cx)).await?; @@ -2682,7 +2859,7 @@ mod tests { use futures::{channel::mpsc, future::LocalBoxFuture, select}; use gpui::{App, AsyncApp, TestAppContext, WeakEntity}; use indoc::indoc; - use project::{FakeFs, Fs}; + use project::{AgentId, FakeFs, Fs}; use rand::{distr, prelude::*}; use serde_json::json; use settings::SettingsStore; @@ -2695,7 +2872,7 @@ mod tests { sync::atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst}, time::Duration, }; - use util::path; + use util::{path, path_list::PathList}; fn init_test(cx: &mut TestAppContext) { env_logger::try_init().ok(); @@ -2713,7 +2890,13 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session( + project, + PathList::new(&[std::path::Path::new(path!("/test"))]), + cx, + ) + }) .await .unwrap(); @@ -2777,7 +2960,13 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, std::path::Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session( + project, + PathList::new(&[std::path::Path::new(path!("/test"))]), + cx, + ) + }) .await .unwrap(); @@ -2865,7 +3054,13 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project.clone(), Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) + }) .await .unwrap(); @@ -2976,7 +3171,9 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3070,7 +3267,9 @@ mod tests { )); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3151,7 +3350,9 @@ mod tests { .unwrap(); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3192,7 +3393,9 @@ mod tests { let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3267,7 +3470,9 @@ mod tests { let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3341,7 +3546,9 @@ mod tests { let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/tmp")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/tmp"))]), cx) + }) .await .unwrap(); @@ -3389,7 +3596,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3480,7 +3689,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3539,7 +3750,9 @@ mod tests { } })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3712,7 +3925,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3788,7 +4003,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3861,7 +4078,9 @@ mod tests { } })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -3982,6 +4201,10 @@ mod tests { } impl AgentConnection for FakeAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("fake") + } + fn telemetry_id(&self) -> SharedString { "fake".into() } @@ -3993,7 +4216,7 @@ mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut App, ) -> Task>> { let session_id = acp::SessionId::new( @@ -4008,7 +4231,7 @@ mod tests { AcpThread::new( None, "Test", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, @@ -4027,7 +4250,7 @@ mod tests { } fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task> { - if self.auth_methods().iter().any(|m| m.id == method) { + if self.auth_methods().iter().any(|m| m.id() == &method) { Task::ready(Ok(())) } else { Task::ready(Err(anyhow!("Invalid Auth Method"))) @@ -4107,7 +4330,9 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4173,7 +4398,9 @@ mod tests { let project = Project::test(fs, [], cx).await; let connection = Rc::new(FakeAgentConnection::new()); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4486,7 +4713,9 @@ mod tests { )); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4560,7 +4789,9 @@ mod tests { })); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4643,7 +4874,9 @@ mod tests { )); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); @@ -4691,7 +4924,9 @@ mod tests { let set_title_calls = connection.set_title_calls.clone(); let thread = cx - .update(|cx| connection.new_session(project, Path::new(path!("/test")), cx)) + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) .await .unwrap(); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 644986bc15eccbe7d2be32ea5ad6e422db930541..33692c90d7915b52d33764ce99f949ffab84e04e 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -5,17 +5,11 @@ use chrono::{DateTime, Utc}; use collections::IndexMap; use gpui::{Entity, SharedString, Task}; use language_model::LanguageModelProviderId; -use project::Project; +use project::{AgentId, Project}; use serde::{Deserialize, Serialize}; -use std::{ - any::Any, - error::Error, - fmt, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, -}; +use std::{any::Any, error::Error, fmt, path::PathBuf, rc::Rc, sync::Arc}; use ui::{App, IconName}; +use util::path_list::PathList; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -28,12 +22,14 @@ impl UserMessageId { } pub trait AgentConnection { + fn agent_id(&self) -> AgentId; + fn telemetry_id(&self) -> SharedString; fn new_session( self: Rc, project: Entity, - cwd: &Path, + _work_dirs: PathList, cx: &mut App, ) -> Task>>; @@ -47,7 +43,7 @@ pub trait AgentConnection { self: Rc, _session_id: acp::SessionId, _project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, _cx: &mut App, ) -> Task>> { @@ -60,7 +56,11 @@ pub trait AgentConnection { } /// Close an existing session. Allows the agent to free the session from memory. - fn close_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task> { + fn close_session( + self: Rc, + _session_id: &acp::SessionId, + _cx: &mut App, + ) -> Task> { Task::ready(Err(anyhow::Error::msg("Closing sessions is not supported"))) } @@ -74,7 +74,7 @@ pub trait AgentConnection { self: Rc, _session_id: acp::SessionId, _project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, _cx: &mut App, ) -> Task>> { @@ -239,9 +239,10 @@ impl AgentSessionListResponse { #[derive(Debug, Clone, PartialEq)] pub struct AgentSessionInfo { pub session_id: acp::SessionId, - pub cwd: Option, + pub work_dirs: Option, pub title: Option, pub updated_at: Option>, + pub created_at: Option>, pub meta: Option, } @@ -249,9 +250,10 @@ impl AgentSessionInfo { pub fn new(session_id: impl Into) -> Self { Self { session_id: session_id.into(), - cwd: None, + work_dirs: None, title: None, updated_at: None, + created_at: None, meta: None, } } @@ -603,6 +605,10 @@ mod test_support { } impl AgentConnection for StubAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("stub") + } + fn telemetry_id(&self) -> SharedString { "stub".into() } @@ -621,7 +627,7 @@ mod test_support { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { static NEXT_SESSION_ID: AtomicUsize = AtomicUsize::new(0); @@ -632,7 +638,7 @@ mod test_support { AcpThread::new( None, "Test", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b63eec154a40de8909d13de2a4e1bd3e9d1e06f3..43dfe7610e34a0399a27a1d28858b938acfc2e0f 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -60,6 +60,9 @@ pub enum MentionUri { GitDiff { base_ref: String, }, + MergeConflict { + file_path: String, + }, } impl MentionUri { @@ -215,6 +218,9 @@ impl MentionUri { let base_ref = single_query_param(&url, "base")?.unwrap_or_else(|| "main".to_string()); Ok(Self::GitDiff { base_ref }) + } else if path.starts_with("/agent/merge-conflict") { + let file_path = single_query_param(&url, "path")?.unwrap_or_default(); + Ok(Self::MergeConflict { file_path }) } else { bail!("invalid zed url: {:?}", input); } @@ -245,6 +251,13 @@ impl MentionUri { } } MentionUri::GitDiff { base_ref } => format!("Branch Diff ({})", base_ref), + MentionUri::MergeConflict { file_path } => { + let name = Path::new(file_path) + .file_name() + .unwrap_or_default() + .to_string_lossy(); + format!("Merge Conflict ({name})") + } MentionUri::Selection { abs_path: path, line_range, @@ -306,6 +319,7 @@ impl MentionUri { MentionUri::Selection { .. } => IconName::Reader.path().into(), MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(), MentionUri::GitDiff { .. } => IconName::GitBranch.path().into(), + MentionUri::MergeConflict { .. } => IconName::GitMergeConflict.path().into(), } } @@ -409,6 +423,11 @@ impl MentionUri { url.query_pairs_mut().append_pair("base", base_ref); url } + MentionUri::MergeConflict { file_path } => { + let mut url = Url::parse("zed:///agent/merge-conflict").unwrap(); + url.query_pairs_mut().append_pair("path", file_path); + url + } } } } diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index b5b0e078ae0e41f5c3527265009fac803757ff1a..30d13effcb53395972879ef109a253be0c134ec1 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -14,7 +14,7 @@ use gpui::{ }; use language::LanguageRegistry; use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; -use project::Project; +use project::{AgentId, Project}; use settings::Settings; use theme::ThemeSettings; use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*}; @@ -48,7 +48,7 @@ pub struct AcpConnectionRegistry { } struct ActiveConnection { - server_name: SharedString, + agent_id: AgentId, connection: Weak, } @@ -65,12 +65,12 @@ impl AcpConnectionRegistry { pub fn set_active_connection( &self, - server_name: impl Into, + agent_id: AgentId, connection: &Rc, cx: &mut Context, ) { self.active_connection.replace(Some(ActiveConnection { - server_name: server_name.into(), + agent_id, connection: Rc::downgrade(connection), })); cx.notify(); @@ -87,7 +87,7 @@ struct AcpTools { } struct WatchedConnection { - server_name: SharedString, + agent_id: AgentId, messages: Vec, list_state: ListState, connection: Weak, @@ -144,7 +144,7 @@ impl AcpTools { }); self.watched_connection = Some(WatchedConnection { - server_name: active_connection.server_name.clone(), + agent_id: active_connection.agent_id.clone(), messages: vec![], list_state: ListState::new(0, ListAlignment::Bottom, px(2048.)), connection: active_connection.connection.clone(), @@ -483,7 +483,7 @@ impl Item for AcpTools { "ACP: {}", self.watched_connection .as_ref() - .map_or("Disconnected", |connection| &connection.server_name) + .map_or("Disconnected", |connection| connection.agent_id.0.as_ref()) ) .into() } diff --git a/crates/action_log/Cargo.toml b/crates/action_log/Cargo.toml index b1a1bf824fb770b8378e596fd0c799a7cf98b13d..5227a61651012279e83a3b6e3e68b1484acb0f66 100644 --- a/crates/action_log/Cargo.toml +++ b/crates/action_log/Cargo.toml @@ -37,7 +37,7 @@ collections = { workspace = true, features = ["test-support"] } clock = { workspace = true, features = ["test-support"] } ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } -indoc.workspace = true + language = { workspace = true, features = ["test-support"] } log.workspace = true pretty_assertions.workspace = true diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 5679f3c58fe52057f7a4a0faa24d5b5db2b5e497..3faf767c7020763eadc7db6c93af42f650a07434 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -209,7 +209,7 @@ impl ActionLog { cx: &mut Context, ) { match event { - BufferEvent::Edited => { + BufferEvent::Edited { .. } => { let Some(tracked_buffer) = self.tracked_buffers.get_mut(&buffer) else { return; }; @@ -1028,6 +1028,11 @@ impl ActionLog { .collect() } + /// Returns the total number of lines added and removed across all unreviewed buffers. + pub fn diff_stats(&self, cx: &App) -> DiffStats { + DiffStats::all_files(&self.changed_buffers(cx), cx) + } + /// Iterate over buffers changed since last read or edited by the model pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator> { self.tracked_buffers @@ -1044,6 +1049,46 @@ impl ActionLog { } } +#[derive(Default, Debug, Clone, Copy)] +pub struct DiffStats { + pub lines_added: u32, + pub lines_removed: u32, +} + +impl DiffStats { + pub fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { + let mut stats = DiffStats::default(); + let diff_snapshot = diff.snapshot(cx); + let buffer_snapshot = buffer.snapshot(); + let base_text = diff_snapshot.base_text(); + + for hunk in diff_snapshot.hunks(&buffer_snapshot) { + let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); + stats.lines_added += added_rows; + + let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; + let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; + let removed_rows = base_end.saturating_sub(base_start); + stats.lines_removed += removed_rows; + } + + stats + } + + pub fn all_files( + changed_buffers: &BTreeMap, Entity>, + cx: &App, + ) -> Self { + let mut total = DiffStats::default(); + for (buffer, diff) in changed_buffers { + let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); + total.lines_added += stats.lines_added; + total.lines_removed += stats.lines_removed; + } + total + } +} + #[derive(Clone)] pub struct ActionLogTelemetry { pub agent_telemetry_id: SharedString, diff --git a/crates/activity_indicator/Cargo.toml b/crates/activity_indicator/Cargo.toml index 99ae5b5b077a14c0909737d64935220698a007c7..ce53f23365d57666e25cac434935514fc4bd7e3f 100644 --- a/crates/activity_indicator/Cargo.toml +++ b/crates/activity_indicator/Cargo.toml @@ -30,4 +30,4 @@ workspace.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } -release_channel.workspace = true + diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 9f563cf0b1b009a496d36a6f090b0f4b476433a7..fe2089d94dc2e3fc812f6cbe39c16c5cadc1a1f5 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -100,9 +100,9 @@ rand.workspace = true reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } tempfile.workspace = true -terminal = { workspace = true, "features" = ["test-support"] } + theme = { workspace = true, "features" = ["test-support"] } -tree-sitter-rust.workspace = true + unindent = { workspace = true } -worktree = { workspace = true, "features" = ["test-support"] } + zlog.workspace = true diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index d9ad55c7127983516dbb5fe0392ef135186b79f7..d4062fec85cb458ade372085d23fa42a47e631ed 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -37,10 +37,11 @@ use futures::channel::{mpsc, oneshot}; use futures::future::Shared; use futures::{FutureExt as _, StreamExt as _, future}; use gpui::{ - App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, + App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task, + WeakEntity, }; use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry}; -use project::{Project, ProjectItem, ProjectPath, Worktree}; +use project::{AgentId, Project, ProjectItem, ProjectPath, Worktree}; use prompt_store::{ ProjectContext, PromptStore, RULES_FILE_NAMES, RulesFileContext, UserRulesContext, WorktreeContext, @@ -48,9 +49,9 @@ use prompt_store::{ use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, update_settings_file}; use std::any::Any; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::rc::Rc; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use util::ResultExt; use util::path_list::PathList; use util::rel_path::RelPath; @@ -65,12 +66,22 @@ pub struct RulesLoadingError { pub message: SharedString, } +struct ProjectState { + project: Entity, + project_context: Entity, + project_context_needs_refresh: watch::Sender<()>, + _maintain_project_context: Task>, + context_server_registry: Entity, + _subscriptions: Vec, +} + /// Holds both the internal Thread and the AcpThread for a session struct Session { /// The internal thread that processes messages thread: Entity, /// The ACP thread that handles protocol communication acp_thread: Entity, + project_id: EntityId, pending_save: Task<()>, _subscriptions: Vec, } @@ -235,79 +246,47 @@ pub struct NativeAgent { /// Session ID -> Session mapping sessions: HashMap, thread_store: Entity, - /// Shared project context for all threads - project_context: Entity, - project_context_needs_refresh: watch::Sender<()>, - _maintain_project_context: Task>, - context_server_registry: Entity, + /// Project-specific state keyed by project EntityId + projects: HashMap, /// Shared templates for all threads templates: Arc, /// Cached model information models: LanguageModels, - project: Entity, prompt_store: Option>, fs: Arc, _subscriptions: Vec, } impl NativeAgent { - pub async fn new( - project: Entity, + pub fn new( thread_store: Entity, templates: Arc, prompt_store: Option>, fs: Arc, - cx: &mut AsyncApp, - ) -> Result> { + cx: &mut App, + ) -> Entity { log::debug!("Creating new NativeAgent"); - let project_context = cx - .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx)) - .await; - - Ok(cx.new(|cx| { - let context_server_store = project.read(cx).context_server_store(); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); - - let mut subscriptions = vec![ - cx.subscribe(&project, Self::handle_project_event), - cx.subscribe( - &LanguageModelRegistry::global(cx), - Self::handle_models_updated_event, - ), - cx.subscribe( - &context_server_store, - Self::handle_context_server_store_updated, - ), - cx.subscribe( - &context_server_registry, - Self::handle_context_server_registry_event, - ), - ]; + cx.new(|cx| { + let mut subscriptions = vec![cx.subscribe( + &LanguageModelRegistry::global(cx), + Self::handle_models_updated_event, + )]; if let Some(prompt_store) = prompt_store.as_ref() { subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event)) } - let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = - watch::channel(()); Self { sessions: HashMap::default(), thread_store, - project_context: cx.new(|_| project_context), - project_context_needs_refresh: project_context_needs_refresh_tx, - _maintain_project_context: cx.spawn(async move |this, cx| { - Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await - }), - context_server_registry, + projects: HashMap::default(), templates, models: LanguageModels::new(cx), - project, prompt_store, fs, _subscriptions: subscriptions, } - })) + }) } fn new_session( @@ -315,10 +294,10 @@ impl NativeAgent { project: Entity, cx: &mut Context, ) -> Entity { - // Create Thread - // Fetch default model from registry settings + let project_id = self.get_or_create_project_state(&project, cx); + let project_state = &self.projects[&project_id]; + let registry = LanguageModelRegistry::read_global(cx); - // Log available models for debugging let available_count = registry.available_models(cx).count(); log::debug!("Total available models: {}", available_count); @@ -328,21 +307,22 @@ impl NativeAgent { }); let thread = cx.new(|cx| { Thread::new( - project.clone(), - self.project_context.clone(), - self.context_server_registry.clone(), + project, + project_state.project_context.clone(), + project_state.context_server_registry.clone(), self.templates.clone(), default_model, cx, ) }); - self.register_session(thread, cx) + self.register_session(thread, project_id, cx) } fn register_session( &mut self, thread_handle: Entity, + project_id: EntityId, cx: &mut Context, ) -> Entity { let connection = Rc::new(NativeAgentConnection(cx.entity())); @@ -405,12 +385,13 @@ impl NativeAgent { Session { thread: thread_handle, acp_thread: acp_thread.clone(), + project_id, _subscriptions: subscriptions, pending_save: Task::ready(()), }, ); - self.update_available_commands(cx); + self.update_available_commands_for_project(project_id, cx); acp_thread } @@ -419,19 +400,102 @@ impl NativeAgent { &self.models } + fn get_or_create_project_state( + &mut self, + project: &Entity, + cx: &mut Context, + ) -> EntityId { + let project_id = project.entity_id(); + if self.projects.contains_key(&project_id) { + return project_id; + } + + let project_context = cx.new(|_| ProjectContext::new(vec![], vec![])); + self.register_project_with_initial_context(project.clone(), project_context, cx); + if let Some(state) = self.projects.get_mut(&project_id) { + state.project_context_needs_refresh.send(()).ok(); + } + project_id + } + + fn register_project_with_initial_context( + &mut self, + project: Entity, + project_context: Entity, + cx: &mut Context, + ) { + let project_id = project.entity_id(); + + let context_server_store = project.read(cx).context_server_store(); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx)); + + let subscriptions = vec![ + cx.subscribe(&project, Self::handle_project_event), + cx.subscribe( + &context_server_store, + Self::handle_context_server_store_updated, + ), + cx.subscribe( + &context_server_registry, + Self::handle_context_server_registry_event, + ), + ]; + + let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) = + watch::channel(()); + + self.projects.insert( + project_id, + ProjectState { + project, + project_context, + project_context_needs_refresh: project_context_needs_refresh_tx, + _maintain_project_context: cx.spawn(async move |this, cx| { + Self::maintain_project_context( + this, + project_id, + project_context_needs_refresh_rx, + cx, + ) + .await + }), + context_server_registry, + _subscriptions: subscriptions, + }, + ); + } + + fn session_project_state(&self, session_id: &acp::SessionId) -> Option<&ProjectState> { + self.sessions + .get(session_id) + .and_then(|session| self.projects.get(&session.project_id)) + } + async fn maintain_project_context( this: WeakEntity, + project_id: EntityId, mut needs_refresh: watch::Receiver<()>, cx: &mut AsyncApp, ) -> Result<()> { while needs_refresh.changed().await.is_ok() { let project_context = this .update(cx, |this, cx| { - Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx) - })? + let state = this + .projects + .get(&project_id) + .context("project state not found")?; + anyhow::Ok(Self::build_project_context( + &state.project, + this.prompt_store.as_ref(), + cx, + )) + })?? .await; this.update(cx, |this, cx| { - this.project_context = cx.new(|_| project_context); + if let Some(state) = this.projects.get_mut(&project_id) { + state.project_context = cx.new(|_| project_context); + } })?; } @@ -620,13 +684,17 @@ impl NativeAgent { fn handle_project_event( &mut self, - _project: Entity, + project: Entity, event: &project::Event, _cx: &mut Context, ) { + let project_id = project.entity_id(); + let Some(state) = self.projects.get_mut(&project_id) else { + return; + }; match event { project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { - self.project_context_needs_refresh.send(()).ok(); + state.project_context_needs_refresh.send(()).ok(); } project::Event::WorktreeUpdatedEntries(_, items) => { if items.iter().any(|(path, _, _)| { @@ -634,7 +702,7 @@ impl NativeAgent { .iter() .any(|name| path.as_ref() == RelPath::unix(name).unwrap()) }) { - self.project_context_needs_refresh.send(()).ok(); + state.project_context_needs_refresh.send(()).ok(); } } _ => {} @@ -647,7 +715,9 @@ impl NativeAgent { _event: &prompt_store::PromptsUpdatedEvent, _cx: &mut Context, ) { - self.project_context_needs_refresh.send(()).ok(); + for state in self.projects.values_mut() { + state.project_context_needs_refresh.send(()).ok(); + } } fn handle_models_updated_event( @@ -677,30 +747,52 @@ impl NativeAgent { fn handle_context_server_store_updated( &mut self, - _store: Entity, + store: Entity, _event: &project::context_server_store::ServerStatusChangedEvent, cx: &mut Context, ) { - self.update_available_commands(cx); + let project_id = self.projects.iter().find_map(|(id, state)| { + if *state.context_server_registry.read(cx).server_store() == store { + Some(*id) + } else { + None + } + }); + if let Some(project_id) = project_id { + self.update_available_commands_for_project(project_id, cx); + } } fn handle_context_server_registry_event( &mut self, - _registry: Entity, + registry: Entity, event: &ContextServerRegistryEvent, cx: &mut Context, ) { match event { ContextServerRegistryEvent::ToolsChanged => {} ContextServerRegistryEvent::PromptsChanged => { - self.update_available_commands(cx); + let project_id = self.projects.iter().find_map(|(id, state)| { + if state.context_server_registry == registry { + Some(*id) + } else { + None + } + }); + if let Some(project_id) = project_id { + self.update_available_commands_for_project(project_id, cx); + } } } } - fn update_available_commands(&self, cx: &mut Context) { - let available_commands = self.build_available_commands(cx); + fn update_available_commands_for_project(&self, project_id: EntityId, cx: &mut Context) { + let available_commands = + Self::build_available_commands_for_project(self.projects.get(&project_id), cx); for session in self.sessions.values() { + if session.project_id != project_id { + continue; + } session.acp_thread.update(cx, |thread, cx| { thread .handle_session_update( @@ -714,8 +806,14 @@ impl NativeAgent { } } - fn build_available_commands(&self, cx: &App) -> Vec { - let registry = self.context_server_registry.read(cx); + fn build_available_commands_for_project( + project_state: Option<&ProjectState>, + cx: &App, + ) -> Vec { + let Some(state) = project_state else { + return vec![]; + }; + let registry = state.context_server_registry.read(cx); let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default(); for context_server_prompt in registry.prompts() { @@ -769,6 +867,7 @@ impl NativeAgent { pub fn load_thread( &mut self, id: acp::SessionId, + project: Entity, cx: &mut Context, ) -> Task>> { let database_future = ThreadsDatabase::connect(cx); @@ -780,41 +879,49 @@ impl NativeAgent { .with_context(|| format!("no thread found with ID: {id:?}"))?; this.update(cx, |this, cx| { + let project_id = this.get_or_create_project_state(&project, cx); + let project_state = this + .projects + .get(&project_id) + .context("project state not found")?; let summarization_model = LanguageModelRegistry::read_global(cx) .thread_summary_model() .map(|c| c.model); - cx.new(|cx| { + Ok(cx.new(|cx| { let mut thread = Thread::from_db( id.clone(), db_thread, - this.project.clone(), - this.project_context.clone(), - this.context_server_registry.clone(), + project_state.project.clone(), + project_state.project_context.clone(), + project_state.context_server_registry.clone(), this.templates.clone(), cx, ); thread.set_summarization_model(summarization_model, cx); thread - }) - }) + })) + })? }) } pub fn open_thread( &mut self, id: acp::SessionId, + project: Entity, cx: &mut Context, ) -> Task>> { if let Some(session) = self.sessions.get(&id) { return Task::ready(Ok(session.acp_thread.clone())); } - let task = self.load_thread(id, cx); + let task = self.load_thread(id, project.clone(), cx); cx.spawn(async move |this, cx| { let thread = task.await?; - let acp_thread = - this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?; + let acp_thread = this.update(cx, |this, cx| { + let project_id = this.get_or_create_project_state(&project, cx); + this.register_session(thread.clone(), project_id, cx) + })?; let events = thread.update(cx, |thread, cx| thread.replay(cx)); cx.update(|cx| { NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) @@ -827,9 +934,10 @@ impl NativeAgent { pub fn thread_summary( &mut self, id: acp::SessionId, + project: Entity, cx: &mut Context, ) -> Task> { - let thread = self.open_thread(id.clone(), cx); + let thread = self.open_thread(id.clone(), project, cx); cx.spawn(async move |this, cx| { let acp_thread = thread.await?; let result = this @@ -857,8 +965,13 @@ impl NativeAgent { return; }; + let project_id = session.project_id; + let Some(state) = self.projects.get(&project_id) else { + return; + }; + let folder_paths = PathList::new( - &self + &state .project .read(cx) .visible_worktrees(cx) @@ -889,15 +1002,22 @@ impl NativeAgent { fn send_mcp_prompt( &self, message_id: UserMessageId, - session_id: agent_client_protocol::SessionId, + session_id: acp::SessionId, prompt_name: String, server_id: ContextServerId, arguments: HashMap, original_content: Vec, cx: &mut Context, ) -> Task> { - let server_store = self.context_server_registry.read(cx).server_store().clone(); - let path_style = self.project.read(cx).path_style(cx); + let Some(state) = self.session_project_state(&session_id) else { + return Task::ready(Err(anyhow!("Project state not found for session"))); + }; + let server_store = state + .context_server_registry + .read(cx) + .server_store() + .clone(); + let path_style = state.project.read(cx).path_style(cx); cx.spawn(async move |this, cx| { let prompt = @@ -996,8 +1116,14 @@ impl NativeAgentConnection { .map(|session| session.thread.clone()) } - pub fn load_thread(&self, id: acp::SessionId, cx: &mut App) -> Task>> { - self.0.update(cx, |this, cx| this.load_thread(id, cx)) + pub fn load_thread( + &self, + id: acp::SessionId, + project: Entity, + cx: &mut App, + ) -> Task>> { + self.0 + .update(cx, |this, cx| this.load_thread(id, project, cx)) } fn run_turn( @@ -1255,7 +1381,13 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector { } } +pub static ZED_AGENT_ID: LazyLock = LazyLock::new(|| AgentId::new("Zed Agent")); + impl acp_thread::AgentConnection for NativeAgentConnection { + fn agent_id(&self) -> AgentId { + ZED_AGENT_ID.clone() + } + fn telemetry_id(&self) -> SharedString { "zed".into() } @@ -1263,10 +1395,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut App, ) -> Task>> { - log::debug!("Creating new thread for project at: {cwd:?}"); + log::debug!("Creating new thread for project at: {work_dirs:?}"); Task::ready(Ok(self .0 .update(cx, |agent, cx| agent.new_session(project, cx)))) @@ -1279,22 +1411,34 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn load_session( self: Rc, session_id: acp::SessionId, - _project: Entity, - _cwd: &Path, + project: Entity, + _work_dirs: PathList, _title: Option, cx: &mut App, ) -> Task>> { self.0 - .update(cx, |agent, cx| agent.open_thread(session_id, cx)) + .update(cx, |agent, cx| agent.open_thread(session_id, project, cx)) } fn supports_close_session(&self) -> bool { true } - fn close_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task> { + fn close_session( + self: Rc, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task> { self.0.update(cx, |agent, _cx| { + let project_id = agent.sessions.get(session_id).map(|s| s.project_id); agent.sessions.remove(session_id); + + if let Some(project_id) = project_id { + let has_remaining = agent.sessions.values().any(|s| s.project_id == project_id); + if !has_remaining { + agent.projects.remove(&project_id); + } + } }); Task::ready(Ok(())) } @@ -1325,8 +1469,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { log::info!("Received prompt request for session: {}", session_id); log::debug!("Prompt blocks count: {}", params.prompt.len()); + let Some(project_state) = self.0.read(cx).session_project_state(&session_id) else { + return Task::ready(Err(anyhow::anyhow!("Session not found"))); + }; + if let Some(parsed_command) = Command::parse(¶ms.prompt) { - let registry = self.0.read(cx).context_server_registry.read(cx); + let registry = project_state.context_server_registry.read(cx); let explicit_server_id = parsed_command .explicit_server_id @@ -1362,10 +1510,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection { cx, ) }); - }; + } }; - let path_style = self.0.read(cx).project.read(cx).path_style(cx); + let path_style = project_state.project.read(cx).path_style(cx); self.run_turn(session_id, cx, move |thread, cx| { let content: Vec = params @@ -1406,7 +1554,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn truncate( &self, - session_id: &agent_client_protocol::SessionId, + session_id: &acp::SessionId, cx: &App, ) -> Option> { self.0.read_with(cx, |agent, _cx| { @@ -1611,6 +1759,7 @@ impl NativeThreadEnvironment { }; let parent_thread = parent_thread_entity.read(cx); let current_depth = parent_thread.depth(); + let parent_session_id = parent_thread.id().clone(); if current_depth >= MAX_SUBAGENT_DEPTH { return Err(anyhow!( @@ -1627,9 +1776,16 @@ impl NativeThreadEnvironment { let session_id = subagent_thread.read(cx).id().clone(); - let acp_thread = self.agent.update(cx, |agent, cx| { - agent.register_session(subagent_thread.clone(), cx) - })?; + let acp_thread = self + .agent + .update(cx, |agent, cx| -> Result> { + let project_id = agent + .sessions + .get(&parent_session_id) + .map(|s| s.project_id) + .context("parent session not found")?; + Ok(agent.register_session(subagent_thread.clone(), project_id, cx)) + })??; let depth = current_depth + 1; @@ -1929,6 +2085,8 @@ impl TerminalHandle for AcpTerminalHandle { #[cfg(test)] mod internal_tests { + use std::path::Path; + use super::*; use acp_thread::{AgentConnection, AgentModelGroupName, AgentModelInfo, MentionUri}; use fs::FakeFs; @@ -1955,18 +2113,27 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); + + // Creating a session registers the project and triggers context building. + let connection = NativeAgentConnection(agent.clone()); + let _acp_thread = cx + .update(|cx| { + Rc::new(connection).new_session( + project.clone(), + PathList::new(&[Path::new("/")]), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + agent.read_with(cx, |agent, cx| { - assert_eq!(agent.project_context.read(cx).worktrees, vec![]) + let project_id = project.entity_id(); + let state = agent.projects.get(&project_id).unwrap(); + assert_eq!(state.project_context.read(cx).worktrees, vec![]) }); let worktree = project @@ -1975,8 +2142,10 @@ mod internal_tests { .unwrap(); cx.run_until_parked(); agent.read_with(cx, |agent, cx| { + let project_id = project.entity_id(); + let state = agent.projects.get(&project_id).unwrap(); assert_eq!( - agent.project_context.read(cx).worktrees, + state.project_context.read(cx).worktrees, vec![WorktreeContext { root_name: "a".into(), abs_path: Path::new("/a").into(), @@ -1989,12 +2158,14 @@ mod internal_tests { fs.insert_file("/a/.rules", Vec::new()).await; cx.run_until_parked(); agent.read_with(cx, |agent, cx| { + let project_id = project.entity_id(); + let state = agent.projects.get(&project_id).unwrap(); let rules_entry = worktree .read(cx) .entry_for_path(rel_path(".rules")) .unwrap(); assert_eq!( - agent.project_context.read(cx).worktrees, + state.project_context.read(cx).worktrees, vec![WorktreeContext { root_name: "a".into(), abs_path: Path::new("/a").into(), @@ -2015,23 +2186,19 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let connection = NativeAgentConnection( - NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(), - ); + let connection = + NativeAgentConnection(cx.update(|cx| { + NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx) + })); // Create a thread/session let acp_thread = cx .update(|cx| { - Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx) + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2095,22 +2262,18 @@ mod internal_tests { let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create the agent and connection - let agent = NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread/session let acp_thread = cx .update(|cx| { - Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx) + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2196,21 +2359,17 @@ mod internal_tests { let project = Project::test(fs.clone(), [], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); let acp_thread = cx .update(|cx| { - Rc::new(connection.clone()).new_session(project.clone(), Path::new("/a"), cx) + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2288,16 +2447,9 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a thinking model. @@ -2324,9 +2476,11 @@ mod internal_tests { // Create a thread and select the thinking model. let acp_thread = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), Path::new("/a"), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2371,7 +2525,9 @@ mod internal_tests { // Reload the thread and verify thinking_enabled is still true. let reloaded_acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) .await .unwrap(); let reloaded_thread = agent.read_with(cx, |agent, _| { @@ -2394,16 +2550,9 @@ mod internal_tests { fs.insert_tree("/", json!({ "a": {} })).await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); // Register a model where id() != name(), like real Anthropic models @@ -2431,9 +2580,11 @@ mod internal_tests { // Create a thread and select the model. let acp_thread = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), Path::new("/a"), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) }) .await .unwrap(); @@ -2478,7 +2629,9 @@ mod internal_tests { // Reload the thread and verify the model was preserved. let reloaded_acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) .await .unwrap(); let reloaded_thread = agent.read_with(cx, |agent, _| { @@ -2513,23 +2666,16 @@ mod internal_tests { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -2642,7 +2788,9 @@ mod internal_tests { )] ); let acp_thread = agent - .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx)) + .update(cx, |agent, cx| { + agent.open_thread(session_id.clone(), project.clone(), cx) + }) .await .unwrap(); acp_thread.read_with(cx, |thread, cx| { diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 2c9b33e4efc4f22059e2914589ca6c635b51c0e5..bde07a040869bf11a1b95bf433bf6af1e2d0a932 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -25,11 +25,10 @@ pub type DbMessage = crate::Message; pub type DbSummary = crate::legacy_thread::DetailedSummaryState; pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone)] pub struct DbThreadMetadata { pub id: acp::SessionId, pub parent_session_id: Option, - #[serde(alias = "summary")] pub title: SharedString, pub updated_at: DateTime, pub created_at: Option>, @@ -42,9 +41,10 @@ impl From<&DbThreadMetadata> for acp_thread::AgentSessionInfo { fn from(meta: &DbThreadMetadata) -> Self { Self { session_id: meta.id.clone(), - cwd: None, + work_dirs: Some(meta.folder_paths.clone()), title: Some(meta.title.clone()), updated_at: Some(meta.updated_at), + created_at: meta.created_at, meta: None, } } @@ -482,7 +482,10 @@ impl ThreadsDatabase { let data_type = DataType::Zstd; let data = compressed; - let created_at = Utc::now().to_rfc3339(); + // Use the thread's updated_at as created_at for new threads. + // This ensures the creation time reflects when the thread was conceptually + // created, not when it was saved to the database. + let created_at = updated_at.clone(); let mut insert = connection.exec_bound::<(Arc, Option>, Option, Option, String, String, DataType, Vec, String)>(indoc! {" INSERT INTO threads (id, parent_id, folder_paths, folder_paths_order, summary, updated_at, data_type, data, created_at) @@ -877,7 +880,6 @@ mod tests { let threads = database.list_threads().await.unwrap(); assert_eq!(threads.len(), 1); - assert_eq!(threads[0].folder_paths, folder_paths); } #[gpui::test] @@ -897,7 +899,6 @@ mod tests { let threads = database.list_threads().await.unwrap(); assert_eq!(threads.len(), 1); - assert!(threads[0].folder_paths.is_empty()); } #[test] diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 18c41670ac4b4ba3146fb207992a7020a44fbd5f..b2c3c913f19a877dcd001bd771809ce7f9a4afa5 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -6,7 +6,8 @@ use agent_settings::AgentSettings; use anyhow::Result; use collections::HashSet; use fs::Fs; -use gpui::{App, Entity, SharedString, Task}; +use gpui::{App, Entity, Task}; +use project::AgentId; use prompt_store::PromptStore; use settings::{LanguageModelSelection, Settings as _, update_settings_file}; @@ -25,8 +26,8 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { - fn name(&self) -> SharedString { - "Zed Agent".into() + fn agent_id(&self) -> AgentId { + crate::ZED_AGENT_ID.clone() } fn logo(&self) -> ui::IconName { @@ -35,11 +36,10 @@ impl AgentServer for NativeAgentServer { fn connect( &self, - delegate: AgentServerDelegate, + _delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { log::debug!("NativeAgentServer::connect"); - let project = delegate.project().clone(); let fs = self.fs.clone(); let thread_store = self.thread_store.clone(); let prompt_store = PromptStore::global(cx); @@ -49,9 +49,8 @@ impl AgentServer for NativeAgentServer { let prompt_store = prompt_store.await?; log::debug!("Creating native agent entity"); - let agent = - NativeAgent::new(project, thread_store, templates, Some(prompt_store), fs, cx) - .await?; + let agent = cx + .update(|cx| NativeAgent::new(thread_store, templates, Some(prompt_store), fs, cx)); // Create the connection wrapper let connection = NativeAgentConnection(agent); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index d33c80a435e84359976d4d8a9edb2bdebd66e0ff..e8a8acefce6d5728cd666d7fb7cb87ec3dcccb3e 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -3177,20 +3177,12 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let fake_fs = cx.update(|cx| fs::FakeFs::new(cx.background_executor().clone())); fake_fs.insert_tree(path!("/test"), json!({})).await; let project = Project::test(fake_fs.clone(), [Path::new("/test")], cx).await; - let cwd = Path::new("/test"); + let cwd = PathList::new(&[Path::new("/test")]); let thread_store = cx.new(|cx| ThreadStore::new(cx)); // Create agent and connection - let agent = NativeAgent::new( - project.clone(), - thread_store, - templates.clone(), - None, - fake_fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx + .update(|cx| NativeAgent::new(thread_store, templates.clone(), None, fake_fs.clone(), cx)); let connection = NativeAgentConnection(agent.clone()); // Create a thread using new_thread @@ -4388,23 +4380,16 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -4530,23 +4515,16 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -4685,23 +4663,16 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -4822,23 +4793,16 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -5201,23 +5165,16 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -5334,23 +5291,16 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); @@ -5515,23 +5465,16 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await; let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = NativeAgent::new( - project.clone(), - thread_store.clone(), - Templates::new(), - None, - fs.clone(), - &mut cx.to_async(), - ) - .await - .unwrap(); + let agent = cx.update(|cx| { + NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = cx .update(|cx| { connection .clone() - .new_session(project.clone(), Path::new(""), cx) + .new_session(project.clone(), PathList::new(&[Path::new("")]), cx) }) .await .unwrap(); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index e61a395e71f93d49d63d378355c89e44359db835..55fdace2cfea1dd77be507cb06f0a9d4b6634cf7 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -219,6 +219,7 @@ impl UserMessage { "\nThe user has specified the following rules that should be applied:\n"; const OPEN_DIAGNOSTICS_TAG: &str = ""; const OPEN_DIFFS_TAG: &str = ""; + const MERGE_CONFLICT_TAG: &str = ""; let mut file_context = OPEN_FILES_TAG.to_string(); let mut directory_context = OPEN_DIRECTORIES_TAG.to_string(); @@ -229,6 +230,7 @@ impl UserMessage { let mut rules_context = OPEN_RULES_TAG.to_string(); let mut diagnostics_context = OPEN_DIAGNOSTICS_TAG.to_string(); let mut diffs_context = OPEN_DIFFS_TAG.to_string(); + let mut merge_conflict_context = MERGE_CONFLICT_TAG.to_string(); for chunk in &self.content { let chunk = match chunk { @@ -336,6 +338,18 @@ impl UserMessage { ) .ok(); } + MentionUri::MergeConflict { file_path } => { + write!( + &mut merge_conflict_context, + "\nMerge conflict in {}:\n{}", + file_path, + MarkdownCodeBlock { + tag: "diff", + text: content + } + ) + .ok(); + } } language_model::MessageContent::Text(uri.as_link().to_string()) @@ -410,6 +424,13 @@ impl UserMessage { .push(language_model::MessageContent::Text(diagnostics_context)); } + if merge_conflict_context.len() > MERGE_CONFLICT_TAG.len() { + merge_conflict_context.push_str("\n"); + message + .content + .push(language_model::MessageContent::Text(merge_conflict_context)); + } + if message.content.len() > len_before_context { message.content.insert( len_before_context, @@ -2549,6 +2570,14 @@ impl Thread { .is_some() { _ = this.update(cx, |this, cx| this.set_title(title.into(), cx)); + } else { + // Emit TitleUpdated even on failure so that the propagation + // chain (agent::Thread → NativeAgent → AcpThread) fires and + // clears any provisional title that was set before the turn. + _ = this.update(cx, |_, cx| { + cx.emit(TitleUpdated); + cx.notify(); + }); } _ = this.update(cx, |this, _| this.pending_title_generation = None); })); diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 961be1da4c09890691adbd5448d7678b2808fe7b..379ae675d4bbf3c2a9570365493317178f38a804 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -91,14 +91,15 @@ impl ThreadStore { let database_connection = ThreadsDatabase::connect(cx); cx.spawn(async move |this, cx| { let database = database_connection.await.map_err(|err| anyhow!(err))?; - let threads = database - .list_threads() - .await? - .into_iter() - .filter(|thread| thread.parent_session_id.is_none()) - .collect::>(); + let all_threads = database.list_threads().await?; this.update(cx, |this, cx| { - this.threads = threads; + this.threads.clear(); + for thread in all_threads { + if thread.parent_session_id.is_some() { + continue; + } + this.threads.push(thread); + } cx.notify(); }) }) @@ -112,13 +113,6 @@ impl ThreadStore { pub fn entries(&self) -> impl Iterator + '_ { self.threads.iter().cloned() } - - /// Returns threads whose folder_paths match the given paths exactly. - pub fn threads_for_paths(&self, paths: &PathList) -> impl Iterator { - self.threads - .iter() - .filter(move |thread| &thread.folder_paths == paths) - } } #[cfg(test)] @@ -294,50 +288,4 @@ mod tests { assert_eq!(entries[0].id, first_id); assert_eq!(entries[1].id, second_id); } - - #[gpui::test] - async fn test_threads_for_paths_filters_correctly(cx: &mut TestAppContext) { - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - cx.run_until_parked(); - - let project_a_paths = PathList::new(&[std::path::PathBuf::from("/home/user/project-a")]); - let project_b_paths = PathList::new(&[std::path::PathBuf::from("/home/user/project-b")]); - - let thread_a = make_thread( - "Thread in A", - Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), - ); - let thread_b = make_thread( - "Thread in B", - Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(), - ); - let thread_a_id = session_id("thread-a"); - let thread_b_id = session_id("thread-b"); - - let save_a = thread_store.update(cx, |store, cx| { - store.save_thread(thread_a_id.clone(), thread_a, project_a_paths.clone(), cx) - }); - save_a.await.unwrap(); - - let save_b = thread_store.update(cx, |store, cx| { - store.save_thread(thread_b_id.clone(), thread_b, project_b_paths.clone(), cx) - }); - save_b.await.unwrap(); - - cx.run_until_parked(); - - thread_store.read_with(cx, |store, _cx| { - let a_threads: Vec<_> = store.threads_for_paths(&project_a_paths).collect(); - assert_eq!(a_threads.len(), 1); - assert_eq!(a_threads[0].id, thread_a_id); - - let b_threads: Vec<_> = store.threads_for_paths(&project_b_paths).collect(); - assert_eq!(b_threads.len(), 1); - assert_eq!(b_threads[0].id, thread_b_id); - - let nonexistent = PathList::new(&[std::path::PathBuf::from("/nonexistent")]); - let no_threads: Vec<_> = store.threads_for_paths(&nonexistent).collect(); - assert!(no_threads.is_empty()); - }); - } } diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 79564bbddea7063d00e18d97c8eab89533b20da5..4cb4d265b3170429430b815d7490099a50678714 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -560,6 +560,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions, show_turn_stats: false, + new_thread_location: Default::default(), } } diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index c326ed3c10170d1c45517103ba02e178bec32c36..574fe078063b0b8e66ceb6cf0503ad139c23cdc4 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -118,7 +118,7 @@ pub struct Edit { pub new_text: String, } -#[derive(Default, Debug, Deserialize)] +#[derive(Clone, Default, Debug, Deserialize)] struct StreamingEditFileToolPartialInput { #[serde(default)] display_description: Option, @@ -132,7 +132,7 @@ struct StreamingEditFileToolPartialInput { edits: Option>, } -#[derive(Default, Debug, Deserialize)] +#[derive(Clone, Default, Debug, Deserialize)] pub struct PartialEdit { #[serde(default)] pub old_text: Option, @@ -314,12 +314,19 @@ impl AgentTool for StreamingEditFileTool { ) -> Task> { cx.spawn(async move |cx: &mut AsyncApp| { let mut state: Option = None; + let mut last_partial: Option = None; loop { futures::select! { partial = input.recv_partial().fuse() => { let Some(partial_value) = partial else { break }; if let Ok(parsed) = serde_json::from_value::(partial_value) { + let path_complete = parsed.path.is_some() + && parsed.path.as_ref() == last_partial.as_ref().and_then(|p| p.path.as_ref()); + + last_partial = Some(parsed.clone()); + if state.is_none() + && path_complete && let StreamingEditFileToolPartialInput { path: Some(path), display_description: Some(display_description), @@ -1907,6 +1914,13 @@ mod tests { let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); // Setup + single edit that stays in-progress (no second edit to prove completion) + sender.send_partial(json!({ + "display_description": "Single edit", + "path": "root/file.txt", + "mode": "edit", + })); + cx.run_until_parked(); + sender.send_partial(json!({ "display_description": "Single edit", "path": "root/file.txt", @@ -3475,6 +3489,12 @@ mod tests { let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); // Transition to BufferResolved + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + })); + cx.run_until_parked(); + sender.send_partial(json!({ "display_description": "Overwrite file", "path": "root/file.txt", @@ -3550,8 +3570,9 @@ mod tests { // Verify buffer still has old content (no content partial yet) let buffer = project.update(cx, |project, cx| { let path = project.find_project_path("root/file.txt", cx).unwrap(); - project.get_open_buffer(&path, cx).unwrap() + project.open_buffer(path, cx) }); + let buffer = buffer.await.unwrap(); assert_eq!( buffer.read_with(cx, |b, _| b.text()), "old line 1\nold line 2\nold line 3\n" @@ -3735,6 +3756,106 @@ mod tests { ); } + #[gpui::test] + async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode( + cx: &mut TestAppContext, + ) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content", + "path": "root" + })); + cx.run_until_parked(); + + // Send final. + sender.send_final(json!({ + "display_description": "Overwrite file", + "mode": "write", + "content": "new_content", + "path": "root/file.txt" + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + + #[gpui::test] + async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode( + cx: &mut TestAppContext, + ) { + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.txt": "old_content"})).await; + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], + "path": "root" + })); + cx.run_until_parked(); + + // Send final. + sender.send_final(json!({ + "display_description": "Overwrite file", + "mode": "edit", + "edits": [{"old_text": "old_content", "new_text": "new_content"}], + "path": "root/file.txt" + })); + cx.run_until_parked(); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new_content"); + } + async fn setup_test_with_fs( cx: &mut TestAppContext, fs: Arc, diff --git a/crates/agent_servers/Cargo.toml b/crates/agent_servers/Cargo.toml index 4d34632a248c5db35666e93cb068c7ec6727fc48..4fb4109129ee5b8896f7a62afe49e0bcaef701ed 100644 --- a/crates/agent_servers/Cargo.toml +++ b/crates/agent_servers/Cargo.toml @@ -61,7 +61,7 @@ nix.workspace = true client = { workspace = true, features = ["test-support"] } env_logger.workspace = true fs.workspace = true -language.workspace = true + indoc.workspace = true acp_thread = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index ceceb5b8ae02a0674b27e0fa18244a94f2b409de..54166f1d553b4ed1ef0f3642517125b30bc5fda8 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -9,18 +9,19 @@ use anyhow::anyhow; use collections::HashMap; use futures::AsyncBufReadExt as _; use futures::io::BufReader; -use project::Project; -use project::agent_server_store::{AgentServerCommand, GEMINI_NAME}; +use project::agent_server_store::{AgentServerCommand, GEMINI_ID}; +use project::{AgentId, Project}; use serde::Deserialize; use settings::Settings as _; use task::ShellBuilder; use util::ResultExt as _; +use util::path_list::PathList; use util::process::Child; use std::path::PathBuf; use std::process::Stdio; +use std::rc::Rc; use std::{any::Any, cell::RefCell}; -use std::{path::Path, rc::Rc}; use thiserror::Error; use anyhow::{Context as _, Result}; @@ -35,7 +36,7 @@ use terminal::terminal_settings::{AlternateScroll, CursorShape, TerminalSettings pub struct UnsupportedVersion; pub struct AcpConnection { - server_name: SharedString, + id: AgentId, display_name: SharedString, telemetry_id: SharedString, connection: Rc, @@ -124,13 +125,14 @@ impl AgentSessionList for AcpSessionList { .into_iter() .map(|s| AgentSessionInfo { session_id: s.session_id, - cwd: Some(s.cwd), + work_dirs: Some(PathList::new(&[s.cwd])), title: s.title.map(Into::into), updated_at: s.updated_at.and_then(|date_str| { chrono::DateTime::parse_from_rfc3339(&date_str) .ok() .map(|dt| dt.with_timezone(&chrono::Utc)) }), + created_at: None, meta: s.meta, }) .collect(), @@ -157,7 +159,7 @@ impl AgentSessionList for AcpSessionList { } pub async fn connect( - server_name: SharedString, + agent_id: AgentId, display_name: SharedString, command: AgentServerCommand, default_mode: Option, @@ -166,7 +168,7 @@ pub async fn connect( cx: &mut AsyncApp, ) -> Result> { let conn = AcpConnection::stdio( - server_name, + agent_id, display_name, command.clone(), default_mode, @@ -182,7 +184,7 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1 impl AcpConnection { pub async fn stdio( - server_name: SharedString, + agent_id: AgentId, display_name: SharedString, command: AgentServerCommand, default_mode: Option, @@ -269,7 +271,7 @@ impl AcpConnection { cx.update(|cx| { AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { - registry.set_active_connection(server_name.clone(), &connection, cx) + registry.set_active_connection(agent_id.clone(), &connection, cx) }); }); @@ -278,7 +280,7 @@ impl AcpConnection { acp::InitializeRequest::new(acp::ProtocolVersion::V1) .client_capabilities( acp::ClientCapabilities::new() - .fs(acp::FileSystemCapability::new() + .fs(acp::FileSystemCapabilities::new() .read_text_file(true) .write_text_file(true)) .terminal(true) @@ -304,7 +306,7 @@ impl AcpConnection { // Use the one the agent provides if we have one .map(|info| info.name.into()) // Otherwise, just use the name - .unwrap_or_else(|| server_name.clone()); + .unwrap_or_else(|| agent_id.0.to_string().into()); let session_list = if response .agent_capabilities @@ -320,7 +322,7 @@ impl AcpConnection { }; // TODO: Remove this override once Google team releases their official auth methods - let auth_methods = if server_name == GEMINI_NAME { + let auth_methods = if agent_id.0.as_ref() == GEMINI_ID { let mut args = command.args.clone(); args.retain(|a| a != "--experimental-acp"); let value = serde_json::json!({ @@ -330,18 +332,18 @@ impl AcpConnection { "env": command.env.clone().unwrap_or_default(), }); let meta = acp::Meta::from_iter([("terminal-auth".to_string(), value)]); - vec![ - acp::AuthMethod::new("spawn-gemini-cli", "Login") + vec![acp::AuthMethod::Agent( + acp::AuthMethodAgent::new("spawn-gemini-cli", "Login") .description("Login with your Google or Vertex AI account") .meta(meta), - ] + )] } else { response.auth_methods }; Ok(Self { + id: agent_id, auth_methods, connection, - server_name, display_name, telemetry_id, sessions, @@ -360,6 +362,102 @@ impl AcpConnection { pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities { &self.agent_capabilities.prompt_capabilities } + + fn apply_default_config_options( + &self, + session_id: &acp::SessionId, + config_options: &Rc>>, + cx: &mut AsyncApp, + ) { + let id = self.id.clone(); + let defaults_to_apply: Vec<_> = { + let config_opts_ref = config_options.borrow(); + config_opts_ref + .iter() + .filter_map(|config_option| { + let default_value = self.default_config_options.get(&*config_option.id.0)?; + + let is_valid = match &config_option.kind { + acp::SessionConfigKind::Select(select) => match &select.options { + acp::SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .any(|opt| &*opt.value.0 == default_value.as_str()), + acp::SessionConfigSelectOptions::Grouped(groups) => { + groups.iter().any(|g| { + g.options + .iter() + .any(|opt| &*opt.value.0 == default_value.as_str()) + }) + } + _ => false, + }, + _ => false, + }; + + if is_valid { + let initial_value = match &config_option.kind { + acp::SessionConfigKind::Select(select) => { + Some(select.current_value.clone()) + } + _ => None, + }; + Some(( + config_option.id.clone(), + default_value.clone(), + initial_value, + )) + } else { + log::warn!( + "`{}` is not a valid value for config option `{}` in {}", + default_value, + config_option.id.0, + id + ); + None + } + }) + .collect() + }; + + for (config_id, default_value, initial_value) in defaults_to_apply { + cx.spawn({ + let default_value_id = acp::SessionConfigValueId::new(default_value.clone()); + let session_id = session_id.clone(); + let config_id_clone = config_id.clone(); + let config_opts = config_options.clone(); + let conn = self.connection.clone(); + async move |_| { + let result = conn + .set_session_config_option(acp::SetSessionConfigOptionRequest::new( + session_id, + config_id_clone.clone(), + default_value_id, + )) + .await + .log_err(); + + if result.is_none() { + if let Some(initial) = initial_value { + let mut opts = config_opts.borrow_mut(); + if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id_clone) { + if let acp::SessionConfigKind::Select(select) = &mut opt.kind { + select.current_value = initial; + } + } + } + } + } + }) + .detach(); + + let mut opts = config_options.borrow_mut(); + if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id) { + if let acp::SessionConfigKind::Select(select) = &mut opt.kind { + select.current_value = acp::SessionConfigValueId::new(default_value); + } + } + } + } } impl Drop for AcpConnection { @@ -369,6 +467,10 @@ impl Drop for AcpConnection { } impl AgentConnection for AcpConnection { + fn agent_id(&self) -> AgentId { + self.id.clone() + } + fn telemetry_id(&self) -> SharedString { self.telemetry_id.clone() } @@ -376,11 +478,14 @@ impl AgentConnection for AcpConnection { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut App, ) -> Task>> { - let name = self.server_name.clone(); - let cwd = cwd.to_path_buf(); + // TODO: remove this once ACP supports multiple working directories + let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { + return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + }; + let name = self.id.0.clone(); let mcp_servers = mcp_servers_for_project(&project, cx); cx.spawn(async move |cx| { @@ -470,89 +575,7 @@ impl AgentConnection for AcpConnection { } if let Some(config_opts) = config_options.as_ref() { - let defaults_to_apply: Vec<_> = { - let config_opts_ref = config_opts.borrow(); - config_opts_ref - .iter() - .filter_map(|config_option| { - let default_value = self.default_config_options.get(&*config_option.id.0)?; - - let is_valid = match &config_option.kind { - acp::SessionConfigKind::Select(select) => match &select.options { - acp::SessionConfigSelectOptions::Ungrouped(options) => { - options.iter().any(|opt| &*opt.value.0 == default_value.as_str()) - } - acp::SessionConfigSelectOptions::Grouped(groups) => groups - .iter() - .any(|g| g.options.iter().any(|opt| &*opt.value.0 == default_value.as_str())), - _ => false, - }, - _ => false, - }; - - if is_valid { - let initial_value = match &config_option.kind { - acp::SessionConfigKind::Select(select) => { - Some(select.current_value.clone()) - } - _ => None, - }; - Some((config_option.id.clone(), default_value.clone(), initial_value)) - } else { - log::warn!( - "`{}` is not a valid value for config option `{}` in {}", - default_value, - config_option.id.0, - name - ); - None - } - }) - .collect() - }; - - for (config_id, default_value, initial_value) in defaults_to_apply { - cx.spawn({ - let default_value_id = acp::SessionConfigValueId::new(default_value.clone()); - let session_id = response.session_id.clone(); - let config_id_clone = config_id.clone(); - let config_opts = config_opts.clone(); - let conn = self.connection.clone(); - async move |_| { - let result = conn - .set_session_config_option( - acp::SetSessionConfigOptionRequest::new( - session_id, - config_id_clone.clone(), - default_value_id, - ), - ) - .await - .log_err(); - - if result.is_none() { - if let Some(initial) = initial_value { - let mut opts = config_opts.borrow_mut(); - if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id_clone) { - if let acp::SessionConfigKind::Select(select) = - &mut opt.kind - { - select.current_value = initial; - } - } - } - } - } - }) - .detach(); - - let mut opts = config_opts.borrow_mut(); - if let Some(opt) = opts.iter_mut().find(|o| o.id == config_id) { - if let acp::SessionConfigKind::Select(select) = &mut opt.kind { - select.current_value = acp::SessionConfigValueId::new(default_value); - } - } - } + self.apply_default_config_options(&response.session_id, config_opts, cx); } let action_log = cx.new(|_| ActionLog::new(project.clone())); @@ -560,7 +583,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, self.display_name.clone(), - Some(cwd), + Some(work_dirs), self.clone(), project, action_log, @@ -601,7 +624,7 @@ impl AgentConnection for AcpConnection { self: Rc, session_id: acp::SessionId, project: Entity, - cwd: &Path, + work_dirs: PathList, title: Option, cx: &mut App, ) -> Task>> { @@ -610,8 +633,11 @@ impl AgentConnection for AcpConnection { "Loading sessions is not supported by this agent.".into() )))); } + // TODO: remove this once ACP supports multiple working directories + let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { + return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + }; - let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); let title = title.unwrap_or_else(|| self.display_name.clone()); @@ -619,7 +645,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, title, - Some(cwd.clone()), + Some(work_dirs.clone()), self.clone(), project, action_log, @@ -640,7 +666,7 @@ impl AgentConnection for AcpConnection { }, ); - cx.spawn(async move |_| { + cx.spawn(async move |cx| { let response = match self .connection .load_session( @@ -657,6 +683,11 @@ impl AgentConnection for AcpConnection { let (modes, models, config_options) = config_state(response.modes, response.models, response.config_options); + + if let Some(config_opts) = config_options.as_ref() { + self.apply_default_config_options(&session_id, config_opts, cx); + } + if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { session.session_modes = modes; session.models = models; @@ -671,7 +702,7 @@ impl AgentConnection for AcpConnection { self: Rc, session_id: acp::SessionId, project: Entity, - cwd: &Path, + work_dirs: PathList, title: Option, cx: &mut App, ) -> Task>> { @@ -685,8 +716,11 @@ impl AgentConnection for AcpConnection { "Resuming sessions is not supported by this agent.".into() )))); } + // TODO: remove this once ACP supports multiple working directories + let Some(cwd) = work_dirs.ordered_paths().next().cloned() else { + return Task::ready(Err(anyhow!("Working directory cannot be empty"))); + }; - let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); let title = title.unwrap_or_else(|| self.display_name.clone()); @@ -694,7 +728,7 @@ impl AgentConnection for AcpConnection { AcpThread::new( None, title, - Some(cwd.clone()), + Some(work_dirs), self.clone(), project, action_log, @@ -715,7 +749,7 @@ impl AgentConnection for AcpConnection { }, ); - cx.spawn(async move |_| { + cx.spawn(async move |cx| { let response = match self .connection .resume_session( @@ -733,6 +767,11 @@ impl AgentConnection for AcpConnection { let (modes, models, config_options) = config_state(response.modes, response.models, response.config_options); + + if let Some(config_opts) = config_options.as_ref() { + self.apply_default_config_options(&session_id, config_opts, cx); + } + if let Some(session) = self.sessions.borrow_mut().get_mut(&session_id) { session.session_modes = modes; session.models = models; @@ -743,6 +782,31 @@ impl AgentConnection for AcpConnection { }) } + fn supports_close_session(&self) -> bool { + self.agent_capabilities.session_capabilities.close.is_some() + } + + fn close_session( + self: Rc, + session_id: &acp::SessionId, + cx: &mut App, + ) -> Task> { + if !self.supports_close_session() { + return Task::ready(Err(anyhow!(LoadError::Other( + "Closing sessions is not supported by this agent.".into() + )))); + } + + let conn = self.connection.clone(); + let session_id = session_id.clone(); + cx.foreground_executor().spawn(async move { + conn.close_session(acp::CloseSessionRequest::new(session_id.clone())) + .await?; + self.sessions.borrow_mut().remove(&session_id); + Ok(()) + }) + } + fn auth_methods(&self) -> &[acp::AuthMethod] { &self.auth_methods } @@ -1372,10 +1436,10 @@ impl acp::Client for ClientDelegate { Ok(acp::CreateTerminalResponse::new(terminal_id)) } - async fn kill_terminal_command( + async fn kill_terminal( &self, - args: acp::KillTerminalCommandRequest, - ) -> Result { + args: acp::KillTerminalRequest, + ) -> Result { self.session_thread(&args.session_id)? .update(&mut self.cx.clone(), |thread, cx| { thread.kill_terminal(args.terminal_id, cx) diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index a07226ca25095fdb7037114d32d5033364a4999f..020e36b999e3586430ae99b12af55a845de91cb8 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -9,12 +9,11 @@ use collections::{HashMap, HashSet}; pub use custom::*; use fs::Fs; use http_client::read_no_proxy_from_env; -use project::agent_server_store::AgentServerStore; +use project::{AgentId, agent_server_store::AgentServerStore}; use acp_thread::AgentConnection; use anyhow::Result; -use gpui::{App, AppContext, Entity, SharedString, Task}; -use project::Project; +use gpui::{App, AppContext, Entity, Task}; use settings::SettingsStore; use std::{any::Any, rc::Rc, sync::Arc}; @@ -22,34 +21,24 @@ pub use acp::AcpConnection; pub struct AgentServerDelegate { store: Entity, - project: Entity, - status_tx: Option>, new_version_available: Option>>, } impl AgentServerDelegate { pub fn new( store: Entity, - project: Entity, - status_tx: Option>, new_version_tx: Option>>, ) -> Self { Self { store, - project, - status_tx, new_version_available: new_version_tx, } } - - pub fn project(&self) -> &Entity { - &self.project - } } pub trait AgentServer: Send { fn logo(&self) -> ui::IconName; - fn name(&self) -> SharedString; + fn agent_id(&self) -> AgentId; fn connect( &self, delegate: AgentServerDelegate, diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index b0669d1fb69e110f0ba206a3579f16738de5e7e2..d9a4469aefa957033a583a1061656dcb090eeec1 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -5,10 +5,10 @@ use anyhow::{Context as _, Result}; use collections::HashSet; use credentials_provider::CredentialsProvider; use fs::Fs; -use gpui::{App, AppContext as _, SharedString, Task}; +use gpui::{App, AppContext as _, Task}; use language_model::{ApiKey, EnvVar}; use project::agent_server_store::{ - AllAgentServersSettings, CLAUDE_AGENT_NAME, CODEX_NAME, ExternalAgentServerName, GEMINI_NAME, + AgentId, AllAgentServersSettings, CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID, }; use settings::{SettingsStore, update_settings_file}; use std::{rc::Rc, sync::Arc}; @@ -16,18 +16,18 @@ use ui::IconName; /// A generic agent server implementation for custom user-defined agents pub struct CustomAgentServer { - name: SharedString, + agent_id: AgentId, } impl CustomAgentServer { - pub fn new(name: SharedString) -> Self { - Self { name } + pub fn new(agent_id: AgentId) -> Self { + Self { agent_id } } } impl AgentServer for CustomAgentServer { - fn name(&self) -> SharedString { - self.name.clone() + fn agent_id(&self) -> AgentId { + self.agent_id.clone() } fn logo(&self) -> IconName { @@ -38,7 +38,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().0.as_ref()) .cloned() }); @@ -55,7 +55,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().0.as_ref()) .cloned() }); @@ -80,23 +80,16 @@ impl AgentServer for CustomAgentServer { fs: Arc, cx: &App, ) { - let name = self.name(); + let agent_id = self.agent_id(); let config_id = config_id.to_string(); let value_id = value_id.to_string(); - update_settings_file(fs, cx, move |settings, _| { + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { @@ -131,20 +124,13 @@ impl AgentServer for CustomAgentServer { } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { - let name = self.name(); - update_settings_file(fs, cx, move |settings, _| { + let agent_id = self.agent_id(); + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { default_mode, .. } @@ -160,7 +146,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .cloned() }); @@ -170,20 +156,13 @@ impl AgentServer for CustomAgentServer { } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { - let name = self.name(); - update_settings_file(fs, cx, move |settings, _| { + let agent_id = self.agent_id(); + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { default_model, .. } @@ -199,7 +178,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .cloned() }); @@ -221,20 +200,13 @@ impl AgentServer for CustomAgentServer { fs: Arc, cx: &App, ) { - let name = self.name(); - update_settings_file(fs, cx, move |settings, _| { + let agent_id = self.agent_id(); + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); let favorite_models = match settings { settings::CustomAgentServerSettings::Custom { @@ -263,7 +235,7 @@ impl AgentServer for CustomAgentServer { let settings = cx.read_global(|settings: &SettingsStore, _| { settings .get::(None) - .get(self.name().as_ref()) + .get(self.agent_id().as_ref()) .cloned() }); @@ -279,22 +251,15 @@ impl AgentServer for CustomAgentServer { fs: Arc, cx: &mut App, ) { - let name = self.name(); + let agent_id = self.agent_id(); let config_id = config_id.to_string(); let value_id = value_id.map(|s| s.to_string()); - update_settings_file(fs, cx, move |settings, _| { + update_settings_file(fs, cx, move |settings, cx| { let settings = settings .agent_servers .get_or_insert_default() - .entry(name.to_string()) - .or_insert_with(|| settings::CustomAgentServerSettings::Extension { - default_model: None, - default_mode: None, - env: Default::default(), - favorite_models: Vec::new(), - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }); + .entry(agent_id.0.to_string()) + .or_insert_with(|| default_settings_for_agent(agent_id, cx)); match settings { settings::CustomAgentServerSettings::Custom { @@ -324,53 +289,35 @@ impl AgentServer for CustomAgentServer { delegate: AgentServerDelegate, cx: &mut App, ) -> Task>> { - let name = self.name(); + let agent_id = self.agent_id(); let display_name = delegate .store .read(cx) - .agent_display_name(&ExternalAgentServerName(name.clone())) - .unwrap_or_else(|| name.clone()); + .agent_display_name(&agent_id) + .unwrap_or_else(|| agent_id.0.clone()); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); - let is_previous_built_in = - matches!(name.as_ref(), CLAUDE_AGENT_NAME | CODEX_NAME | GEMINI_NAME); - let (default_config_options, is_registry_agent) = - cx.read_global(|settings: &SettingsStore, _| { - let agent_settings = settings - .get::(None) - .get(self.name().as_ref()); - - let is_registry = agent_settings - .map(|s| { - matches!( - s, - project::agent_server_store::CustomAgentServerSettings::Registry { .. } - ) - }) - .unwrap_or(false); - - let config_options = agent_settings - .map(|s| match s { - project::agent_server_store::CustomAgentServerSettings::Custom { - default_config_options, - .. - } - | project::agent_server_store::CustomAgentServerSettings::Extension { - default_config_options, - .. - } - | project::agent_server_store::CustomAgentServerSettings::Registry { - default_config_options, - .. - } => default_config_options.clone(), - }) - .unwrap_or_default(); - - (config_options, is_registry) - }); - - // Intermediate step to allow for previous built-ins to also be triggered if they aren't in settings yet. - let is_registry_agent = is_registry_agent || is_previous_built_in; + let is_registry_agent = is_registry_agent(agent_id.clone(), cx); + let default_config_options = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .get(self.agent_id().as_ref()) + .map(|s| match s { + project::agent_server_store::CustomAgentServerSettings::Custom { + default_config_options, + .. + } + | project::agent_server_store::CustomAgentServerSettings::Extension { + default_config_options, + .. + } + | project::agent_server_store::CustomAgentServerSettings::Registry { + default_config_options, + .. + } => default_config_options.clone(), + }) + .unwrap_or_default() + }); if is_registry_agent { if let Some(registry_store) = project::AgentRegistryStore::try_global(cx) { @@ -383,11 +330,11 @@ impl AgentServer for CustomAgentServer { extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned()); } if is_registry_agent { - match name.as_ref() { - CLAUDE_AGENT_NAME => { + match agent_id.as_ref() { + CLAUDE_AGENT_ID => { extra_env.insert("ANTHROPIC_API_KEY".into(), "".into()); } - CODEX_NAME => { + CODEX_ID => { if let Ok(api_key) = std::env::var("CODEX_API_KEY") { extra_env.insert("CODEX_API_KEY".into(), api_key); } @@ -395,7 +342,7 @@ impl AgentServer for CustomAgentServer { extra_env.insert("OPEN_AI_API_KEY".into(), api_key); } } - GEMINI_NAME => { + GEMINI_ID => { extra_env.insert("SURFACE".to_owned(), "zed".to_owned()); } _ => {} @@ -403,28 +350,25 @@ impl AgentServer for CustomAgentServer { } let store = delegate.store.downgrade(); cx.spawn(async move |cx| { - if is_registry_agent && name.as_ref() == GEMINI_NAME { + if is_registry_agent && agent_id.as_ref() == GEMINI_ID { if let Some(api_key) = cx.update(api_key_for_gemini_cli).await.ok() { extra_env.insert("GEMINI_API_KEY".into(), api_key); } } let command = store .update(cx, |store, cx| { - let agent = store - .get_external_agent(&ExternalAgentServerName(name.clone())) - .with_context(|| { - format!("Custom agent server `{}` is not registered", name) - })?; + let agent = store.get_external_agent(&agent_id).with_context(|| { + format!("Custom agent server `{}` is not registered", agent_id) + })?; anyhow::Ok(agent.get_command( extra_env, - delegate.status_tx, delegate.new_version_available, &mut cx.to_async(), )) })?? .await?; let connection = crate::acp::connect( - name, + agent_id, display_name, command, default_mode, @@ -458,3 +402,228 @@ fn api_key_for_gemini_cli(cx: &mut App) -> Task> { ) }) } + +fn is_registry_agent(agent_id: impl Into, cx: &App) -> bool { + let agent_id = agent_id.into(); + let is_previous_built_in = + matches!(agent_id.0.as_ref(), CLAUDE_AGENT_ID | CODEX_ID | GEMINI_ID); + let is_in_registry = project::AgentRegistryStore::try_global(cx) + .map(|store| store.read(cx).agent(&agent_id).is_some()) + .unwrap_or(false); + let is_settings_registry = cx.read_global(|settings: &SettingsStore, _| { + settings + .get::(None) + .get(agent_id.as_ref()) + .is_some_and(|s| { + matches!( + s, + project::agent_server_store::CustomAgentServerSettings::Registry { .. } + ) + }) + }); + is_previous_built_in || is_in_registry || is_settings_registry +} + +fn default_settings_for_agent( + agent_id: impl Into, + cx: &App, +) -> settings::CustomAgentServerSettings { + if is_registry_agent(agent_id, cx) { + settings::CustomAgentServerSettings::Registry { + default_model: None, + default_mode: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + } + } else { + settings::CustomAgentServerSettings::Extension { + default_model: None, + default_mode: None, + env: Default::default(), + favorite_models: Vec::new(), + default_config_options: Default::default(), + favorite_config_option_values: Default::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use collections::HashMap; + use gpui::TestAppContext; + use project::agent_registry_store::{ + AgentRegistryStore, RegistryAgent, RegistryAgentMetadata, RegistryNpxAgent, + }; + use settings::Settings as _; + use ui::SharedString; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + fn init_registry_with_agents(cx: &mut TestAppContext, agent_ids: &[&str]) { + let agents: Vec = agent_ids + .iter() + .map(|id| { + let id = SharedString::from(id.to_string()); + RegistryAgent::Npx(RegistryNpxAgent { + metadata: RegistryAgentMetadata { + id: AgentId::new(id.clone()), + name: id.clone(), + description: SharedString::from(""), + version: SharedString::from("1.0.0"), + repository: None, + icon_path: None, + }, + package: id, + args: Vec::new(), + env: HashMap::default(), + }) + }) + .collect(); + cx.update(|cx| { + AgentRegistryStore::init_test_global(cx, agents); + }); + } + + fn set_agent_server_settings( + cx: &mut TestAppContext, + entries: Vec<(&str, settings::CustomAgentServerSettings)>, + ) { + cx.update(|cx| { + AllAgentServersSettings::override_global( + project::agent_server_store::AllAgentServersSettings( + entries + .into_iter() + .map(|(name, settings)| (name.to_string(), settings.into())) + .collect(), + ), + cx, + ); + }); + } + + #[gpui::test] + fn test_previous_builtins_are_registry(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + assert!(is_registry_agent(CLAUDE_AGENT_ID, cx)); + assert!(is_registry_agent(CODEX_ID, cx)); + assert!(is_registry_agent(GEMINI_ID, cx)); + }); + } + + #[gpui::test] + fn test_unknown_agent_is_not_registry(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + assert!(!is_registry_agent("my-custom-agent", cx)); + }); + } + + #[gpui::test] + fn test_agent_in_registry_store_is_registry(cx: &mut TestAppContext) { + init_test(cx); + init_registry_with_agents(cx, &["some-new-registry-agent"]); + cx.update(|cx| { + assert!(is_registry_agent("some-new-registry-agent", cx)); + assert!(!is_registry_agent("not-in-registry", cx)); + }); + } + + #[gpui::test] + fn test_agent_with_registry_settings_type_is_registry(cx: &mut TestAppContext) { + init_test(cx); + set_agent_server_settings( + cx, + vec![( + "agent-from-settings", + settings::CustomAgentServerSettings::Registry { + env: HashMap::default(), + default_mode: None, + default_model: None, + favorite_models: Vec::new(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + }, + )], + ); + cx.update(|cx| { + assert!(is_registry_agent("agent-from-settings", cx)); + }); + } + + #[gpui::test] + fn test_agent_with_extension_settings_type_is_not_registry(cx: &mut TestAppContext) { + init_test(cx); + set_agent_server_settings( + cx, + vec![( + "my-extension-agent", + settings::CustomAgentServerSettings::Extension { + env: HashMap::default(), + default_mode: None, + default_model: None, + favorite_models: Vec::new(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + }, + )], + ); + cx.update(|cx| { + assert!(!is_registry_agent("my-extension-agent", cx)); + }); + } + + #[gpui::test] + fn test_default_settings_for_builtin_agent(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + assert!(matches!( + default_settings_for_agent(CODEX_ID, cx), + settings::CustomAgentServerSettings::Registry { .. } + )); + assert!(matches!( + default_settings_for_agent(CLAUDE_AGENT_ID, cx), + settings::CustomAgentServerSettings::Registry { .. } + )); + assert!(matches!( + default_settings_for_agent(GEMINI_ID, cx), + settings::CustomAgentServerSettings::Registry { .. } + )); + }); + } + + #[gpui::test] + fn test_default_settings_for_extension_agent(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + assert!(matches!( + default_settings_for_agent("some-extension-agent", cx), + settings::CustomAgentServerSettings::Extension { .. } + )); + }); + } + + #[gpui::test] + fn test_default_settings_for_agent_in_registry(cx: &mut TestAppContext) { + init_test(cx); + init_registry_with_agents(cx, &["new-registry-agent"]); + cx.update(|cx| { + assert!(matches!( + default_settings_for_agent("new-registry-agent", cx), + settings::CustomAgentServerSettings::Registry { .. } + )); + assert!(matches!( + default_settings_for_agent("not-in-registry", cx), + settings::CustomAgentServerSettings::Extension { .. } + )); + }); + } +} diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index a0150d41726c94dc830be70e006f4370de919ead..b9365296c3fdb9ed7dc45c1c146d0abd7a831fce 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -14,6 +14,7 @@ use std::{ time::Duration, }; use util::path; +use util::path_list::PathList; pub async fn test_basic(server: F, cx: &mut TestAppContext) where @@ -431,13 +432,15 @@ pub async fn new_test_thread( cx: &mut TestAppContext, ) -> Entity { let store = project.read_with(cx, |project, _| project.agent_server_store().clone()); - let delegate = AgentServerDelegate::new(store, project.clone(), None, None); + let delegate = AgentServerDelegate::new(store, None); let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap(); - cx.update(|cx| connection.new_session(project.clone(), current_dir.as_ref(), cx)) - .await - .unwrap() + cx.update(|cx| { + connection.new_session(project.clone(), PathList::new(&[current_dir.as_ref()]), cx) + }) + .await + .unwrap() } pub async fn run_until_first_tool_call( diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 01f74de2f2ca5be863dbe27174e5131b9b8a657c..15f35a931dedad303c46895c487655b9ddbc7496 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -30,7 +30,7 @@ util.workspace = true [dev-dependencies] fs.workspace = true gpui = { workspace = true, features = ["test-support"] } -paths.workspace = true + serde_json_lenient.workspace = true serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 02341af42b9247ba07cb3f8c771a51626cd721ed..d5d4f16eb742a92f6abf8081c43709f161ef4038 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -12,7 +12,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, - NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode, + NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode, }; pub use crate::agent_profile::*; @@ -51,6 +51,7 @@ pub struct AgentSettings { pub message_editor_min_lines: usize, pub show_turn_stats: bool, pub tool_permissions: ToolPermissions, + pub new_thread_location: NewThreadLocation, } impl AgentSettings { @@ -438,6 +439,7 @@ impl Settings for AgentSettings { message_editor_min_lines: agent.message_editor_min_lines.unwrap(), show_turn_stats: agent.show_turn_stats.unwrap(), tool_permissions: compile_tool_permissions(agent.tool_permissions), + new_thread_location: agent.new_thread_location.unwrap_or_default(), } } } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 3e46e14b53c46a2aec3ac9552246a10ffc2aeee9..7a0910726e03221dc0a105d69c4852e7515e0c35 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -121,7 +121,7 @@ acp_thread = { workspace = true, features = ["test-support"] } agent = { workspace = true, features = ["test-support"] } assistant_text_thread = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } -clock.workspace = true + db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } eval_utils.workspace = true @@ -132,11 +132,8 @@ languages = { workspace = true, features = ["test-support"] } language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } -recent_projects = { workspace = true, features = ["test-support"] } -remote_connection = { workspace = true, features = ["test-support"] } -title_bar = { workspace = true, features = ["test-support"] } semver.workspace = true reqwest_client.workspace = true -tempfile.workspace = true + tree-sitter-md.workspace = true unindent.workspace = true diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index aa316ba7c5efe5f679764cd7d4626a1f1310e4c6..7c2f23fcbce43bed271c58b750145d75655d16ba 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -28,7 +28,7 @@ use language_model::{ use language_models::AllLanguageModelSettings; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ - agent_server_store::{AgentServerStore, ExternalAgentServerName, ExternalAgentSource}, + agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, }; use settings::{Settings, SettingsStore, update_settings_file}; @@ -228,6 +228,7 @@ impl AgentConfiguration { .unwrap_or(false); v_flex() + .min_w_0() .w_full() .when(is_expanded, |this| this.mb_2()) .child( @@ -312,6 +313,7 @@ impl AgentConfiguration { ) .child( v_flex() + .min_w_0() .w_full() .px_2() .gap_1() @@ -330,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(); @@ -355,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(); @@ -424,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({ @@ -459,6 +464,7 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .w_full() .child(self.render_section_title( "LLM Providers", @@ -498,6 +504,7 @@ impl AgentConfiguration { Plan::ZedFree => ("Free", Color::Default, free_chip_bg), Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), + Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), }; @@ -521,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({ @@ -559,6 +567,7 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .border_b_1() .border_color(cx.theme().colors().border) .child(self.render_section_title( @@ -802,9 +811,12 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .id(item_id.clone()) .child( h_flex() + .min_w_0() + .w_full() .justify_between() .child( h_flex() @@ -820,13 +832,13 @@ impl AgentConfiguration { .tooltip(Tooltip::text(tooltip_text)) .child(status_indicator), ) - .child(Label::new(item_id).truncate()) + .child(Label::new(item_id).flex_shrink_0().truncate()) .child( div() .id("extension-source") + .min_w_0() .mt_0p5() .mx_1() - .flex_none() .tooltip(Tooltip::text(source_tooltip)) .child( Icon::new(source_icon) @@ -962,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({ @@ -1019,6 +1032,7 @@ impl AgentConfiguration { }); v_flex() + .min_w_0() .border_b_1() .border_color(cx.theme().colors().border) .child( @@ -1089,7 +1103,7 @@ impl AgentConfiguration { ExternalAgentSource::Custom => None, }; - let agent_server_name = ExternalAgentServerName(id.clone()); + let agent_server_name = AgentId(id.clone()); let uninstall_button = match source { ExternalAgentSource::Extension => Some( @@ -1217,6 +1231,7 @@ impl Render for AgentConfiguration { .id("assistant-configuration-content") .track_scroll(&self.scroll_handle) .size_full() + .min_w_0() .overflow_y_scroll() .child(self.render_agent_servers_section(cx)) .child(self.render_context_servers_section(window, cx)) 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..334aaf4026527938144cf12e25c9a7a23d5c28ac 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 @@ -68,14 +68,17 @@ impl AddLlmProviderInput { let provider_name = single_line_input("Provider Name", provider.name(), None, 1, window, cx); let api_url = single_line_input("API URL", provider.api_url(), None, 2, window, cx); - let api_key = single_line_input( - "API Key", - "000000000000000000000000000000000000000000000000", - None, - 3, - window, - cx, - ); + let api_key = cx.new(|cx| { + InputField::new( + window, + cx, + "000000000000000000000000000000000000000000000000", + ) + .label("API Key") + .tab_index(3) + .tab_stop(true) + .masked(true) + }); Self { provider_name, @@ -340,10 +343,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 +450,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_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..79644eb26886c4ea23b9440473193a8f15bec977 --- /dev/null +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -0,0 +1,179 @@ +use std::rc::Rc; + +use acp_thread::{AgentConnection, LoadError}; +use agent_servers::{AgentServer, AgentServerDelegate}; +use anyhow::Result; +use collections::HashMap; +use futures::{FutureExt, future::Shared}; +use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task}; +use project::{AgentServerStore, AgentServersUpdated, Project}; +use watch::Receiver; + +use crate::{Agent, ThreadHistory}; + +pub enum AgentConnectionEntry { + Connecting { + connect_task: Shared>>, + }, + Connected(AgentConnectedState), + Error { + error: LoadError, + }, +} + +#[derive(Clone)] +pub struct AgentConnectedState { + pub connection: Rc, + pub history: Entity, +} + +impl AgentConnectionEntry { + pub fn wait_for_connection(&self) -> Shared>> { + match self { + AgentConnectionEntry::Connecting { connect_task } => connect_task.clone(), + AgentConnectionEntry::Connected(state) => Task::ready(Ok(state.clone())).shared(), + AgentConnectionEntry::Error { error } => Task::ready(Err(error.clone())).shared(), + } + } + + pub fn history(&self) -> Option<&Entity> { + match self { + AgentConnectionEntry::Connected(state) => Some(&state.history), + _ => None, + } + } +} + +pub enum AgentConnectionEntryEvent { + NewVersionAvailable(SharedString), +} + +impl EventEmitter for AgentConnectionEntry {} + +pub struct AgentConnectionStore { + project: Entity, + entries: HashMap>, + _subscriptions: Vec, +} + +impl AgentConnectionStore { + pub fn new(project: Entity, cx: &mut Context) -> Self { + let agent_server_store = project.read(cx).agent_server_store().clone(); + let subscription = cx.subscribe(&agent_server_store, Self::handle_agent_servers_updated); + Self { + project, + entries: HashMap::default(), + _subscriptions: vec![subscription], + } + } + + pub fn entry(&self, key: &Agent) -> Option<&Entity> { + self.entries.get(key) + } + + pub fn request_connection( + &mut self, + key: Agent, + server: Rc, + cx: &mut Context, + ) -> Entity { + self.entries.get(&key).cloned().unwrap_or_else(|| { + let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx); + let connect_task = connect_task.shared(); + + let entry = cx.new(|_cx| AgentConnectionEntry::Connecting { + connect_task: connect_task.clone(), + }); + + self.entries.insert(key.clone(), entry.clone()); + + cx.spawn({ + let key = key.clone(); + let entry = entry.clone(); + async move |this, cx| match connect_task.await { + Ok(connected_state) => { + entry.update(cx, |entry, cx| { + if let AgentConnectionEntry::Connecting { .. } = entry { + *entry = AgentConnectionEntry::Connected(connected_state); + cx.notify(); + } + }); + } + Err(error) => { + entry.update(cx, |entry, cx| { + if let AgentConnectionEntry::Connecting { .. } = entry { + *entry = AgentConnectionEntry::Error { error }; + cx.notify(); + } + }); + this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); + } + } + }) + .detach(); + + cx.spawn({ + let entry = entry.clone(); + async move |this, cx| { + while let Ok(version) = new_version_rx.recv().await { + if let Some(version) = version { + entry.update(cx, |_entry, cx| { + cx.emit(AgentConnectionEntryEvent::NewVersionAvailable( + version.clone().into(), + )); + }); + this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); + } + } + } + }) + .detach(); + + entry + }) + } + + fn handle_agent_servers_updated( + &mut self, + store: Entity, + _: &AgentServersUpdated, + cx: &mut Context, + ) { + let store = store.read(cx); + self.entries.retain(|key, _| match key { + Agent::NativeAgent => true, + Agent::Custom { id } => store.external_agents.contains_key(id), + }); + cx.notify(); + } + + fn start_connection( + &self, + server: Rc, + cx: &mut Context, + ) -> ( + Receiver>, + Task>, + ) { + let (new_version_tx, new_version_rx) = watch::channel::>(None); + + let agent_server_store = self.project.read(cx).agent_server_store().clone(); + let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx)); + + let connect_task = server.connect(delegate, cx); + let connect_task = cx.spawn(async move |_this, cx| match connect_task.await { + Ok(connection) => cx.update(|cx| { + let history = cx.new(|cx| ThreadHistory::new(connection.session_list(cx), cx)); + Ok(AgentConnectedState { + connection, + history, + }) + }), + Err(err) => match err.downcast::() { + Ok(load_error) => Err(load_error), + Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))), + }, + }); + (new_version_rx, connect_task) + } +} diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 13e62eb502de1d4bf454b47b216374a0abf2bc79..a44546fb2bfdfe4800d8087a6370635c6e96de9e 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, @@ -1804,7 +1805,7 @@ mod tests { use settings::SettingsStore; use std::{path::Path, rc::Rc}; use util::path; - use workspace::MultiWorkspace; + use workspace::{MultiWorkspace, PathList}; #[gpui::test] async fn test_multibuffer_agent_diff(cx: &mut TestAppContext) { @@ -1832,9 +1833,11 @@ mod tests { let connection = Rc::new(acp_thread::StubAgentConnection::new()); let thread = cx .update(|cx| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/test")), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) }) .await .unwrap(); @@ -2023,9 +2026,11 @@ mod tests { let connection = Rc::new(acp_thread::StubAgentConnection::new()); let thread = cx .update(|_, cx| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/test")), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/test"))]), + cx, + ) }) .await .unwrap(); 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 be12610a82f571edf140f8a30e8775fa377aac60..403c11c00b24f9de7c8c1b56a3c8ac02a3bdb77f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -13,42 +13,47 @@ use acp_thread::{AcpThread, MentionUri, ThreadStatus}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; use agent_client_protocol as acp; use agent_servers::AgentServer; +use collections::HashSet; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use itertools::Itertools; use project::{ - ExternalAgentServerName, - agent_server_store::{CLAUDE_AGENT_NAME, CODEX_NAME, GEMINI_NAME}, + AgentId, + agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID}, }; use serde::{Deserialize, Serialize}; use settings::{LanguageModelProviderSetting, LanguageModelSelection}; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; -use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff}; +use zed_actions::agent::{ + ConflictContent, OpenClaudeAgentOnboardingModal, ReauthenticateAgent, + ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff, +}; -use crate::ManageProfiles; -use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; +use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault}; use crate::{ - AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow, - InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, - OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn, - ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, ToggleStartThreadInSelector, + AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn, + Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, + OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, + StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, - connection_view::{AcpThreadViewEvent, ThreadView}, + conversation_view::{AcpThreadViewEvent, ThreadView}, slash_command::SlashCommandCompletionProvider, text_thread_editor::{AgentPanelDelegate, TextThreadEditor, make_lsp_adapter_delegate}, ui::EndTrialUpsell, }; use crate::{ - AgentInitialContent, ExternalAgent, ExternalSourcePrompt, NewExternalAgentThread, + Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread, NewNativeAgentThreadFromSummary, }; use crate::{ - ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent, + ExpandMessageEditor, ThreadHistoryView, text_thread_history::{TextThreadHistory, TextThreadHistoryEvent}, }; +use crate::{ManageProfiles, ThreadHistoryViewEvent}; +use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; -use anyhow::{Result, anyhow}; +use anyhow::{Context as _, Result, anyhow}; use assistant_slash_command::SlashCommandWorkingSet; use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary}; use client::UserStore; @@ -60,9 +65,10 @@ use extension_host::ExtensionStore; use fs::Fs; use git::repository::validate_worktree_directory; use gpui::{ - Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner, - DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, - Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, + Action, Animation, AnimationExt, AnyElement, AnyView, App, AsyncWindowContext, ClipboardItem, + Corner, DismissEvent, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, + Focusable, KeyContext, MouseButton, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, + deferred, prelude::*, pulsating_between, }; use language::LanguageRegistry; use language_model::{ConfigurationError, LanguageModelRegistry}; @@ -74,15 +80,16 @@ 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, Indicator, KeyBinding, + PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize, }; -use util::ResultExt as _; +use util::{ResultExt as _, debug_panic}; use workspace::{ - CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace, - WorkspaceId, + CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar, + MultiWorkspace, OpenResult, PathList, SIDEBAR_RESIZE_HANDLE_SIZE, SerializedPathList, + ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, + multi_workspace_enabled, }; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, @@ -94,6 +101,55 @@ const AGENT_PANEL_KEY: &str = "agent_panel"; const RECENTLY_UPDATED_MENU_LIMIT: usize = 6; const DEFAULT_THREAD_TITLE: &str = "New Thread"; +#[derive(Default)] +struct SidebarsByWindow( + collections::HashMap>, +); + +impl gpui::Global for SidebarsByWindow {} + +pub(crate) fn sidebar_is_open(window: &Window, cx: &App) -> bool { + if !multi_workspace_enabled(cx) { + return false; + } + let window_id = window.window_handle().window_id(); + cx.try_global::() + .and_then(|sidebars| sidebars.0.get(&window_id)?.upgrade()) + .is_some_and(|sidebar| sidebar.read(cx).is_open()) +} + +fn find_or_create_sidebar_for_window( + window: &mut Window, + cx: &mut App, +) -> Option> { + let window_id = window.window_handle().window_id(); + let multi_workspace = window.root::().flatten()?; + + if !cx.has_global::() { + cx.set_global(SidebarsByWindow::default()); + } + + cx.global_mut::() + .0 + .retain(|_, weak| weak.upgrade().is_some()); + + let existing = cx + .global::() + .0 + .get(&window_id) + .and_then(|weak| weak.upgrade()); + + if let Some(sidebar) = existing { + return Some(sidebar); + } + + let sidebar = cx.new(|cx| crate::sidebar::Sidebar::new(multi_workspace, window, cx)); + cx.global_mut::() + .0 + .insert(window_id, sidebar.downgrade()); + Some(sidebar) +} + fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option { let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY); let key = i64::from(workspace_id).to_string(); @@ -124,7 +180,7 @@ fn read_legacy_serialized_panel() -> Option { .and_then(|json| serde_json::from_str::(&json).log_err()) } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug)] struct SerializedAgentPanel { width: Option, selected_agent: Option, @@ -134,12 +190,12 @@ struct SerializedAgentPanel { start_thread_in: Option, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug)] struct SerializedActiveThread { session_id: String, agent_type: AgentType, title: Option, - cwd: Option, + work_dirs: Option, } pub fn init(cx: &mut App) { @@ -219,9 +275,9 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenAgentDiff, window, cx| { let thread = workspace .panel::(cx) - .and_then(|panel| panel.read(cx).active_connection_view().cloned()) - .and_then(|thread_view| { - thread_view + .and_then(|panel| panel.read(cx).active_conversation().cloned()) + .and_then(|conversation| { + conversation .read(cx) .active_thread() .map(|r| r.read(cx).thread.clone()) @@ -255,18 +311,6 @@ pub fn init(cx: &mut App) { }); } }) - .register_action(|workspace, _: &ToggleStartThreadInSelector, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| { - panel.toggle_start_thread_in_selector( - &ToggleStartThreadInSelector, - window, - cx, - ); - }); - } - }) .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| { AcpOnboardingModal::toggle(workspace, window, cx) }) @@ -358,28 +402,229 @@ pub fn init(cx: &mut App) { ); }); }) + .register_action( + |workspace, action: &ResolveConflictsWithAgent, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + let content_blocks = build_conflict_resolution_prompt(&action.conflicts); + + workspace.focus_panel::(window, cx); + + panel.update(cx, |panel, cx| { + panel.external_thread( + None, + None, + None, + None, + Some(AgentInitialContent::ContentBlock { + blocks: content_blocks, + auto_submit: true, + }), + true, + window, + cx, + ); + }); + }, + ) + .register_action( + |workspace, action: &ResolveConflictedFilesWithAgent, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + let content_blocks = + build_conflicted_files_resolution_prompt(&action.conflicted_file_paths); + + workspace.focus_panel::(window, cx); + + panel.update(cx, |panel, cx| { + panel.external_thread( + None, + None, + None, + None, + Some(AgentInitialContent::ContentBlock { + blocks: content_blocks, + auto_submit: true, + }), + true, + window, + cx, + ); + }); + }, + ) .register_action(|workspace, action: &StartThreadIn, _window, cx| { if let Some(panel) = workspace.panel::(cx) { panel.update(cx, |panel, cx| { panel.set_start_thread_in(action, cx); }); } + }) + .register_action(|workspace, _: &CycleStartThreadIn, _window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.cycle_start_thread_in(cx); + }); + } + }) + .register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| { + if !multi_workspace_enabled(cx) { + return; + } + if let Some(panel) = workspace.panel::(cx) { + if let Some(sidebar) = panel.read(cx).sidebar.clone() { + let was_open = sidebar.read(cx).is_open(); + sidebar.update(cx, |sidebar, cx| { + sidebar.toggle(window, cx); + }); + // When closing the sidebar, restore focus to the active pane + // to avoid "zombie focus" on the now-hidden sidebar elements + if was_open { + let active_pane = workspace.active_pane().clone(); + let pane_focus = active_pane.read(cx).focus_handle(cx); + window.focus(&pane_focus, cx); + } + } + } + }) + .register_action(|workspace, _: &FocusWorkspaceSidebar, window, cx| { + if !multi_workspace_enabled(cx) { + return; + } + if let Some(panel) = workspace.panel::(cx) { + if let Some(sidebar) = panel.read(cx).sidebar.clone() { + sidebar.update(cx, |sidebar, cx| { + sidebar.focus_or_unfocus(workspace, window, cx); + }); + } + } }); }, ) .detach(); } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum HistoryKind { - AgentThreads, +fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock { + let mention_uri = MentionUri::MergeConflict { + file_path: conflict.file_path.clone(), + }; + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents(acp::TextResourceContents::new( + conflict.conflict_text.clone(), + mention_uri.to_uri().to_string(), + )), + )) +} + +fn build_conflict_resolution_prompt(conflicts: &[ConflictContent]) -> Vec { + if conflicts.is_empty() { + return Vec::new(); + } + + let mut blocks = Vec::new(); + + if conflicts.len() == 1 { + let conflict = &conflicts[0]; + + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + "Please resolve the following merge conflict in ", + ))); + let mention = MentionUri::File { + abs_path: PathBuf::from(conflict.file_path.clone()), + }; + blocks.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + mention.name(), + mention.to_uri(), + ))); + + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + indoc::formatdoc!( + "\nThe conflict is between branch `{ours}` (ours) and `{theirs}` (theirs). + + Analyze both versions carefully and resolve the conflict by editing \ + the file directly. Choose the resolution that best preserves the intent \ + of both changes, or combine them if appropriate. + + ", + ours = conflict.ours_branch_name, + theirs = conflict.theirs_branch_name, + ), + ))); + } else { + let n = conflicts.len(); + let unique_files: HashSet<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect(); + let ours = &conflicts[0].ours_branch_name; + let theirs = &conflicts[0].theirs_branch_name; + blocks.push(acp::ContentBlock::Text(acp::TextContent::new( + indoc::formatdoc!( + "Please resolve all {n} merge conflicts below. + + The conflicts are between branch `{ours}` (ours) and `{theirs}` (theirs). + + For each conflict, analyze both versions carefully and resolve them \ + by editing the file{suffix} directly. Choose resolutions that best preserve \ + the intent of both changes, or combine them if appropriate. + + ", + suffix = if unique_files.len() > 1 { "s" } else { "" }, + ), + ))); + } + + for conflict in conflicts { + blocks.push(conflict_resource_block(conflict)); + } + + blocks +} + +fn build_conflicted_files_resolution_prompt( + conflicted_file_paths: &[String], +) -> Vec { + if conflicted_file_paths.is_empty() { + return Vec::new(); + } + + let instruction = indoc::indoc!( + "The following files have unresolved merge conflicts. Please open each \ + file, find the conflict markers (`<<<<<<<` / `=======` / `>>>>>>>`), \ + and resolve every conflict by editing the files directly. + + Choose resolutions that best preserve the intent of both changes, \ + or combine them if appropriate. + + Files with conflicts: + ", + ); + + let mut content = vec![acp::ContentBlock::Text(acp::TextContent::new(instruction))]; + for path in conflicted_file_paths { + let mention = MentionUri::File { + abs_path: PathBuf::from(path), + }; + content.push(acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + mention.name(), + mention.to_uri(), + ))); + content.push(acp::ContentBlock::Text(acp::TextContent::new("\n"))); + } + content +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum History { + AgentThreads { view: Entity }, TextThreads, } enum ActiveView { Uninitialized, AgentThread { - server_view: Entity, + conversation_view: Entity, }, TextThread { text_thread_editor: Entity, @@ -388,7 +633,7 @@ enum ActiveView { _subscriptions: Vec, }, History { - kind: HistoryKind, + history: History, }, Configuration, } @@ -400,16 +645,76 @@ enum WhichFontSize { } // TODO unify this with ExternalAgent -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Serialize)] pub enum AgentType { #[default] NativeAgent, TextThread, Custom { - name: SharedString, + #[serde(rename = "name")] + id: AgentId, }, } +// Custom impl handles legacy variant names from before the built-in agents were moved to +// the registry: "ClaudeAgent" -> Custom { name: "claude-acp" }, "Codex" -> Custom { name: +// "codex-acp" }, "Gemini" -> Custom { name: "gemini" }. +// Can be removed at some point in the future and go back to #[derive(Deserialize)]. +impl<'de> Deserialize<'de> for AgentType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + + if let Some(s) = value.as_str() { + return match s { + "NativeAgent" => Ok(Self::NativeAgent), + "TextThread" => Ok(Self::TextThread), + "ClaudeAgent" | "ClaudeCode" => Ok(Self::Custom { + id: CLAUDE_AGENT_ID.into(), + }), + "Codex" => Ok(Self::Custom { + id: CODEX_ID.into(), + }), + "Gemini" => Ok(Self::Custom { + id: GEMINI_ID.into(), + }), + other => Err(serde::de::Error::unknown_variant( + other, + &[ + "NativeAgent", + "TextThread", + "Custom", + "ClaudeAgent", + "ClaudeCode", + "Codex", + "Gemini", + ], + )), + }; + } + + if let Some(obj) = value.as_object() { + if let Some(inner) = obj.get("Custom") { + #[derive(Deserialize)] + struct CustomFields { + name: SharedString, + } + let fields: CustomFields = + serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?; + return Ok(Self::Custom { + id: AgentId::new(fields.name), + }); + } + } + + Err(serde::de::Error::custom( + "expected a string variant or {\"Custom\": {\"name\": ...}}", + )) + } +} + impl AgentType { pub fn is_native(&self) -> bool { matches!(self, Self::NativeAgent) @@ -418,7 +723,7 @@ impl AgentType { fn label(&self) -> SharedString { match self { Self::NativeAgent | Self::TextThread => "Zed Agent".into(), - Self::Custom { name, .. } => name.into(), + Self::Custom { id, .. } => id.0.clone(), } } @@ -430,11 +735,11 @@ impl AgentType { } } -impl From for AgentType { - fn from(value: ExternalAgent) -> Self { +impl From for AgentType { + fn from(value: Agent) -> Self { match value { - ExternalAgent::Custom { name } => Self::Custom { name }, - ExternalAgent::NativeAgent => Self::NativeAgent, + Agent::Custom { id } => Self::Custom { id }, + Agent::NativeAgent => Self::NativeAgent, } } } @@ -562,18 +867,18 @@ pub struct AgentPanel { project: Entity, fs: Arc, language_registry: Arc, - acp_history: Entity, text_thread_history: Entity, thread_store: Entity, text_thread_store: Entity, prompt_store: Option>, + connection_store: Entity, context_server_registry: Entity, configuration: Option>, configuration_subscription: Option, focus_handle: FocusHandle, active_view: ActiveView, previous_view: Option, - background_threads: HashMap>, + background_threads: HashMap>, new_thread_menu_handle: PopoverMenuHandle, start_thread_in_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, @@ -585,7 +890,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, @@ -595,6 +900,7 @@ pub struct AgentPanel { last_configuration_error_telemetry: Option, on_boarding_upsell_dismissed: AtomicBool, _active_view_observation: Option, + pub(crate) sidebar: Option>, } impl AgentPanel { @@ -604,21 +910,22 @@ 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| { let thread = thread.read(cx); let title = thread.title(); + let work_dirs = thread.work_dirs().cloned(); 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 { None }, - cwd: None, + work_dirs: work_dirs.map(|dirs| dirs.serialize()), } }); @@ -627,7 +934,7 @@ impl AgentPanel { workspace_id, SerializedAgentPanel { width, - selected_agent: Some(selected_agent), + selected_agent: Some(selected_agent_type), last_active_thread, start_thread_in, }, @@ -676,7 +983,7 @@ impl AgentPanel { let last_active_thread = if let Some(thread_info) = serialized_panel .as_ref() - .and_then(|p| p.last_active_thread.clone()) + .and_then(|p| p.last_active_thread.as_ref()) { if thread_info.agent_type.is_native() { let session_id = acp::SessionId::new(thread_info.session_id.clone()); @@ -713,7 +1020,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 = @@ -741,8 +1048,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.clone().into(), + thread_info.work_dirs.as_ref().map(|dirs| PathList::deserialize(dirs)), + thread_info.title.as_ref().map(|t| t.clone().into()), + false, + window, + cx, + ); + } }); } panel @@ -766,30 +1083,13 @@ impl AgentPanel { let client = workspace.client().clone(); let workspace_id = workspace.database_id(); let workspace = workspace.weak_handle(); - let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let thread_store = ThreadStore::global(cx); - let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx)); let text_thread_history = cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx)); - cx.subscribe_in( - &acp_history, - window, - |this, _, event, window, cx| match event { - ThreadHistoryEvent::Open(thread) => { - this.load_agent_thread( - thread.session_id.clone(), - thread.cwd.clone(), - thread.title.clone(), - window, - cx, - ); - } - }, - ) - .detach(); + cx.subscribe_in( &text_thread_history, window, @@ -809,15 +1109,18 @@ impl AgentPanel { window.defer(cx, move |window, cx| { let panel = weak_panel.clone(); let agent_navigation_menu = - ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| { + ContextMenu::build_persistent(window, cx, move |mut menu, window, cx| { if let Some(panel) = panel.upgrade() { - if let Some(kind) = panel.read(cx).history_kind_for_selected_agent(cx) { - menu = - Self::populate_recently_updated_menu_section(menu, panel, kind, cx); - let view_all_label = match kind { - HistoryKind::AgentThreads => "View All", - HistoryKind::TextThreads => "View All Text Threads", + if let Some(history) = panel + .update(cx, |panel, cx| panel.history_for_selected_agent(window, cx)) + { + let view_all_label = match history { + History::AgentThreads { .. } => "View All", + History::TextThreads => "View All Text Threads", }; + menu = Self::populate_recently_updated_menu_section( + menu, panel, history, cx, + ); menu = menu.action(view_all_label, Box::new(OpenHistory)); } } @@ -883,6 +1186,17 @@ impl AgentPanel { None }; + let connection_store = cx.new(|cx| { + let mut store = AgentConnectionStore::new(project.clone(), cx); + // Register the native agent right away, so that it is available for + // the inline assistant etc. + store.request_connection( + Agent::NativeAgent, + Agent::NativeAgent.server(fs.clone(), thread_store.clone()), + cx, + ); + store + }); let mut panel = Self { workspace_id, active_view, @@ -893,6 +1207,7 @@ impl AgentPanel { language_registry, text_thread_store, prompt_store, + connection_store, configuration: None, configuration_subscription: None, focus_handle: cx.focus_handle(), @@ -910,10 +1225,9 @@ impl AgentPanel { zoomed: false, pending_serialization: None, onboarding, - acp_history, 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, @@ -923,10 +1237,17 @@ impl AgentPanel { last_configuration_error_telemetry: None, on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()), _active_view_observation: None, + sidebar: None, }; // Initial sync of agent servers from extensions panel.sync_agent_servers_from_extensions(cx); + + cx.defer_in(window, move |this, window, cx| { + this.sidebar = find_or_create_sidebar_for_window(window, cx); + cx.notify(); + }); + panel } @@ -968,22 +1289,22 @@ impl AgentPanel { &self.thread_store } - pub fn history(&self) -> &Entity { - &self.acp_history + pub fn connection_store(&self) -> &Entity { + &self.connection_store } pub fn open_thread( &mut self, session_id: acp::SessionId, - cwd: Option, + work_dirs: Option, title: Option, window: &mut Window, cx: &mut Context, ) { self.external_thread( - Some(crate::ExternalAgent::NativeAgent), + Some(crate::Agent::NativeAgent), Some(session_id), - cwd, + work_dirs, title, None, true, @@ -1013,9 +1334,11 @@ impl AgentPanel { .unwrap_or(false) } - pub fn active_connection_view(&self) -> Option<&Entity> { + pub fn active_conversation(&self) -> Option<&Entity> { match &self.active_view { - ActiveView::AgentThread { server_view, .. } => Some(server_view), + ActiveView::AgentThread { + conversation_view, .. + } => Some(conversation_view), ActiveView::Uninitialized | ActiveView::TextThread { .. } | ActiveView::History { .. } @@ -1033,27 +1356,42 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - let Some(thread) = self - .acp_history + let session_id = action.from_session_id.clone(); + + let Some(history) = self + .connection_store .read(cx) - .session_for_id(&action.from_session_id) + .entry(&Agent::NativeAgent) + .and_then(|e| e.read(cx).history().cloned()) else { + debug_panic!("Native agent is not registered"); return; }; - self.external_thread( - Some(ExternalAgent::NativeAgent), - None, - None, - None, - Some(AgentInitialContent::ThreadSummary { - session_id: thread.session_id, - title: thread.title, - }), - true, - window, - cx, - ); + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |this, window, cx| { + let thread = history + .read(cx) + .session_for_id(&session_id) + .context("Session not found")?; + + this.external_thread( + Some(Agent::NativeAgent), + None, + None, + None, + Some(AgentInitialContent::ThreadSummary { + session_id: thread.session_id, + title: thread.title, + }), + true, + window, + cx, + ); + anyhow::Ok(()) + }) + }) + .detach_and_log_err(cx); } fn new_text_thread(&mut self, window: &mut Window, cx: &mut Context) { @@ -1080,8 +1418,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); } @@ -1101,9 +1439,9 @@ impl AgentPanel { fn external_thread( &mut self, - agent_choice: Option, + agent_choice: Option, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, initial_content: Option, focus: bool, @@ -1119,7 +1457,7 @@ impl AgentPanel { #[derive(Serialize, Deserialize)] struct LastUsedExternalAgent { - agent: crate::ExternalAgent, + agent: crate::Agent, } let thread_store = self.thread_store.clone(); @@ -1141,10 +1479,10 @@ impl AgentPanel { .detach(); let server = agent.server(fs, thread_store); - self.create_external_thread( + self.create_agent_thread( server, resume_session_id, - cwd, + work_dirs, title, initial_content, workspace, @@ -1157,7 +1495,7 @@ impl AgentPanel { } else { cx.spawn_in(window, async move |this, cx| { let ext_agent = if is_via_collab { - ExternalAgent::NativeAgent + Agent::NativeAgent } else { cx.background_spawn(async move { KEY_VALUE_STORE.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) @@ -1169,15 +1507,15 @@ impl AgentPanel { serde_json::from_str::(&value).log_err() }) .map(|agent| agent.agent) - .unwrap_or(ExternalAgent::NativeAgent) + .unwrap_or(Agent::NativeAgent) }; 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, + work_dirs, title, initial_content, workspace, @@ -1220,11 +1558,11 @@ impl AgentPanel { } fn expand_message_editor(&mut self, window: &mut Window, cx: &mut Context) { - let Some(thread_view) = self.active_connection_view() else { + let Some(conversation_view) = self.active_conversation() else { return; }; - let Some(active_thread) = thread_view.read(cx).active_thread().cloned() else { + let Some(active_thread) = conversation_view.read(cx).active_thread().cloned() else { return; }; @@ -1234,13 +1572,52 @@ impl AgentPanel { }) } - fn history_kind_for_selected_agent(&self, cx: &App) -> Option { - match self.selected_agent { - AgentType::NativeAgent => Some(HistoryKind::AgentThreads), - AgentType::TextThread => Some(HistoryKind::TextThreads), - AgentType::Custom { .. } => { - if self.acp_history.read(cx).has_session_list() { - Some(HistoryKind::AgentThreads) + fn has_history_for_selected_agent(&self, cx: &App) -> bool { + match &self.selected_agent_type { + AgentType::TextThread | AgentType::NativeAgent => true, + AgentType::Custom { id } => { + let agent = Agent::Custom { id: id.clone() }; + self.connection_store + .read(cx) + .entry(&agent) + .map_or(false, |entry| entry.read(cx).history().is_some()) + } + } + } + + fn history_for_selected_agent( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + match &self.selected_agent_type { + AgentType::TextThread => Some(History::TextThreads), + AgentType::NativeAgent => { + let history = self + .connection_store + .read(cx) + .entry(&Agent::NativeAgent)? + .read(cx) + .history()? + .clone(); + + Some(History::AgentThreads { + view: self.create_thread_history_view(Agent::NativeAgent, history, window, cx), + }) + } + AgentType::Custom { id, .. } => { + let agent = Agent::Custom { id: id.clone() }; + let history = self + .connection_store + .read(cx) + .entry(&agent)? + .read(cx) + .history()? + .clone(); + if history.read(cx).has_session_list() { + Some(History::AgentThreads { + view: self.create_thread_history_view(agent, history, window, cx), + }) } else { None } @@ -1248,13 +1625,45 @@ 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, + move |this, _, event, window, cx| match event { + ThreadHistoryViewEvent::Open(thread) => { + this.load_agent_thread( + agent.clone(), + thread.session_id.clone(), + thread.work_dirs.clone(), + thread.title.clone(), + true, + window, + cx, + ); + } + }, + ) + .detach(); + view + } + fn open_history(&mut self, window: &mut Window, cx: &mut Context) { - let Some(kind) = self.history_kind_for_selected_agent(cx) else { + let Some(history) = self.history_for_selected_agent(window, cx) else { return; }; - if let ActiveView::History { kind: active_kind } = self.active_view { - if active_kind == kind { + if let ActiveView::History { + history: active_history, + } = &self.active_view + { + if active_history == &history { if let Some(previous_view) = self.previous_view.take() { self.set_active_view(previous_view, true, window, cx); } @@ -1262,7 +1671,7 @@ impl AgentPanel { } } - self.set_active_view(ActiveView::History { kind }, true, window, cx); + self.set_active_view(ActiveView::History { history }, true, window, cx); cx.notify(); } @@ -1304,8 +1713,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); } @@ -1335,7 +1744,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - if self.history_kind_for_selected_agent(cx).is_none() { + if !self.has_history_for_selected_agent(cx) { return; } self.agent_navigation_menu_handle.toggle(window, cx); @@ -1359,15 +1768,6 @@ impl AgentPanel { self.new_thread_menu_handle.toggle(window, cx); } - pub fn toggle_start_thread_in_selector( - &mut self, - _: &ToggleStartThreadInSelector, - window: &mut Window, - cx: &mut Context, - ) { - self.start_thread_in_menu_handle.toggle(window, cx); - } - pub fn increase_font_size( &mut self, action: &IncreaseBufferFontSize, @@ -1488,8 +1888,8 @@ impl AgentPanel { cx: &mut Context, ) { if let Some(workspace) = self.workspace.upgrade() - && let Some(thread_view) = self.active_connection_view() - && let Some(active_thread) = thread_view.read(cx).active_thread().cloned() + && let Some(conversation_view) = self.active_conversation() + && let Some(active_thread) = conversation_view.read(cx).active_thread().cloned() { active_thread.update(cx, |thread, cx| { thread @@ -1679,21 +2079,23 @@ impl AgentPanel { } } - pub fn as_active_server_view(&self) -> Option<&Entity> { + pub fn active_conversation_view(&self) -> Option<&Entity> { match &self.active_view { - ActiveView::AgentThread { server_view } => Some(server_view), + ActiveView::AgentThread { conversation_view } => Some(conversation_view), _ => None, } } - pub fn as_active_thread_view(&self, cx: &App) -> Option> { - let server_view = self.as_active_server_view()?; + pub fn active_thread_view(&self, cx: &App) -> Option> { + let server_view = self.active_conversation_view()?; server_view.read(cx).active_thread().cloned() } pub fn active_agent_thread(&self, cx: &App) -> Option> { match &self.active_view { - ActiveView::AgentThread { server_view, .. } => server_view + ActiveView::AgentThread { + conversation_view, .. + } => conversation_view .read(cx) .active_thread() .map(|r| r.read(cx).thread.clone()), @@ -1711,7 +2113,7 @@ impl AgentPanel { pub fn parent_threads(&self, cx: &App) -> Vec> { let mut views = Vec::new(); - if let Some(server_view) = self.as_active_server_view() { + if let Some(server_view) = self.active_conversation_view() { if let Some(thread_view) = server_view.read(cx).parent_thread(cx) { views.push(thread_view); } @@ -1727,11 +2129,11 @@ impl AgentPanel { } fn retain_running_thread(&mut self, old_view: ActiveView, cx: &mut Context) { - let ActiveView::AgentThread { server_view } = old_view else { + let ActiveView::AgentThread { conversation_view } = old_view else { return; }; - let Some(thread_view) = server_view.read(cx).parent_thread(cx) else { + let Some(thread_view) = conversation_view.read(cx).parent_thread(cx) else { return; }; @@ -1745,14 +2147,15 @@ impl AgentPanel { return; } - self.background_threads.insert(session_id, server_view); + self.background_threads + .insert(session_id, conversation_view); } pub(crate) fn active_native_agent_thread(&self, cx: &App) -> Option> { match &self.active_view { - ActiveView::AgentThread { server_view, .. } => { - server_view.read(cx).as_native_thread(cx) - } + ActiveView::AgentThread { + conversation_view, .. + } => conversation_view.read(cx).as_native_thread(cx), _ => None, } } @@ -1776,7 +2179,7 @@ impl AgentPanel { let was_in_agent_history = matches!( self.active_view, ActiveView::History { - kind: HistoryKind::AgentThreads + history: History::AgentThreads { .. } } ); let current_is_uninitialized = matches!(self.active_view, ActiveView::Uninitialized); @@ -1808,24 +2211,26 @@ impl AgentPanel { // Re-subscribe whenever the ConnectionView changes, since the inner // ThreadView may have been replaced (e.g. navigating between threads). self._active_view_observation = match &self.active_view { - ActiveView::AgentThread { server_view } => { + ActiveView::AgentThread { conversation_view } => { self._thread_view_subscription = - Self::subscribe_to_active_thread_view(server_view, window, cx); - let focus_handle = server_view.focus_handle(cx); + Self::subscribe_to_active_thread_view(conversation_view, window, cx); + let focus_handle = conversation_view.focus_handle(cx); self._active_thread_focus_subscription = Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| { cx.emit(AgentPanelEvent::ThreadFocused); cx.notify(); })); - Some( - cx.observe_in(server_view, window, |this, server_view, window, cx| { + Some(cx.observe_in( + conversation_view, + window, + |this, server_view, window, cx| { this._thread_view_subscription = Self::subscribe_to_active_thread_view(&server_view, window, cx); cx.emit(AgentPanelEvent::ActiveViewChanged); this.serialize(cx); cx.notify(); - }), - ) + }, + )) } _ => { self._thread_view_subscription = None; @@ -1834,16 +2239,13 @@ impl AgentPanel { } }; - let is_in_agent_history = matches!( - self.active_view, - ActiveView::History { - kind: HistoryKind::AgentThreads + if let ActiveView::History { history } = &self.active_view { + if !was_in_agent_history && let History::AgentThreads { view } = history { + view.update(cx, |view, cx| { + view.history() + .update(cx, |history, cx| history.refresh_full_history(cx)) + }); } - ); - - if !was_in_agent_history && is_in_agent_history { - self.acp_history - .update(cx, |history, cx| history.refresh_full_history(cx)); } if focus { @@ -1855,14 +2257,14 @@ impl AgentPanel { fn populate_recently_updated_menu_section( mut menu: ContextMenu, panel: Entity, - kind: HistoryKind, + history: History, cx: &mut Context, ) -> ContextMenu { - match kind { - HistoryKind::AgentThreads => { - let entries = panel + match history { + History::AgentThreads { view } => { + let entries = view .read(cx) - .acp_history + .history() .read(cx) .sessions() .iter() @@ -1891,20 +2293,24 @@ 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.work_dirs.clone(), + entry.title.clone(), + true, + window, + cx, + ); + } }) .ok(); } }); } } - HistoryKind::TextThreads => { + History::TextThreads => { let entries = panel .read(cx) .text_thread_store @@ -1947,12 +2353,8 @@ impl AgentPanel { menu.separator() } - pub fn selected_agent(&self) -> AgentType { - self.selected_agent.clone() - } - fn subscribe_to_active_thread_view( - server_view: &Entity, + server_view: &Entity, window: &mut Window, cx: &mut Context, ) -> Option { @@ -1999,10 +2401,32 @@ impl AgentPanel { cx.notify(); } - fn selected_external_agent(&self) -> Option { - match &self.selected_agent { - AgentType::NativeAgent => Some(ExternalAgent::NativeAgent), - AgentType::Custom { name } => Some(ExternalAgent::Custom { name: name.clone() }), + fn cycle_start_thread_in(&mut self, cx: &mut Context) { + let next = match self.start_thread_in { + StartThreadIn::LocalProject => StartThreadIn::NewWorktree, + StartThreadIn::NewWorktree => StartThreadIn::LocalProject, + }; + self.set_start_thread_in(&next, cx); + } + + fn reset_start_thread_in_to_default(&mut self, cx: &mut Context) { + use settings::{NewThreadLocation, Settings}; + let default = AgentSettings::get_global(cx).new_thread_location; + let start_thread_in = match default { + NewThreadLocation::LocalProject => StartThreadIn::LocalProject, + NewThreadLocation::NewWorktree => StartThreadIn::NewWorktree, + }; + if self.start_thread_in != start_thread_in { + self.start_thread_in = start_thread_in; + self.serialize(cx); + cx.notify(); + } + } + + pub(crate) fn selected_agent(&self) -> Option { + match &self.selected_agent_type { + AgentType::NativeAgent => Some(Agent::NativeAgent), + AgentType::Custom { id } => Some(Agent::Custom { id: id.clone() }), AgentType::TextThread => None, } } @@ -2056,6 +2480,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + self.reset_start_thread_in_to_default(cx); self.new_agent_thread_inner(agent, true, window, cx); } @@ -2071,7 +2496,7 @@ impl AgentPanel { window.dispatch_action(NewTextThread.boxed_clone(), cx); } AgentType::NativeAgent => self.external_thread( - Some(crate::ExternalAgent::NativeAgent), + Some(crate::Agent::NativeAgent), None, None, None, @@ -2080,8 +2505,8 @@ impl AgentPanel { window, cx, ), - AgentType::Custom { name } => self.external_thread( - Some(crate::ExternalAgent::Custom { name }), + AgentType::Custom { id } => self.external_thread( + Some(crate::Agent::Custom { id }), None, None, None, @@ -2095,31 +2520,26 @@ impl AgentPanel { pub fn load_agent_thread( &mut self, + agent: Agent, 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, - session_id: acp::SessionId, - cwd: Option, + work_dirs: Option, title: Option, focus: bool, window: &mut Window, cx: &mut Context, ) { - if let Some(server_view) = self.background_threads.remove(&session_id) { - self.set_active_view(ActiveView::AgentThread { server_view }, focus, window, cx); + if let Some(conversation_view) = self.background_threads.remove(&session_id) { + self.set_active_view( + ActiveView::AgentThread { conversation_view }, + focus, + window, + cx, + ); return; } - if let ActiveView::AgentThread { server_view } = &self.active_view { - if server_view + if let ActiveView::AgentThread { conversation_view } = &self.active_view { + if conversation_view .read(cx) .active_thread() .map(|t| t.read(cx).id.clone()) @@ -2130,8 +2550,8 @@ impl AgentPanel { } } - if let Some(ActiveView::AgentThread { server_view }) = &self.previous_view { - if server_view + if let Some(ActiveView::AgentThread { conversation_view }) = &self.previous_view { + if conversation_view .read(cx) .active_thread() .map(|t| t.read(cx).id.clone()) @@ -2143,13 +2563,10 @@ impl AgentPanel { } } - let Some(agent) = self.selected_external_agent() else { - return; - }; self.external_thread( Some(agent), Some(session_id), - cwd, + work_dirs, title, None, focus, @@ -2158,23 +2575,23 @@ impl AgentPanel { ); } - pub(crate) fn create_external_thread( + pub(crate) fn create_agent_thread( &mut self, server: Rc, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, initial_content: Option, workspace: WeakEntity, project: Entity, - ext_agent: ExternalAgent, + ext_agent: Agent, focus: bool, window: &mut Window, cx: &mut Context, ) { - let selected_agent = AgentType::from(ext_agent); - if self.selected_agent != selected_agent { - self.selected_agent = selected_agent; + let selected_agent = AgentType::from(ext_agent.clone()); + if self.selected_agent_type != selected_agent { + self.selected_agent_type = selected_agent; self.serialize(cx); } let thread_store = server @@ -2183,26 +2600,29 @@ impl AgentPanel { .is_some() .then(|| self.thread_store.clone()); - let server_view = cx.new(|cx| { - crate::ConnectionView::new( + let connection_store = self.connection_store.clone(); + + let conversation_view = cx.new(|cx| { + crate::ConversationView::new( server, + connection_store, + ext_agent, resume_session_id, - cwd, + work_dirs, title, initial_content, workspace.clone(), project, thread_store, self.prompt_store.clone(), - self.acp_history.clone(), window, cx, ) }); - cx.observe(&server_view, |this, server_view, cx| { + cx.observe(&conversation_view, |this, server_view, cx| { let is_active = this - .as_active_server_view() + .active_conversation_view() .is_some_and(|active| active.entity_id() == server_view.entity_id()); if is_active { cx.emit(AgentPanelEvent::ActiveViewChanged); @@ -2214,7 +2634,12 @@ impl AgentPanel { }) .detach(); - self.set_active_view(ActiveView::AgentThread { server_view }, focus, window, cx); + self.set_active_view( + ActiveView::AgentThread { conversation_view }, + focus, + window, + cx, + ); } fn active_thread_has_messages(&self, cx: &App) -> bool { @@ -2241,6 +2666,12 @@ impl AgentPanel { } } + // TODO: The mapping from workspace root paths to git repositories needs a + // unified approach across the codebase: this method, `sidebar::is_root_repo`, + // thread persistence (which PathList is saved to the database), and thread + // querying (which PathList is used to read threads back). All of these need + // to agree on how repos are resolved for a given workspace, especially in + // multi-root and nested-repo configurations. /// Partitions the project's visible worktrees into git-backed repositories /// and plain (non-git) paths. Git repos will have worktrees created for /// them; non-git paths are carried over to the new workspace as-is. @@ -2418,8 +2849,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(); } @@ -2618,21 +3049,16 @@ impl AgentPanel { workspace.set_dock_structure(dock_structure, window, cx); })); - let (new_window_handle, _) = cx + let OpenResult { + window: new_window_handle, + workspace: new_workspace, + .. + } = cx .update(|_window, cx| { Workspace::new_local(all_paths, app_state, window_handle, None, init, false, cx) })? .await?; - let new_workspace = new_window_handle.update(cx, |multi_workspace, _window, _cx| { - let workspaces = multi_workspace.workspaces(); - workspaces.last().cloned() - })?; - - let Some(new_workspace) = new_workspace else { - anyhow::bail!("New workspace was not added to MultiWorkspace"); - }; - let panels_task = new_window_handle.update(cx, |_, _, cx| { new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()) })?; @@ -2731,10 +3157,12 @@ impl Focusable for AgentPanel { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.active_view { ActiveView::Uninitialized => self.focus_handle.clone(), - ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx), - ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => self.acp_history.focus_handle(cx), - HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), + ActiveView::AgentThread { + conversation_view, .. + } => conversation_view.focus_handle(cx), + ActiveView::History { history: kind } => match kind { + History::AgentThreads { view } => view.read(cx).focus_handle(cx), + History::TextThreads => self.text_thread_history.focus_handle(cx), }, ActiveView::TextThread { text_thread_editor, .. @@ -2816,8 +3244,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); } } @@ -2860,52 +3288,53 @@ impl AgentPanel { const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…"; let content = match &self.active_view { - ActiveView::AgentThread { server_view } => { - let is_generating_title = server_view - .read(cx) - .as_native_thread(cx) - .map_or(false, |t| t.read(cx).is_generating_title()); + ActiveView::AgentThread { conversation_view } => { + let server_view_ref = conversation_view.read(cx); + let is_generating_title = server_view_ref.as_native_thread(cx).is_some() + && server_view_ref.parent_thread(cx).map_or(false, |tv| { + tv.read(cx).thread.read(cx).has_provisional_title() + }); - if let Some(title_editor) = server_view - .read(cx) + if let Some(title_editor) = server_view_ref .parent_thread(cx) .map(|r| r.read(cx).title_editor.clone()) { - let container = div() - .w_full() - .on_action({ - let thread_view = server_view.downgrade(); - move |_: &menu::Confirm, window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window, cx); - } - } - }) - .on_action({ - let thread_view = server_view.downgrade(); - move |_: &editor::actions::Cancel, window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.focus_handle(cx).focus(window, cx); - } - } - }) - .child(title_editor); - if is_generating_title { - container + Label::new("New Thread…") + .color(Color::Muted) + .truncate() .with_animation( "generating_title", Animation::new(Duration::from_secs(2)) .repeat() .with_easing(pulsating_between(0.4, 0.8)), - |div, delta| div.opacity(delta), + |label, delta| label.alpha(delta), ) .into_any_element() } else { - container.into_any_element() + div() + .w_full() + .on_action({ + let conversation_view = conversation_view.downgrade(); + move |_: &menu::Confirm, window, cx| { + if let Some(conversation_view) = conversation_view.upgrade() { + conversation_view.focus_handle(cx).focus(window, cx); + } + } + }) + .on_action({ + let conversation_view = conversation_view.downgrade(); + move |_: &editor::actions::Cancel, window, cx| { + if let Some(conversation_view) = conversation_view.upgrade() { + conversation_view.focus_handle(cx).focus(window, cx); + } + } + }) + .child(title_editor) + .into_any_element() } } else { - Label::new(server_view.read(cx).title(cx)) + Label::new(conversation_view.read(cx).title(cx)) .color(Color::Muted) .truncate() .into_any_element() @@ -2968,10 +3397,10 @@ impl AgentPanel { .into_any_element(), } } - ActiveView::History { kind } => { + ActiveView::History { history: kind } => { let title = match kind { - HistoryKind::AgentThreads => "History", - HistoryKind::TextThreads => "Text Thread History", + History::AgentThreads { .. } => "History", + History::TextThreads => "Text Thread History", }; Label::new(title).truncate().into_any_element() } @@ -2990,9 +3419,9 @@ impl AgentPanel { .into_any() } - fn handle_regenerate_thread_title(thread_view: Entity, cx: &mut App) { - thread_view.update(cx, |thread_view, cx| { - if let Some(thread) = thread_view.as_native_thread(cx) { + fn handle_regenerate_thread_title(conversation_view: Entity, cx: &mut App) { + conversation_view.update(cx, |conversation_view, cx| { + if let Some(thread) = conversation_view.as_native_thread(cx) { thread.update(cx, |thread, cx| { thread.generate_title(cx); }); @@ -3040,18 +3469,20 @@ impl AgentPanel { _ => false, }; - let thread_view = match &self.active_view { - ActiveView::AgentThread { server_view } => Some(server_view.clone()), + let conversation_view = match &self.active_view { + ActiveView::AgentThread { conversation_view } => Some(conversation_view.clone()), _ => None, }; let thread_with_messages = match &self.active_view { - ActiveView::AgentThread { server_view } => { - server_view.read(cx).has_user_submitted_prompt(cx) + ActiveView::AgentThread { conversation_view } => { + conversation_view.read(cx).has_user_submitted_prompt(cx) } _ => false, }; let has_auth_methods = match &self.active_view { - ActiveView::AgentThread { server_view } => server_view.read(cx).has_auth_methods(), + ActiveView::AgentThread { conversation_view } => { + conversation_view.read(cx).has_auth_methods() + } _ => false, }; @@ -3095,13 +3526,13 @@ impl AgentPanel { .separator(); } - if let Some(thread_view) = thread_view.as_ref() { + if let Some(conversation_view) = conversation_view.as_ref() { menu = menu .entry("Regenerate Thread Title", None, { - let thread_view = thread_view.clone(); + let conversation_view = conversation_view.clone(); move |_, cx| { Self::handle_regenerate_thread_title( - thread_view.clone(), + conversation_view.clone(), cx, ); } @@ -3200,9 +3631,12 @@ impl AgentPanel { } fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement { + use settings::{NewThreadLocation, Settings}; + let focus_handle = self.focus_handle(cx); let has_git_repo = self.project_has_git_repository(cx); let is_via_collab = self.project.read(cx).is_via_collab(); + let fs = self.fs.clone(); let is_creating = matches!( self.worktree_creation_status, @@ -3212,6 +3646,10 @@ impl AgentPanel { let current_target = self.start_thread_in; let trigger_label = self.start_thread_in.label(); + let new_thread_location = AgentSettings::get_global(cx).new_thread_location; + let is_local_default = new_thread_location == NewThreadLocation::LocalProject; + let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree; + let icon = if self.start_thread_in_menu_handle.is_deployed() { IconName::ChevronUp } else { @@ -3219,11 +3657,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; @@ -3239,7 +3673,7 @@ impl AgentPanel { move |_window, cx| { Tooltip::for_action_in( "Start Thread In…", - &ToggleStartThreadInSelector, + &CycleStartThreadIn, &focus_handle, cx, ) @@ -3248,6 +3682,7 @@ impl AgentPanel { .menu(move |window, cx| { let is_local_selected = current_target == StartThreadIn::LocalProject; let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree; + let fs = fs.clone(); Some(ContextMenu::build(window, cx, move |menu, _window, _cx| { let new_worktree_disabled = !has_git_repo || is_via_collab; @@ -3256,18 +3691,53 @@ impl AgentPanel { .item( ContextMenuEntry::new("Current Project") .toggleable(IconPosition::End, is_local_selected) - .handler(|window, cx| { - window - .dispatch_action(Box::new(StartThreadIn::LocalProject), cx); + .documentation_aside(documentation_side, move |_| { + HoldForDefault::new(is_local_default) + .more_content(false) + .into_any_element() + }) + .handler({ + let fs = fs.clone(); + move |window, cx| { + if window.modifiers().secondary() { + update_settings_file(fs.clone(), cx, |settings, _| { + settings + .agent + .get_or_insert_default() + .set_new_thread_location( + NewThreadLocation::LocalProject, + ); + }); + } + window.dispatch_action( + Box::new(StartThreadIn::LocalProject), + cx, + ); + } }), ) .item({ let entry = ContextMenuEntry::new("New Worktree") .toggleable(IconPosition::End, is_new_worktree_selected) .disabled(new_worktree_disabled) - .handler(|window, cx| { - window - .dispatch_action(Box::new(StartThreadIn::NewWorktree), cx); + .handler({ + let fs = fs.clone(); + move |window, cx| { + if window.modifiers().secondary() { + update_settings_file(fs.clone(), cx, |settings, _| { + settings + .agent + .get_or_insert_default() + .set_new_thread_location( + NewThreadLocation::NewWorktree, + ); + }); + } + window.dispatch_action( + Box::new(StartThreadIn::NewWorktree), + cx, + ); + } }); if new_worktree_disabled { @@ -3283,7 +3753,11 @@ impl AgentPanel { .into_any_element() }) } else { - entry + entry.documentation_aside(documentation_side, move |_| { + HoldForDefault::new(is_new_worktree_default) + .more_content(false) + .into_any_element() + }) } }) })) @@ -3296,25 +3770,148 @@ impl AgentPanel { }) } + fn sidebar_info(&self, cx: &App) -> Option<(AnyView, Pixels, bool)> { + if !multi_workspace_enabled(cx) { + return None; + } + let sidebar = self.sidebar.as_ref()?; + let is_open = sidebar.read(cx).is_open(); + let width = sidebar.read(cx).width(cx); + let view: AnyView = sidebar.clone().into(); + Some((view, width, is_open)) + } + + fn render_sidebar_toggle(&self, docked_right: bool, cx: &Context) -> Option { + if !multi_workspace_enabled(cx) { + return None; + } + let sidebar = self.sidebar.as_ref()?; + let sidebar_read = sidebar.read(cx); + if sidebar_read.is_open() { + return None; + } + let has_notifications = sidebar_read.has_notifications(cx); + + let icon = if docked_right { + IconName::ThreadsSidebarRightClosed + } else { + IconName::ThreadsSidebarLeftClosed + }; + + Some( + h_flex() + .h_full() + .px_1() + .map(|this| { + if docked_right { + this.border_l_1() + } else { + this.border_r_1() + } + }) + .border_color(cx.theme().colors().border_variant) + .child( + IconButton::new("toggle-workspace-sidebar", icon) + .icon_size(IconSize::Small) + .when(has_notifications, |button| { + button + .indicator(Indicator::dot().color(Color::Accent)) + .indicator_border_color(Some( + cx.theme().colors().tab_bar_background, + )) + }) + .tooltip(move |_, cx| { + Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }), + ) + .into_any_element(), + ) + } + + fn render_sidebar(&self, cx: &Context) -> Option { + let (sidebar_view, sidebar_width, is_open) = self.sidebar_info(cx)?; + if !is_open { + return None; + } + + let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; + let sidebar = self.sidebar.as_ref()?.downgrade(); + + let resize_handle = deferred( + div() + .id("sidebar-resize-handle") + .absolute() + .when(docked_right, |this| { + this.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) + .when(!docked_right, |this| { + this.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) + .top(px(0.)) + .h_full() + .w(SIDEBAR_RESIZE_HANDLE_SIZE) + .cursor_col_resize() + .on_drag(DraggedSidebar, |dragged, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| dragged.clone()) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up(MouseButton::Left, move |event, _, cx| { + if event.click_count == 2 { + sidebar + .update(cx, |sidebar, cx| { + sidebar.set_width(None, cx); + }) + .ok(); + cx.stop_propagation(); + } + }) + .occlude(), + ); + + Some( + div() + .id("sidebar-container") + .relative() + .h_full() + .w(sidebar_width) + .flex_shrink_0() + .when(docked_right, |this| this.border_l_1()) + .when(!docked_right, |this| this.border_r_1()) + .border_color(cx.theme().colors().border) + .child(sidebar_view) + .child(resize_handle) + .into_any_element(), + ) + } + fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); + 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 { id, .. } = &self.selected_agent_type { let store = agent_server_store.read(cx); - let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + let icon = store.agent_icon(&id); let label = store - .agent_display_name(&ExternalAgentServerName(name.clone())) - .unwrap_or_else(|| self.selected_agent.label()); + .agent_display_name(&id) + .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 { - ActiveView::AgentThread { server_view } => server_view.read(cx).as_native_thread(cx), + ActiveView::AgentThread { conversation_view } => { + conversation_view.read(cx).as_native_thread(cx) + } ActiveView::Uninitialized | ActiveView::TextThread { .. } | ActiveView::History { .. } @@ -3324,7 +3921,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(); @@ -3435,24 +4032,24 @@ impl AgentPanel { registry_store.as_ref().map(|s| s.read(cx)); struct AgentMenuItem { - id: ExternalAgentServerName, + id: AgentId, display_name: SharedString, } let agent_items = agent_server_store .external_agents() - .map(|name| { + .map(|agent_id| { let display_name = agent_server_store - .agent_display_name(name) + .agent_display_name(agent_id) .or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) + .and_then(|store| store.agent(agent_id)) .map(|a| a.name().clone()) }) - .unwrap_or_else(|| name.0.clone()); + .unwrap_or_else(|| agent_id.0.clone()); AgentMenuItem { - id: name.clone(), + id: agent_id.clone(), display_name, } }) @@ -3468,7 +4065,7 @@ impl AgentPanel { .or_else(|| { registry_store_ref .as_ref() - .and_then(|store| store.agent(item.id.0.as_str())) + .and_then(|store| store.agent(&item.id)) .and_then(|a| a.icon_path().cloned()) }); @@ -3481,7 +4078,7 @@ impl AgentPanel { entry = entry .when( is_agent_selected(AgentType::Custom { - name: item.id.0.clone(), + id: item.id.clone(), }), |this| { this.action(Box::new( @@ -3503,7 +4100,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: agent_id.0.clone(), + id: agent_id.clone(), }, window, cx, @@ -3528,20 +4125,20 @@ impl AgentPanel { let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx)); - let previous_built_in_ids: &[ExternalAgentServerName] = - &[CLAUDE_AGENT_NAME.into(), CODEX_NAME.into(), GEMINI_NAME.into()]; + let previous_built_in_ids: &[AgentId] = + &[CLAUDE_AGENT_ID.into(), CODEX_ID.into(), GEMINI_ID.into()]; let promoted_items = previous_built_in_ids .iter() .filter(|id| { !agent_server_store.external_agents.contains_key(*id) }) - .filter_map(|name| { + .filter_map(|id| { let display_name = registry_store_ref .as_ref() - .and_then(|store| store.agent(name.0.as_ref())) + .and_then(|store| store.agent(&id)) .map(|a| a.name().clone())?; - Some((name.clone(), display_name)) + Some((id.clone(), display_name)) }) .sorted_unstable_by_key(|(_, display_name)| display_name.to_lowercase()) .collect::>(); @@ -3552,7 +4149,7 @@ impl AgentPanel { let icon_path = registry_store_ref .as_ref() - .and_then(|store| store.agent(agent_id.0.as_str())) + .and_then(|store| store.agent(agent_id)) .and_then(|a| a.icon_path().cloned()); if let Some(icon_path) = icon_path { @@ -3599,7 +4196,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: agent_id.0.clone(), + id: agent_id.clone(), }, window, cx, @@ -3634,13 +4231,13 @@ impl AgentPanel { }; let is_thread_loading = self - .active_connection_view() + .active_conversation() .map(|thread| thread.read(cx).is_loading()) .unwrap_or(false); 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() @@ -3650,7 +4247,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)) }) }) @@ -3677,7 +4274,7 @@ impl AgentPanel { selected_agent.into_any_element() }; - let show_history_menu = self.history_kind_for_selected_agent(cx).is_some(); + let show_history_menu = self.has_history_for_selected_agent(cx); let has_v2_flag = cx.has_flag::(); let is_empty_state = !self.active_thread_has_messages(cx); @@ -3686,7 +4283,27 @@ impl AgentPanel { ActiveView::History { .. } | ActiveView::Configuration ); - let use_v2_empty_toolbar = has_v2_flag && is_empty_state && !is_in_history_or_config; + let is_text_thread = matches!(&self.active_view, ActiveView::TextThread { .. }); + + let use_v2_empty_toolbar = + has_v2_flag && is_empty_state && !is_in_history_or_config && !is_text_thread; + + let is_sidebar_open = self + .sidebar + .as_ref() + .map(|s| s.read(cx).is_open()) + .unwrap_or(false); + + let base_container = h_flex() + .id("agent-panel-toolbar") + .h(Tab::container_height(cx)) + .max_w_full() + .flex_none() + .justify_between() + .gap_2() + .bg(cx.theme().colors().tab_bar_background) + .border_b_1() + .border_color(cx.theme().colors().border); if use_v2_empty_toolbar { let (chevron_icon, icon_color, label_color) = @@ -3696,32 +4313,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") @@ -3746,38 +4353,36 @@ impl AgentPanel { y: px(1.0), }); - h_flex() - .id("agent-panel-toolbar") - .h(Tab::container_height(cx)) - .max_w_full() - .flex_none() - .justify_between() - .gap_2() - .bg(cx.theme().colors().tab_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) + base_container .child( h_flex() .size_full() - .gap(DynamicSpacing::Base04.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) + .gap_1() + .when(is_sidebar_open || docked_right, |this| this.pl_1()) + .when(!docked_right, |this| { + this.children(self.render_sidebar_toggle(false, cx)) + }) .child(agent_selector_menu) .child(self.render_start_thread_in_selector(cx)), ) .child( h_flex() + .h_full() .flex_none() - .gap(DynamicSpacing::Base02.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .pr(DynamicSpacing::Base06.rems(cx)) - .when(show_history_menu, |this| { + .gap_1() + .pl_1() + .pr_1() + .when(show_history_menu && !has_v2_flag, |this| { this.child(self.render_recent_entries_menu( IconName::MenuAltTemp, Corner::TopRight, cx, )) }) - .child(self.render_panel_options_menu(window, cx)), + .child(self.render_panel_options_menu(window, cx)) + .when(docked_right, |this| { + this.children(self.render_sidebar_toggle(true, cx)) + }), ) .into_any_element() } else { @@ -3800,21 +4405,20 @@ impl AgentPanel { .with_handle(self.new_thread_menu_handle.clone()) .menu(move |window, cx| new_thread_menu_builder(window, cx)); - h_flex() - .id("agent-panel-toolbar") - .h(Tab::container_height(cx)) - .max_w_full() - .flex_none() - .justify_between() - .gap_2() - .bg(cx.theme().colors().tab_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) + base_container .child( h_flex() .size_full() - .gap(DynamicSpacing::Base04.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) + .map(|this| { + if is_sidebar_open || docked_right { + this.pl_1().gap_1() + } else { + this.pl_0().gap_0p5() + } + }) + .when(!docked_right, |this| { + this.children(self.render_sidebar_toggle(false, cx)) + }) .child(match &self.active_view { ActiveView::History { .. } | ActiveView::Configuration => { self.render_toolbar_back_button(cx).into_any_element() @@ -3825,19 +4429,23 @@ impl AgentPanel { ) .child( h_flex() + .h_full() .flex_none() - .gap(DynamicSpacing::Base02.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .pr(DynamicSpacing::Base06.rems(cx)) + .gap_1() + .pl_1() + .pr_1() .child(new_thread_menu) - .when(show_history_menu, |this| { + .when(show_history_menu && !has_v2_flag, |this| { this.child(self.render_recent_entries_menu( IconName::MenuAltTemp, Corner::TopRight, cx, )) }) - .child(self.render_panel_options_menu(window, cx)), + .child(self.render_panel_options_menu(window, cx)) + .when(docked_right, |this| { + this.children(self.render_sidebar_toggle(true, cx)) + }), ) .into_any_element() } @@ -3936,26 +4544,30 @@ impl AgentPanel { return false; } + let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) + .visible_providers() + .iter() + .any(|provider| { + provider.is_authenticated(cx) + && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID + }); + match &self.active_view { ActiveView::Uninitialized | ActiveView::History { .. } | ActiveView::Configuration => { false } - ActiveView::AgentThread { server_view, .. } - if server_view.read(cx).as_native_thread(cx).is_none() => - { - false + ActiveView::AgentThread { + conversation_view, .. + } if conversation_view.read(cx).as_native_thread(cx).is_none() => false, + ActiveView::AgentThread { conversation_view } => { + let history_is_empty = conversation_view + .read(cx) + .history() + .is_none_or(|h| h.read(cx).is_empty()); + history_is_empty || !has_configured_non_zed_providers } - _ => { - let history_is_empty = self.acp_history.read(cx).is_empty(); - - let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx) - .visible_providers() - .iter() - .any(|provider| { - provider.is_authenticated(cx) - && provider.id() != language_model::ZED_CLOUD_PROVIDER_ID - }); - + ActiveView::TextThread { .. } => { + let history_is_empty = self.text_thread_history.read(cx).is_empty(); history_is_empty || !has_configured_non_zed_providers } } @@ -4213,9 +4825,9 @@ impl AgentPanel { cx: &mut Context, ) { match &self.active_view { - ActiveView::AgentThread { server_view } => { - server_view.update(cx, |thread_view, cx| { - thread_view.insert_dragged_files(paths, added_worktrees, window, cx); + ActiveView::AgentThread { conversation_view } => { + conversation_view.update(cx, |conversation_view, cx| { + conversation_view.insert_dragged_files(paths, added_worktrees, window, cx); }); } ActiveView::TextThread { @@ -4309,14 +4921,15 @@ impl Render for AgentPanel { .on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::toggle_navigation_menu)) .on_action(cx.listener(Self::toggle_options_menu)) - .on_action(cx.listener(Self::toggle_start_thread_in_selector)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) .on_action(cx.listener(Self::reset_font_size)) .on_action(cx.listener(Self::toggle_zoom)) .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { - if let Some(thread_view) = this.active_connection_view() { - thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx)) + if let Some(conversation_view) = this.active_conversation() { + conversation_view.update(cx, |conversation_view, cx| { + conversation_view.reauthenticate(window, cx) + }) } })) .child(self.render_toolbar(window, cx)) @@ -4334,12 +4947,14 @@ impl Render for AgentPanel { match &self.active_view { ActiveView::Uninitialized => parent, - ActiveView::AgentThread { server_view, .. } => parent - .child(server_view.clone()) + ActiveView::AgentThread { + conversation_view, .. + } => parent + .child(conversation_view.clone()) .child(self.render_drag_target(cx)), - ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => parent.child(self.acp_history.clone()), - HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()), + ActiveView::History { history: kind } => match kind { + History::AgentThreads { view } => parent.child(view.clone()), + History::TextThreads => parent.child(self.text_thread_history.clone()), }, ActiveView::TextThread { text_thread_editor, @@ -4377,14 +4992,44 @@ impl Render for AgentPanel { }) .children(self.render_trial_end_upsell(window, cx)); + let sidebar = self.render_sidebar(cx); + let has_sidebar = sidebar.is_some(); + let docked_right = agent_panel_dock_position(cx) == DockPosition::Right; + + let panel = h_flex() + .size_full() + .when(has_sidebar, |this| { + this.on_drag_move(cx.listener( + move |this, e: &DragMoveEvent, _window, cx| { + if let Some(sidebar) = &this.sidebar { + let width = if docked_right { + e.bounds.right() - e.event.position.x + } else { + e.event.position.x + }; + sidebar.update(cx, |sidebar, cx| { + sidebar.set_width(Some(width), cx); + }); + } + }, + )) + }) + .map(|this| { + if docked_right { + this.child(content).children(sidebar) + } else { + this.children(sidebar).child(content) + } + }); + match self.active_view.which_font_size_used() { WhichFontSize::AgentFont => { WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) .size_full() - .child(content) + .child(panel) .into_any() } - _ => content.into_any(), + _ => panel.into_any(), } } } @@ -4414,17 +5059,26 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { let Some(panel) = workspace.read(cx).panel::(cx) else { return; }; + let Some(history) = panel + .read(cx) + .connection_store() + .read(cx) + .entry(&crate::Agent::NativeAgent) + .and_then(|s| s.read(cx).history()) + else { + log::error!("No connection entry found for native agent"); + return; + }; let project = workspace.read(cx).project().downgrade(); let panel = panel.read(cx); let thread_store = panel.thread_store().clone(); - let history = panel.history().downgrade(); assistant.assist( prompt_editor, self.workspace.clone(), project, thread_store, None, - history, + history.downgrade(), initial_prompt, window, cx, @@ -4501,9 +5155,9 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(thread_view) = panel.active_connection_view() { - thread_view.update(cx, |thread_view, cx| { - thread_view.insert_selections(window, cx); + if let Some(conversation_view) = panel.active_conversation() { + conversation_view.update(cx, |conversation_view, cx| { + conversation_view.insert_selections(window, cx); }); } else if let Some(text_thread_editor) = panel.active_text_thread_editor() { let snapshot = buffer.read(cx).snapshot(cx); @@ -4539,9 +5193,9 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { // Wait to create a new context until the workspace is no longer // being updated. cx.defer_in(window, move |panel, window, cx| { - if let Some(thread_view) = panel.active_connection_view() { - thread_view.update(cx, |thread_view, cx| { - thread_view.insert_terminal_text(text, window, cx); + if let Some(conversation_view) = panel.active_conversation() { + conversation_view.update(cx, |conversation_view, cx| { + conversation_view.insert_terminal_text(text, window, cx); }); } else if let Some(text_thread_editor) = panel.active_text_thread_editor() { text_thread_editor.update(cx, |text_thread_editor, cx| { @@ -4591,11 +5245,11 @@ impl AgentPanel { let workspace = self.workspace.clone(); let project = self.project.clone(); - let ext_agent = ExternalAgent::Custom { - name: server.name(), + let ext_agent = Agent::Custom { + id: server.agent_id(), }; - self.create_external_thread( + self.create_agent_thread( server, None, None, None, None, workspace, project, ext_agent, true, window, cx, ); } @@ -4604,8 +5258,8 @@ impl AgentPanel { /// /// This is a test-only accessor that exposes the private `active_thread_view()` /// method for test assertions. Not compiled into production builds. - pub fn active_thread_view_for_tests(&self) -> Option<&Entity> { - self.active_connection_view() + pub fn active_thread_view_for_tests(&self) -> Option<&Entity> { + self.active_conversation() } /// Sets the start_thread_in value directly, bypassing validation. @@ -4667,7 +5321,7 @@ impl AgentPanel { #[cfg(test)] mod tests { use super::*; - use crate::connection_view::tests::{StubAgentServer, init_test}; + use crate::conversation_view::tests::{StubAgentServer, init_test}; use crate::test_support::{active_session_id, open_thread_with_connection, send_message}; use acp_thread::{StubAgentConnection, ThreadStatus}; use assistant_text_thread::TextThreadStore; @@ -4743,7 +5397,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| { @@ -4753,8 +5407,8 @@ mod tests { panel_b.update(cx, |panel, _cx| { panel.width = Some(px(400.0)); - panel.selected_agent = AgentType::Custom { - name: "claude-acp".into(), + panel.selected_agent_type = AgentType::Custom { + id: "claude-acp".into(), }; }); @@ -4786,11 +5440,11 @@ 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!( - panel.active_connection_view().is_some(), + panel.active_conversation().is_some(), "workspace A should have its active thread restored" ); }); @@ -4803,14 +5457,14 @@ mod tests { "workspace B width should be restored" ); assert_eq!( - panel.selected_agent, + panel.selected_agent_type, AgentType::Custom { - name: "claude-acp".into() + id: "claude-acp".into() }, "workspace B agent type should be restored" ); assert!( - panel.active_connection_view().is_none(), + panel.active_conversation().is_none(), "workspace B should have no active thread" ); }); @@ -4863,6 +5517,286 @@ mod tests { cx.run_until_parked(); } + /// Extracts the text from a Text content block, panicking if it's not Text. + fn expect_text_block(block: &acp::ContentBlock) -> &str { + match block { + acp::ContentBlock::Text(t) => t.text.as_str(), + other => panic!("expected Text block, got {:?}", other), + } + } + + /// Extracts the (text_content, uri) from a Resource content block, panicking + /// if it's not a TextResourceContents resource. + fn expect_resource_block(block: &acp::ContentBlock) -> (&str, &str) { + match block { + acp::ContentBlock::Resource(r) => match &r.resource { + acp::EmbeddedResourceResource::TextResourceContents(t) => { + (t.text.as_str(), t.uri.as_str()) + } + other => panic!("expected TextResourceContents, got {:?}", other), + }, + other => panic!("expected Resource block, got {:?}", other), + } + } + + #[test] + fn test_build_conflict_resolution_prompt_single_conflict() { + let conflicts = vec![ConflictContent { + file_path: "src/main.rs".to_string(), + conflict_text: "<<<<<<< HEAD\nlet x = 1;\n=======\nlet x = 2;\n>>>>>>> feature" + .to_string(), + ours_branch_name: "HEAD".to_string(), + theirs_branch_name: "feature".to_string(), + }]; + + let blocks = build_conflict_resolution_prompt(&conflicts); + // 2 Text blocks + 1 ResourceLink + 1 Resource for the conflict + assert_eq!( + blocks.len(), + 4, + "expected 2 text + 1 resource link + 1 resource block" + ); + + let intro_text = expect_text_block(&blocks[0]); + assert!( + intro_text.contains("Please resolve the following merge conflict in"), + "prompt should include single-conflict intro text" + ); + + match &blocks[1] { + acp::ContentBlock::ResourceLink(link) => { + assert!( + link.uri.contains("file://"), + "resource link URI should use file scheme" + ); + assert!( + link.uri.contains("main.rs"), + "resource link URI should reference file path" + ); + } + other => panic!("expected ResourceLink block, got {:?}", other), + } + + let body_text = expect_text_block(&blocks[2]); + assert!( + body_text.contains("`HEAD` (ours)"), + "prompt should mention ours branch" + ); + assert!( + body_text.contains("`feature` (theirs)"), + "prompt should mention theirs branch" + ); + assert!( + body_text.contains("editing the file directly"), + "prompt should instruct the agent to edit the file" + ); + + let (resource_text, resource_uri) = expect_resource_block(&blocks[3]); + assert!( + resource_text.contains("<<<<<<< HEAD"), + "resource should contain the conflict text" + ); + assert!( + resource_uri.contains("merge-conflict"), + "resource URI should use the merge-conflict scheme" + ); + assert!( + resource_uri.contains("main.rs"), + "resource URI should reference the file path" + ); + } + + #[test] + fn test_build_conflict_resolution_prompt_multiple_conflicts_same_file() { + let conflicts = vec![ + ConflictContent { + file_path: "src/lib.rs".to_string(), + conflict_text: "<<<<<<< main\nfn a() {}\n=======\nfn a_v2() {}\n>>>>>>> dev" + .to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ConflictContent { + file_path: "src/lib.rs".to_string(), + conflict_text: "<<<<<<< main\nfn b() {}\n=======\nfn b_v2() {}\n>>>>>>> dev" + .to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ]; + + let blocks = build_conflict_resolution_prompt(&conflicts); + // 1 Text instruction + 2 Resource blocks + assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks"); + + let text = expect_text_block(&blocks[0]); + assert!( + text.contains("all 2 merge conflicts"), + "prompt should mention the total count" + ); + assert!( + text.contains("`main` (ours)"), + "prompt should mention ours branch" + ); + assert!( + text.contains("`dev` (theirs)"), + "prompt should mention theirs branch" + ); + // Single file, so "file" not "files" + assert!( + text.contains("file directly"), + "single file should use singular 'file'" + ); + + let (resource_a, _) = expect_resource_block(&blocks[1]); + let (resource_b, _) = expect_resource_block(&blocks[2]); + assert!( + resource_a.contains("fn a()"), + "first resource should contain first conflict" + ); + assert!( + resource_b.contains("fn b()"), + "second resource should contain second conflict" + ); + } + + #[test] + fn test_build_conflict_resolution_prompt_multiple_conflicts_different_files() { + let conflicts = vec![ + ConflictContent { + file_path: "src/a.rs".to_string(), + conflict_text: "<<<<<<< main\nA\n=======\nB\n>>>>>>> dev".to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ConflictContent { + file_path: "src/b.rs".to_string(), + conflict_text: "<<<<<<< main\nC\n=======\nD\n>>>>>>> dev".to_string(), + ours_branch_name: "main".to_string(), + theirs_branch_name: "dev".to_string(), + }, + ]; + + let blocks = build_conflict_resolution_prompt(&conflicts); + // 1 Text instruction + 2 Resource blocks + assert_eq!(blocks.len(), 3, "expected 1 text + 2 resource blocks"); + + let text = expect_text_block(&blocks[0]); + assert!( + text.contains("files directly"), + "multiple files should use plural 'files'" + ); + + let (_, uri_a) = expect_resource_block(&blocks[1]); + let (_, uri_b) = expect_resource_block(&blocks[2]); + assert!( + uri_a.contains("a.rs"), + "first resource URI should reference a.rs" + ); + assert!( + uri_b.contains("b.rs"), + "second resource URI should reference b.rs" + ); + } + + #[test] + fn test_build_conflicted_files_resolution_prompt_file_paths_only() { + let file_paths = vec![ + "src/main.rs".to_string(), + "src/lib.rs".to_string(), + "tests/integration.rs".to_string(), + ]; + + let blocks = build_conflicted_files_resolution_prompt(&file_paths); + // 1 instruction Text block + (ResourceLink + newline Text) per file + assert_eq!( + blocks.len(), + 1 + (file_paths.len() * 2), + "expected instruction text plus resource links and separators" + ); + + let text = expect_text_block(&blocks[0]); + assert!( + text.contains("unresolved merge conflicts"), + "prompt should describe the task" + ); + assert!( + text.contains("conflict markers"), + "prompt should mention conflict markers" + ); + + for (index, path) in file_paths.iter().enumerate() { + let link_index = 1 + (index * 2); + let newline_index = link_index + 1; + + match &blocks[link_index] { + acp::ContentBlock::ResourceLink(link) => { + assert!( + link.uri.contains("file://"), + "resource link URI should use file scheme" + ); + assert!( + link.uri.contains(path), + "resource link URI should reference file path: {path}" + ); + } + other => panic!( + "expected ResourceLink block at index {}, got {:?}", + link_index, other + ), + } + + let separator = expect_text_block(&blocks[newline_index]); + assert_eq!( + separator, "\n", + "expected newline separator after each file" + ); + } + } + + #[test] + fn test_build_conflict_resolution_prompt_empty_conflicts() { + let blocks = build_conflict_resolution_prompt(&[]); + assert!( + blocks.is_empty(), + "empty conflicts should produce no blocks, got {} blocks", + blocks.len() + ); + } + + #[test] + fn test_build_conflicted_files_resolution_prompt_empty_paths() { + let blocks = build_conflicted_files_resolution_prompt(&[]); + assert!( + blocks.is_empty(), + "empty paths should produce no blocks, got {} blocks", + blocks.len() + ); + } + + #[test] + fn test_conflict_resource_block_structure() { + let conflict = ConflictContent { + file_path: "src/utils.rs".to_string(), + conflict_text: "<<<<<<< HEAD\nold code\n=======\nnew code\n>>>>>>> branch".to_string(), + ours_branch_name: "HEAD".to_string(), + theirs_branch_name: "branch".to_string(), + }; + + let block = conflict_resource_block(&conflict); + let (text, uri) = expect_resource_block(&block); + + assert_eq!( + text, conflict.conflict_text, + "resource text should be the raw conflict" + ); + assert!( + uri.starts_with("zed:///agent/merge-conflict"), + "URI should use the zed merge-conflict scheme, got: {uri}" + ); + assert!(uri.contains("utils.rs"), "URI should encode the file path"); + } + async fn setup_panel(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { init_test(cx); cx.update(|cx| { @@ -4947,7 +5881,7 @@ mod tests { send_message(&panel, &mut cx); let weak_view_a = panel.read_with(&cx, |panel, _cx| { - panel.active_connection_view().unwrap().downgrade() + panel.active_conversation().unwrap().downgrade() }); // Thread A should be idle (auto-completed via set_next_prompt_updates). @@ -5007,7 +5941,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. @@ -5310,4 +6252,77 @@ mod tests { ); }); } + + #[test] + fn test_deserialize_legacy_agent_type_variants() { + assert_eq!( + serde_json::from_str::(r#""ClaudeAgent""#).unwrap(), + AgentType::Custom { + id: CLAUDE_AGENT_ID.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""ClaudeCode""#).unwrap(), + AgentType::Custom { + id: CLAUDE_AGENT_ID.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""Codex""#).unwrap(), + AgentType::Custom { + id: CODEX_ID.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""Gemini""#).unwrap(), + AgentType::Custom { + id: GEMINI_ID.into(), + }, + ); + } + + #[test] + fn test_deserialize_current_agent_type_variants() { + assert_eq!( + serde_json::from_str::(r#""NativeAgent""#).unwrap(), + AgentType::NativeAgent, + ); + assert_eq!( + serde_json::from_str::(r#""TextThread""#).unwrap(), + AgentType::TextThread, + ); + assert_eq!( + serde_json::from_str::(r#"{"Custom":{"name":"my-agent"}}"#).unwrap(), + AgentType::Custom { + id: "my-agent".into(), + }, + ); + } + + #[test] + fn test_deserialize_legacy_serialized_panel() { + let json = serde_json::json!({ + "width": 300.0, + "selected_agent": "ClaudeAgent", + "last_active_thread": { + "session_id": "test-session", + "agent_type": "Codex", + }, + }); + + let panel: SerializedAgentPanel = serde_json::from_value(json).unwrap(); + assert_eq!( + panel.selected_agent, + Some(AgentType::Custom { + id: CLAUDE_AGENT_ID.into(), + }), + ); + let thread = panel.last_active_thread.unwrap(); + assert_eq!( + thread.agent_type, + AgentType::Custom { + id: CODEX_ID.into(), + }, + ); + } } 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/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 8cf18a872e8c3f2332c1633d34833d7a09ad5c95..dde70f15e8084144d9beb1d4fb9563cf12fb942e 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,4 +1,5 @@ mod agent_configuration; +pub(crate) mod agent_connection_store; mod agent_diff; mod agent_model_selector; mod agent_panel; @@ -7,9 +8,9 @@ mod branch_names; mod buffer_codegen; mod completion_provider; mod config_options; -pub(crate) mod connection_view; mod context; mod context_server_configuration; +pub(crate) mod conversation_view; mod entry_view_state; mod external_source_prompt; mod favorite_models; @@ -22,6 +23,7 @@ mod mode_selector; mod model_selector; mod model_selector_popover; mod profile_selector; +pub mod sidebar; mod slash_command; mod slash_command_picker; mod terminal_codegen; @@ -31,6 +33,9 @@ pub mod test_support; mod text_thread_editor; mod text_thread_history; mod thread_history; +mod thread_history_view; +mod thread_metadata_store; +mod threads_archive_view; mod ui; use std::rc::Rc; @@ -51,7 +56,7 @@ use language::{ use language_model::{ ConfiguredModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; -use project::DisableAiSettings; +use project::{AgentId, DisableAiSettings}; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -66,13 +71,14 @@ pub use crate::agent_panel::{ use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; -pub(crate) use connection_view::ConnectionView; +pub(crate) use conversation_view::ConversationView; pub use external_source_prompt::ExternalSourcePrompt; pub(crate) use mode_selector::ModeSelector; pub(crate) use model_selector::ModelSelector; pub(crate) use model_selector_popover::ModelSelectorPopover; pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor}; -pub(crate) use thread_history::*; +pub(crate) use thread_history::ThreadHistory; +pub(crate) use thread_history_view::*; use zed_actions; actions!( @@ -82,8 +88,8 @@ actions!( NewTextThread, /// Toggles the menu to create new agent threads. ToggleNewThreadMenu, - /// Toggles the selector for choosing where new threads start (current project or new worktree). - ToggleStartThreadInSelector, + /// Cycles through the options for where new threads start (current project or new worktree). + CycleStartThreadIn, /// Toggles the navigation menu for switching between threads and views. ToggleNavigationMenu, /// Toggles the options menu for agent settings and preferences. @@ -201,7 +207,7 @@ pub struct NewThread; #[serde(deny_unknown_fields)] pub struct NewExternalAgentThread { /// Which agent to use for the conversation. - agent: Option, + agent: Option, } #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] @@ -212,14 +218,76 @@ pub struct NewNativeAgentThreadFromSummary { } // TODO unify this with AgentType -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] -pub enum ExternalAgent { +pub enum Agent { NativeAgent, - Custom { name: SharedString }, + Custom { + #[serde(rename = "name")] + id: AgentId, + }, } -impl ExternalAgent { +// Custom impl handles legacy variant names from before the built-in agents were moved to +// the registry: "claude_code" -> Custom { name: "claude-acp" }, "codex" -> Custom { name: +// "codex-acp" }, "gemini" -> Custom { name: "gemini" }. +// Can be removed at some point in the future and go back to #[derive(Deserialize)]. +impl<'de> serde::Deserialize<'de> for Agent { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use project::agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID}; + + let value = serde_json::Value::deserialize(deserializer)?; + + if let Some(s) = value.as_str() { + return match s { + "native_agent" => Ok(Self::NativeAgent), + "claude_code" | "claude_agent" => Ok(Self::Custom { + id: CLAUDE_AGENT_ID.into(), + }), + "codex" => Ok(Self::Custom { + id: CODEX_ID.into(), + }), + "gemini" => Ok(Self::Custom { + id: GEMINI_ID.into(), + }), + other => Err(serde::de::Error::unknown_variant( + other, + &[ + "native_agent", + "custom", + "claude_agent", + "claude_code", + "codex", + "gemini", + ], + )), + }; + } + + if let Some(obj) = value.as_object() { + if let Some(inner) = obj.get("custom") { + #[derive(serde::Deserialize)] + struct CustomFields { + name: SharedString, + } + let fields: CustomFields = + serde_json::from_value(inner.clone()).map_err(serde::de::Error::custom)?; + return Ok(Self::Custom { + id: AgentId::new(fields.name), + }); + } + } + + Err(serde::de::Error::custom( + "expected a string variant or {\"custom\": {\"name\": ...}}", + )) + } +} + +impl Agent { pub fn server( &self, fs: Arc, @@ -227,7 +295,9 @@ impl ExternalAgent { ) -> Rc { match self { Self::NativeAgent => Rc::new(agent::NativeAgentServer::new(fs, thread_store)), - Self::Custom { name } => Rc::new(agent_servers::CustomAgentServer::new(name.clone())), + Self::Custom { id: name } => { + Rc::new(agent_servers::CustomAgentServer::new(name.clone())) + } } } } @@ -316,6 +386,7 @@ pub fn init( agent_panel::init(cx); context_server_configuration::init(language_registry.clone(), fs.clone(), cx); TextThreadEditor::init(cx); + thread_metadata_store::init(cx); register_slash_commands(cx); inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); @@ -594,6 +665,7 @@ mod tests { message_editor_min_lines: 1, tool_permissions: Default::default(), show_turn_stats: false, + new_thread_location: Default::default(), }; cx.update(|cx| { @@ -685,4 +757,42 @@ mod tests { ); }); } + + #[test] + fn test_deserialize_legacy_external_agent_variants() { + use project::agent_server_store::{CLAUDE_AGENT_ID, CODEX_ID, GEMINI_ID}; + + assert_eq!( + serde_json::from_str::(r#""claude_code""#).unwrap(), + Agent::Custom { + id: CLAUDE_AGENT_ID.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""codex""#).unwrap(), + Agent::Custom { + id: CODEX_ID.into(), + }, + ); + assert_eq!( + serde_json::from_str::(r#""gemini""#).unwrap(), + Agent::Custom { + id: GEMINI_ID.into(), + }, + ); + } + + #[test] + fn test_deserialize_current_external_agent_variants() { + assert_eq!( + serde_json::from_str::(r#""native_agent""#).unwrap(), + Agent::NativeAgent, + ); + assert_eq!( + serde_json::from_str::(r#"{"custom":{"name":"my-agent"}}"#).unwrap(), + Agent::Custom { + id: "my-agent".into(), + }, + ); + } } diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 40ad7bc729269d5dae3364ecf3e0de6e5ee5b0ec..d8c45755413ffb14433e3eeb4309e869de195a75 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -64,6 +64,7 @@ pub(crate) enum PromptContextType { Thread, Rules, Diagnostics, + BranchDiff, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -102,6 +103,7 @@ impl TryFrom<&str> for PromptContextType { "thread" => Ok(Self::Thread), "rule" => Ok(Self::Rules), "diagnostics" => Ok(Self::Diagnostics), + "diff" => Ok(Self::BranchDiff), _ => Err(format!("Invalid context picker mode: {}", value)), } } @@ -116,6 +118,7 @@ impl PromptContextType { Self::Thread => "thread", Self::Rules => "rule", Self::Diagnostics => "diagnostics", + Self::BranchDiff => "branch diff", } } @@ -127,6 +130,7 @@ impl PromptContextType { Self::Thread => "Threads", Self::Rules => "Rules", Self::Diagnostics => "Diagnostics", + Self::BranchDiff => "Branch Diff", } } @@ -138,6 +142,7 @@ impl PromptContextType { Self::Thread => IconName::Thread, Self::Rules => IconName::Reader, Self::Diagnostics => IconName::Warning, + Self::BranchDiff => IconName::GitBranch, } } } @@ -150,6 +155,12 @@ pub(crate) enum Match { Fetch(SharedString), Rules(RulesContextEntry), Entry(EntryMatch), + BranchDiff(BranchDiffMatch), +} + +#[derive(Debug, Clone)] +pub struct BranchDiffMatch { + pub base_ref: SharedString, } impl Match { @@ -162,6 +173,7 @@ impl Match { Match::Symbol(_) => 1., Match::Rules(_) => 1., Match::Fetch(_) => 1., + Match::BranchDiff(_) => 1., } } } @@ -781,6 +793,47 @@ impl PromptCompletionProvider { } } + fn build_branch_diff_completion( + base_ref: SharedString, + source_range: Range, + source: Arc, + editor: WeakEntity, + mention_set: WeakEntity, + workspace: Entity, + cx: &mut App, + ) -> Completion { + let uri = MentionUri::GitDiff { + base_ref: base_ref.to_string(), + }; + let crease_text: SharedString = format!("Branch Diff (vs {})", base_ref).into(); + let display_text = format!("@{}", crease_text); + let new_text = format!("[{}]({}) ", display_text, uri.to_uri()); + let new_text_len = new_text.len(); + let icon_path = uri.icon_path(cx); + + Completion { + replace_range: source_range.clone(), + new_text, + label: CodeLabel::plain(crease_text.to_string(), None), + documentation: None, + source: project::CompletionSource::Custom, + icon_path: Some(icon_path), + match_start: None, + snippet_deduplication_key: None, + insert_text_mode: None, + confirm: Some(confirm_completion_callback( + crease_text, + source_range.start, + new_text_len - 1, + uri, + source, + editor, + mention_set, + workspace, + )), + } + } + fn search_slash_commands(&self, query: String, cx: &mut App) -> Task> { let commands = self.source.available_commands(cx); if commands.is_empty() { @@ -812,6 +865,27 @@ impl PromptCompletionProvider { }) } + fn fetch_branch_diff_match( + &self, + workspace: &Entity, + cx: &mut App, + ) -> Option>> { + let project = workspace.read(cx).project().clone(); + let repo = project.read(cx).active_repository(cx)?; + + let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false)); + + Some(cx.spawn(async move |_cx| { + let base_ref = default_branch_receiver + .await + .ok() + .and_then(|r| r.ok()) + .flatten()?; + + Some(BranchDiffMatch { base_ref }) + })) + } + fn search_mentions( &self, mode: Option, @@ -892,6 +966,8 @@ impl PromptCompletionProvider { Some(PromptContextType::Diagnostics) => Task::ready(Vec::new()), + Some(PromptContextType::BranchDiff) => Task::ready(Vec::new()), + None if query.is_empty() => { let recent_task = self.recent_context_picker_entries(&workspace, cx); let entries = self @@ -905,9 +981,25 @@ impl PromptCompletionProvider { }) .collect::>(); + let branch_diff_task = if self + .source + .supports_context(PromptContextType::BranchDiff, cx) + { + self.fetch_branch_diff_match(&workspace, cx) + } else { + None + }; + cx.spawn(async move |_cx| { let mut matches = recent_task.await; matches.extend(entries); + + if let Some(branch_diff_task) = branch_diff_task { + if let Some(branch_diff_match) = branch_diff_task.await { + matches.push(Match::BranchDiff(branch_diff_match)); + } + } + matches }) } @@ -924,7 +1016,16 @@ impl PromptCompletionProvider { .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword())) .collect::>(); - cx.background_spawn(async move { + let branch_diff_task = if self + .source + .supports_context(PromptContextType::BranchDiff, cx) + { + self.fetch_branch_diff_match(&workspace, cx) + } else { + None + }; + + cx.spawn(async move |cx| { let mut matches = search_files_task .await .into_iter() @@ -949,6 +1050,26 @@ impl PromptCompletionProvider { }) })); + if let Some(branch_diff_task) = branch_diff_task { + let branch_diff_keyword = PromptContextType::BranchDiff.keyword(); + let branch_diff_matches = fuzzy::match_strings( + &[StringMatchCandidate::new(0, branch_diff_keyword)], + &query, + false, + true, + 1, + &Arc::new(AtomicBool::default()), + cx.background_executor().clone(), + ) + .await; + + if !branch_diff_matches.is_empty() { + if let Some(branch_diff_match) = branch_diff_task.await { + matches.push(Match::BranchDiff(branch_diff_match)); + } + } + } + matches.sort_by(|a, b| { b.score() .partial_cmp(&a.score()) @@ -1364,6 +1485,17 @@ impl CompletionProvider for PromptCompletio cx, ) } + Match::BranchDiff(branch_diff) => { + Some(Self::build_branch_diff_completion( + branch_diff.base_ref, + source_range.clone(), + source.clone(), + editor.clone(), + mention_set.clone(), + workspace.clone(), + cx, + )) + } }) .collect::>() }); 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/conversation_view.rs similarity index 84% rename from crates/agent_ui/src/connection_view.rs rename to crates/agent_ui/src/conversation_view.rs index 07841c42215795ffcccf9f7e5ca684f42a59b498..c9a6c7334d22cc3159c4fc6dde02fe57c8676ae9 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -5,10 +5,12 @@ use acp_thread::{ UserMessageId, }; use acp_thread::{AgentConnection, Plan}; -use action_log::{ActionLog, ActionLogTelemetry}; +use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; use agent_client_protocol::{self as acp, PromptCapabilities}; -use agent_servers::{AgentServer, AgentServerDelegate}; +use agent_servers::AgentServer; +#[cfg(test)] +use agent_servers::AgentServerDelegate; use agent_settings::{AgentProfileId, AgentSettings}; use anyhow::{Result, anyhow}; use arrayvec::ArrayVec; @@ -34,17 +36,17 @@ use gpui::{ use language::Buffer; use language_model::LanguageModelRegistry; use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; -use project::{AgentServerStore, ExternalAgentServerName, Project, ProjectEntryId}; +use project::{AgentId, AgentServerStore, Project, ProjectEntryId}; use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore}; use std::cell::RefCell; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; use terminal_view::terminal_panel::TerminalPanel; -use text::{Anchor, ToPoint as _}; +use text::Anchor; use theme::AgentFontSize; use ui::{ Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, @@ -54,6 +56,7 @@ use ui::{ }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use util::{debug_panic, defer}; +use workspace::PathList; use workspace::{ CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId, }; @@ -65,18 +68,22 @@ use super::entry_view_state::EntryViewState; use super::thread_history::ThreadHistory; use crate::ModeSelector; use crate::ModelSelectorPopover; +use crate::agent_connection_store::{ + AgentConnectedState, AgentConnectionEntryEvent, AgentConnectionStore, +}; use crate::agent_diff::AgentDiff; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; +use crate::thread_metadata_store::ThreadMetadataStore; use crate::ui::{AgentNotification, AgentNotificationEvent}; use crate::{ - AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, - ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, - EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu, - OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage, SendImmediately, - SendNextQueuedMessage, ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu, - ToggleThinkingMode, UndoLastReject, + Agent, AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, + AuthorizeToolCall, ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, + CycleThinkingEffort, EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, + OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, + RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode, + ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject, }; const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30); @@ -299,17 +306,18 @@ pub enum AcpServerViewEvent { ActiveThreadChanged, } -impl EventEmitter for ConnectionView {} +impl EventEmitter for ConversationView {} -pub struct ConnectionView { +pub struct ConversationView { agent: Rc, + connection_store: Entity, + connection_key: Agent, agent_server_store: Entity, workspace: WeakEntity, project: Entity, thread_store: Option>, prompt_store: Option>, server_state: ServerState, - history: Entity, focus_handle: FocusHandle, notifications: Vec>, notification_subscriptions: HashMap, Vec>, @@ -317,7 +325,7 @@ pub struct ConnectionView { _subscriptions: Vec, } -impl ConnectionView { +impl ConversationView { pub fn has_auth_methods(&self) -> bool { self.as_connected().map_or(false, |connected| { !connected.connection.auth_methods().is_empty() @@ -413,7 +421,9 @@ pub struct ConnectedServerState { active_id: Option, threads: HashMap>, connection: Rc, + history: Entity, conversation: Entity, + _connection_entry_subscription: Subscription, } enum AuthState { @@ -434,9 +444,7 @@ impl AuthState { struct LoadingView { session_id: Option, - title: SharedString, _load_task: Task<()>, - _update_title_task: Task>, } impl ConnectedServerState { @@ -456,10 +464,13 @@ impl ConnectedServerState { } pub fn close_all_sessions(&self, cx: &mut App) -> Task<()> { - let tasks = self - .threads - .keys() - .map(|id| self.connection.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; @@ -467,18 +478,19 @@ impl ConnectedServerState { } } -impl ConnectionView { +impl ConversationView { pub fn new( agent: Rc, + connection_store: Entity, + connection_key: Agent, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, initial_content: Option, workspace: WeakEntity, project: Entity, thread_store: Option>, prompt_store: Option>, - history: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -509,6 +521,8 @@ impl ConnectionView { Self { agent: agent.clone(), + connection_store: connection_store.clone(), + connection_key: connection_key.clone(), agent_server_store, workspace, project: project.clone(), @@ -516,8 +530,10 @@ impl ConnectionView { prompt_store, server_state: Self::initial_state( agent.clone(), + connection_store, + connection_key, resume_session_id, - cwd, + work_dirs, title, project, initial_content, @@ -527,7 +543,6 @@ impl ConnectionView { notifications: Vec::new(), notification_subscriptions: HashMap::default(), auth_task: None, - history, _subscriptions: subscriptions, focus_handle: cx.focus_handle(), } @@ -550,7 +565,7 @@ impl ConnectionView { let thread = thread_view.read(cx).thread.read(cx); ( Some(thread.session_id().clone()), - thread.cwd().cloned(), + thread.work_dirs().cloned(), Some(thread.title()), ) }) @@ -558,6 +573,8 @@ impl ConnectionView { let state = Self::initial_state( self.agent.clone(), + self.connection_store.clone(), + self.connection_key.clone(), resume_session_id, cwd, title, @@ -584,8 +601,10 @@ impl ConnectionView { fn initial_state( agent: Rc, + connection_store: Entity, + connection_key: Agent, resume_session_id: Option, - cwd: Option, + work_dirs: Option, title: Option, project: Entity, initial_content: Option, @@ -621,48 +640,42 @@ impl ConnectionView { } }) .collect(); - let session_cwd = cwd - .filter(|cwd| { - // Validate with the normalized path (rejects `..` traversals), - // but return the original cwd to preserve its path separators. - // On Windows, `normalize_lexically` rebuilds the path with - // backslashes via `PathBuf::push`, which would corrupt - // forward-slash Linux paths used by WSL agents. - util::paths::normalize_lexically(cwd) - .ok() - .is_some_and(|normalized| { - worktree_roots - .iter() - .any(|root| normalized.starts_with(root.as_ref())) - }) - }) - .map(|path| path.into()) - .or_else(|| worktree_roots.first().cloned()) - .unwrap_or_else(|| paths::home_dir().as_path().into()); - - let (status_tx, mut status_rx) = watch::channel("Loading…".into()); - let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None); - let delegate = AgentServerDelegate::new( - project.read(cx).agent_server_store().clone(), - project.clone(), - Some(status_tx), - Some(new_version_available_tx), - ); + let session_work_dirs = work_dirs.unwrap_or_else(|| { + if worktree_roots.is_empty() { + PathList::new(&[paths::home_dir().as_path()]) + } else { + PathList::new(&worktree_roots) + } + }); + + let connection_entry = connection_store.update(cx, |store, cx| { + store.request_connection(connection_key, agent.clone(), cx) + }); + + let connection_entry_subscription = + cx.subscribe(&connection_entry, |this, _entry, event, cx| match event { + AgentConnectionEntryEvent::NewVersionAvailable(version) => { + if let Some(thread) = this.active_thread() { + thread.update(cx, |thread, cx| { + thread.new_server_version_available = Some(version.clone()); + cx.notify(); + }); + } + } + }); + + let connect_result = connection_entry.read(cx).wait_for_connection(); - let connect_task = agent.connect(delegate, cx); let load_session_id = resume_session_id.clone(); let load_task = cx.spawn_in(window, async move |this, cx| { - let connection = match connect_task.await { - Ok(connection) => connection, + let (connection, history) = match connect_result.await { + Ok(AgentConnectedState { + connection, + history, + }) => (connection, history), Err(err) => { this.update_in(cx, |this, window, cx| { - if err.downcast_ref::().is_some() { - this.handle_load_error(load_session_id.clone(), err, window, cx); - } else if let Some(active) = this.active_thread() { - active.update(cx, |active, cx| active.handle_thread_error(err, cx)); - } else { - this.handle_load_error(load_session_id.clone(), err, window, cx); - } + this.handle_load_error(load_session_id.clone(), err, window, cx); cx.notify(); }) .log_err(); @@ -679,7 +692,7 @@ impl ConnectionView { connection.clone().load_session( session_id, project.clone(), - &session_cwd, + session_work_dirs, title, cx, ) @@ -688,7 +701,7 @@ impl ConnectionView { connection.clone().resume_session( session_id, project.clone(), - &session_cwd, + session_work_dirs, title, cx, ) @@ -703,7 +716,7 @@ impl ConnectionView { cx.update(|_, cx| { connection .clone() - .new_session(project.clone(), session_cwd.as_ref(), cx) + .new_session(project.clone(), session_work_dirs, cx) }) .log_err() }; @@ -719,7 +732,7 @@ impl ConnectionView { Self::handle_auth_required( this, err, - agent.name(), + agent.agent_id(), connection, window, cx, @@ -748,6 +761,7 @@ impl ConnectionView { conversation.clone(), resumed_without_history, initial_content, + history.clone(), window, cx, ); @@ -761,14 +775,6 @@ impl ConnectionView { } let id = current.read(cx).thread.read(cx).session_id().clone(); - let session_list = if connection.supports_session_history() { - connection.session_list(cx) - } else { - None - }; - this.history.update(cx, |history, cx| { - history.set_session_list(session_list, cx); - }); this.set_server_state( ServerState::Connected(ConnectedServerState { connection, @@ -776,52 +782,28 @@ impl ConnectionView { active_id: Some(id.clone()), threads: HashMap::from_iter([(id, current)]), conversation, + history, + _connection_entry_subscription: connection_entry_subscription, }), cx, ); } Err(err) => { - this.handle_load_error(load_session_id.clone(), err, window, cx); + this.handle_load_error( + load_session_id.clone(), + LoadError::Other(err.to_string().into()), + window, + cx, + ); } }; }) .log_err(); }); - cx.spawn(async move |this, cx| { - while let Ok(new_version) = new_version_available_rx.recv().await { - if let Some(new_version) = new_version { - this.update(cx, |this, cx| { - if let Some(thread) = this.active_thread() { - thread.update(cx, |thread, _cx| { - thread.new_server_version_available = Some(new_version.into()); - }); - } - cx.notify(); - }) - .ok(); - } - } - }) - .detach(); - - let loading_view = cx.new(|cx| { - let update_title_task = cx.spawn(async move |this, cx| { - loop { - let status = status_rx.recv().await?; - this.update(cx, |this: &mut LoadingView, cx| { - this.title = status; - cx.notify(); - })?; - } - }); - - LoadingView { - session_id: resume_session_id, - title: "Loading…".into(), - _load_task: load_task, - _update_title_task: update_title_task, - } + let loading_view = cx.new(|_cx| LoadingView { + session_id: resume_session_id, + _load_task: load_task, }); ServerState::Loading(loading_view) @@ -834,10 +816,11 @@ impl ConnectionView { conversation: Entity, resumed_without_history: bool, initial_content: Option, + history: Entity, window: &mut Window, cx: &mut Context, ) -> Entity { - let agent_name = self.agent.name(); + let agent_id = self.agent.agent_id(); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![])); @@ -850,11 +833,11 @@ impl ConnectionView { self.workspace.clone(), self.project.downgrade(), self.thread_store.clone(), - self.history.downgrade(), + history.downgrade(), self.prompt_store.clone(), prompt_capabilities.clone(), available_commands.clone(), - self.agent.name(), + self.agent.agent_id(), ) }); @@ -977,19 +960,19 @@ impl ConnectionView { let agent_display_name = self .agent_server_store .read(cx) - .agent_display_name(&ExternalAgentServerName(agent_name.clone())) - .unwrap_or_else(|| agent_name.clone()); + .agent_display_name(&agent_id.clone()) + .unwrap_or_else(|| agent_id.0.clone()); let agent_icon = self.agent.logo(); let agent_icon_from_external_svg = self .agent_server_store .read(cx) - .agent_icon(&ExternalAgentServerName(self.agent.name())) + .agent_icon(&self.agent.agent_id()) .or_else(|| { project::AgentRegistryStore::try_global(cx).and_then(|store| { store .read(cx) - .agent(self.agent.name().as_ref()) + .agent(&self.agent.agent_id()) .and_then(|a| a.icon_path().cloned()) }) }); @@ -1003,7 +986,7 @@ impl ConnectionView { weak, agent_icon, agent_icon_from_external_svg, - agent_name, + agent_id, agent_display_name, self.workspace.clone(), entry_view_state, @@ -1017,7 +1000,7 @@ impl ConnectionView { resumed_without_history, self.project.downgrade(), self.thread_store.clone(), - self.history.clone(), + history, self.prompt_store.clone(), initial_content, subscriptions, @@ -1030,7 +1013,7 @@ impl ConnectionView { fn handle_auth_required( this: WeakEntity, err: AuthRequired, - agent_name: SharedString, + agent_id: AgentId, connection: Rc, window: &mut Window, cx: &mut App, @@ -1059,7 +1042,7 @@ impl ConnectionView { let view = registry.read(cx).provider(&provider_id).map(|provider| { provider.configuration_view( - language_model::ConfigurationViewTargetAgent::Other(agent_name), + language_model::ConfigurationViewTargetAgent::Other(agent_id.0), window, cx, ) @@ -1099,6 +1082,8 @@ impl ConnectionView { threads: HashMap::default(), connection, conversation: cx.new(|_cx| Conversation::default()), + history: cx.new(|cx| ThreadHistory::new(None, cx)), + _connection_entry_subscription: Subscription::new(|| {}), }), cx, ); @@ -1111,7 +1096,7 @@ impl ConnectionView { fn handle_load_error( &mut self, session_id: Option, - err: anyhow::Error, + err: LoadError, window: &mut Window, cx: &mut Context, ) { @@ -1125,15 +1110,10 @@ impl ConnectionView { self.focus_handle.focus(window, cx) } } - let load_error = if let Some(load_err) = err.downcast_ref::() { - load_err.clone() - } else { - LoadError::Other(format!("{:#}", err).into()) - }; - self.emit_load_error_telemetry(&load_error); + self.emit_load_error_telemetry(&err); self.set_server_state( ServerState::LoadError { - error: load_error, + error: err, session_id, }, cx, @@ -1172,17 +1152,19 @@ impl ConnectionView { &self.workspace } - pub fn title(&self, cx: &App) -> SharedString { + pub fn title(&self, _cx: &App) -> SharedString { match &self.server_state { ServerState::Connected(_) => "New Thread".into(), - ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(), + ServerState::Loading(_) => "Loading…".into(), ServerState::LoadError { error, .. } => match error { - LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), + LoadError::Unsupported { .. } => { + format!("Upgrade {}", self.agent.agent_id()).into() + } LoadError::FailedToInstall(_) => { - format!("Failed to Install {}", self.agent.name()).into() + format!("Failed to Install {}", self.agent.agent_id()).into() } - LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(), - LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(), + LoadError::Exited { .. } => format!("{} Exited", self.agent.agent_id()).into(), + LoadError::Other(_) => format!("Error Loading {}", self.agent.agent_id()).into(), }, } } @@ -1274,13 +1256,14 @@ impl ConnectionView { } } AcpThreadEvent::EntryUpdated(index) => { - if let Some(entry_view_state) = self - .thread_view(&thread_id) - .map(|active| active.read(cx).entry_view_state.clone()) - { + if let Some(active) = self.thread_view(&thread_id) { + let entry_view_state = active.read(cx).entry_view_state.clone(); entry_view_state.update(cx, |view_state, cx| { view_state.sync_entry(*index, thread, window, cx) }); + active.update(cx, |active, cx| { + active.auto_expand_streaming_thought(cx); + }); } } AcpThreadEvent::EntriesRemoved(range) => { @@ -1312,6 +1295,7 @@ impl ConnectionView { if let Some(active) = self.thread_view(&thread_id) { active.update(cx, |active, _cx| { active.thread_retry_status.take(); + active.clear_auto_expand_tracking(); }); } if is_subagent { @@ -1444,7 +1428,7 @@ impl ConnectionView { .connection() .auth_methods() .iter() - .any(|method| method.id.0.as_ref() == "claude-login") + .any(|method| method.id().0.as_ref() == "claude-login") { available_commands.push(acp::AvailableCommand::new("login", "Authenticate")); available_commands.push(acp::AvailableCommand::new("logout", "Authenticate")); @@ -1460,8 +1444,8 @@ impl ConnectionView { let agent_display_name = self .agent_server_store .read(cx) - .agent_display_name(&ExternalAgentServerName(self.agent.name())) - .unwrap_or_else(|| self.agent.name()); + .agent_display_name(&self.agent.agent_id()) + .unwrap_or_else(|| self.agent.agent_id().0.to_string().into()); if let Some(active) = self.active_thread() { let new_placeholder = @@ -1508,10 +1492,15 @@ impl ConnectionView { let agent_telemetry_id = connection.telemetry_id(); // Check for the experimental "terminal-auth" _meta field - let auth_method = connection.auth_methods().iter().find(|m| m.id == method); + let auth_method = connection.auth_methods().iter().find(|m| m.id() == &method); if let Some(terminal_auth) = auth_method - .and_then(|a| a.meta.as_ref()) + .and_then(|a| match a { + acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(), + acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(), + acp::AuthMethod::Agent(agent) => agent.meta.as_ref(), + _ => None, + }) .and_then(|m| m.get("terminal-auth")) { // Extract terminal auth details from meta @@ -1677,24 +1666,21 @@ impl ConnectionView { { return; } - let root_dir = self - .project + let Some(parent_thread) = connected.threads.get(&parent_id) else { + return; + }; + let work_dirs = parent_thread .read(cx) - .worktrees(cx) - .filter_map(|worktree| { - if worktree.read(cx).is_single_file() { - Some(worktree.read(cx).abs_path().parent()?.into()) - } else { - Some(worktree.read(cx).abs_path()) - } - }) - .next(); - let cwd = root_dir.unwrap_or_else(|| paths::home_dir().as_path().into()); + .thread + .read(cx) + .work_dirs() + .cloned() + .unwrap_or_else(|| PathList::new(&[paths::home_dir().as_path()])); let subagent_thread_task = connected.connection.clone().load_session( subagent_id.clone(), self.project.clone(), - &cwd, + work_dirs, None, cx, ); @@ -1702,10 +1688,10 @@ impl ConnectionView { cx.spawn_in(window, async move |this, cx| { let subagent_thread = subagent_thread_task.await?; this.update_in(cx, |this, window, cx| { - let conversation = this + let Some((conversation, history)) = this .as_connected() - .map(|connected| connected.conversation.clone()); - let Some(conversation) = conversation else { + .map(|connected| (connected.conversation.clone(), connected.history.clone())) + else { return; }; conversation.update(cx, |conversation, cx| { @@ -1717,6 +1703,7 @@ impl ConnectionView { conversation, false, None, + history, window, cx, ); @@ -1879,8 +1866,8 @@ impl ConnectionView { let agent_display_name = self .agent_server_store .read(cx) - .agent_display_name(&ExternalAgentServerName(self.agent.name())) - .unwrap_or_else(|| self.agent.name()); + .agent_display_name(&self.agent.agent_id()) + .unwrap_or_else(|| self.agent.agent_id().0); let show_fallback_description = auth_methods.len() > 1 && configuration_view.is_none() @@ -1895,7 +1882,7 @@ impl ConnectionView { .enumerate() .rev() .map(|(ix, method)| { - let (method_id, name) = (method.id.0.clone(), method.name.clone()); + let (method_id, name) = (method.id().0.clone(), method.name().to_string()); let agent_telemetry_id = connection.telemetry_id(); Button::new(method_id.clone(), name) @@ -1907,8 +1894,8 @@ impl ConnectionView { this.style(ButtonStyle::Outlined) } }) - .when_some(method.description.clone(), |this, description| { - this.tooltip(Tooltip::text(description)) + .when_some(method.description(), |this, description| { + this.tooltip(Tooltip::text(description.to_string())) }) .on_click({ cx.listener(move |this, _, window, cx| { @@ -2041,7 +2028,7 @@ impl ConnectionView { LoadError::Other(_) => "other", }; - let agent_name = self.agent.name(); + let agent_name = self.agent.agent_id(); telemetry::event!( "Agent Panel Error Shown", @@ -2100,7 +2087,7 @@ impl ConnectionView { cx: &mut Context, ) -> AnyElement { let (heading_label, description_label) = ( - format!("Upgrade {} to work with Zed", self.agent.name()), + format!("Upgrade {} to work with Zed", self.agent.agent_id()), if version.is_empty() { format!( "Currently using {}, which does not report a valid --version", @@ -2220,12 +2207,14 @@ impl ConnectionView { let needed_count = self.queued_messages_len(cx); let queued_messages = self.queued_message_contents(cx); - let agent_name = self.agent.name(); + let agent_name = self.agent.agent_id(); let workspace = self.workspace.clone(); let project = self.project.downgrade(); - let history = self.history.downgrade(); - - let Some(thread) = self.active_thread() else { + let Some(connected) = self.as_connected() else { + return; + }; + let history = connected.history.downgrade(); + let Some(thread) = connected.active_view() else { return; }; let prompt_capabilities = thread.read(cx).prompt_capabilities.clone(); @@ -2324,7 +2313,7 @@ impl ConnectionView { fn render_markdown(&self, markdown: Entity, style: MarkdownStyle) -> MarkdownElement { let workspace = self.workspace.clone(); MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| { - crate::connection_view::thread_view::open_link(text, &workspace, window, cx); + crate::conversation_view::thread_view::open_link(text, &workspace, window, cx); }) } @@ -2353,7 +2342,7 @@ impl ConnectionView { } if let Some(multi_workspace) = window.root::().flatten() { - multi_workspace.read(cx).is_sidebar_open() + crate::agent_panel::sidebar_is_open(window, cx) || self.agent_panel_visible(&multi_workspace, cx) } else { self.workspace @@ -2397,7 +2386,7 @@ impl ConnectionView { } // TODO: Change this once we have title summarization for external agents. - let title = self.agent.name(); + let title = self.agent.agent_id().0; match settings.notify_when_agent_waiting { NotifyWhenAgentWaiting::PrimaryScreen => { @@ -2586,7 +2575,7 @@ impl ConnectionView { .unwrap_or_else(|| SharedString::from("The model")) } else { // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI") - self.agent.name() + self.agent.agent_id().0 } } @@ -2597,7 +2586,7 @@ impl ConnectionView { } pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { - let agent_name = self.agent.name(); + let agent_id = self.agent.agent_id(); if let Some(active) = self.active_thread() { active.update(cx, |active, cx| active.clear_thread_error(cx)); } @@ -2607,22 +2596,29 @@ impl ConnectionView { return; }; window.defer(cx, |window, cx| { - Self::handle_auth_required( - this, - AuthRequired::new(), - agent_name, - connection, - window, - cx, - ); + Self::handle_auth_required(this, AuthRequired::new(), agent_id, connection, window, cx); }) } + pub fn history(&self) -> Option<&Entity> { + self.as_connected().map(|c| &c.history) + } + pub fn delete_history_entry(&mut self, session_id: &acp::SessionId, cx: &mut Context) { - let task = self + let Some(connected) = self.as_connected() else { + return; + }; + + let task = connected .history .update(cx, |history, cx| history.delete_session(&session_id, cx)); task.detach_and_log_err(cx); + + if let Some(store) = ThreadMetadataStore::try_global(cx) { + store + .update(cx, |store, cx| store.delete(session_id.clone(), cx)) + .detach_and_log_err(cx); + } } } @@ -2635,7 +2631,7 @@ fn loading_contents_spinner(size: IconSize) -> AnyElement { } fn placeholder_text(agent_name: &str, has_commands: bool) -> String { - if agent_name == "Zed Agent" { + if agent_name == agent::ZED_AGENT_ID.as_ref() { format!("Message the {} — @ to include context", agent_name) } else if has_commands { format!( @@ -2647,7 +2643,7 @@ fn placeholder_text(agent_name: &str, has_commands: bool) -> String { } } -impl Focusable for ConnectionView { +impl Focusable for ConversationView { fn focus_handle(&self, cx: &App) -> FocusHandle { match self.active_thread() { Some(thread) => thread.read(cx).focus_handle(cx), @@ -2657,7 +2653,7 @@ impl Focusable for ConnectionView { } #[cfg(any(test, feature = "test-support"))] -impl ConnectionView { +impl ConversationView { /// Expands a tool call so its content is visible. /// This is primarily useful for visual testing. pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context) { @@ -2670,7 +2666,7 @@ impl ConnectionView { } } -impl Render for ConnectionView { +impl Render for ConversationView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_queued_message_editors(window, cx); let v2_flag = cx.has_flag::(); @@ -2790,9 +2786,10 @@ pub(crate) mod tests { async fn test_drop(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, _cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; - let weak_view = thread_view.downgrade(); - drop(thread_view); + let (conversation_view, _cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; + let weak_view = conversation_view.downgrade(); + drop(conversation_view); assert!(!weak_view.is_upgradable()); } @@ -2805,14 +2802,14 @@ pub(crate) mod tests { }; let initial_content = AgentInitialContent::FromExternalSource(prompt); - let (thread_view, cx) = setup_thread_view_with_initial_content( + let (conversation_view, cx) = setup_conversation_view_with_initial_content( StubAgentServer::default_response(), initial_content, cx, ) .await; - active_thread(&thread_view, cx).read_with(cx, |view, cx| { + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { assert!(view.show_external_source_prompt_warning); assert_eq!(view.thread.read(cx).entries().len(), 0); assert_eq!(view.message_editor.read(cx).text(cx), "Write me a script"); @@ -2828,17 +2825,18 @@ pub(crate) mod tests { }; let initial_content = AgentInitialContent::FromExternalSource(prompt); - let (thread_view, cx) = setup_thread_view_with_initial_content( + let (conversation_view, cx) = setup_conversation_view_with_initial_content( StubAgentServer::default_response(), initial_content, cx, ) .await; - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); - active_thread(&thread_view, cx).read_with(cx, |view, cx| { + active_thread(&conversation_view, cx).read_with(cx, |view, cx| { assert!(!view.show_external_source_prompt_warning); assert_eq!(view.message_editor.read(cx).text(cx), ""); assert_eq!(view.thread.read(cx).entries().len(), 2); @@ -2849,16 +2847,18 @@ pub(crate) mod tests { async fn test_notification_for_stop_event(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); cx.deactivate_window(); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -2873,17 +2873,18 @@ pub(crate) mod tests { async fn test_notification_for_error(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(SaboteurAgentConnection), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(SaboteurAgentConnection), cx).await; - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); cx.deactivate_window(); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -2908,13 +2909,15 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - // Create history without an initial session list - it will be set after connection - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - let thread_view = cx.update(|window, cx| { + let conversation_view = cx.update(|window, cx| { cx.new(|cx| { - ConnectionView::new( + ConversationView::new( Rc::new(StubAgentServer::default_response()), + connection_store, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -2923,7 +2926,6 @@ pub(crate) mod tests { project, Some(thread_store), None, - history.clone(), window, cx, ) @@ -2933,8 +2935,16 @@ pub(crate) mod tests { // Wait for connection to establish cx.run_until_parked(); + let history = cx.update(|_window, cx| { + conversation_view + .read(cx) + .history() + .expect("Missing history") + .clone() + }); + // Initially empty because StubAgentConnection.session_list() returns None - active_thread(&thread_view, cx).read_with(cx, |view, _cx| { + active_thread(&conversation_view, cx).read_with(cx, |view, _cx| { assert_eq!(view.recent_history_entries.len(), 0); }); @@ -2946,7 +2956,7 @@ pub(crate) mod tests { }); cx.run_until_parked(); - active_thread(&thread_view, cx).read_with(cx, |view, _cx| { + active_thread(&conversation_view, cx).read_with(cx, |view, _cx| { assert_eq!(view.recent_history_entries.len(), 1); assert_eq!( view.recent_history_entries[0].session_id, @@ -2962,7 +2972,7 @@ pub(crate) mod tests { }); cx.run_until_parked(); - active_thread(&thread_view, cx).read_with(cx, |view, _cx| { + active_thread(&conversation_view, cx).read_with(cx, |view, _cx| { assert_eq!(view.recent_history_entries.len(), 1); assert_eq!( view.recent_history_entries[0].session_id, @@ -2976,7 +2986,7 @@ pub(crate) mod tests { init_test(cx); let session = AgentSessionInfo::new(SessionId::new("history-session")); - let (thread_view, history, cx) = setup_thread_view_with_history( + let (conversation_view, history, cx) = setup_thread_view_with_history( StubAgentServer::new(SessionHistoryConnection::new(vec![session.clone()])), cx, ) @@ -2989,7 +2999,7 @@ pub(crate) mod tests { ); }); - active_thread(&thread_view, cx).read_with(cx, |view, _cx| { + active_thread(&conversation_view, cx).read_with(cx, |view, _cx| { assert_eq!(view.recent_history_entries.len(), 1); assert_eq!( view.recent_history_entries[0].session_id, @@ -3009,12 +3019,15 @@ pub(crate) mod tests { 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 history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - let thread_view = cx.update(|window, cx| { + let conversation_view = cx.update(|window, cx| { cx.new(|cx| { - ConnectionView::new( + ConversationView::new( Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)), + connection_store, + Agent::Custom { id: "Test".into() }, Some(SessionId::new("resume-session")), None, None, @@ -3023,7 +3036,6 @@ pub(crate) mod tests { project, Some(thread_store), None, - history, window, cx, ) @@ -3032,7 +3044,7 @@ pub(crate) mod tests { cx.run_until_parked(); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { let state = view.active_thread().unwrap(); assert!(state.read(cx).resumed_without_history); assert_eq!(state.read(cx).list_state.item_count(), 0); @@ -3059,24 +3071,26 @@ pub(crate) mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); + let captured_cwd = connection.captured_work_dirs.clone(); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); - let _thread_view = cx.update(|window, cx| { + let _conversation_view = cx.update(|window, cx| { cx.new(|cx| { - ConnectionView::new( + ConversationView::new( Rc::new(StubAgentServer::new(connection)), + connection_store, + Agent::Custom { id: "Test".into() }, Some(SessionId::new("session-1")), - Some(PathBuf::from("/project/subdir")), + Some(PathList::new(&[PathBuf::from("/project/subdir")])), None, None, workspace.downgrade(), project, Some(thread_store), None, - history, window, cx, ) @@ -3086,132 +3100,31 @@ pub(crate) mod tests { cx.run_until_parked(); assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project/subdir")), + captured_cwd.lock().as_ref().unwrap(), + &PathList::new(&[Path::new("/project/subdir")]), "Should use session cwd when it's inside the project" ); } - #[gpui::test] - async fn test_resume_thread_uses_fallback_cwd_when_outside_project(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], 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 connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); - - let _thread_view = cx.update(|window, cx| { - cx.new(|cx| { - ConnectionView::new( - Rc::new(StubAgentServer::new(connection)), - Some(SessionId::new("session-1")), - Some(PathBuf::from("/some/other/path")), - None, - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - history, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project")), - "Should use fallback project cwd when session cwd is outside the project" - ); - } - - #[gpui::test] - async fn test_resume_thread_rejects_unnormalized_cwd_outside_project(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "file.txt": "hello" - }), - ) - .await; - let project = Project::test(fs, [Path::new("/project")], 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 connection = CwdCapturingConnection::new(); - let captured_cwd = connection.captured_cwd.clone(); - - let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); - - let _thread_view = cx.update(|window, cx| { - cx.new(|cx| { - ConnectionView::new( - Rc::new(StubAgentServer::new(connection)), - Some(SessionId::new("session-1")), - Some(PathBuf::from("/project/../outside")), - None, - None, - workspace.downgrade(), - project, - Some(thread_store), - None, - history, - window, - cx, - ) - }) - }); - - cx.run_until_parked(); - - assert_eq!( - captured_cwd.lock().as_deref(), - Some(Path::new("/project")), - "Should reject unnormalized cwd that resolves outside the project and use fallback cwd" - ); - } - #[gpui::test] async fn test_refusal_handling(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(RefusalAgentConnection), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(RefusalAgentConnection), cx).await; - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Do something harmful", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Check that the refusal error is set - thread_view.read_with(cx, |thread_view, cx| { + conversation_view.read_with(cx, |thread_view, cx| { let state = thread_view.active_thread().unwrap(); assert!( matches!(state.read(cx).thread_error, Some(ThreadError::Refusal)), @@ -3224,9 +3137,9 @@ pub(crate) mod tests { async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = setup_thread_view(FailingAgentServer, cx).await; + let (conversation_view, cx) = setup_conversation_view(FailingAgentServer, cx).await; - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { let title = view.title(cx); assert_eq!( title.as_ref(), @@ -3260,11 +3173,12 @@ pub(crate) mod tests { init_test(cx); let connection = AuthGatedAgentConnection::new(); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; // When new_session returns AuthRequired, the server should transition // to Connected + Unauthenticated rather than getting stuck in Loading. - thread_view.read_with(cx, |view, _cx| { + conversation_view.read_with(cx, |view, _cx| { let connected = view .as_connected() .expect("Should be in Connected state even though auth is required"); @@ -3282,7 +3196,7 @@ pub(crate) mod tests { ); }); - thread_view.read_with(cx, |view, _cx| { + conversation_view.read_with(cx, |view, _cx| { assert!( view.active_thread().is_none(), "active_thread() should be None when unauthenticated without a session" @@ -3292,7 +3206,7 @@ pub(crate) mod tests { // Authenticate using the real authenticate flow on ConnectionView. // This calls connection.authenticate(), which flips the internal flag, // then on success triggers reset() -> new_session() which now succeeds. - thread_view.update_in(cx, |view, window, cx| { + conversation_view.update_in(cx, |view, window, cx| { view.authenticate( acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID), window, @@ -3302,7 +3216,7 @@ pub(crate) mod tests { cx.run_until_parked(); // After auth, the server should have an active thread in the Ok state. - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { let connected = view .as_connected() .expect("Should still be in Connected state after auth"); @@ -3347,16 +3261,18 @@ pub(crate) mod tests { connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); cx.deactivate_window(); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -3371,11 +3287,12 @@ pub(crate) mod tests { async fn test_notification_when_panel_hidden(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; - add_to_workspace(thread_view.clone(), cx); + add_to_workspace(conversation_view.clone(), cx); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); @@ -3385,7 +3302,8 @@ pub(crate) mod tests { // Note: In the test environment, the panel is not actually added to the dock, // so is_agent_panel_hidden will return true - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -3402,9 +3320,10 @@ pub(crate) mod tests { async fn test_notification_still_works_when_window_inactive(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); @@ -3412,7 +3331,8 @@ pub(crate) mod tests { // Deactivate window - should show notification regardless of setting cx.deactivate_window(); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -3476,13 +3396,16 @@ pub(crate) mod tests { // Set up thread view in workspace 1 let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); - let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx))); let agent = StubAgentServer::default_response(); - let thread_view = cx.update(|window, cx| { + let conversation_view = cx.update(|window, cx| { cx.new(|cx| { - ConnectionView::new( + ConversationView::new( Rc::new(agent), + connection_store, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -3491,7 +3414,6 @@ pub(crate) mod tests { project1.clone(), Some(thread_store), None, - history, window, cx, ) @@ -3499,7 +3421,7 @@ pub(crate) mod tests { }); cx.run_until_parked(); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); @@ -3526,7 +3448,8 @@ pub(crate) mod tests { // Window is active, agent panel is visible in workspace1, but workspace1 // is in the background. The notification should show because the user // can't actually see the agent panel. - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -3573,16 +3496,18 @@ pub(crate) mod tests { ); }); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); // Window is active - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -3599,18 +3524,20 @@ pub(crate) mod tests { async fn test_notification_closed_when_thread_view_dropped(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; - let weak_view = thread_view.downgrade(); + let weak_view = conversation_view.downgrade(); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); cx.deactivate_window(); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -3623,7 +3550,7 @@ pub(crate) mod tests { ); // Drop the thread view (simulating navigation to a new thread) - drop(thread_view); + drop(conversation_view); drop(message_editor); // Trigger an update to flush effects, which will call release_dropped_entities cx.update(|_window, _cx| {}); @@ -3644,43 +3571,50 @@ pub(crate) mod tests { ); } - async fn setup_thread_view( + async fn setup_conversation_view( agent: impl AgentServer + 'static, cx: &mut TestAppContext, - ) -> (Entity, &mut VisualTestContext) { - let (thread_view, _history, cx) = setup_thread_view_with_history(agent, cx).await; - (thread_view, cx) + ) -> (Entity, &mut VisualTestContext) { + let (conversation_view, _history, cx) = + setup_conversation_view_with_history_and_initial_content(agent, None, cx).await; + (conversation_view, cx) } async fn setup_thread_view_with_history( agent: impl AgentServer + 'static, cx: &mut TestAppContext, ) -> ( - Entity, + Entity, Entity, &mut VisualTestContext, ) { - setup_thread_view_with_history_and_initial_content(agent, None, cx).await + let (conversation_view, history, cx) = + setup_conversation_view_with_history_and_initial_content(agent, None, cx).await; + (conversation_view, history.expect("Missing history"), cx) } - async fn setup_thread_view_with_initial_content( + async fn setup_conversation_view_with_initial_content( agent: impl AgentServer + 'static, initial_content: AgentInitialContent, cx: &mut TestAppContext, - ) -> (Entity, &mut VisualTestContext) { - let (thread_view, _history, cx) = - setup_thread_view_with_history_and_initial_content(agent, Some(initial_content), cx) - .await; - (thread_view, cx) + ) -> (Entity, &mut VisualTestContext) { + let (conversation_view, _history, cx) = + setup_conversation_view_with_history_and_initial_content( + agent, + Some(initial_content), + cx, + ) + .await; + (conversation_view, cx) } - async fn setup_thread_view_with_history_and_initial_content( + async fn setup_conversation_view_with_history_and_initial_content( agent: impl AgentServer + 'static, initial_content: Option, cx: &mut TestAppContext, ) -> ( - Entity, - Entity, + Entity, + Option>, &mut VisualTestContext, ) { let fs = FakeFs::new(cx.executor()); @@ -3690,12 +3624,17 @@ pub(crate) mod tests { 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 history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); + + let agent_key = Agent::Custom { id: "Test".into() }; - let thread_view = cx.update(|window, cx| { + let conversation_view = cx.update(|window, cx| { cx.new(|cx| { - ConnectionView::new( + ConversationView::new( Rc::new(agent), + connection_store.clone(), + agent_key.clone(), None, None, None, @@ -3704,23 +3643,31 @@ pub(crate) mod tests { project, Some(thread_store), None, - history.clone(), window, cx, ) }) }); cx.run_until_parked(); - (thread_view, history, cx) + + let history = cx.update(|_window, cx| { + connection_store + .read(cx) + .entry(&agent_key) + .and_then(|e| e.read(cx).history().cloned()) + }); + + (conversation_view, history, cx) } - fn add_to_workspace(thread_view: Entity, cx: &mut VisualTestContext) { - let workspace = thread_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone()); + fn add_to_workspace(conversation_view: Entity, cx: &mut VisualTestContext) { + let workspace = + conversation_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone()); workspace .update_in(cx, |workspace, window, cx| { workspace.add_item_to_active_pane( - Box::new(cx.new(|_| ThreadViewItem(thread_view.clone()))), + Box::new(cx.new(|_| ThreadViewItem(conversation_view.clone()))), None, true, window, @@ -3730,7 +3677,7 @@ pub(crate) mod tests { .unwrap(); } - struct ThreadViewItem(Entity); + struct ThreadViewItem(Entity); impl Item for ThreadViewItem { type Event = (); @@ -3753,8 +3700,16 @@ pub(crate) mod tests { } impl Render for ThreadViewItem { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - self.0.clone().into_any_element() + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Render the title editor in the element tree too. In the real app + // it is part of the agent panel + let title_editor = self + .0 + .read(cx) + .active_thread() + .map(|t| t.read(cx).title_editor.clone()); + + v_flex().children(title_editor).child(self.0.clone()) } } @@ -3786,7 +3741,7 @@ pub(crate) mod tests { ui::IconName::Ai } - fn name(&self) -> SharedString { + fn agent_id(&self) -> AgentId { "Test".into() } @@ -3810,8 +3765,8 @@ pub(crate) mod tests { ui::IconName::AiOpenAi } - fn name(&self) -> SharedString { - "Codex CLI".into() + fn agent_id(&self) -> AgentId { + AgentId::new("Codex CLI") } fn connect( @@ -3897,6 +3852,10 @@ pub(crate) mod tests { } impl AgentConnection for SessionHistoryConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("history-connection") + } + fn telemetry_id(&self) -> SharedString { "history-connection".into() } @@ -3904,7 +3863,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + _work_dirs: PathList, cx: &mut App, ) -> Task>> { let thread = build_test_thread( @@ -3957,6 +3916,10 @@ pub(crate) mod tests { struct ResumeOnlyAgentConnection; impl AgentConnection for ResumeOnlyAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("resume-only") + } + fn telemetry_id(&self) -> SharedString { "resume-only".into() } @@ -3964,7 +3927,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - _cwd: &Path, + _work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { let thread = build_test_thread( @@ -3985,7 +3948,7 @@ pub(crate) mod tests { self: Rc, session_id: acp::SessionId, project: Entity, - _cwd: &Path, + _work_dirs: PathList, _title: Option, cx: &mut App, ) -> Task>> { @@ -4037,12 +4000,19 @@ pub(crate) mod tests { fn new() -> Self { Self { authenticated: Arc::new(Mutex::new(false)), - auth_method: acp::AuthMethod::new(Self::AUTH_METHOD_ID, "Test Login"), + auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new( + Self::AUTH_METHOD_ID, + "Test Login", + )), } } } impl AgentConnection for AuthGatedAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("auth-gated") + } + fn telemetry_id(&self) -> SharedString { "auth-gated".into() } @@ -4050,7 +4020,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { if !*self.authenticated.lock() { @@ -4065,7 +4035,7 @@ pub(crate) mod tests { AcpThread::new( None, "AuthGatedAgent", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, @@ -4090,7 +4060,7 @@ pub(crate) mod tests { method_id: acp::AuthMethodId, _cx: &mut App, ) -> Task> { - if method_id == self.auth_method.id { + if &method_id == self.auth_method.id() { *self.authenticated.lock() = true; Task::ready(Ok(())) } else { @@ -4120,6 +4090,10 @@ pub(crate) mod tests { struct SaboteurAgentConnection; impl AgentConnection for SaboteurAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("saboteur") + } + fn telemetry_id(&self) -> SharedString { "saboteur".into() } @@ -4127,7 +4101,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { Task::ready(Ok(cx.new(|cx| { @@ -4135,7 +4109,7 @@ pub(crate) mod tests { AcpThread::new( None, "SaboteurAgentConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, @@ -4186,6 +4160,10 @@ pub(crate) mod tests { struct RefusalAgentConnection; impl AgentConnection for RefusalAgentConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("refusal") + } + fn telemetry_id(&self) -> SharedString { "refusal".into() } @@ -4193,7 +4171,7 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { Task::ready(Ok(cx.new(|cx| { @@ -4201,7 +4179,7 @@ pub(crate) mod tests { AcpThread::new( None, "RefusalAgentConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self, project, action_log, @@ -4249,18 +4227,22 @@ pub(crate) mod tests { #[derive(Clone)] struct CwdCapturingConnection { - captured_cwd: Arc>>, + captured_work_dirs: Arc>>, } impl CwdCapturingConnection { fn new() -> Self { Self { - captured_cwd: Arc::new(Mutex::new(None)), + captured_work_dirs: Arc::new(Mutex::new(None)), } } } impl AgentConnection for CwdCapturingConnection { + fn agent_id(&self) -> AgentId { + AgentId::new("cwd-capturing") + } + fn telemetry_id(&self) -> SharedString { "cwd-capturing".into() } @@ -4268,16 +4250,16 @@ pub(crate) mod tests { fn new_session( self: Rc, project: Entity, - cwd: &Path, + work_dirs: PathList, cx: &mut gpui::App, ) -> Task>> { - *self.captured_cwd.lock() = Some(cwd.to_path_buf()); + *self.captured_work_dirs.lock() = Some(work_dirs.clone()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( None, "CwdCapturingConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, @@ -4302,17 +4284,17 @@ pub(crate) mod tests { self: Rc, session_id: acp::SessionId, project: Entity, - cwd: &Path, + work_dirs: PathList, _title: Option, cx: &mut App, ) -> Task>> { - *self.captured_cwd.lock() = Some(cwd.to_path_buf()); + *self.captured_work_dirs.lock() = Some(work_dirs.clone()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( None, "CwdCapturingConnection", - Some(cwd.to_path_buf()), + Some(work_dirs), self.clone(), project, action_log, @@ -4361,6 +4343,7 @@ pub(crate) mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); + ThreadMetadataStore::init_global(cx); theme::init(theme::LoadThemes::JustBase, cx); editor::init(cx); agent_panel::init(cx); @@ -4370,11 +4353,11 @@ pub(crate) mod tests { } fn active_thread( - thread_view: &Entity, + conversation_view: &Entity, cx: &TestAppContext, ) -> Entity { cx.read(|cx| { - thread_view + conversation_view .read(cx) .active_thread() .expect("No active thread") @@ -4383,10 +4366,10 @@ pub(crate) mod tests { } fn message_editor( - thread_view: &Entity, + conversation_view: &Entity, cx: &TestAppContext, ) -> Entity { - let thread = active_thread(thread_view, cx); + let thread = active_thread(conversation_view, cx); cx.read(|cx| thread.read(cx).message_editor.clone()) } @@ -4409,13 +4392,16 @@ pub(crate) mod tests { 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 history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx))); + let connection_store = + cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx))); let connection = Rc::new(StubAgentConnection::new()); - let thread_view = cx.update(|window, cx| { + let conversation_view = cx.update(|window, cx| { cx.new(|cx| { - ConnectionView::new( + ConversationView::new( Rc::new(StubAgentServer::new(connection.as_ref().clone())), + connection_store, + Agent::Custom { id: "Test".into() }, None, None, None, @@ -4424,7 +4410,6 @@ pub(crate) mod tests { project.clone(), Some(thread_store.clone()), None, - history, window, cx, ) @@ -4433,7 +4418,7 @@ pub(crate) mod tests { cx.run_until_parked(); - let thread = thread_view + let thread = conversation_view .read_with(cx, |view, cx| { view.active_thread().map(|r| r.read(cx).thread.clone()) }) @@ -4459,7 +4444,7 @@ pub(crate) mod tests { assert_eq!(thread.entries().len(), 2); }); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { let entry_view_state = view .active_thread() .map(|active| active.read(cx).entry_view_state.clone()) @@ -4500,7 +4485,7 @@ pub(crate) mod tests { user_message.id.clone().unwrap() }); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { let entry_view_state = view .active_thread() .unwrap() @@ -4539,7 +4524,7 @@ pub(crate) mod tests { assert_eq!(thread.entries().len(), 2); }); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { let active = view.active_thread().unwrap(); active .read(cx) @@ -4572,10 +4557,10 @@ pub(crate) mod tests { acp::ContentChunk::new("Response 1".into()), )]); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; - let thread = thread_view + let thread = conversation_view .read_with(cx, |view, cx| { view.active_thread().map(|r| r.read(cx).thread.clone()) }) @@ -4598,12 +4583,12 @@ pub(crate) mod tests { cx.run_until_parked(); // Move somewhere else first so we're not trivially already on the last user prompt. - active_thread(&thread_view, cx).update(cx, |view, cx| { + active_thread(&conversation_view, cx).update(cx, |view, cx| { view.scroll_to_top(cx); }); cx.run_until_parked(); - active_thread(&thread_view, cx).update(cx, |view, cx| { + active_thread(&conversation_view, cx).update(cx, |view, cx| { view.scroll_to_most_recent_user_prompt(cx); let scroll_top = view.list_state.logical_scroll_top(); // Entries layout is: [User1, Assistant1, User2, Assistant2] @@ -4617,10 +4602,11 @@ pub(crate) mod tests { ) { init_test(cx); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; // With no entries, scrolling should be a no-op and must not panic. - active_thread(&thread_view, cx).update(cx, |view, cx| { + active_thread(&conversation_view, cx).update(cx, |view, cx| { view.scroll_to_most_recent_user_prompt(cx); let scroll_top = view.list_state.logical_scroll_top(); assert_eq!(scroll_top.item_ix, 0); @@ -4637,18 +4623,20 @@ pub(crate) mod tests { acp::ContentChunk::new("Response".into()), )]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Original message to edit", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); - let user_message_editor = thread_view.read_with(cx, |view, cx| { + let user_message_editor = conversation_view.read_with(cx, |view, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -4669,7 +4657,7 @@ pub(crate) mod tests { // Focus cx.focus(&user_message_editor); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -4687,7 +4675,7 @@ pub(crate) mod tests { window.dispatch_action(Box::new(editor::actions::Cancel), cx); }); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -4706,16 +4694,17 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("", window, cx); }); let thread = cx.read(|cx| { - thread_view + conversation_view .read(cx) .active_thread() .unwrap() @@ -4725,7 +4714,7 @@ pub(crate) mod tests { }); let entries_before = cx.read(|cx| thread.read(cx).entries().len()); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| { + active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| { view.send(window, cx); }); cx.run_until_parked(); @@ -4747,19 +4736,20 @@ pub(crate) mod tests { acp::ContentChunk::new("Response".into()), )]); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Original message to edit", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); - let user_message_editor = thread_view.read_with(cx, |view, cx| { + let user_message_editor = conversation_view.read_with(cx, |view, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -4807,7 +4797,7 @@ pub(crate) mod tests { cx.run_until_parked(); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -4850,19 +4840,20 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Original message to edit", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); - let (user_message_editor, session_id) = thread_view.read_with(cx, |view, cx| { + let (user_message_editor, session_id) = conversation_view.read_with(cx, |view, cx| { let thread = view.active_thread().unwrap().read(cx).thread.read(cx); assert_eq!(thread.entries().len(), 1); @@ -4884,7 +4875,7 @@ pub(crate) mod tests { // Focus cx.focus(&user_message_editor); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -4897,7 +4888,7 @@ pub(crate) mod tests { editor.set_text("Edited message content", window, cx); }); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -4915,7 +4906,7 @@ pub(crate) mod tests { connection.end_turn(session_id, acp::StopReason::EndTurn); }); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -4929,7 +4920,7 @@ pub(crate) mod tests { cx.update(|window, cx| { assert!(user_message_editor.focus_handle(cx).is_focused(window)); assert_eq!( - thread_view + conversation_view .read(cx) .active_thread() .and_then(|active| active.read(cx).editing_message), @@ -4943,7 +4934,7 @@ pub(crate) mod tests { } struct GeneratingThreadSetup { - thread_view: Entity, + conversation_view: Entity, thread: Entity, message_editor: Entity, } @@ -4953,17 +4944,18 @@ pub(crate) mod tests { ) -> (GeneratingThreadSetup, &mut VisualTestContext) { let connection = StubAgentConnection::new(); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); - let (thread, session_id) = thread_view.read_with(cx, |view, cx| { + let (thread, session_id) = conversation_view.read_with(cx, |view, cx| { let thread = view .active_thread() .as_ref() @@ -4994,7 +4986,7 @@ pub(crate) mod tests { ( GeneratingThreadSetup { - thread_view, + conversation_view, thread, message_editor, }, @@ -5009,13 +5001,13 @@ pub(crate) mod tests { let (setup, cx) = setup_generating_thread(cx).await; let focus_handle = setup - .thread_view + .conversation_view .read_with(cx, |view, cx| view.focus_handle(cx)); cx.update(|window, cx| { window.focus(&focus_handle, cx); }); - setup.thread_view.update_in(cx, |_, window, cx| { + setup.conversation_view.update_in(cx, |_, window, cx| { window.dispatch_action(menu::Cancel.boxed_clone(), cx); }); @@ -5054,11 +5046,11 @@ pub(crate) mod tests { async fn test_escape_when_idle_is_noop(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(StubAgentConnection::new()), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let thread = thread_view.read_with(cx, |view, cx| { + let thread = conversation_view.read_with(cx, |view, cx| { view.active_thread().unwrap().read(cx).thread.clone() }); @@ -5066,12 +5058,12 @@ pub(crate) mod tests { assert_eq!(thread.status(), ThreadStatus::Idle); }); - let focus_handle = thread_view.read_with(cx, |view, _cx| view.focus_handle.clone()); + let focus_handle = conversation_view.read_with(cx, |view, _cx| view.focus_handle.clone()); cx.update(|window, cx| { window.focus(&focus_handle, cx); }); - thread_view.update_in(cx, |_, window, cx| { + conversation_view.update_in(cx, |_, window, cx| { window.dispatch_action(menu::Cancel.boxed_clone(), cx); }); @@ -5088,17 +5080,18 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Message 1", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); - let (thread, session_id) = thread_view.read_with(cx, |view, cx| { + let (thread, session_id) = conversation_view.read_with(cx, |view, cx| { let thread = view.active_thread().unwrap().read(cx).thread.clone(); (thread.clone(), thread.read(cx).session_id().clone()) @@ -5137,7 +5130,7 @@ pub(crate) mod tests { message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Message 2", window, cx); }); - active_thread(&thread_view, cx) + active_thread(&conversation_view, cx) .update_in(cx, |view, window, cx| view.interrupt_and_send(window, cx)); cx.update(|_, cx| { @@ -5219,18 +5212,20 @@ pub(crate) mod tests { acp::ContentChunk::new("Response".into()), )]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Original message to edit", window, cx) }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); - let user_message_editor = thread_view.read_with(cx, |thread_view, cx| { - thread_view + let user_message_editor = conversation_view.read_with(cx, |conversation_view, cx| { + conversation_view .active_thread() .map(|active| &active.read(cx).entry_view_state) .as_ref() @@ -5244,7 +5239,7 @@ pub(crate) mod tests { }); cx.focus(&user_message_editor); - thread_view.read_with(cx, |view, cx| { + conversation_view.read_with(cx, |view, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -5260,8 +5255,11 @@ pub(crate) mod tests { // Create a simple buffer with some text so we can create a selection // that will then be added to the message being edited. - let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| { - (thread_view.workspace.clone(), thread_view.project.clone()) + let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| { + ( + conversation_view.workspace.clone(), + conversation_view.project.clone(), + ) }); let buffer = project.update(cx, |project, cx| { project.create_local_buffer("let a = 10 + 10;", None, false, cx) @@ -5283,7 +5281,7 @@ pub(crate) mod tests { }) .unwrap(); - thread_view.update_in(cx, |view, window, cx| { + conversation_view.update_in(cx, |view, window, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -5309,18 +5307,22 @@ pub(crate) mod tests { acp::ContentChunk::new("Response".into()), )]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Can you review this snippet ", window, cx) }); // Create a simple buffer with some text so we can create a selection // that will then be added to the message being edited. - let (workspace, project) = thread_view.read_with(cx, |thread_view, _cx| { - (thread_view.workspace.clone(), thread_view.project.clone()) + let (workspace, project) = conversation_view.read_with(cx, |conversation_view, _cx| { + ( + conversation_view.workspace.clone(), + conversation_view.project.clone(), + ) }); let buffer = project.update(cx, |project, cx| { project.create_local_buffer("let a = 10 + 10;", None, false, cx) @@ -5342,7 +5344,7 @@ pub(crate) mod tests { }) .unwrap(); - thread_view.update_in(cx, |view, window, cx| { + conversation_view.update_in(cx, |view, window, cx| { assert_eq!( view.active_thread() .and_then(|active| active.read(cx).editing_message), @@ -5381,7 +5383,8 @@ pub(crate) mod tests { connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; // Disable notifications to avoid popup windows cx.update(|_window, cx| { @@ -5394,18 +5397,19 @@ pub(crate) mod tests { ); }); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Run cargo build", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Verify the tool call is in WaitingForConfirmation state with the expected options - thread_view.read_with(cx, |thread_view, cx| { - let thread = thread_view + conversation_view.read_with(cx, |conversation_view, cx| { + let thread = conversation_view .active_thread() .expect("Thread should exist") .read(cx) @@ -5489,7 +5493,8 @@ pub(crate) mod tests { connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; // Disable notifications cx.update(|_window, cx| { @@ -5502,18 +5507,19 @@ pub(crate) mod tests { ); }); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Edit the main file", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Verify the options - thread_view.read_with(cx, |thread_view, cx| { - let thread = thread_view + conversation_view.read_with(cx, |conversation_view, cx| { + let thread = conversation_view .active_thread() .expect("Thread should exist") .read(cx) @@ -5577,7 +5583,8 @@ pub(crate) mod tests { connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; // Disable notifications cx.update(|_window, cx| { @@ -5590,18 +5597,19 @@ pub(crate) mod tests { ); }); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Fetch the docs", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Verify the options - thread_view.read_with(cx, |thread_view, cx| { - let thread = thread_view + conversation_view.read_with(cx, |conversation_view, cx| { + let thread = conversation_view .active_thread() .expect("Thread should exist") .read(cx) @@ -5668,7 +5676,8 @@ pub(crate) mod tests { connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; // Disable notifications cx.update(|_window, cx| { @@ -5681,18 +5690,19 @@ pub(crate) mod tests { ); }); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Run the deploy script", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Verify only 2 options (no pattern button when command doesn't match pattern) - thread_view.read_with(cx, |thread_view, cx| { - let thread = thread_view + conversation_view.read_with(cx, |conversation_view, cx| { + let thread = conversation_view .active_thread() .expect("Thread should exist") .read(cx) @@ -5767,8 +5777,9 @@ pub(crate) mod tests { connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(conversation_view.clone(), cx); cx.update(|_window, cx| { AgentSettings::override_global( @@ -5780,18 +5791,19 @@ pub(crate) mod tests { ); }); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Run tests", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Verify tool call is waiting for confirmation - thread_view.read_with(cx, |thread_view, cx| { - let tool_call = thread_view.pending_tool_call(cx); + conversation_view.read_with(cx, |conversation_view, cx| { + let tool_call = conversation_view.pending_tool_call(cx); assert!( tool_call.is_some(), "Expected a tool call waiting for confirmation" @@ -5799,7 +5811,7 @@ pub(crate) mod tests { }); // Dispatch the AuthorizeToolCall action (simulating dropdown menu selection) - thread_view.update_in(cx, |_, window, cx| { + conversation_view.update_in(cx, |_, window, cx| { window.dispatch_action( crate::AuthorizeToolCall { tool_call_id: "action-test-1".to_string(), @@ -5814,8 +5826,8 @@ pub(crate) mod tests { cx.run_until_parked(); // Verify tool call is no longer waiting for confirmation (was authorized) - thread_view.read_with(cx, |thread_view, cx| { - let tool_call = thread_view.pending_tool_call(cx); + conversation_view.read_with(cx, |conversation_view, cx| { + let tool_call = conversation_view.pending_tool_call(cx); assert!( tool_call.is_none(), "Tool call should no longer be waiting for confirmation after AuthorizeToolCall action" @@ -5843,8 +5855,9 @@ pub(crate) mod tests { connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(conversation_view.clone(), cx); cx.update(|_window, cx| { AgentSettings::override_global( @@ -5856,12 +5869,13 @@ pub(crate) mod tests { ); }); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Install dependencies", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); @@ -5882,7 +5896,7 @@ pub(crate) mod tests { }; // Dispatch action with the pattern option (simulating "Always allow `npm` commands") - thread_view.update_in(cx, |_, window, cx| { + conversation_view.update_in(cx, |_, window, cx| { window.dispatch_action( crate::AuthorizeToolCall { tool_call_id: "pattern-action-test-1".to_string(), @@ -5897,8 +5911,8 @@ pub(crate) mod tests { cx.run_until_parked(); // Verify tool call was authorized - thread_view.read_with(cx, |thread_view, cx| { - let tool_call = thread_view.pending_tool_call(cx); + conversation_view.read_with(cx, |conversation_view, cx| { + let tool_call = conversation_view.pending_tool_call(cx); assert!( tool_call.is_none(), "Tool call should be authorized after selecting pattern option" @@ -5926,8 +5940,9 @@ pub(crate) mod tests { connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); - let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; - add_to_workspace(thread_view.clone(), cx); + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection), cx).await; + add_to_workspace(conversation_view.clone(), cx); cx.update(|_window, cx| { AgentSettings::override_global( @@ -5939,26 +5954,27 @@ pub(crate) mod tests { ); }); - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Push changes", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); cx.run_until_parked(); // Use default granularity (last option = "Only this time") // Simulate clicking the Deny button - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| { + active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| { view.reject_once(&RejectOnce, window, cx) }); cx.run_until_parked(); // Verify tool call was rejected (no longer waiting for confirmation) - thread_view.read_with(cx, |thread_view, cx| { - let tool_call = thread_view.pending_tool_call(cx); + conversation_view.read_with(cx, |conversation_view, cx| { + let tool_call = conversation_view.pending_tool_call(cx); assert!( tool_call.is_none(), "Tool call should be rejected after Deny" @@ -6024,9 +6040,11 @@ pub(crate) mod tests { async fn test_manually_editing_title_updates_acp_thread_title(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; + add_to_workspace(conversation_view.clone(), cx); - let active = active_thread(&thread_view, cx); + let active = active_thread(&conversation_view, cx); let title_editor = cx.read(|cx| active.read(cx).title_editor.clone()); let thread = cx.read(|cx| active.read(cx).thread.clone()); @@ -6034,9 +6052,12 @@ pub(crate) mod tests { assert!(!editor.read_only(cx)); }); - title_editor.update_in(cx, |editor, window, cx| { - editor.set_text("My Custom Title", window, cx); - }); + cx.focus(&conversation_view); + cx.focus(&title_editor); + + cx.dispatch_action(editor::actions::DeleteLine); + cx.simulate_input("My Custom Title"); + cx.run_until_parked(); title_editor.read_with(cx, |editor, cx| { @@ -6051,10 +6072,10 @@ pub(crate) mod tests { async fn test_title_editor_is_read_only_when_set_title_unsupported(cx: &mut TestAppContext) { init_test(cx); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(ResumeOnlyAgentConnection), cx).await; - let active = active_thread(&thread_view, cx); + let active = active_thread(&conversation_view, cx); let title_editor = cx.read(|cx| active.read(cx).title_editor.clone()); title_editor.read_with(cx, |editor, cx| { @@ -6071,16 +6092,17 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); - let (thread_view, cx) = - setup_thread_view(StubAgentServer::new(connection.clone()), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(connection.clone()), cx).await; - let message_editor = message_editor(&thread_view, cx); + let message_editor = message_editor(&conversation_view, cx); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Some prompt", window, cx); }); - active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + active_thread(&conversation_view, cx) + .update_in(cx, |view, window, cx| view.send(window, cx)); - let session_id = thread_view.read_with(cx, |view, cx| { + let session_id = conversation_view.read_with(cx, |view, cx| { view.active_thread() .unwrap() .read(cx) @@ -6098,8 +6120,8 @@ pub(crate) mod tests { cx.run_until_parked(); - thread_view.read_with(cx, |thread_view, cx| { - let state = thread_view.active_thread().unwrap(); + conversation_view.read_with(cx, |conversation_view, cx| { + let state = conversation_view.active_thread().unwrap(); let error = &state.read(cx).thread_error; match error { Some(ThreadError::Other { message, .. }) => { @@ -6390,11 +6412,11 @@ pub(crate) mod tests { async fn test_move_queued_message_to_empty_main_editor(cx: &mut TestAppContext) { init_test(cx); - let (connection_view, cx) = - setup_thread_view(StubAgentServer::default_response(), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; // Add a plain-text message to the queue directly. - active_thread(&connection_view, cx).update_in(cx, |thread, window, cx| { + active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| { thread.add_to_queue( vec![acp::ContentBlock::Text(acp::TextContent::new( "queued message".to_string(), @@ -6411,12 +6433,12 @@ pub(crate) mod tests { cx.run_until_parked(); // Queue should now be empty. - let queue_len = active_thread(&connection_view, cx) + let queue_len = active_thread(&conversation_view, cx) .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); assert_eq!(queue_len, 0, "Queue should be empty after move"); // Main editor should contain the queued message text. - let text = message_editor(&connection_view, cx).update(cx, |editor, cx| editor.text(cx)); + let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx)); assert_eq!( text, "queued message", "Main editor should contain the moved queued message" @@ -6427,11 +6449,11 @@ pub(crate) mod tests { async fn test_move_queued_message_to_non_empty_main_editor(cx: &mut TestAppContext) { init_test(cx); - let (connection_view, cx) = - setup_thread_view(StubAgentServer::default_response(), cx).await; + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; // Seed the main editor with existing content. - message_editor(&connection_view, cx).update_in(cx, |editor, window, cx| { + message_editor(&conversation_view, cx).update_in(cx, |editor, window, cx| { editor.set_message( vec![acp::ContentBlock::Text(acp::TextContent::new( "existing content".to_string(), @@ -6442,7 +6464,7 @@ pub(crate) mod tests { }); // Add a plain-text message to the queue. - active_thread(&connection_view, cx).update_in(cx, |thread, window, cx| { + active_thread(&conversation_view, cx).update_in(cx, |thread, window, cx| { thread.add_to_queue( vec![acp::ContentBlock::Text(acp::TextContent::new( "queued message".to_string(), @@ -6456,15 +6478,242 @@ pub(crate) mod tests { cx.run_until_parked(); // Queue should now be empty. - let queue_len = active_thread(&connection_view, cx) + let queue_len = active_thread(&conversation_view, cx) .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); assert_eq!(queue_len, 0, "Queue should be empty after move"); // Main editor should contain existing content + separator + queued content. - let text = message_editor(&connection_view, cx).update(cx, |editor, cx| editor.text(cx)); + let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx)); assert_eq!( text, "existing content\n\nqueued message", "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 conversation_view = cx.update(|window, cx| { + cx.new(|cx| { + ConversationView::new( + Rc::new(StubAgentServer::default_response()), + connection_store, + Agent::Custom { id: "Test".into() }, + None, + None, + None, + None, + workspace.downgrade(), + project, + Some(thread_store), + None, + window, + cx, + ) + }) + }); + + cx.run_until_parked(); + + conversation_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" + ); + }); + + conversation_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 (conversation_view, cx) = + setup_conversation_view(StubAgentServer::new(CloseCapableConnection::new()), cx).await; + + cx.run_until_parked(); + + let close_capable = conversation_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") + }); + + conversation_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 (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; + + cx.run_until_parked(); + + let result = conversation_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 agent_id(&self) -> AgentId { + AgentId::new("close-capable") + } + + fn telemetry_id(&self) -> SharedString { + "close-capable".into() + } + + fn new_session( + self: Rc, + project: Entity, + work_dirs: PathList, + cx: &mut gpui::App, + ) -> Task>> { + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let thread = cx.new(|cx| { + AcpThread::new( + None, + "CloseCapableConnection", + Some(work_dirs), + 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/conversation_view/thread_view.rs similarity index 96% rename from crates/agent_ui/src/connection_view/thread_view.rs rename to crates/agent_ui/src/conversation_view/thread_view.rs index 0519362ab1194a6e21ff9b3f213112f94f4cce55..3bee1499d983da06e345cae23c3deba5973b22e6 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -156,43 +156,6 @@ impl ThreadFeedbackState { } } -#[derive(Default, Clone, Copy)] -struct DiffStats { - lines_added: u32, - lines_removed: u32, -} - -impl DiffStats { - fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self { - let mut stats = DiffStats::default(); - let diff_snapshot = diff.snapshot(cx); - let buffer_snapshot = buffer.snapshot(); - let base_text = diff_snapshot.base_text(); - - for hunk in diff_snapshot.hunks(&buffer_snapshot) { - let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); - stats.lines_added += added_rows; - - let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row; - let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row; - let removed_rows = base_end.saturating_sub(base_start); - stats.lines_removed += removed_rows; - } - - stats - } - - fn all_files(changed_buffers: &BTreeMap, Entity>, cx: &App) -> Self { - let mut total = DiffStats::default(); - for (buffer, diff) in changed_buffers { - let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx); - total.lines_added += stats.lines_added; - total.lines_removed += stats.lines_removed; - } - total - } -} - pub enum AcpThreadViewEvent { FirstSendRequested { content: Vec }, } @@ -204,10 +167,10 @@ pub struct ThreadView { pub parent_id: Option, pub thread: Entity, pub(crate) conversation: Entity, - pub server_view: WeakEntity, + pub server_view: WeakEntity, pub agent_icon: IconName, pub agent_icon_from_external_svg: Option, - pub agent_name: SharedString, + pub agent_id: AgentId, pub focus_handle: FocusHandle, pub workspace: WeakEntity, pub entry_view_state: Entity, @@ -231,6 +194,7 @@ pub struct ThreadView { pub expanded_tool_calls: HashSet, pub expanded_tool_call_raw_inputs: HashSet, pub expanded_thinking_blocks: HashSet<(usize, usize)>, + auto_expanded_thinking_block: Option<(usize, usize)>, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, @@ -292,10 +256,10 @@ impl ThreadView { parent_id: Option, thread: Entity, conversation: Entity, - server_view: WeakEntity, + server_view: WeakEntity, agent_icon: IconName, agent_icon_from_external_svg: Option, - agent_name: SharedString, + agent_id: AgentId, agent_display_name: SharedString, workspace: WeakEntity, entry_view_state: Entity, @@ -336,7 +300,7 @@ impl ThreadView { prompt_store, prompt_capabilities.clone(), available_commands.clone(), - agent_name.clone(), + agent_id.clone(), &placeholder, editor::EditorMode::AutoHeight { min_lines: AgentSettings::get_global(cx).message_editor_min_lines, @@ -378,7 +342,7 @@ impl ThreadView { let show_codex_windows_warning = cfg!(windows) && project.upgrade().is_some_and(|p| p.read(cx).is_local()) - && agent_name == "Codex"; + && agent_id.as_ref() == "Codex"; let title_editor = { let can_edit = thread.update(cx, |thread, cx| thread.can_set_title(cx)); @@ -439,7 +403,7 @@ impl ThreadView { server_view, agent_icon, agent_icon_from_external_svg, - agent_name, + agent_id, workspace, entry_view_state, title_editor, @@ -462,6 +426,7 @@ impl ThreadView { expanded_tool_calls: HashSet::default(), expanded_tool_call_raw_inputs: HashSet::default(), expanded_thinking_blocks: HashSet::default(), + auto_expanded_thinking_block: None, subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, @@ -665,6 +630,7 @@ impl ThreadView { if let Some(AgentThreadEntry::UserMessage(user_message)) = self.thread.read(cx).entries().get(event.entry_index) && user_message.id.is_some() + && !self.is_subagent() { self.editing_message = Some(event.entry_index); cx.notify(); @@ -674,6 +640,7 @@ impl ThreadView { if let Some(AgentThreadEntry::UserMessage(user_message)) = self.thread.read(cx).entries().get(event.entry_index) && user_message.id.is_some() + && !self.is_subagent() { if editor.read(cx).text(cx).as_str() == user_message.content.to_markdown(cx) { self.editing_message = None; @@ -683,7 +650,9 @@ impl ThreadView { } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SendImmediately) => {} ViewEvent::MessageEditorEvent(editor, MessageEditorEvent::Send) => { - self.regenerate(event.entry_index, editor.clone(), window, cx); + if !self.is_subagent() { + self.regenerate(event.entry_index, editor.clone(), window, cx); + } } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => { self.cancel_editing(&Default::default(), window, cx); @@ -770,10 +739,13 @@ impl ThreadView { } } })); + if self.parent_id.is_none() { + self.suppress_merge_conflict_notification(cx); + } generation } - pub fn stop_turn(&mut self, generation: usize) { + pub fn stop_turn(&mut self, generation: usize, cx: &mut Context) { if self.turn_fields.turn_generation != generation { return; } @@ -784,6 +756,25 @@ impl ThreadView { .map(|started| started.elapsed()); self.turn_fields.last_turn_tokens = self.turn_fields.turn_tokens.take(); self.turn_fields._turn_timer_task = None; + if self.parent_id.is_none() { + self.unsuppress_merge_conflict_notification(cx); + } + } + + fn suppress_merge_conflict_notification(&self, cx: &mut Context) { + self.workspace + .update(cx, |workspace, cx| { + workspace.suppress_notification(&workspace::merge_conflict_notification_id(), cx); + }) + .ok(); + } + + fn unsuppress_merge_conflict_notification(&self, cx: &mut Context) { + self.workspace + .update(cx, |workspace, _cx| { + workspace.unsuppress(workspace::merge_conflict_notification_id()); + }) + .ok(); } pub fn update_turn_tokens(&mut self, cx: &App) { @@ -888,13 +879,13 @@ impl ThreadView { let connection = self.thread.read(cx).connection().clone(); window.defer(cx, { - let agent_name = self.agent_name.clone(); + let agent_id = self.agent_id.clone(); let server_view = self.server_view.clone(); move |window, cx| { - ConnectionView::handle_auth_required( + ConversationView::handle_auth_required( server_view.clone(), AuthRequired::new(), - agent_name, + agent_id, connection, window, cx, @@ -993,7 +984,7 @@ impl ThreadView { let mut cx = cx.clone(); move || { this.update(&mut cx, |this, cx| { - this.stop_turn(generation); + this.stop_turn(generation, cx); cx.notify(); }) .ok(); @@ -1464,6 +1455,13 @@ impl ThreadView { match event { EditorEvent::BufferEdited => { + // We only want to set the title if the user has actively edited + // it. If the title editor is not focused, we programmatically + // changed the text, so we don't want to set the title again. + if !title_editor.read(cx).is_focused(window) { + return; + } + let new_title = title_editor.read(cx).text(cx); thread.update(cx, |thread, cx| { thread @@ -1757,7 +1755,7 @@ impl ThreadView { pub fn sync_thread( &mut self, project: Entity, - server_view: Entity, + server_view: Entity, window: &mut Window, cx: &mut Context, ) { @@ -2704,6 +2702,14 @@ impl ThreadView { return div().into_any_element(); } + let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle; + if let Some(model_selector) = &self.model_selector { + model_selector.update(cx, |selector, _| selector.set_disabled(is_generating)); + } + if let Some(profile_selector) = &self.profile_selector { + profile_selector.update(cx, |selector, _| selector.set_disabled(is_generating)); + } + let focus_handle = self.message_editor.focus_handle(cx); let editor_bg_color = cx.theme().colors().editor_background; let editor_expanded = self.editor_expanded; @@ -3253,6 +3259,7 @@ impl ThreadView { return None; } + let is_generating = self.thread.read(cx).status() != ThreadStatus::Idle; let thinking = thread.thinking_enabled(); let (tooltip_label, icon, color) = if thinking { @@ -3274,8 +3281,13 @@ impl ThreadView { let thinking_toggle = IconButton::new("thinking-mode", icon) .icon_size(IconSize::Small) .icon_color(color) - .tooltip(move |_, cx| { - Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx) + .disabled(is_generating) + .tooltip(move |window, cx| { + if is_generating { + Tooltip::text("Disabled until generation is done")(window, cx) + } else { + Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx) + } }) .on_click(cx.listener(move |this, _, _window, cx| { if let Some(thread) = this.as_native_thread(cx) { @@ -3307,6 +3319,7 @@ impl ThreadView { let right_btn = self.render_effort_selector( model.supported_effort_levels(), thread.thinking_effort().cloned(), + is_generating, cx, ); @@ -3321,6 +3334,7 @@ impl ThreadView { &self, supported_effort_levels: Vec, selected_effort: Option, + disabled: bool, cx: &Context, ) -> impl IntoElement { let weak_self = cx.weak_entity(); @@ -3389,6 +3403,7 @@ impl ThreadView { PopoverMenu::new("effort-selector") .trigger_with_tooltip( ButtonLike::new_rounded_right("effort-selector-trigger") + .disabled(disabled) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child(Label::new(label).size(LabelSize::Small).color(label_color)) .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), @@ -3570,6 +3585,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() @@ -3685,6 +3701,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); + }); + } + }), + ) }) } @@ -3692,16 +3722,16 @@ impl ThreadView { let following = self.is_following(cx); let tooltip_label = if following { - if self.agent_name == "Zed Agent" { - format!("Stop Following the {}", self.agent_name) + if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + format!("Stop Following the {}", self.agent_id) } else { - format!("Stop Following {}", self.agent_name) + format!("Stop Following {}", self.agent_id) } } else { - if self.agent_name == "Zed Agent" { - format!("Follow the {}", self.agent_name) + if self.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref() { + format!("Follow the {}", self.agent_id) } else { - format!("Follow {}", self.agent_name) + format!("Follow {}", self.agent_id) } }; @@ -3788,14 +3818,12 @@ impl ThreadView { .as_ref() .is_some_and(|checkpoint| checkpoint.show); - let agent_name = self.agent_name.clone(); let is_subagent = self.is_subagent(); - - let non_editable_icon = || { - IconButton::new("non_editable", IconName::PencilUnavailable) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .style(ButtonStyle::Transparent) + let is_editable = message.id.is_some() && !is_subagent; + let agent_name = if is_subagent { + "subagents".into() + } else { + self.agent_id.clone() }; v_flex() @@ -3816,19 +3844,16 @@ impl ThreadView { .gap_1p5() .w_full() .children(rules_item) - .children(message.id.clone().and_then(|message_id| { - message.checkpoint.as_ref()?.show.then(|| { + .when(is_editable && has_checkpoint_button, |this| { + this.children(message.id.clone().map(|message_id| { h_flex() .px_3() .gap_2() .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| { @@ -3836,8 +3861,8 @@ impl ThreadView { })) ) .child(Divider::horizontal()) - }) - })) + })) + }) .child( div() .relative() @@ -3853,8 +3878,11 @@ impl ThreadView { }) .border_color(cx.theme().colors().border) .map(|this| { - if is_subagent { - return this.border_dashed(); + if !is_editable { + if is_subagent { + return this.border_dashed(); + } + return this; } if editing && editor_focus { return this.border_color(focus_border); @@ -3862,12 +3890,9 @@ impl ThreadView { if editing && !editor_focus { return this.border_dashed() } - if message.id.is_some() { - return this.shadow_md().hover(|s| { - s.border_color(focus_border.opacity(0.8)) - }); - } - this + this.shadow_md().hover(|s| { + s.border_color(focus_border.opacity(0.8)) + }) }) .text_xs() .child(editor.clone().into_any_element()) @@ -3885,20 +3910,7 @@ impl ThreadView { .overflow_hidden(); let is_loading_contents = self.is_loading_contents; - if is_subagent { - this.child( - base_container.border_dashed().child( - non_editable_icon().tooltip(move |_, cx| { - Tooltip::with_meta( - "Unavailable Editing", - None, - "Editing subagent messages is currently not supported.", - cx, - ) - }), - ), - ) - } else if message.id.is_some() { + if is_editable { this.child( base_container .child( @@ -3937,26 +3949,29 @@ impl ThreadView { this.child( base_container .border_dashed() - .child( - non_editable_icon() - .tooltip(Tooltip::element({ - move |_, _| { - v_flex() - .gap_1() - .child(Label::new("Unavailable Editing")).child( - div().max_w_64().child( - Label::new(format!( - "Editing previous messages is not available for {} yet.", - agent_name.clone() - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element() - } - })) - ) + .child(IconButton::new("non_editable", IconName::PencilUnavailable) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .style(ButtonStyle::Transparent) + .tooltip(Tooltip::element({ + let agent_name = agent_name.clone(); + move |_, _| { + v_flex() + .gap_1() + .child(Label::new("Unavailable Editing")) + .child( + div().max_w_64().child( + Label::new(format!( + "Editing previous messages is not available for {} yet.", + agent_name + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element() + } + }))), ) } }), @@ -4574,6 +4589,53 @@ impl ThreadView { .into_any_element() } + /// If the last entry's last chunk is a streaming thought block, auto-expand it. + /// Also collapses the previously auto-expanded block when a new one starts. + pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context) { + let key = { + let thread = self.thread.read(cx); + if thread.status() != ThreadStatus::Generating { + return; + } + let entries = thread.entries(); + let last_ix = entries.len().saturating_sub(1); + match entries.get(last_ix) { + Some(AgentThreadEntry::AssistantMessage(msg)) => match msg.chunks.last() { + Some(AssistantMessageChunk::Thought { .. }) => { + Some((last_ix, msg.chunks.len() - 1)) + } + _ => None, + }, + _ => None, + } + }; + + if let Some(key) = key { + if self.auto_expanded_thinking_block != Some(key) { + if let Some(old_key) = self.auto_expanded_thinking_block.replace(key) { + self.expanded_thinking_blocks.remove(&old_key); + } + self.expanded_thinking_blocks.insert(key); + cx.notify(); + } + } else if self.auto_expanded_thinking_block.is_some() { + // The last chunk is no longer a thought (model transitioned to responding), + // so collapse the previously auto-expanded block. + self.collapse_auto_expanded_thinking_block(); + cx.notify(); + } + } + + fn collapse_auto_expanded_thinking_block(&mut self) { + if let Some(key) = self.auto_expanded_thinking_block.take() { + self.expanded_thinking_blocks.remove(&key); + } + } + + pub(crate) fn clear_auto_expand_tracking(&mut self) { + self.auto_expanded_thinking_block = None; + } + fn render_thinking_block( &self, entry_ix: usize, @@ -4595,20 +4657,6 @@ impl ThreadView { .entry(entry_ix) .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); - let thinking_content = { - div() - .id(("thinking-content", chunk_ix)) - .when_some(scroll_handle, |this, scroll_handle| { - this.track_scroll(&scroll_handle) - }) - .text_ui_sm(cx) - .overflow_hidden() - .child(self.render_markdown( - chunk, - MarkdownStyle::themed(MarkdownFont::Agent, window, cx), - )) - }; - v_flex() .gap_1() .child( @@ -4664,11 +4712,19 @@ impl ThreadView { .when(is_open, |this| { this.child( div() + .id(("thinking-content", chunk_ix)) .ml_1p5() .pl_3p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) - .child(thinking_content), + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .overflow_hidden() + .child(self.render_markdown( + chunk, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + )), ) }) .into_any_element() @@ -4870,6 +4926,7 @@ impl ThreadView { cx: &Context, ) -> Div { v_flex() + .group(group.clone()) .p_1p5() .bg(self.tool_card_header_bg(cx)) .when(is_preview, |this| { @@ -5780,10 +5837,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( @@ -5814,10 +5872,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( @@ -5884,9 +5943,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( @@ -5959,24 +6020,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; }; @@ -5992,8 +6064,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(); @@ -6370,9 +6440,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(); @@ -7236,7 +7308,7 @@ impl ThreadView { .on_click(cx.listener({ move |this, _, window, cx| { let server_view = this.server_view.clone(); - let agent_name = this.agent_name.clone(); + let agent_name = this.agent_id.clone(); this.clear_thread_error(cx); if let Some(message) = this.in_flight_prompt.take() { @@ -7246,7 +7318,7 @@ impl ThreadView { } let connection = this.thread.read(cx).connection().clone(); window.defer(cx, |window, cx| { - ConnectionView::handle_auth_required( + ConversationView::handle_auth_required( server_view, AuthRequired::new(), agent_name, @@ -7271,7 +7343,7 @@ impl ThreadView { .unwrap_or_else(|| SharedString::from("The model")) } else { // ACP agent - use the agent name (e.g., "Claude Agent", "Gemini CLI") - self.agent_name.clone() + self.agent_id.0.clone() } } @@ -7438,7 +7510,7 @@ impl ThreadView { // TODO: Add keyboard navigation. let is_hovered = self.hovered_recent_history_item == Some(index); - crate::thread_history::HistoryEntryElement::new( + crate::thread_history_view::HistoryEntryElement::new( entry, self.server_view.clone(), ) @@ -7467,19 +7539,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) @@ -7751,6 +7820,9 @@ impl Render for ThreadView { this.toggle_fast_mode(cx); })) .on_action(cx.listener(|this, _: &ToggleThinkingMode, _window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, cx| { thread.set_thinking_enabled(!thread.thinking_enabled(), cx); @@ -7758,9 +7830,19 @@ impl Render for ThreadView { } })) .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } this.cycle_thinking_effort(cx); })) - .on_action(cx.listener(Self::toggle_thinking_effort_menu)) + .on_action( + cx.listener(|this, action: &ToggleThinkingEffortMenu, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } + this.toggle_thinking_effort_menu(action, window, cx); + }), + ) .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| { this.send_queued_message_at_index(0, true, window, cx); })) @@ -7778,6 +7860,9 @@ impl Render for ThreadView { cx.notify(); })) .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.toggle_category_picker( @@ -7798,6 +7883,9 @@ impl Render for ThreadView { } })) .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.cycle_category_option( @@ -7822,6 +7910,9 @@ impl Render for ThreadView { } })) .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.toggle_category_picker( @@ -7841,6 +7932,9 @@ impl Render for ThreadView { } })) .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| { + if this.thread.read(cx).status() != ThreadStatus::Idle { + return; + } if let Some(config_options_view) = this.config_options_view.clone() { let handled = config_options_view.update(cx, |view, cx| { view.cycle_category_option( @@ -7995,6 +8089,7 @@ pub(crate) fn open_link( MentionUri::Diagnostics { .. } => {} MentionUri::TerminalSelection { .. } => {} MentionUri::GitDiff { .. } => {} + MentionUri::MergeConflict { .. } => {} }) } else { cx.open_url(&url); diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index aef7f1f335eff7d092f924b9883ab0d64bbf65a8..92075616547d7917119b42cf762557ce163d0a2a 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -8,10 +8,10 @@ use collections::HashMap; use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window, + ScrollHandle, TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; -use project::Project; +use project::{AgentId, Project}; use prompt_store::PromptStore; use rope::Point; use settings::Settings as _; @@ -31,7 +31,7 @@ pub struct EntryViewState { entries: Vec, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, } impl EntryViewState { @@ -43,7 +43,7 @@ impl EntryViewState { prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, ) -> Self { Self { workspace, @@ -54,7 +54,7 @@ impl EntryViewState { entries: Vec::new(), prompt_capabilities, available_commands, - agent_name, + agent_id, } } @@ -96,7 +96,7 @@ impl EntryViewState { self.prompt_store.clone(), self.prompt_capabilities.clone(), self.available_commands.clone(), - self.agent_name.clone(), + self.agent_id.clone(), "Edit message - @ to include context", editor::EditorMode::AutoHeight { min_lines: 1, @@ -468,7 +468,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use util::path; - use workspace::MultiWorkspace; + use workspace::{MultiWorkspace, PathList}; #[gpui::test] async fn test_diff_sync(cx: &mut TestAppContext) { @@ -495,9 +495,11 @@ mod tests { let connection = Rc::new(StubAgentConnection::new()); let thread = cx .update(|_, cx| { - connection - .clone() - .new_session(project.clone(), Path::new(path!("/project")), cx) + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new(path!("/project"))]), + cx, + ) }) .await .unwrap(); @@ -508,8 +510,7 @@ mod tests { }); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let view_state = cx.new(|_cx| { EntryViewState::new( diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 4e7eecfe07aac84269cb1d325cc5a95943578863..1fc66f6079fa146440a1f5a594d9f160e4580ab2 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -266,7 +266,7 @@ impl InlineAssistant { return; }; - let configuration_error = || { + let configuration_error = |cx| { let model_registry = LanguageModelRegistry::read_global(cx); model_registry.configuration_error(model_registry.inline_assistant_model(), cx) }; @@ -278,7 +278,15 @@ impl InlineAssistant { let prompt_store = agent_panel.prompt_store().as_ref().cloned(); let thread_store = agent_panel.thread_store().clone(); - let history = agent_panel.history().downgrade(); + let Some(history) = agent_panel + .connection_store() + .read(cx) + .entry(&crate::Agent::NativeAgent) + .and_then(|s| s.read(cx).history().cloned()) + else { + log::error!("No connection entry found for native agent"); + return; + }; let handle_assist = |window: &mut Window, cx: &mut Context| match inline_assist_target { @@ -290,7 +298,7 @@ impl InlineAssistant { workspace.project().downgrade(), thread_store, prompt_store, - history, + history.downgrade(), action.prompt.clone(), window, cx, @@ -305,7 +313,7 @@ impl InlineAssistant { workspace.project().downgrade(), thread_store, prompt_store, - history, + history.downgrade(), action.prompt.clone(), window, cx, @@ -314,7 +322,7 @@ impl InlineAssistant { } }; - if let Some(error) = configuration_error() { + if let Some(error) = configuration_error(cx) { if let ConfigurationError::ProviderNotAuthenticated(provider) = error { cx.spawn(async move |_, cx| { cx.update(|cx| provider.authenticate(cx)).await?; @@ -322,7 +330,7 @@ impl InlineAssistant { }) .detach_and_log_err(cx); - if configuration_error().is_none() { + if configuration_error(cx).is_none() { handle_assist(window, cx); } } else { @@ -1969,7 +1977,16 @@ impl CodeActionProvider for AssistantCodeActionProvider { .panel::(cx) .context("missing agent panel")? .read(cx); - anyhow::Ok((panel.thread_store().clone(), panel.history().downgrade())) + + let history = panel + .connection_store() + .read(cx) + .entry(&crate::Agent::NativeAgent) + .and_then(|e| e.read(cx).history()) + .context("no history found for native agent")? + .downgrade(); + + anyhow::Ok((panel.thread_store().clone(), history)) })??; let editor = editor.upgrade().context("editor was released")?; let range = editor @@ -2155,7 +2172,7 @@ pub mod test { }); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = cx.new(|cx| crate::ThreadHistory::new(None, window, cx)); + let history = cx.new(|cx| crate::ThreadHistory::new(None, cx)); // Add editor to workspace workspace.update(cx, |workspace, cx| { 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 792bfc11a63471e02b22835823fa8c59cdfc9bcf..782d2b353c8f3599ba38486a4cf558f448b31bcf 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -147,10 +147,13 @@ impl MentionSet { include_errors, include_warnings, } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx), + MentionUri::GitDiff { base_ref } => { + self.confirm_mention_for_git_diff(base_ref.into(), cx) + } MentionUri::PastedImage | MentionUri::Selection { .. } | MentionUri::TerminalSelection { .. } - | MentionUri::GitDiff { .. } => { + | MentionUri::MergeConflict { .. } => { Task::ready(Err(anyhow!("Unsupported mention URI type for paste"))) } } @@ -297,9 +300,12 @@ impl MentionSet { debug_panic!("unexpected terminal URI"); Task::ready(Err(anyhow!("unexpected terminal URI"))) } - MentionUri::GitDiff { .. } => { - debug_panic!("unexpected git diff URI"); - Task::ready(Err(anyhow!("unexpected git diff URI"))) + MentionUri::GitDiff { base_ref } => { + self.confirm_mention_for_git_diff(base_ref.into(), cx) + } + MentionUri::MergeConflict { .. } => { + debug_panic!("unexpected merge conflict URI"); + Task::ready(Err(anyhow!("unexpected merge conflict URI"))) } }; let task = cx @@ -548,19 +554,17 @@ impl MentionSet { project.read(cx).fs().clone(), thread_store, )); - let delegate = AgentServerDelegate::new( - project.read(cx).agent_server_store().clone(), - project.clone(), - None, - None, - ); + let delegate = + AgentServerDelegate::new(project.read(cx).agent_server_store().clone(), None); let connection = server.connect(delegate, cx); cx.spawn(async move |_, cx| { let agent = connection.await?; let agent = agent.downcast::().unwrap(); let summary = agent .0 - .update(cx, |agent, cx| agent.thread_summary(id, cx)) + .update(cx, |agent, cx| { + agent.thread_summary(id, project.clone(), cx) + }) .await?; Ok(Mention::Text { content: summary.to_string(), @@ -599,6 +603,42 @@ impl MentionSet { }) }) } + + pub fn confirm_mention_for_git_diff( + &self, + base_ref: SharedString, + cx: &mut Context, + ) -> Task> { + let Some(project) = self.project.upgrade() else { + return Task::ready(Err(anyhow!("project not found"))); + }; + + let Some(repo) = project.read(cx).active_repository(cx) else { + return Task::ready(Err(anyhow!("no active repository"))); + }; + + let diff_receiver = repo.update(cx, |repo, cx| { + repo.diff( + git::repository::DiffType::MergeBase { base_ref: base_ref }, + cx, + ) + }); + + cx.spawn(async move |_, _| { + let diff_text = diff_receiver.await??; + if diff_text.is_empty() { + Ok(Mention::Text { + content: "No changes found in branch diff.".into(), + tracked_buffers: Vec::new(), + }) + } else { + Ok(Mention::Text { + content: diff_text, + tracked_buffers: Vec::new(), + }) + } + }) + } } #[cfg(test)] diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 933e24e83c0450dcbdde27d49abebb7fda2fa119..fd625db07b0c34cdf90a9913f574d38df32e97f8 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -27,13 +27,14 @@ use gpui::{ KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, Language, language_settings::InlayHintKind}; +use project::AgentId; use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; use prompt_store::PromptStore; 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}; @@ -45,7 +46,7 @@ pub struct MessageEditor { workspace: WeakEntity, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, thread_store: Option>, _subscriptions: Vec, _parse_slash_command_task: Task<()>, @@ -80,6 +81,7 @@ impl PromptCompletionProviderDelegate for Entity { PromptContextType::Diagnostics, PromptContextType::Fetch, PromptContextType::Rules, + PromptContextType::BranchDiff, ]); } supported @@ -112,7 +114,7 @@ impl MessageEditor { prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, - agent_name: SharedString, + agent_id: AgentId, placeholder: &str, mode: EditorMode, window: &mut Window, @@ -235,7 +237,7 @@ impl MessageEditor { workspace, prompt_capabilities, available_commands, - agent_name, + agent_id, thread_store, _subscriptions: subscriptions, _parse_slash_command_task: Task::ready(()), @@ -378,7 +380,7 @@ impl MessageEditor { fn validate_slash_commands( text: &str, available_commands: &[acp::AvailableCommand], - agent_name: &str, + agent_id: &AgentId, ) -> Result<()> { if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) { if let Some(command_name) = parsed_command.command { @@ -391,7 +393,7 @@ impl MessageEditor { return Err(anyhow!( "The /{} command is not supported by {}.\n\nAvailable commands: {}", command_name, - agent_name, + agent_id, if available_commands.is_empty() { "none".to_string() } else { @@ -415,11 +417,11 @@ impl MessageEditor { ) -> Task, Vec>)>> { let text = self.editor.read(cx).text(cx); let available_commands = self.available_commands.borrow().clone(); - let agent_name = self.agent_name.clone(); + let agent_id = self.agent_id.clone(); let build_task = self.build_content_blocks(full_mention_content, cx); cx.spawn(async move |_, _cx| { - Self::validate_slash_commands(&text, &available_commands, &agent_name)?; + Self::validate_slash_commands(&text, &available_commands, &agent_id)?; build_task.await }) } @@ -1040,6 +1042,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, @@ -1078,11 +1162,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() } }), @@ -1598,7 +1680,7 @@ mod tests { use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType}; use crate::{ - connection_view::tests::init_test, + conversation_view::tests::init_test, message_editor::{Mention, MessageEditor, parse_mention_links}, }; @@ -1707,8 +1789,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -1821,8 +1902,7 @@ mod tests { 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 history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let workspace_handle = workspace.downgrade(); let message_editor = workspace.update_in(cx, |_, window, cx| { cx.new(|cx| { @@ -1977,8 +2057,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), @@ -2212,8 +2291,7 @@ mod tests { } let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -2708,8 +2786,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2809,8 +2886,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let session_id = acp::SessionId::new("thread-123"); let title = Some("Previous Conversation".into()); @@ -2885,8 +2961,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2942,8 +3017,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -2997,8 +3071,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3053,8 +3126,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3118,8 +3190,7 @@ mod tests { let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3278,8 +3349,7 @@ mod tests { }); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); // Create a new `MessageEditor`. The `EditorMode::full()` has to be used // to ensure we have a fixed viewport, so we can eventually actually @@ -3399,8 +3469,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3482,8 +3551,7 @@ mod tests { let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { let workspace_handle = cx.weak_entity(); @@ -3567,8 +3635,7 @@ mod tests { 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 history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { @@ -3720,8 +3787,7 @@ mod tests { 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 history = - cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx))); + let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let message_editor = cx.update(|window, cx| { cx.new(|cx| { 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 257337b6b0b8a39645bc38b4d814b250d7b5e1f9..74ebd78ba61681325cc4905be8d577b225e50e92 100644 --- a/crates/agent_ui/src/model_selector_popover.rs +++ b/crates/agent_ui/src/model_selector_popover.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use acp_thread::{AgentModelIcon, AgentModelInfo, AgentModelSelector}; use fs::Fs; -use gpui::{Entity, FocusHandle}; +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}; @@ -13,6 +13,7 @@ use crate::{ModelSelector, model_selector::acp_model_selector}; pub struct ModelSelectorPopover { selector: Entity, menu_handle: PopoverMenuHandle, + disabled: bool, } impl ModelSelectorPopover { @@ -30,10 +31,18 @@ impl ModelSelectorPopover { acp_model_selector(selector, agent_server, fs, focus_handle.clone(), window, cx) }), menu_handle, + disabled: false, } } + pub fn set_disabled(&mut self, disabled: bool) { + self.disabled = disabled; + } + pub fn toggle(&self, window: &mut Window, cx: &mut Context) { + if self.disabled { + return; + } self.menu_handle.toggle(window, cx); } @@ -42,6 +51,9 @@ impl ModelSelectorPopover { } pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context) { + if self.disabled { + return; + } self.selector.update(cx, |selector, cx| { selector.delegate.cycle_favorite_models(window, cx); }); @@ -61,26 +73,35 @@ impl Render for ModelSelectorPopover { let (color, icon) = if self.menu_handle.is_deployed() { (Color::Accent, IconName::ChevronUp) + } else if self.disabled { + (Color::Disabled, IconName::ChevronDown) } else { (Color::Muted, IconName::ChevronDown) }; let show_cycle_row = selector.delegate.favorites_count() > 1; + let disabled = self.disabled; - let tooltip = Tooltip::element({ - move |_, _cx| { - ModelSelectorTooltip::new() - .show_cycle_row(show_cycle_row) - .into_any_element() - } - }); + let tooltip: Box AnyView> = if disabled { + Box::new(Tooltip::text("Disabled until generation is done")) + } else { + Box::new(Tooltip::element({ + move |_, _cx| { + ModelSelectorTooltip::new() + .show_cycle_row(show_cycle_row) + .into_any_element() + } + })) + }; PickerPopoverMenu::new( self.selector.clone(), - ButtonLike::new("active-model") - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + Button::new("active-model", model_name) + .label_size(LabelSize::Small) + .color(color) + .disabled(self.disabled) .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), @@ -89,13 +110,17 @@ impl Render for ModelSelectorPopover { .size(IconSize::XSmall), ) }) - .child( - Label::new(model_name) - .color(color) - .size(LabelSize::Small) - .ml_0p5(), - ) - .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)), + .end_icon( + Icon::new(icon) + .map(|this| { + if self.disabled { + this.color(Color::Disabled) + } else { + this.color(Color::Muted) + } + }) + .size(IconSize::XSmall), + ), tooltip, gpui::Corner::BottomRight, cx, diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 926549c22f88bcb0937dddf7c3ff1b32060ed297..661f887b53116094b5a8694bf93b21389bd9f58b 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/crates/agent_ui/src/profile_selector.rs @@ -5,8 +5,8 @@ use agent_settings::{ use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ - Action, AnyElement, App, BackgroundExecutor, Context, DismissEvent, Entity, FocusHandle, - Focusable, ForegroundExecutor, SharedString, Subscription, Task, Window, + Action, AnyElement, AnyView, App, BackgroundExecutor, Context, DismissEvent, Entity, + FocusHandle, Focusable, ForegroundExecutor, SharedString, Subscription, Task, Window, }; use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu}; use settings::{Settings as _, SettingsStore, update_settings_file}; @@ -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 @@ -34,6 +34,7 @@ pub trait ProfileProvider { pub struct ProfileSelector { profiles: AvailableProfiles, pending_refresh: bool, + disabled: bool, fs: Arc, provider: Arc, picker: Option>>, @@ -57,6 +58,7 @@ impl ProfileSelector { Self { profiles: AgentProfile::available_profiles(cx), pending_refresh: false, + disabled: false, fs, provider, picker: None, @@ -70,7 +72,19 @@ impl ProfileSelector { self.picker_handle.clone() } + pub fn set_disabled(&mut self, disabled: bool) { + self.disabled = disabled; + } + + pub fn is_disabled(&self) -> bool { + self.disabled + } + pub fn cycle_profile(&mut self, cx: &mut Context) { + if self.disabled { + return; + } + if !self.provider.profiles_supported(cx) { return; } @@ -175,18 +189,17 @@ impl Render for ProfileSelector { }; let trigger_button = Button::new("profile-selector", selected_profile) + .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)); - PickerPopoverMenu::new( - picker, - trigger_button, - Tooltip::element({ + let disabled = self.disabled; + + let tooltip: Box AnyView> = if disabled { + Box::new(Tooltip::text("Disabled until generation is done")) + } else { + Box::new(Tooltip::element({ move |_window, cx| { let container = || h_flex().gap_1().justify_between(); v_flex() @@ -206,7 +219,13 @@ impl Render for ProfileSelector { ) .into_any() } - }), + })) + }; + + PickerPopoverMenu::new( + picker, + trigger_button, + tooltip, gpui::Corner::BottomRight, cx, ) diff --git a/crates/agent_ui/src/sidebar.rs b/crates/agent_ui/src/sidebar.rs new file mode 100644 index 0000000000000000000000000000000000000000..87f4efeb0cf145f635b9af9d6923ec53a8e38ff5 --- /dev/null +++ b/crates/agent_ui/src/sidebar.rs @@ -0,0 +1,4981 @@ +use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; +use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent}; +use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread}; +use acp_thread::ThreadStatus; +use action_log::DiffStats; +use agent::ThreadStore; +use agent_client_protocol::{self as acp}; +use agent_settings::AgentSettings; +use chrono::Utc; +use db::kvp::KEY_VALUE_STORE; +use editor::Editor; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; +use gpui::{ + Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels, + Render, SharedString, WeakEntity, Window, actions, list, prelude::*, px, +}; +use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use project::{AgentId, Event as ProjectEvent}; +use settings::Settings; +use std::collections::{HashMap, HashSet}; +use std::mem; +use std::path::Path; +use std::sync::Arc; +use theme::ActiveTheme; +use ui::{ + AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem, + Tooltip, WithScrollbar, prelude::*, +}; +use util::ResultExt as _; +use util::path_list::PathList; +use workspace::{ + MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled, +}; +use zed_actions::editor::{MoveDown, MoveUp}; + +actions!( + agents_sidebar, + [ + /// Collapses the selected entry in the workspace sidebar. + CollapseSelectedEntry, + /// Expands the selected entry in the workspace sidebar. + ExpandSelectedEntry, + ] +); + +const DEFAULT_WIDTH: Pixels = px(320.0); +const MIN_WIDTH: Pixels = px(200.0); +const MAX_WIDTH: Pixels = px(800.0); +const DEFAULT_THREADS_SHOWN: usize = 5; +const SIDEBAR_STATE_KEY: &str = "sidebar_state"; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum SidebarView { + #[default] + ThreadList, + Archive, +} + +fn read_sidebar_open_state(multi_workspace_id: u64) -> bool { + KEY_VALUE_STORE + .scoped(SIDEBAR_STATE_KEY) + .read(&multi_workspace_id.to_string()) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).ok()) + .unwrap_or(false) +} + +async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) { + if let Ok(json) = serde_json::to_string(&is_open) { + KEY_VALUE_STORE + .scoped(SIDEBAR_STATE_KEY) + .write(multi_workspace_id.to_string(), json) + .await + .log_err(); + } +} + +#[derive(Clone, Debug)] +struct ActiveThreadInfo { + session_id: acp::SessionId, + title: SharedString, + status: AgentThreadStatus, + icon: IconName, + icon_from_external_svg: Option, + is_background: bool, + is_title_generating: bool, + diff_stats: DiffStats, +} + +impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { + fn from(info: &ActiveThreadInfo) -> Self { + Self { + session_id: info.session_id.clone(), + work_dirs: None, + title: Some(info.title.clone()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + } + } +} + +#[derive(Clone)] +enum ThreadEntryWorkspace { + Open(Entity), + Closed(PathList), +} + +#[derive(Clone)] +struct ThreadEntry { + agent: Agent, + session_info: acp_thread::AgentSessionInfo, + icon: IconName, + icon_from_external_svg: Option, + status: AgentThreadStatus, + workspace: ThreadEntryWorkspace, + is_live: bool, + is_background: bool, + is_title_generating: bool, + highlight_positions: Vec, + worktree_name: Option, + worktree_highlight_positions: Vec, + diff_stats: DiffStats, +} + +#[derive(Clone)] +enum ListEntry { + ProjectHeader { + path_list: PathList, + label: SharedString, + workspace: Entity, + highlight_positions: Vec, + has_threads: bool, + }, + Thread(ThreadEntry), + ViewMore { + path_list: PathList, + remaining_count: usize, + is_fully_expanded: bool, + }, + NewThread { + path_list: PathList, + workspace: Entity, + }, +} + +impl From for ListEntry { + fn from(thread: ThreadEntry) -> Self { + ListEntry::Thread(thread) + } +} + +#[derive(Default)] +struct SidebarContents { + entries: Vec, + notified_threads: HashSet, + project_header_indices: Vec, +} + +impl SidebarContents { + fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool { + self.notified_threads.contains(session_id) + } +} + +fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { + let mut positions = Vec::new(); + let mut query_chars = query.chars().peekable(); + + for (byte_idx, candidate_char) in candidate.char_indices() { + if let Some(&query_char) = query_chars.peek() { + if candidate_char.eq_ignore_ascii_case(&query_char) { + positions.push(byte_idx); + query_chars.next(); + } + } else { + break; + } + } + + if query_chars.peek().is_none() { + Some(positions) + } else { + None + } +} + +// TODO: The mapping from workspace root paths to git repositories needs a +// unified approach across the codebase: this function, `AgentPanel::classify_worktrees`, +// thread persistence (which PathList is saved to the database), and thread +// querying (which PathList is used to read threads back). All of these need +// to agree on how repos are resolved for a given workspace, especially in +// multi-root and nested-repo configurations. +fn root_repository_snapshots( + workspace: &Entity, + cx: &App, +) -> Vec { + let path_list = workspace_path_list(workspace, cx); + let project = workspace.read(cx).project().read(cx); + project + .repositories(cx) + .values() + .filter_map(|repo| { + let snapshot = repo.read(cx).snapshot(); + let is_root = path_list + .paths() + .iter() + .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); + is_root.then_some(snapshot) + }) + .collect() +} + +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()); + } + } + if names.is_empty() { + // TODO: Can we do something better in this case? + "Empty Workspace".into() + } else { + names.join(", ").into() + } +} + +pub struct Sidebar { + multi_workspace: WeakEntity, + persistence_key: Option, + is_open: bool, + width: Pixels, + focus_handle: FocusHandle, + filter_editor: Entity, + list_state: ListState, + contents: SidebarContents, + /// The index of the list item that currently has the keyboard focus + /// + /// Note: This is NOT the same as the active item. + selection: Option, + focused_thread: Option, + active_entry_index: Option, + hovered_thread_index: Option, + collapsed_groups: HashSet, + expanded_groups: HashMap, + view: SidebarView, + archive_view: Option>, + _subscriptions: Vec, + _update_entries_task: Option>, +} + +impl Sidebar { + pub fn new( + multi_workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + cx.on_focus_in(&focus_handle, window, Self::focus_in) + .detach(); + + let filter_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search…", window, cx); + editor + }); + + cx.subscribe_in( + &multi_workspace, + window, + |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { + MultiWorkspaceEvent::ActiveWorkspaceChanged => { + this.update_entries(false, cx); + } + MultiWorkspaceEvent::WorkspaceAdded(workspace) => { + this.subscribe_to_workspace(workspace, window, cx); + this.update_entries(false, cx); + } + MultiWorkspaceEvent::WorkspaceRemoved(_) => { + this.update_entries(false, cx); + } + }, + ) + .detach(); + + cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { + if let editor::EditorEvent::BufferEdited = event { + let query = this.filter_editor.read(cx).text(cx); + if !query.is_empty() { + this.selection.take(); + } + this.update_entries(!query.is_empty(), cx); + } + }) + .detach(); + + cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { + this.update_entries(false, cx); + }) + .detach(); + + cx.observe_flag::(window, |_is_enabled, this, _window, cx| { + this.update_entries(false, cx); + }) + .detach(); + + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + cx.defer_in(window, move |this, window, cx| { + for workspace in &workspaces { + this.subscribe_to_workspace(workspace, window, cx); + } + this.update_entries(false, cx); + }); + + let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0); + let is_open = persistence_key + .map(read_sidebar_open_state) + .unwrap_or(false); + + Self { + _update_entries_task: None, + multi_workspace: multi_workspace.downgrade(), + persistence_key, + is_open, + width: DEFAULT_WIDTH, + focus_handle, + filter_editor, + list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), + contents: SidebarContents::default(), + selection: None, + focused_thread: None, + active_entry_index: None, + hovered_thread_index: None, + collapsed_groups: HashSet::new(), + expanded_groups: HashMap::new(), + view: SidebarView::default(), + archive_view: None, + _subscriptions: Vec::new(), + } + } + + fn subscribe_to_workspace( + &self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let project = workspace.read(cx).project().clone(); + cx.subscribe_in( + &project, + window, + |this, _project, event, _window, cx| match event { + ProjectEvent::WorktreeAdded(_) + | ProjectEvent::WorktreeRemoved(_) + | ProjectEvent::WorktreeOrderChanged => { + this.update_entries(false, cx); + } + _ => {} + }, + ) + .detach(); + + let git_store = workspace.read(cx).project().read(cx).git_store().clone(); + cx.subscribe_in( + &git_store, + window, + |this, _, event: &project::git_store::GitStoreEvent, window, cx| { + if matches!( + event, + project::git_store::GitStoreEvent::RepositoryUpdated( + _, + project::git_store::RepositoryEvent::GitWorktreeListChanged, + _, + ) + ) { + this.prune_stale_worktree_workspaces(window, cx); + this.update_entries(false, cx); + } + }, + ) + .detach(); + + cx.subscribe_in( + workspace, + window, + |this, _workspace, event: &workspace::Event, window, cx| { + if let workspace::Event::PanelAdded(view) = event { + if let Ok(agent_panel) = view.clone().downcast::() { + this.subscribe_to_agent_panel(&agent_panel, window, cx); + } + } + }, + ) + .detach(); + + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + self.subscribe_to_agent_panel(&agent_panel, window, cx); + } + } + + fn subscribe_to_agent_panel( + &self, + agent_panel: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + cx.subscribe_in( + agent_panel, + window, + |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { + AgentPanelEvent::ActiveViewChanged + | AgentPanelEvent::ThreadFocused + | AgentPanelEvent::BackgroundThreadChanged => { + this.update_entries(false, cx); + } + }, + ) + .detach(); + } + + fn all_thread_infos_for_workspace( + workspace: &Entity, + cx: &App, + ) -> Vec { + let Some(agent_panel) = workspace.read(cx).panel::(cx) else { + return Vec::new(); + }; + let agent_panel_ref = agent_panel.read(cx); + + agent_panel_ref + .parent_threads(cx) + .into_iter() + .map(|thread_view| { + let thread_view_ref = thread_view.read(cx); + let thread = thread_view_ref.thread.read(cx); + + let icon = thread_view_ref.agent_icon; + let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); + let title = thread.title(); + let is_native = thread_view_ref.as_native_thread(cx).is_some(); + let is_title_generating = is_native && thread.has_provisional_title(); + let session_id = thread.session_id().clone(); + let is_background = agent_panel_ref.is_background_thread(&session_id); + + let status = if thread.is_waiting_for_confirmation() { + AgentThreadStatus::WaitingForConfirmation + } else if thread.had_error() { + AgentThreadStatus::Error + } else { + match thread.status() { + ThreadStatus::Generating => AgentThreadStatus::Running, + ThreadStatus::Idle => AgentThreadStatus::Completed, + } + }; + + let diff_stats = thread.action_log().read(cx).diff_stats(cx); + + ActiveThreadInfo { + session_id, + title, + status, + icon, + icon_from_external_svg, + is_background, + is_title_generating, + diff_stats, + } + }) + .collect() + } + + fn rebuild_contents(&mut self, thread_entries: Vec, cx: &App) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + let mw = multi_workspace.read(cx); + let workspaces = mw.workspaces().to_vec(); + let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); + + self.focused_thread = active_workspace + .as_ref() + .and_then(|ws| ws.read(cx).panel::(cx)) + .and_then(|panel| panel.read(cx).active_conversation().cloned()) + .and_then(|cv| cv.read(cx).parent_id(cx)); + + let mut threads_by_paths: HashMap> = HashMap::new(); + for row in thread_entries { + threads_by_paths + .entry(row.folder_paths.clone()) + .or_default() + .push(row); + } + + // Build a lookup for agent icons from the first workspace's AgentServerStore. + let agent_server_store = workspaces + .first() + .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()); + + let query = self.filter_editor.read(cx).text(cx); + + let previous = mem::take(&mut self.contents); + + let old_statuses: HashMap = previous + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::Thread(thread) if thread.is_live => { + Some((thread.session_info.session_id.clone(), thread.status)) + } + _ => None, + }) + .collect(); + + let mut entries = Vec::new(); + let mut notified_threads = previous.notified_threads; + // Track all session IDs we add to entries so we can prune stale + // notifications without a separate pass at the end. + let mut current_session_ids: HashSet = HashSet::new(); + // Compute active_entry_index inline during the build pass. + let mut active_entry_index: Option = None; + + // Identify absorbed workspaces in a single pass. A workspace is + // "absorbed" when it points at a git worktree checkout whose main + // repo is open as another workspace — its threads appear under the + // main repo's header instead of getting their own. + let mut main_repo_workspace: HashMap, usize> = HashMap::new(); + let mut absorbed: HashMap = HashMap::new(); + let mut pending: HashMap, Vec<(usize, SharedString, Arc)>> = HashMap::new(); + let mut absorbed_workspace_by_path: HashMap, usize> = HashMap::new(); + + for (i, workspace) in workspaces.iter().enumerate() { + for snapshot in root_repository_snapshots(workspace, cx) { + if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path { + main_repo_workspace + .entry(snapshot.work_directory_abs_path.clone()) + .or_insert(i); + if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { + for (ws_idx, name, ws_path) in waiting { + absorbed.insert(ws_idx, (i, name)); + absorbed_workspace_by_path.insert(ws_path, ws_idx); + } + } + } else { + let name: SharedString = snapshot + .work_directory_abs_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string() + .into(); + if let Some(&main_idx) = + main_repo_workspace.get(&snapshot.original_repo_abs_path) + { + absorbed.insert(i, (main_idx, name)); + absorbed_workspace_by_path + .insert(snapshot.work_directory_abs_path.clone(), i); + } else { + pending + .entry(snapshot.original_repo_abs_path.clone()) + .or_default() + .push((i, name, snapshot.work_directory_abs_path.clone())); + } + } + } + } + + for (ws_index, workspace) in workspaces.iter().enumerate() { + if absorbed.contains_key(&ws_index) { + continue; + } + + 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(); + + let mut threads: Vec = Vec::new(); + + if should_load_threads { + let mut seen_session_ids: HashSet = HashSet::new(); + + // Read threads from SidebarDb for this workspace's path list. + if let Some(rows) = threads_by_paths.get(&path_list) { + for row in rows { + seen_session_ids.insert(row.session_id.clone()); + let (agent, icon, icon_from_external_svg) = match &row.agent_id { + None => (Agent::NativeAgent, IconName::ZedAgent, None), + Some(id) => { + let custom_icon = agent_server_store + .as_ref() + .and_then(|store| store.read(cx).agent_icon(&id)); + ( + Agent::Custom { id: id.clone() }, + IconName::Terminal, + custom_icon, + ) + } + }; + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); + } + } + + // Load threads from linked git worktrees of this workspace's repos. + { + let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = + Vec::new(); + for snapshot in root_repository_snapshots(workspace, cx) { + if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { + continue; + } + for git_worktree in snapshot.linked_worktrees() { + let name = git_worktree + .path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + linked_worktree_queries.push(( + PathList::new(std::slice::from_ref(&git_worktree.path)), + name.into(), + Arc::from(git_worktree.path.as_path()), + )); + } + } + + for (worktree_path_list, worktree_name, worktree_path) in + &linked_worktree_queries + { + let target_workspace = + match absorbed_workspace_by_path.get(worktree_path.as_ref()) { + Some(&idx) => ThreadEntryWorkspace::Open(workspaces[idx].clone()), + None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), + }; + + if let Some(rows) = threads_by_paths.get(worktree_path_list) { + for row in rows { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; + } + let (agent, icon, icon_from_external_svg) = match &row.agent_id { + None => (Agent::NativeAgent, IconName::ZedAgent, None), + Some(name) => { + let custom_icon = + agent_server_store.as_ref().and_then(|store| { + store + .read(cx) + .agent_icon(&AgentId(name.clone().into())) + }); + ( + Agent::Custom { + id: AgentId::new(name.clone()), + }, + IconName::Terminal, + custom_icon, + ) + } + }; + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: target_workspace.clone(), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: Some(worktree_name.clone()), + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); + } + } + } + } + + let live_infos = Self::all_thread_infos_for_workspace(workspace, cx); + + if !live_infos.is_empty() { + let thread_index_by_session: HashMap = threads + .iter() + .enumerate() + .map(|(i, t)| (t.session_info.session_id.clone(), i)) + .collect(); + + for info in &live_infos { + let Some(&idx) = thread_index_by_session.get(&info.session_id) else { + continue; + }; + + let thread = &mut threads[idx]; + thread.session_info.title = Some(info.title.clone()); + thread.status = info.status; + thread.icon = info.icon; + thread.icon_from_external_svg = info.icon_from_external_svg.clone(); + thread.is_live = true; + thread.is_background = info.is_background; + thread.is_title_generating = info.is_title_generating; + thread.diff_stats = info.diff_stats; + } + } + + // Update notification state for live threads in the same pass. + let is_active_workspace = active_workspace + .as_ref() + .is_some_and(|active| active == workspace); + + for thread in &threads { + let session_id = &thread.session_info.session_id; + if thread.is_background && thread.status == AgentThreadStatus::Completed { + notified_threads.insert(session_id.clone()); + } else if thread.status == AgentThreadStatus::Completed + && !is_active_workspace + && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) + { + notified_threads.insert(session_id.clone()); + } + + if is_active_workspace && !thread.is_background { + notified_threads.remove(session_id); + } + } + + // Sort by created_at (newest first), falling back to updated_at + // for threads without a created_at (e.g., ACP sessions). + threads.sort_by(|a, b| { + let a_time = a.session_info.created_at.or(a.session_info.updated_at); + let b_time = b.session_info.created_at.or(b.session_info.updated_at); + b_time.cmp(&a_time) + }); + } + + if !query.is_empty() { + let has_threads = !threads.is_empty(); + + let workspace_highlight_positions = + fuzzy_match_positions(&query, &label).unwrap_or_default(); + let workspace_matched = !workspace_highlight_positions.is_empty(); + + let mut matched_threads: Vec = Vec::new(); + for mut thread in threads { + let title = thread + .session_info + .title + .as_ref() + .map(|s| s.as_ref()) + .unwrap_or(""); + if let Some(positions) = fuzzy_match_positions(&query, title) { + thread.highlight_positions = positions; + } + if let Some(worktree_name) = &thread.worktree_name { + if let Some(positions) = fuzzy_match_positions(&query, worktree_name) { + thread.worktree_highlight_positions = positions; + } + } + let worktree_matched = !thread.worktree_highlight_positions.is_empty(); + if workspace_matched + || !thread.highlight_positions.is_empty() + || worktree_matched + { + matched_threads.push(thread); + } + } + + if matched_threads.is_empty() && !workspace_matched { + continue; + } + + if active_entry_index.is_none() + && self.focused_thread.is_none() + && active_workspace + .as_ref() + .is_some_and(|active| active == workspace) + { + active_entry_index = Some(entries.len()); + } + + entries.push(ListEntry::ProjectHeader { + path_list: path_list.clone(), + label, + workspace: workspace.clone(), + highlight_positions: workspace_highlight_positions, + has_threads, + }); + + // Track session IDs and compute active_entry_index as we add + // thread entries. + for thread in matched_threads { + current_session_ids.insert(thread.session_info.session_id.clone()); + if active_entry_index.is_none() { + if let Some(focused) = &self.focused_thread { + if &thread.session_info.session_id == focused { + active_entry_index = Some(entries.len()); + } + } + } + entries.push(thread.into()); + } + } else { + let has_threads = !threads.is_empty(); + + // Check if this header is the active entry before pushing it. + if active_entry_index.is_none() + && self.focused_thread.is_none() + && active_workspace + .as_ref() + .is_some_and(|active| active == workspace) + { + active_entry_index = Some(entries.len()); + } + + entries.push(ListEntry::ProjectHeader { + path_list: path_list.clone(), + label, + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_threads, + }); + + if is_collapsed { + continue; + } + + let total = threads.len(); + + let extra_batches = self.expanded_groups.get(&path_list).copied().unwrap_or(0); + let threads_to_show = + DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN); + let count = threads_to_show.min(total); + let is_fully_expanded = count >= total; + + // Track session IDs and compute active_entry_index as we add + // thread entries. + for thread in threads.into_iter().take(count) { + current_session_ids.insert(thread.session_info.session_id.clone()); + if active_entry_index.is_none() { + if let Some(focused) = &self.focused_thread { + if &thread.session_info.session_id == focused { + active_entry_index = Some(entries.len()); + } + } + } + entries.push(thread.into()); + } + + if total > DEFAULT_THREADS_SHOWN { + entries.push(ListEntry::ViewMore { + path_list: path_list.clone(), + remaining_count: total.saturating_sub(count), + is_fully_expanded, + }); + } + + if total == 0 { + entries.push(ListEntry::NewThread { + path_list: path_list.clone(), + workspace: workspace.clone(), + }); + } + } + } + + // Prune stale notifications using the session IDs we collected during + // the build pass (no extra scan needed). + notified_threads.retain(|id| current_session_ids.contains(id)); + + let project_header_indices = entries + .iter() + .enumerate() + .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i)) + .collect(); + + self.active_entry_index = active_entry_index; + self.contents = SidebarContents { + entries, + notified_threads, + project_header_indices, + }; + } + + fn update_entries(&mut self, select_first_thread: bool, cx: &mut Context) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + if !multi_workspace_enabled(cx) { + return; + } + + let had_notifications = self.has_notifications(cx); + + let scroll_position = self.list_state.logical_scroll_top(); + + let list_thread_entries_task = ThreadMetadataStore::global(cx).read(cx).list(cx); + + self._update_entries_task.take(); + self._update_entries_task = Some(cx.spawn(async move |this, cx| { + let Some(thread_entries) = list_thread_entries_task.await.log_err() else { + return; + }; + this.update(cx, |this, cx| { + this.rebuild_contents(thread_entries, cx); + + if select_first_thread { + this.selection = this + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Thread(_))) + .or_else(|| { + if this.contents.entries.is_empty() { + None + } else { + Some(0) + } + }); + } + + this.list_state.reset(this.contents.entries.len()); + this.list_state.scroll_to(scroll_position); + + if had_notifications != this.has_notifications(cx) { + multi_workspace.update(cx, |_, cx| { + cx.notify(); + }); + } + + cx.notify(); + }) + .ok(); + })); + } + + fn render_list_entry( + &mut self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(entry) = self.contents.entries.get(ix) else { + return div().into_any_element(); + }; + let is_focused = self.focus_handle.is_focused(window) + || self.filter_editor.focus_handle(cx).is_focused(window); + // is_selected means the keyboard selector is here. + let is_selected = is_focused && self.selection == Some(ix); + + let is_group_header_after_first = + ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. }); + + let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; + + let rendered = match entry { + ListEntry::ProjectHeader { + path_list, + label, + workspace, + highlight_positions, + has_threads, + } => self.render_project_header( + ix, + false, + path_list, + label, + workspace, + highlight_positions, + *has_threads, + is_selected, + docked_right, + cx, + ), + ListEntry::Thread(thread) => { + self.render_thread(ix, thread, is_selected, docked_right, cx) + } + ListEntry::ViewMore { + path_list, + remaining_count, + is_fully_expanded, + } => self.render_view_more( + ix, + path_list, + *remaining_count, + *is_fully_expanded, + is_selected, + cx, + ), + ListEntry::NewThread { + path_list, + workspace, + } => self.render_new_thread(ix, path_list, workspace, is_selected, cx), + }; + + if is_group_header_after_first { + v_flex() + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(rendered) + .into_any_element() + } else { + rendered + } + } + + fn render_project_header( + &self, + ix: usize, + is_sticky: bool, + path_list: &PathList, + label: &SharedString, + workspace: &Entity, + highlight_positions: &[usize], + has_threads: bool, + is_selected: bool, + docked_right: bool, + cx: &mut Context, + ) -> AnyElement { + let id_prefix = if is_sticky { "sticky-" } else { "" }; + let id = SharedString::from(format!("{id_prefix}project-header-{ix}")); + let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}")); + let ib_id = SharedString::from(format!("{id_prefix}project-header-new-thread-{ix}")); + + let is_collapsed = self.collapsed_groups.contains(path_list); + let disclosure_icon = if is_collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }; + let workspace_for_new_thread = workspace.clone(); + let workspace_for_remove = workspace.clone(); + // let workspace_for_activate = workspace.clone(); + + let path_list_for_toggle = path_list.clone(); + let path_list_for_collapse = path_list.clone(); + let view_more_expanded = self.expanded_groups.contains_key(path_list); + + let multi_workspace = self.multi_workspace.upgrade(); + let workspace_count = multi_workspace + .as_ref() + .map_or(0, |mw| mw.read(cx).workspaces().len()); + let is_active_workspace = self.focused_thread.is_none() + && multi_workspace + .as_ref() + .is_some_and(|mw| mw.read(cx).workspace() == workspace); + + let label = if highlight_positions.is_empty() { + Label::new(label.clone()) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + } else { + HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + }; + + ListItem::new(id) + .group_name(group_name) + .toggle_state(is_active_workspace) + .focused(is_selected) + .docked_right(docked_right) + .child( + h_flex() + .relative() + .min_w_0() + .w_full() + .py_1() + .gap_1p5() + .child( + Icon::new(disclosure_icon) + .size(IconSize::Small) + .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))), + ) + .child(label), + ) + .end_hover_gradient_overlay(true) + .end_hover_slot( + h_flex() + .gap_1() + .when(workspace_count > 1, |this| { + this.child( + IconButton::new( + SharedString::from(format!( + "{id_prefix}project-header-remove-{ix}", + )), + IconName::Close, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Remove Project")) + .on_click(cx.listener( + move |this, _, window, cx| { + this.remove_workspace(&workspace_for_remove, window, cx); + }, + )), + ) + }) + .when(view_more_expanded && !is_collapsed, |this| { + this.child( + IconButton::new( + SharedString::from(format!( + "{id_prefix}project-header-collapse-{ix}", + )), + IconName::ListCollapse, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Collapse Displayed Threads")) + .on_click(cx.listener({ + let path_list_for_collapse = path_list_for_collapse.clone(); + move |this, _, _window, cx| { + this.selection = None; + this.expanded_groups.remove(&path_list_for_collapse); + this.update_entries(false, cx); + } + })), + ) + }) + .when(has_threads, |this| { + this.child( + IconButton::new(ib_id, IconName::NewThread) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("New Thread")) + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + this.create_new_thread(&workspace_for_new_thread, window, cx); + })), + ) + }), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.selection = None; + this.toggle_collapse(&path_list_for_toggle, window, cx); + })) + // TODO: Decide if we really want the header to be activating different workspaces + // .on_click(cx.listener(move |this, _, window, cx| { + // this.selection = None; + // this.activate_workspace(&workspace_for_activate, window, cx); + // })) + .into_any_element() + } + + fn render_sticky_header( + &self, + docked_right: bool, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let scroll_top = self.list_state.logical_scroll_top(); + + let &header_idx = self + .contents + .project_header_indices + .iter() + .rev() + .find(|&&idx| idx <= scroll_top.item_ix)?; + + let needs_sticky = header_idx < scroll_top.item_ix + || (header_idx == scroll_top.item_ix && scroll_top.offset_in_item > px(0.)); + + if !needs_sticky { + return None; + } + + let ListEntry::ProjectHeader { + path_list, + label, + workspace, + highlight_positions, + has_threads, + } = self.contents.entries.get(header_idx)? + else { + return None; + }; + + let is_focused = self.focus_handle.is_focused(window) + || self.filter_editor.focus_handle(cx).is_focused(window); + let is_selected = is_focused && self.selection == Some(header_idx); + + let header_element = self.render_project_header( + header_idx, + true, + &path_list, + &label, + &workspace, + &highlight_positions, + *has_threads, + is_selected, + docked_right, + cx, + ); + + let top_offset = self + .contents + .project_header_indices + .iter() + .find(|&&idx| idx > header_idx) + .and_then(|&next_idx| { + let bounds = self.list_state.bounds_for_item(next_idx)?; + let viewport = self.list_state.viewport_bounds(); + let y_in_viewport = bounds.origin.y - viewport.origin.y; + let header_height = bounds.size.height; + (y_in_viewport < header_height).then_some(y_in_viewport - header_height) + }) + .unwrap_or(px(0.)); + + let element = v_flex() + .absolute() + .top(top_offset) + .left_0() + .w_full() + .bg(cx.theme().colors().surface_background) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(header_element) + .into_any_element(); + + Some(element) + } + + fn activate_workspace( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), cx); + }); + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.focus_active_workspace(window, cx); + }); + } + + fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + + // Collect all worktree paths that are currently listed by any main + // repo open in any workspace. + let mut known_worktree_paths: HashSet = HashSet::new(); + for workspace in &workspaces { + for snapshot in root_repository_snapshots(workspace, cx) { + if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { + continue; + } + for git_worktree in snapshot.linked_worktrees() { + known_worktree_paths.insert(git_worktree.path.to_path_buf()); + } + } + } + + // Find workspaces that consist of exactly one root folder which is a + // stale worktree checkout. Multi-root workspaces are never pruned — + // losing one worktree shouldn't destroy a workspace that also + // contains other folders. + let mut to_remove: Vec> = Vec::new(); + for workspace in &workspaces { + let path_list = workspace_path_list(workspace, cx); + if path_list.paths().len() != 1 { + continue; + } + let should_prune = root_repository_snapshots(workspace, cx) + .iter() + .any(|snapshot| { + snapshot.work_directory_abs_path != snapshot.original_repo_abs_path + && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) + }); + if should_prune { + to_remove.push(workspace.clone()); + } + } + + for workspace in &to_remove { + self.remove_workspace(workspace, window, cx); + } + } + + fn remove_workspace( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + let Some(index) = multi_workspace + .workspaces() + .iter() + .position(|w| w == workspace) + else { + return; + }; + multi_workspace.remove_workspace(index, window, cx); + }); + } + + fn toggle_collapse( + &mut self, + path_list: &PathList, + _window: &mut Window, + cx: &mut Context, + ) { + if self.collapsed_groups.contains(path_list) { + self.collapsed_groups.remove(path_list); + } else { + self.collapsed_groups.insert(path_list.clone()); + } + self.update_entries(false, cx); + } + + fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context) {} + + fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + if self.reset_filter_editor_text(window, cx) { + self.update_entries(false, cx); + } else { + self.focus_handle.focus(window, cx); + } + } + + fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) -> bool { + self.filter_editor.update(cx, |editor, cx| { + if editor.buffer().read(cx).len(cx).0 > 0 { + editor.set_text("", window, cx); + true + } else { + false + } + }) + } + + fn has_filter_query(&self, cx: &App) -> bool { + !self.filter_editor.read(cx).text(cx).is_empty() + } + + fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { + self.select_next(&SelectNext, window, cx); + } + + fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { + self.select_previous(&SelectPrevious, window, cx); + } + + fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { + let next = match self.selection { + Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1, + None if !self.contents.entries.is_empty() => 0, + _ => return, + }; + self.selection = Some(next); + self.list_state.scroll_to_reveal_item(next); + cx.notify(); + } + + fn select_previous( + &mut self, + _: &SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + let prev = match self.selection { + Some(ix) if ix > 0 => ix - 1, + None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1, + _ => return, + }; + self.selection = Some(prev); + self.list_state.scroll_to_reveal_item(prev); + cx.notify(); + } + + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { + if !self.contents.entries.is_empty() { + self.selection = Some(0); + self.list_state.scroll_to_reveal_item(0); + cx.notify(); + } + } + + fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + if let Some(last) = self.contents.entries.len().checked_sub(1) { + self.selection = Some(last); + self.list_state.scroll_to_reveal_item(last); + cx.notify(); + } + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let Some(ix) = self.selection else { return }; + let Some(entry) = self.contents.entries.get(ix) else { + return; + }; + + match entry { + ListEntry::ProjectHeader { workspace, .. } => { + let workspace = workspace.clone(); + self.activate_workspace(&workspace, window, cx); + } + ListEntry::Thread(thread) => { + let session_info = thread.session_info.clone(); + match &thread.workspace { + ThreadEntryWorkspace::Open(workspace) => { + let workspace = workspace.clone(); + 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, + cx, + ); + } + } + } + ListEntry::ViewMore { + path_list, + is_fully_expanded, + .. + } => { + let path_list = path_list.clone(); + if *is_fully_expanded { + self.expanded_groups.remove(&path_list); + } else { + let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0); + self.expanded_groups.insert(path_list, current + 1); + } + self.update_entries(false, cx); + } + ListEntry::NewThread { workspace, .. } => { + let workspace = workspace.clone(); + self.create_new_thread(&workspace, window, cx); + } + } + } + + fn activate_thread( + &mut self, + agent: Agent, + session_info: acp_thread::AgentSessionInfo, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), cx); + }); + + workspace.update(cx, |workspace, cx| { + workspace.open_panel::(window, cx); + }); + + 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.work_dirs, + session_info.title, + true, + window, + cx, + ); + }); + } + + self.update_entries(false, cx); + } + + fn open_workspace_and_activate_thread( + &mut self, + agent: Agent, + session_info: acp_thread::AgentSessionInfo, + path_list: PathList, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + let paths: Vec = + path_list.paths().iter().map(|p| p.to_path_buf()).collect(); + + let open_task = multi_workspace.update(cx, |mw, cx| mw.open_project(paths, window, cx)); + + cx.spawn_in(window, async move |this, cx| { + let workspace = open_task.await?; + this.update_in(cx, |this, 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, + ) { + if let Some(path_list) = &session_info.work_dirs { + if let Some(workspace) = self.find_open_workspace_for_path_list(&path_list, cx) { + self.activate_thread(agent, session_info, &workspace, window, cx); + } else { + let path_list = path_list.clone(); + 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, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { return }; + + match self.contents.entries.get(ix) { + Some(ListEntry::ProjectHeader { path_list, .. }) => { + if self.collapsed_groups.contains(path_list) { + let path_list = path_list.clone(); + self.collapsed_groups.remove(&path_list); + self.update_entries(false, cx); + } else if ix + 1 < self.contents.entries.len() { + self.selection = Some(ix + 1); + self.list_state.scroll_to_reveal_item(ix + 1); + cx.notify(); + } + } + _ => {} + } + } + + fn collapse_selected_entry( + &mut self, + _: &CollapseSelectedEntry, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { return }; + + match self.contents.entries.get(ix) { + Some(ListEntry::ProjectHeader { path_list, .. }) => { + if !self.collapsed_groups.contains(path_list) { + let path_list = path_list.clone(); + self.collapsed_groups.insert(path_list); + self.update_entries(false, cx); + } + } + Some( + ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. }, + ) => { + for i in (0..ix).rev() { + if let Some(ListEntry::ProjectHeader { path_list, .. }) = + self.contents.entries.get(i) + { + let path_list = path_list.clone(); + self.selection = Some(i); + self.collapsed_groups.insert(path_list); + self.update_entries(false, cx); + break; + } + } + } + None => {} + } + } + + fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + let Some(thread_store) = ThreadStore::try_global(cx) else { + return; + }; + thread_store.update(cx, |store, cx| { + store + .delete_thread(session_id.clone(), cx) + .detach_and_log_err(cx); + }); + + ThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.delete(session_id.clone(), cx)) + .detach_and_log_err(cx); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { + return; + }; + let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { + return; + }; + if thread.agent != Agent::NativeAgent { + return; + } + let session_id = thread.session_info.session_id.clone(); + self.delete_thread(&session_id, cx); + } + + fn render_thread( + &self, + ix: usize, + thread: &ThreadEntry, + is_focused: bool, + docked_right: bool, + cx: &mut Context, + ) -> AnyElement { + let has_notification = self + .contents + .is_thread_notified(&thread.session_info.session_id); + + let title: SharedString = thread + .session_info + .title + .clone() + .unwrap_or_else(|| "Untitled".into()); + let session_info = thread.session_info.clone(); + let thread_workspace = thread.workspace.clone(); + + let is_hovered = self.hovered_thread_index == Some(ix); + let is_selected = self.focused_thread.as_ref() == Some(&session_info.session_id); + let can_delete = thread.agent == Agent::NativeAgent; + let session_id_for_delete = thread.session_info.session_id.clone(); + let focus_handle = self.focus_handle.clone(); + + let id = SharedString::from(format!("thread-entry-{}", ix)); + + let timestamp = thread + .session_info + .created_at + .or(thread.session_info.updated_at) + .map(|entry_time| { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + + let minutes = duration.num_minutes(); + let hours = duration.num_hours(); + let days = duration.num_days(); + let weeks = days / 7; + let months = days / 30; + + if minutes < 60 { + format!("{}m", minutes.max(1)) + } else if hours < 24 { + format!("{}h", hours) + } else if weeks < 4 { + format!("{}w", weeks.max(1)) + } else { + format!("{}mo", months.max(1)) + } + }); + + ThreadItem::new(id, title) + .icon(thread.icon) + .when_some(thread.icon_from_external_svg.clone(), |this, svg| { + this.custom_icon_from_external_svg(svg) + }) + .when_some(thread.worktree_name.clone(), |this, name| { + this.worktree(name) + }) + .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) + .when_some(timestamp, |this, ts| this.timestamp(ts)) + .highlight_positions(thread.highlight_positions.to_vec()) + .status(thread.status) + .generating_title(thread.is_title_generating) + .notified(has_notification) + .when(thread.diff_stats.lines_added > 0, |this| { + this.added(thread.diff_stats.lines_added as usize) + }) + .when(thread.diff_stats.lines_removed > 0, |this| { + this.removed(thread.diff_stats.lines_removed as usize) + }) + .selected(is_selected) + .focused(is_focused) + .docked_right(docked_right) + .hovered(is_hovered) + .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| { + if *is_hovered { + this.hovered_thread_index = Some(ix); + } else if this.hovered_thread_index == Some(ix) { + this.hovered_thread_index = None; + } + cx.notify(); + })) + .when(is_hovered && can_delete, |this| { + this.action_slot( + IconButton::new("delete-thread", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let session_id = session_id_for_delete.clone(); + cx.listener(move |this, _, _window, cx| { + this.delete_thread(&session_id, cx); + cx.stop_propagation(); + }) + }), + ) + }) + .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() + } + + fn render_filter_input(&self) -> impl IntoElement { + self.filter_editor.clone() + } + + fn render_view_more( + &self, + ix: usize, + path_list: &PathList, + remaining_count: usize, + is_fully_expanded: bool, + is_selected: bool, + cx: &mut Context, + ) -> AnyElement { + let path_list = path_list.clone(); + let id = SharedString::from(format!("view-more-{}", ix)); + + let (icon, label) = if is_fully_expanded { + (IconName::ListCollapse, "Collapse") + } else { + (IconName::Plus, "View More") + }; + + ListItem::new(id) + .focused(is_selected) + .child( + h_flex() + .py_1() + .gap_1p5() + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) + .child(Label::new(label).color(Color::Muted)) + .when(!is_fully_expanded, |this| { + this.child( + Label::new(format!("({})", remaining_count)) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.5))), + ) + }), + ) + .on_click(cx.listener(move |this, _, _window, cx| { + this.selection = None; + if is_fully_expanded { + this.expanded_groups.remove(&path_list); + } else { + let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0); + this.expanded_groups.insert(path_list.clone(), current + 1); + } + this.update_entries(false, cx); + })) + .into_any_element() + } + + fn create_new_thread( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), cx); + }); + + workspace.update(cx, |workspace, cx| { + if let Some(agent_panel) = workspace.panel::(cx) { + agent_panel.update(cx, |panel, cx| { + panel.new_thread(&NewThread, window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); + } + + fn render_new_thread( + &self, + ix: usize, + _path_list: &PathList, + workspace: &Entity, + is_selected: bool, + cx: &mut Context, + ) -> AnyElement { + let workspace = workspace.clone(); + + div() + .w_full() + .p_2() + .pt_1p5() + .child( + Button::new( + SharedString::from(format!("new-thread-btn-{}", ix)), + "New Thread", + ) + .full_width() + .style(ButtonStyle::Outlined) + .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; + this.create_new_thread(&workspace, window, cx); + })), + ) + .into_any_element() + } + + fn render_thread_list_header( + &self, + docked_right: bool, + cx: &mut Context, + ) -> impl IntoElement { + let has_query = self.has_filter_query(cx); + + h_flex() + .h(Tab::container_height(cx)) + .flex_none() + .gap_1p5() + .border_b_1() + .border_color(cx.theme().colors().border) + .when(!docked_right, |this| { + this.child(self.render_sidebar_toggle_button(false, cx)) + }) + .child(self.render_filter_input()) + .child( + h_flex() + .gap_0p5() + .when(!docked_right, |this| this.pr_1p5()) + .when(has_query, |this| { + this.child( + IconButton::new("clear_filter", IconName::Close) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_entries(false, cx); + })), + ) + }) + .child( + IconButton::new("archive", IconName::Archive) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Archive")) + .on_click(cx.listener(|this, _, window, cx| { + this.show_archive(window, cx); + })), + ), + ) + .when(docked_right, |this| { + this.pl_2() + .pr_0p5() + .child(self.render_sidebar_toggle_button(true, cx)) + }) + } + + fn render_sidebar_toggle_button( + &self, + docked_right: bool, + cx: &mut Context, + ) -> impl IntoElement { + let icon = if docked_right { + IconName::ThreadsSidebarRightOpen + } else { + IconName::ThreadsSidebarLeftOpen + }; + + h_flex() + .h_full() + .px_1() + .map(|this| { + if docked_right { + this.pr_1p5().border_l_1() + } else { + this.border_r_1() + } + }) + .border_color(cx.theme().colors().border_variant) + .child( + IconButton::new("sidebar-close-toggle", icon) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }), + ) + } +} + +impl Sidebar { + pub fn is_open(&self) -> bool { + self.is_open + } + + fn show_archive(&mut self, window: &mut Window, cx: &mut Context) { + let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| { + w.read(cx) + .workspaces() + .get(w.read(cx).active_workspace_index()) + .cloned() + }) else { + return; + }; + + let Some(agent_panel) = active_workspace.read(cx).panel::(cx) else { + return; + }; + + let thread_store = agent_panel.read(cx).thread_store().clone(); + let fs = active_workspace.read(cx).project().read(cx).fs().clone(); + let agent_connection_store = agent_panel.read(cx).connection_store().clone(); + let agent_server_store = active_workspace + .read(cx) + .project() + .read(cx) + .agent_server_store() + .clone(); + + let archive_view = cx.new(|cx| { + ThreadsArchiveView::new( + agent_connection_store, + agent_server_store, + thread_store, + fs, + window, + cx, + ) + }); + let subscription = cx.subscribe_in( + &archive_view, + window, + |this, _, event: &ThreadsArchiveViewEvent, window, cx| match event { + ThreadsArchiveViewEvent::Close => { + this.show_thread_list(window, cx); + } + ThreadsArchiveViewEvent::OpenThread { + agent, + session_info, + } => { + this.show_thread_list(window, cx); + this.activate_archived_thread(agent.clone(), session_info.clone(), window, cx); + } + }, + ); + + self._subscriptions.push(subscription); + self.archive_view = Some(archive_view); + self.view = SidebarView::Archive; + cx.notify(); + } + + fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context) { + self.view = SidebarView::ThreadList; + self.archive_view = None; + self._subscriptions.clear(); + window.focus(&self.focus_handle, cx); + cx.notify(); + } + + pub fn set_open(&mut self, open: bool, cx: &mut Context) { + if self.is_open == open { + return; + } + self.is_open = open; + cx.notify(); + if let Some(key) = self.persistence_key { + let is_open = self.is_open; + cx.background_spawn(async move { + save_sidebar_open_state(key, is_open).await; + }) + .detach(); + } + } + + pub fn toggle(&mut self, window: &mut Window, cx: &mut Context) { + let new_state = !self.is_open; + self.set_open(new_state, cx); + if new_state { + cx.focus_self(window); + } + } + + pub fn focus_or_unfocus( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + if self.is_open { + let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx); + if sidebar_is_focused { + let active_pane = workspace.active_pane().clone(); + let pane_focus = active_pane.read(cx).focus_handle(cx); + window.focus(&pane_focus, cx); + } else { + cx.focus_self(window); + } + } else { + self.set_open(true, cx); + cx.focus_self(window); + } + } + + pub fn width(&self, _cx: &App) -> Pixels { + self.width + } + + pub fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); + cx.notify(); + } + + pub fn has_notifications(&self, _cx: &App) -> bool { + !self.contents.notified_threads.is_empty() + } +} + +impl Focusable for Sidebar { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.filter_editor.focus_handle(cx) + } +} + +impl Render for Sidebar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font = theme::setup_ui_font(window, cx); + let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right; + let sticky_header = self.render_sticky_header(docked_right, window, cx); + + v_flex() + .id("workspace-sidebar") + .key_context("ThreadsSidebar") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::editor_move_down)) + .on_action(cx.listener(Self::editor_move_up)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::expand_selected_entry)) + .on_action(cx.listener(Self::collapse_selected_entry)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::remove_selected_thread)) + .font(ui_font) + .size_full() + .bg(cx.theme().colors().surface_background) + .map(|this| match self.view { + SidebarView::ThreadList => this + .child(self.render_thread_list_header(docked_right, cx)) + .child( + v_flex() + .relative() + .flex_1() + .overflow_hidden() + .child( + list( + self.list_state.clone(), + cx.processor(Self::render_list_entry), + ) + .flex_1() + .size_full(), + ) + .when_some(sticky_header, |this, header| this.child(header)) + .vertical_scrollbar_for(&self.list_state, window, cx), + ), + SidebarView::Archive => { + if let Some(archive_view) = &self.archive_view { + this.child(archive_view.clone()) + } else { + this + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{active_session_id, open_thread_with_connection, send_message}; + use acp_thread::StubAgentConnection; + use agent::ThreadStore; + use assistant_text_thread::TextThreadStore; + use chrono::DateTime; + use feature_flags::FeatureFlagAppExt as _; + use fs::FakeFs; + use gpui::TestAppContext; + use pretty_assertions::assert_eq; + use std::{path::PathBuf, sync::Arc}; + use util::path_list::PathList; + + fn init_test(cx: &mut TestAppContext) { + crate::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + } + + async fn init_test_project( + worktree_path: &str, + cx: &mut TestAppContext, + ) -> Entity { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await + } + + fn setup_sidebar( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> Entity { + let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx); + sidebar + } + + fn setup_sidebar_with_agent_panel( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> (Entity, Entity) { + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + let project = workspace.read_with(cx, |ws, _cx| ws.project().clone()); + let panel = add_agent_panel(&workspace, &project, cx); + workspace.update_in(cx, |workspace, window, cx| { + workspace.right_dock().update(cx, |dock, cx| { + if let Some(panel_ix) = dock.panel_index_for_type::() { + dock.activate_panel(panel_ix, window, cx); + } + dock.set_open(true, window, cx); + }); + }); + cx.run_until_parked(); + let sidebar = panel.read_with(cx, |panel, _cx| { + panel + .sidebar + .clone() + .expect("AgentPanel should have created a sidebar") + }); + (sidebar, panel) + } + + async fn save_n_test_threads( + count: u32, + path_list: &PathList, + cx: &mut gpui::VisualTestContext, + ) { + for i in 0..count { + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + format!("Thread {}", i + 1).into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + } + + async fn save_test_thread_metadata( + session_id: &acp::SessionId, + path_list: PathList, + cx: &mut TestAppContext, + ) { + save_thread_metadata( + session_id.clone(), + "Test".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list, + cx, + ) + .await; + } + + async fn save_named_thread_metadata( + session_id: &str, + title: &str, + path_list: &PathList, + cx: &mut gpui::VisualTestContext, + ) { + save_thread_metadata( + acp::SessionId::new(Arc::from(session_id)), + SharedString::from(title.to_string()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + } + + async fn save_thread_metadata( + session_id: acp::SessionId, + title: SharedString, + updated_at: DateTime, + path_list: PathList, + cx: &mut TestAppContext, + ) { + let metadata = ThreadMetadata { + session_id, + agent_id: None, + title, + updated_at, + created_at: None, + folder_paths: path_list, + }; + let task = cx.update(|cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) + }); + task.await.unwrap(); + } + + fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.set_open(true, cx); + cx.focus_self(window); + }); + cx.run_until_parked(); + } + + fn visible_entries_as_strings( + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> Vec { + sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .map(|(ix, entry)| { + let selected = if sidebar.selection == Some(ix) { + " <== selected" + } else { + "" + }; + match entry { + ListEntry::ProjectHeader { + label, + path_list, + highlight_positions: _, + .. + } => { + let icon = if sidebar.collapsed_groups.contains(path_list) { + ">" + } else { + "v" + }; + format!("{} [{}]{}", icon, label, selected) + } + ListEntry::Thread(thread) => { + let title = thread + .session_info + .title + .as_ref() + .map(|s| s.as_ref()) + .unwrap_or("Untitled"); + let active = if thread.is_live { " *" } else { "" }; + let status_str = match thread.status { + AgentThreadStatus::Running => " (running)", + AgentThreadStatus::Error => " (error)", + AgentThreadStatus::WaitingForConfirmation => " (waiting)", + _ => "", + }; + let notified = if sidebar + .contents + .is_thread_notified(&thread.session_info.session_id) + { + " (!)" + } else { + "" + }; + let worktree = thread + .worktree_name + .as_ref() + .map(|name| format!(" {{{}}}", name)) + .unwrap_or_default(); + format!( + " {}{}{}{}{}{}", + title, worktree, active, status_str, notified, selected + ) + } + ListEntry::ViewMore { + remaining_count, + is_fully_expanded, + .. + } => { + if *is_fully_expanded { + format!(" - Collapse{}", selected) + } else { + format!(" + View More ({}){}", remaining_count, selected) + } + } + ListEntry::NewThread { .. } => { + format!(" [+ New Thread]{}", selected) + } + } + }) + .collect() + }) + } + + #[gpui::test] + async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]"] + ); + } + + #[gpui::test] + async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix crash in project panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-2")), + "Add inline diff view".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + ] + ); + } + + #[gpui::test] + async fn test_workspace_lifecycle(cx: &mut TestAppContext) { + let project = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Single workspace with a thread + let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-a1")), + "Thread A1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); + + // Add a second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Thread A1", + "v [Empty Workspace]", + " [+ New Thread]" + ] + ); + + // Remove the second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.remove_workspace(1, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); + } + + #[gpui::test] + async fn test_view_more_pagination(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(12, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Thread 12", + " Thread 11", + " Thread 10", + " Thread 9", + " Thread 8", + " + View More (7)", + ] + ); + } + + #[gpui::test] + async fn test_view_more_batched_expansion(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse + save_n_test_threads(17, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Initially shows 5 threads + View More (12 remaining) + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More (12)"))); + + // Focus and navigate to View More, then confirm to expand by one batch + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // Now shows 10 threads + View More (7 remaining) + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 12); // header + 10 threads + View More + assert!(entries.iter().any(|e| e.contains("View More (7)"))); + + // Expand again by one batch + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(false, cx); + }); + cx.run_until_parked(); + + // Now shows 15 threads + View More (2 remaining) + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 17); // header + 15 threads + View More + assert!(entries.iter().any(|e| e.contains("View More (2)"))); + + // Expand one more time - should show all 17 threads with Collapse button + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(false, cx); + }); + cx.run_until_parked(); + + // All 17 threads shown with Collapse button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 19); // header + 17 threads + Collapse + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); + + // Click collapse - should go back to showing 5 threads + sidebar.update_in(cx, |s, _window, cx| { + s.expanded_groups.remove(&path_list); + s.update_entries(false, cx); + }); + cx.run_until_parked(); + + // Back to initial state: 5 threads + View More (12 remaining) + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More (12)"))); + } + + #[gpui::test] + async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Collapse + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + + // Expand + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + } + + #[gpui::test] + async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); + let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); + + sidebar.update_in(cx, |s, _window, _cx| { + s.collapsed_groups.insert(collapsed_path.clone()); + s.contents + .notified_threads + .insert(acp::SessionId::new(Arc::from("t-5"))); + s.contents.entries = vec![ + // Expanded project header + ListEntry::ProjectHeader { + path_list: expanded_path.clone(), + label: "expanded-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_threads: true, + }, + // 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")), + work_dirs: None, + title: Some("Completed thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }), + // 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")), + work_dirs: None, + title: Some("Running thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Running, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }), + // 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")), + work_dirs: None, + title: Some("Error thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Error, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }), + // 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")), + work_dirs: None, + title: Some("Waiting thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::WaitingForConfirmation, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }), + // 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")), + work_dirs: None, + title: Some("Notified thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: true, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }), + // View More entry + ListEntry::ViewMore { + path_list: expanded_path.clone(), + remaining_count: 42, + is_fully_expanded: false, + }, + // Collapsed project header + ListEntry::ProjectHeader { + path_list: collapsed_path.clone(), + label: "collapsed-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_threads: true, + }, + ]; + // Select the Running thread (index 2) + s.selection = Some(2); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [expanded-project]", + " Completed thread", + " Running thread * (running) <== selected", + " Error thread * (error)", + " Waiting thread (waiting)", + " Notified thread * (!)", + " + View More (42)", + "> [collapsed-project]", + ] + ); + + // Move selection to the collapsed header + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = Some(7); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx).last().cloned(), + Some("> [collapsed-project] <== selected".to_string()), + ); + + // Clear selection + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = None; + }); + + // No entry should have the selected marker + let entries = visible_entries_as_strings(&sidebar, cx); + for entry in &entries { + assert!( + !entry.contains("<== selected"), + "unexpected selection marker in: {}", + entry + ); + } + } + + #[gpui::test] + async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Entries: [header, thread3, thread2, thread1] + // Focusing the sidebar does not set a selection; select_next/select_previous + // handle None gracefully by starting from the first or last entry. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Move down through remaining entries + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // At the end, selection stays on the last entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // Move back up + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // At the top, selection stays on the first entry + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + } + + #[gpui::test] + async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // SelectLast jumps to the end + cx.dispatch_action(SelectLast); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // SelectFirst jumps to the beginning + cx.dispatch_action(SelectFirst); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + } + + #[gpui::test] + async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Initially no selection + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Open the sidebar so it's rendered, then focus it to trigger focus_in. + // focus_in no longer sets a default selection. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Manually set a selection, blur, then refocus — selection should be preserved + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.update(|window, _cx| { + window.blur(); + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + } + + #[gpui::test] + async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + + // Add an agent panel to workspace 1 so the sidebar renders when it's active. + setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Thread 1", + "v [Empty Workspace]", + " [+ New Thread]", + ] + ); + + // Switch to workspace 1 so we can verify confirm switches back. + 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 + ); + + // Focus the sidebar and manually select the header (index 0) + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + // Press confirm on project header (workspace 0) to activate it. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + + // Focus should have moved out of the sidebar to the workspace center. + let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); + workspace_0.update_in(cx, |workspace, window, cx| { + let pane_focus = workspace.active_pane().read(cx).focus_handle(cx); + assert!( + pane_focus.contains_focused(window, cx), + "Confirming a project header should focus the workspace center pane" + ); + }); + } + + #[gpui::test] + async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(8, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show header + 5 threads + "View More (3)" + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); + assert!(entries.iter().any(|e| e.contains("View More (3)"))); + + // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); + + // Confirm on "View More" to expand + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // All 8 threads should now be visible with a "Collapse" button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); + } + + #[gpui::test] + async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); + + // Focus sidebar and manually select the header (index 0). Press left to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Press right to expand + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); + + // Press right again on already-expanded header moves selection down + cx.dispatch_action(ExpandSelectedEntry); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + } + + #[gpui::test] + async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), then navigate down to the thread (child) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1 <== selected",] + ); + + // Pressing left on a child collapses the parent group and selects it + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + } + + #[gpui::test] + async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { + let project = init_test_project("/empty-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Even an empty project has the header and a new thread button + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [empty-project]", " [+ New Thread]"] + ); + + // Focus sidebar — focus_in does not set a selection + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 (header) + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectNext moves to the new thread button + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // At the end, selection stays on the last entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // SelectPrevious goes back to the header + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + } + + #[gpui::test] + async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), navigate down to the thread (index 1) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // Collapse the group, which removes the thread from the list + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + + // Selection should be clamped to the last valid index (0 = header) + let selection = sidebar.read_with(cx, |s, _| s.selection); + let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); + assert!( + selection.unwrap_or(0) < entry_count, + "selection {} should be within bounds (entries: {})", + selection.unwrap_or(0), + entry_count, + ); + } + + fn add_agent_panel( + workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> Entity { + workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + } + + #[gpui::test] + async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Open thread A and keep it generating. + let connection = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection.clone(), cx); + send_message(&panel, cx); + + let session_id_a = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; + + cx.update(|_, cx| { + connection.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Open thread B (idle, default response) — thread A goes to background. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id_b = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; + + cx.run_until_parked(); + + let mut entries = visible_entries_as_strings(&sidebar, cx); + entries[1..].sort(); + assert_eq!( + entries, + vec!["v [my-project]", " Hello *", " Hello * (running)",] + ); + } + + #[gpui::test] + async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { + let project_a = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Open thread on workspace A and keep it generating. + let connection_a = StubAgentConnection::new(); + open_thread_with_connection(&panel_a, connection_a.clone(), cx); + send_message(&panel_a, cx); + + let session_id_a = active_session_id(&panel_a, cx); + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + + cx.update(|_, cx| { + connection_a.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Add a second workspace and activate it (making workspace A the background). + let fs = cx.update(|_, cx| ::global(cx)); + let project_b = project::Project::test(fs, [], cx).await; + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + cx.run_until_parked(); + + // Thread A is still running; no notification yet. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Hello * (running)", + "v [Empty Workspace]", + " [+ New Thread]", + ] + ); + + // Complete thread A's turn (transition Running → Completed). + connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); + cx.run_until_parked(); + + // The completed background thread shows a notification indicator. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Hello * (!)", + "v [Empty Workspace]", + " [+ New Thread]", + ] + ); + } + + fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, window, cx| { + window.focus(&sidebar.filter_editor.focus_handle(cx), cx); + sidebar.filter_editor.update(cx, |editor, cx| { + editor.set_text(query, window, cx); + }); + }); + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in project panel", 3), + ("t-2", "Add inline diff view", 2), + ("t-3", "Refactor settings module", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + " Refactor settings module", + ] + ); + + // User types "diff" in the search box — only the matching thread remains, + // with its workspace header preserved for context. + type_in_search(&sidebar, "diff", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Add inline diff view <== selected",] + ); + + // User changes query to something with no matches — list is empty. + type_in_search(&sidebar, "nonexistent", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); + } + + #[gpui::test] + async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { + // Scenario: A user remembers a thread title but not the exact casing. + // Search should match case-insensitively so they can still find it. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix Crash In Project Panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // Lowercase query matches mixed-case title. + type_in_search(&sidebar, "fix crash", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); + + // Uppercase query also matches the same title. + type_in_search(&sidebar, "FIX CRASH", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); + } + + #[gpui::test] + async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { + // Scenario: A user searches, finds what they need, then presses Escape + // to dismiss the filter and see the full list again. + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the full list is showing. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread", " Beta thread",] + ); + + // User types a search query to filter down. + open_and_focus_sidebar(&sidebar, cx); + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread <== selected",] + ); + + // User presses Escape — filter clears, full list is restored. + cx.dispatch_action(Cancel); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Alpha thread <== selected", + " Beta thread", + ] + ); + } + + #[gpui::test] + async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { + let project_a = init_test_project("/project-a", 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); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar", + " Add tests for editor", + "v [Empty Workspace]", + " Refactor sidebar layout", + " Fix typo in README", + ] + ); + + // "sidebar" matches a thread in each workspace — both headers stay visible. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar <== selected", + "v [Empty Workspace]", + " Refactor sidebar layout", + ] + ); + + // "typo" only matches in the second workspace — the first header disappears. + type_in_search(&sidebar, "typo", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [Empty Workspace]", " Fix typo in README <== selected",] + ); + + // "project-a" matches the first workspace name — the header appears + // with all child threads included. + type_in_search(&sidebar, "project-a", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + } + + #[gpui::test] + async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { + let project_a = init_test_project("/alpha-project", 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); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // "alpha" matches the workspace name "alpha-project" but no thread titles. + // The workspace header should appear with all child threads included. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // "sidebar" matches thread titles in both workspaces but not workspace names. + // Both headers appear with their matching threads. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + "v [Empty Workspace]", + " Refactor sidebar layout", + ] + ); + + // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r + // doesn't match) — but does not match either workspace name or any thread. + // Actually let's test something simpler: a query that matches both a workspace + // name AND some threads in that workspace. Matching threads should still appear. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + "v [Empty Workspace]", + " Fix typo in README", + ] + ); + + // A query that matches a workspace name AND a thread in that same workspace. + // Both the header (highlighted) and all child threads should appear. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // Now search for something that matches only a workspace name when there + // are also threads with matching titles — the non-matching workspace's + // threads should still appear if their titles match. + type_in_search(&sidebar, "alp", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + } + + #[gpui::test] + async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create 8 threads. The oldest one has a unique name and will be + // behind View More (only 5 shown by default). + for i in 0..8u32 { + let title = if i == 0 { + "Hidden gem thread".to_string() + } else { + format!("Thread {}", i + 1) + }; + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the thread is not visible and View More is shown. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("View More")), + "should have View More button" + ); + assert!( + !entries.iter().any(|e| e.contains("Hidden gem")), + "Hidden gem should be behind View More" + ); + + // User searches for the hidden thread — it appears, and View More is gone. + type_in_search(&sidebar, "hidden gem", cx); + let filtered = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + filtered, + vec!["v [my-project]", " Hidden gem thread <== selected",] + ); + assert!( + !filtered.iter().any(|e| e.contains("View More")), + "View More should not appear when filtering" + ); + } + + #[gpui::test] + async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Important thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // User focuses the sidebar and collapses the group using keyboard: + // manually select the header, then press CollapseSelectedEntry to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // User types a search — the thread appears even though its group is collapsed. + type_in_search(&sidebar, "important", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]", " Important thread <== selected",] + ); + } + + #[gpui::test] + async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in panel", 3), + ("t-2", "Fix lint warnings", 2), + ("t-3", "Add new feature", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // User types "fix" — two threads match. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); + + // Selection starts on the first matching thread. User presses + // SelectNext to move to the second match. + cx.dispatch_action(SelectNext); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel", + " Fix lint warnings <== selected", + ] + ); + + // User can also jump back with SelectPrevious. + cx.dispatch_action(SelectPrevious); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); + } + + #[gpui::test] + async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("hist-1")), + "Historical Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Historical Thread", + "v [Empty Workspace]", + " [+ New Thread]", + ] + ); + + // Switch to workspace 1 so we can verify the confirm switches back. + 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 + ); + + // Confirm on the historical (non-live) thread at index 1. + // Before a previous fix, the workspace field was Option and + // historical threads had None, so activate_thread early-returned + // without switching the workspace. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + } + + #[gpui::test] + async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-1")), + "Thread A".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-2")), + "Thread B".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + cx.run_until_parked(); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread A", " Thread B",] + ); + + // Keyboard confirm preserves selection. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + assert_eq!( + sidebar.read_with(cx, |sidebar, _| sidebar.selection), + Some(1) + ); + + // Click handlers clear selection to None so no highlight lingers + // after a click regardless of focus state. The hover style provides + // visual feedback during mouse interaction instead. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = None; + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + sidebar.toggle_collapse(&path_list, window, cx); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); + + // When the user tabs back into the sidebar, focus_in no longer + // restores selection — it stays None. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.focus_in(window, cx); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); + } + + #[gpui::test] + async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { + let project = init_test_project("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate the agent generating a title. The notification chain is: + // AcpThread::set_title emits TitleUpdated → + // ConnectionView::handle_thread_event calls cx.notify() → + // AgentPanel observer fires and emits AgentPanelEvent → + // Sidebar subscription calls update_entries / rebuild_contents. + // + // Before the fix, handle_thread_event did NOT call cx.notify() for + // TitleUpdated, so the AgentPanel observer never fired and the + // sidebar kept showing the old title. + let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); + thread.update(cx, |thread, cx| { + thread + .set_title("Friendly Greeting with AI".into(), cx) + .detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Friendly Greeting with AI *"] + ); + } + + #[gpui::test] + async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { + let project_a = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Save a thread so it appears in the list. + let connection_a = StubAgentConnection::new(); + connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel_a, connection_a, cx); + send_message(&panel_a, cx); + let session_id_a = active_session_id(&panel_a, cx); + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + + // Add a second workspace with its own agent panel. + let fs = cx.update(|_, cx| ::global(cx)); + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + let panel_b = add_agent_panel(&workspace_b, &project_b, cx); + cx.run_until_parked(); + + let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); + + // ── 1. Initial state: no focused thread ────────────────────────────── + // Workspace B is active (just added) and has no thread, so its header + // is the active entry. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread, None, + "Initially no thread should be focused" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), + "Active entry should be the active workspace header" + ); + }); + + // ── 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(), + work_dirs: None, + title: Some("Test".into()), + updated_at: None, + created_at: None, + meta: None, + }, + &workspace_a, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "After clicking a thread, it should be the focused thread" + ); + let active_entry = sidebar.active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a), + "Active entry should be the clicked thread" + ); + }); + + workspace_a.read_with(cx, |workspace, cx| { + assert!( + workspace.panel::(cx).is_some(), + "Agent panel should exist" + ); + let dock = workspace.right_dock().read(cx); + assert!( + dock.is_open(), + "Clicking a thread should open the agent panel dock" + ); + }); + + // ── 3. Open thread in workspace B, then click it via sidebar ───────── + let connection_b = StubAgentConnection::new(); + connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Thread B".into()), + )]); + open_thread_with_connection(&panel_b, connection_b, cx); + send_message(&panel_b, cx); + let session_id_b = active_session_id(&panel_b, cx); + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; + cx.run_until_parked(); + + // Opening a thread in a non-active workspace should NOT change + // focused_thread — it's derived from the active workspace. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Opening a thread in a non-active workspace should not affect focused_thread" + ); + }); + + // Workspace A is currently active. Click a thread in workspace B, + // 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(), + work_dirs: None, + title: Some("Thread B".into()), + updated_at: None, + created_at: None, + meta: None, + }, + &workspace_b, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b), + "Clicking a thread in another workspace should focus that thread" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b), + "Active entry should be the cross-workspace thread" + ); + }); + + // ── 4. Switch workspace → focused_thread reflects new workspace ────── + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_next_workspace(window, cx); + }); + cx.run_until_parked(); + + // Workspace A is now active. Its agent panel still has session_id_a + // loaded, so focused_thread should reflect that. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Switching workspaces should derive focused_thread from the new active workspace" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a), + "Active entry should be workspace_a's active thread" + ); + }); + + // ── 5. Opening a thread in a non-active workspace is ignored ────────── + let connection_b2 = StubAgentConnection::new(); + connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("New thread".into()), + )]); + open_thread_with_connection(&panel_b, connection_b2, cx); + send_message(&panel_b, cx); + let session_id_b2 = active_session_id(&panel_b, cx); + save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; + cx.run_until_parked(); + + // Workspace A is still active, so focused_thread stays on session_id_a. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Opening a thread in a non-active workspace should not affect focused_thread" + ); + }); + + // ── 6. Activating workspace B shows its active thread ──────────────── + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_workspace(&workspace_b, window, cx); + }); + cx.run_until_parked(); + + // Workspace B is now active with session_id_b2 loaded. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Activating workspace_b should show workspace_b's active thread" + ); + let active_entry = sidebar + .active_entry_index + .and_then(|ix| sidebar.contents.entries.get(ix)); + assert!( + matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2), + "Active entry should be workspace_b's active thread" + ); + }); + + // ── 7. Switching back to workspace A reflects its thread ───────────── + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_next_workspace(window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Switching back to workspace_a should show its active thread" + ); + }); + } + + async fn init_test_project_with_git( + worktree_path: &str, + cx: &mut TestAppContext, + ) -> (Entity, Arc) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + worktree_path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; + (project, fs) + } + + #[gpui::test] + async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: "refs/heads/rosewood".into(), + sha: "abc".into(), + }); + }) + .unwrap(); + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; + save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Search for "rosewood" — should match the worktree name, not the title. + type_in_search(&sidebar, "rosewood", cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Fix Bug {rosewood} <== selected"], + ); + } + + #[gpui::test] + async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread against a worktree path that doesn't exist yet. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread is not visible yet — no worktree knows about this path. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " [+ New Thread]"] + ); + + // Now add the worktree to the git state and trigger a rescan. + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), true, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: "refs/heads/rosewood".into(), + sha: "abc".into(), + }); + }) + .unwrap(); + + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Worktree Thread {rosewood}",] + ); + } + + #[gpui::test] + async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Create the main repo directory (not opened as a workspace yet). + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Two worktree checkouts whose .git files point back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-b", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-b", + "src": {}, + }), + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; + + project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; + project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + // Open both worktrees as workspaces — no main repo yet. + let (multi_workspace, cx) = cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); + save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Without the main repo, each worktree has its own header. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [wt-feature-a]", + " Thread A", + "v [wt-feature-b]", + " Thread B", + ] + ); + + // Configure the main repo to list both worktrees before opening + // it so the initial git scan picks them up. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: "refs/heads/feature-a".into(), + sha: "aaa".into(), + }); + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: "refs/heads/feature-b".into(), + sha: "bbb".into(), + }); + }) + .unwrap(); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(main_project.clone(), window, cx); + }); + cx.run_until_parked(); + + // Both worktree workspaces should now be absorbed under the main + // repo header, with worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); + + // Remove feature-b from the main repo's linked worktrees. + // The feature-b workspace should be pruned automatically. + fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| { + state + .worktrees + .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b")); + }) + .unwrap(); + + cx.run_until_parked(); + + // feature-b's workspace is pruned; feature-a remains absorbed + // under the main repo. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Thread A {wt-feature-a}",] + ); + } + + #[gpui::test] + async fn test_clicking_worktree_thread_opens_workspace_when_none_exists( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: "refs/heads/feature-a".into(), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Only open the main repo — no workspace for the worktree. + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(main_project.clone(), window, cx) + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread for the worktree path (no workspace for it). + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread should appear under the main repo with a worktree chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " WT Thread {wt-feature-a}"], + ); + + // Only 1 workspace should exist. + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 1, + ); + + // Focus the sidebar and select the worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(1); // index 0 is header, 1 is the thread + }); + + // Confirm to open the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // A new workspace should have been created for the worktree path. + let new_workspace = multi_workspace.read_with(cx, |mw, _| { + assert_eq!( + mw.workspaces().len(), + 2, + "confirming a worktree thread without a workspace should open one", + ); + mw.workspaces()[1].clone() + }); + + 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")]), + "the new workspace should have been opened for the worktree path", + ); + } + + #[gpui::test] + async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( + cx: &mut TestAppContext, + ) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: "refs/heads/feature-a".into(), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = + project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(main_project.clone(), window, cx) + }); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate the main workspace before setting up the sidebar. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // The worktree workspace should be absorbed under the main repo. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0], "v [project]"); + assert!(entries.contains(&" Main Thread".to_string())); + assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); + + let wt_thread_index = entries + .iter() + .position(|e| e.contains("WT Thread")) + .expect("should find the worktree thread entry"); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0, + "main workspace should be active initially" + ); + + // Focus the sidebar and select the absorbed worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(wt_thread_index); + }); + + // Confirm to activate the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // The worktree workspace should now be active, not the main one. + let active_workspace = multi_workspace.read_with(cx, |mw, _| { + mw.workspaces()[mw.active_workspace_index()].clone() + }); + assert_eq!( + active_workspace, worktree_workspace, + "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_test_thread_metadata(&session_id, path_list_b.clone(), 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(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + 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")), + work_dirs: Some(PathList::new(&[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")), + work_dirs: 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")); + + 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(), + work_dirs: Some(path_list_b), + 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/test_support.rs b/crates/agent_ui/src/test_support.rs index 05a6b0925fb9151cc18d7096c8bf4f2674054073..66c8c447a827e7f36c3098b4835026836ef8ccd8 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -1,7 +1,8 @@ use acp_thread::{AgentConnection, StubAgentConnection}; use agent_client_protocol as acp; use agent_servers::{AgentServer, AgentServerDelegate}; -use gpui::{Entity, SharedString, Task, TestAppContext, VisualTestContext}; +use gpui::{Entity, Task, TestAppContext, VisualTestContext}; +use project::AgentId; use settings::SettingsStore; use std::any::Any; use std::rc::Rc; @@ -37,7 +38,7 @@ where ui::IconName::Ai } - fn name(&self) -> SharedString { + fn agent_id(&self) -> AgentId { "Test".into() } @@ -81,7 +82,7 @@ pub fn open_thread_with_connection( } pub fn send_message(panel: &Entity, cx: &mut VisualTestContext) { - let thread_view = panel.read_with(cx, |panel, cx| panel.as_active_thread_view(cx).unwrap()); + let thread_view = panel.read_with(cx, |panel, cx| panel.active_thread_view(cx).unwrap()); let message_editor = thread_view.read_with(cx, |view, _cx| view.message_editor.clone()); message_editor.update_in(cx, |editor, window, cx| { editor.set_text("Hello", window, cx); 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/text_thread_history.rs b/crates/agent_ui/src/text_thread_history.rs index c19f64bc3503ab38c83dc9534d64fae5c23cc21c..7a2a4ff91ddae0531df200118b55151a8dbb4499 100644 --- a/crates/agent_ui/src/text_thread_history.rs +++ b/crates/agent_ui/src/text_thread_history.rs @@ -116,6 +116,10 @@ impl TextThreadHistory { this } + pub fn is_empty(&self) -> bool { + self.visible_items.is_empty() + } + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { let entries = self.text_thread_store.update(cx, |store, _| { store.ordered_text_threads().cloned().collect::>() diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index 6601616e9f2ef447beb448f2753460fa7c380fa6..48d0b11b00103bbcf8399e6f7f77f8804051a465 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -1,163 +1,38 @@ -use crate::ConnectionView; -use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread}; use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate}; use agent_client_protocol as acp; -use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; -use editor::{Editor, EditorEvent}; -use fuzzy::StringMatchCandidate; -use gpui::{ - App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, - UniformListScrollHandle, WeakEntity, Window, uniform_list, -}; -use std::{fmt::Display, ops::Range, rc::Rc}; -use text::Bias; -use time::{OffsetDateTime, UtcOffset}; -use ui::{ - ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, - WithScrollbar, prelude::*, -}; - -const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); - -fn thread_title(entry: &AgentSessionInfo) -> &SharedString { - entry - .title - .as_ref() - .filter(|title| !title.is_empty()) - .unwrap_or(DEFAULT_TITLE) -} +use gpui::{App, Task}; +use std::rc::Rc; +use ui::prelude::*; pub struct ThreadHistory { session_list: Option>, sessions: Vec, - scroll_handle: UniformListScrollHandle, - selected_index: usize, - hovered_index: Option, - search_editor: Entity, - search_query: SharedString, - visible_items: Vec, - local_timezone: UtcOffset, - confirming_delete_history: bool, - _visible_items_task: Task<()>, _refresh_task: Task<()>, _watch_task: Option>, - _subscriptions: Vec, -} - -enum ListItemType { - BucketSeparator(TimeBucket), - Entry { - entry: AgentSessionInfo, - format: EntryTimeFormat, - }, - SearchResult { - entry: AgentSessionInfo, - positions: Vec, - }, -} - -impl ListItemType { - fn history_entry(&self) -> Option<&AgentSessionInfo> { - match self { - ListItemType::Entry { entry, .. } => Some(entry), - ListItemType::SearchResult { entry, .. } => Some(entry), - _ => None, - } - } } -pub enum ThreadHistoryEvent { - Open(AgentSessionInfo), -} - -impl EventEmitter for ThreadHistory {} - impl ThreadHistory { - pub fn new( - session_list: Option>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let search_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search threads...", window, cx); - editor - }); - - let search_editor_subscription = - cx.subscribe(&search_editor, |this, search_editor, event, cx| { - if let EditorEvent::BufferEdited = event { - let query = search_editor.read(cx).text(cx); - if this.search_query != query { - this.search_query = query.into(); - this.update_visible_items(false, cx); - } - } - }); - - let scroll_handle = UniformListScrollHandle::default(); - + pub fn new(session_list: Option>, cx: &mut Context) -> Self { let mut this = Self { session_list: None, sessions: Vec::new(), - scroll_handle, - selected_index: 0, - hovered_index: None, - visible_items: Default::default(), - search_editor, - local_timezone: UtcOffset::from_whole_seconds( - chrono::Local::now().offset().local_minus_utc(), - ) - .unwrap(), - search_query: SharedString::default(), - confirming_delete_history: false, - _subscriptions: vec![search_editor_subscription], - _visible_items_task: Task::ready(()), _refresh_task: Task::ready(()), _watch_task: None, }; - this.set_session_list(session_list, cx); + this.set_session_list_impl(session_list, cx); this } - fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { - let entries = self.sessions.clone(); - let new_list_items = if self.search_query.is_empty() { - self.add_list_separators(entries, cx) - } else { - self.filter_search_results(entries, cx) - }; - let selected_history_entry = if preserve_selected_item { - self.selected_history_entry().cloned() - } else { - None - }; - - self._visible_items_task = cx.spawn(async move |this, cx| { - let new_visible_items = new_list_items.await; - this.update(cx, |this, cx| { - let new_selected_index = if let Some(history_entry) = selected_history_entry { - new_visible_items - .iter() - .position(|visible_entry| { - visible_entry - .history_entry() - .is_some_and(|entry| entry.session_id == history_entry.session_id) - }) - .unwrap_or(0) - } else { - 0 - }; - - this.visible_items = new_visible_items; - this.set_selected_index(new_selected_index, Bias::Right, cx); - cx.notify(); - }) - .ok(); - }); + #[cfg(any(test, feature = "test-support"))] + pub fn set_session_list( + &mut self, + session_list: Option>, + cx: &mut Context, + ) { + self.set_session_list_impl(session_list, cx); } - pub fn set_session_list( + fn set_session_list_impl( &mut self, session_list: Option>, cx: &mut Context, @@ -170,9 +45,6 @@ impl ThreadHistory { self.session_list = session_list; self.sessions.clear(); - self.visible_items.clear(); - self.selected_index = 0; - self._visible_items_task = Task::ready(()); self._refresh_task = Task::ready(()); let Some(session_list) = self.session_list.as_ref() else { @@ -181,9 +53,8 @@ impl ThreadHistory { return; }; let Some(rx) = session_list.watch(cx) else { - // No watch support - do a one-time refresh self._watch_task = None; - self.refresh_sessions(false, false, cx); + self.refresh_sessions(false, cx); return; }; session_list.notify_refresh(); @@ -191,7 +62,6 @@ impl ThreadHistory { self._watch_task = Some(cx.spawn(async move |this, cx| { while let Ok(first_update) = rx.recv().await { let mut updates = vec![first_update]; - // Collect any additional updates that are already in the channel while let Ok(update) = rx.try_recv() { updates.push(update); } @@ -202,7 +72,7 @@ impl ThreadHistory { .any(|u| matches!(u, SessionListUpdate::Refresh)); if needs_refresh { - this.refresh_sessions(true, false, cx); + this.refresh_sessions(false, cx); } else { for update in updates { if let SessionListUpdate::SessionInfo { session_id, update } = update { @@ -217,7 +87,7 @@ impl ThreadHistory { } pub(crate) fn refresh_full_history(&mut self, cx: &mut Context) { - self.refresh_sessions(true, true, cx); + self.refresh_sessions(true, cx); } fn apply_info_update( @@ -258,23 +128,15 @@ impl ThreadHistory { session.meta = Some(meta); } - self.update_visible_items(true, cx); + cx.notify(); } - fn refresh_sessions( - &mut self, - preserve_selected_item: bool, - load_all_pages: bool, - cx: &mut Context, - ) { + fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context) { let Some(session_list) = self.session_list.clone() else { - self.update_visible_items(preserve_selected_item, cx); + cx.notify(); return; }; - // If a new refresh arrives while pagination is in progress, the previous - // `_refresh_task` is cancelled. This is intentional (latest refresh wins), - // but means sessions may be in a partial state until the new refresh completes. self._refresh_task = cx.spawn(async move |this, cx| { let mut cursor: Option = None; let mut is_first_page = true; @@ -305,7 +167,7 @@ impl ThreadHistory { } else { this.sessions.extend(page_sessions); } - this.update_visible_items(preserve_selected_item, cx); + cx.notify(); }) .ok(); @@ -378,693 +240,11 @@ impl ThreadHistory { } } - fn add_list_separators( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - cx.background_spawn(async move { - let mut items = Vec::with_capacity(entries.len() + 1); - let mut bucket = None; - let today = Local::now().naive_local().date(); - - for entry in entries.into_iter() { - let entry_bucket = entry - .updated_at - .map(|timestamp| { - let entry_date = timestamp.with_timezone(&Local).naive_local().date(); - TimeBucket::from_dates(today, entry_date) - }) - .unwrap_or(TimeBucket::All); - - if Some(entry_bucket) != bucket { - bucket = Some(entry_bucket); - items.push(ListItemType::BucketSeparator(entry_bucket)); - } - - items.push(ListItemType::Entry { - entry, - format: entry_bucket.into(), - }); - } - items - }) - } - - fn filter_search_results( - &self, - entries: Vec, - cx: &App, - ) -> Task> { - let query = self.search_query.clone(); - cx.background_spawn({ - let executor = cx.background_executor().clone(); - async move { - let mut candidates = Vec::with_capacity(entries.len()); - - for (idx, entry) in entries.iter().enumerate() { - candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); - } - - const MAX_MATCHES: usize = 100; - - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - true, - MAX_MATCHES, - &Default::default(), - executor, - ) - .await; - - matches - .into_iter() - .map(|search_match| ListItemType::SearchResult { - entry: entries[search_match.candidate_id].clone(), - positions: search_match.positions, - }) - .collect() - } - }) - } - - fn search_produced_no_matches(&self) -> bool { - self.visible_items.is_empty() && !self.search_query.is_empty() - } - - fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { - self.get_history_entry(self.selected_index) - } - - fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { - self.visible_items.get(visible_items_ix)?.history_entry() - } - - fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { - if self.visible_items.len() == 0 { - self.selected_index = 0; - return; - } - while matches!( - self.visible_items.get(index), - None | Some(ListItemType::BucketSeparator(..)) - ) { - index = match bias { - Bias::Left => { - if index == 0 { - self.visible_items.len() - 1 - } else { - index - 1 - } - } - Bias::Right => { - if index >= self.visible_items.len() - 1 { - 0 - } else { - index + 1 - } - } - }; - } - self.selected_index = index; - self.scroll_handle - .scroll_to_item(index, ScrollStrategy::Top); - cx.notify() - } - - pub fn select_previous( - &mut self, - _: &menu::SelectPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == 0 { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } else { - self.set_selected_index(self.selected_index - 1, Bias::Left, cx); - } - } - - pub fn select_next( - &mut self, - _: &menu::SelectNext, - _window: &mut Window, - cx: &mut Context, - ) { - if self.selected_index == self.visible_items.len() - 1 { - self.set_selected_index(0, Bias::Right, cx); + pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task> { + if let Some(session_list) = self.session_list.as_ref() { + session_list.delete_sessions(cx) } else { - self.set_selected_index(self.selected_index + 1, Bias::Right, cx); - } - } - - fn select_first( - &mut self, - _: &menu::SelectFirst, - _window: &mut Window, - cx: &mut Context, - ) { - self.set_selected_index(0, Bias::Right, cx); - } - - fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { - self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); - } - - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - self.confirm_entry(self.selected_index, cx); - } - - fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(ix) else { - return; - }; - cx.emit(ThreadHistoryEvent::Open(entry.clone())); - } - - fn remove_selected_thread( - &mut self, - _: &RemoveSelectedThread, - _window: &mut Window, - cx: &mut Context, - ) { - self.remove_thread(self.selected_index, cx) - } - - fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { - let Some(entry) = self.get_history_entry(visible_item_ix) else { - return; - }; - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { - return; - } - let task = session_list.delete_session(&entry.session_id, cx); - task.detach_and_log_err(cx); - } - - fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { - let Some(session_list) = self.session_list.as_ref() else { - return; - }; - if !session_list.supports_delete() { - return; - } - session_list.delete_sessions(cx).detach_and_log_err(cx); - self.confirming_delete_history = false; - cx.notify(); - } - - fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = true; - cx.notify(); - } - - fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { - self.confirming_delete_history = false; - cx.notify(); - } - - fn render_list_items( - &mut self, - range: Range, - _window: &mut Window, - cx: &mut Context, - ) -> Vec { - self.visible_items - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) - .collect() - } - - fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { - match item { - ListItemType::Entry { entry, format } => self - .render_history_entry(entry, *format, ix, Vec::default(), cx) - .into_any(), - ListItemType::SearchResult { entry, positions } => self.render_history_entry( - entry, - EntryTimeFormat::DateAndTime, - ix, - positions.clone(), - cx, - ), - ListItemType::BucketSeparator(bucket) => div() - .px(DynamicSpacing::Base06.rems(cx)) - .pt_2() - .pb_1() - .child( - Label::new(bucket.to_string()) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .into_any_element(), - } - } - - fn render_history_entry( - &self, - entry: &AgentSessionInfo, - format: EntryTimeFormat, - ix: usize, - highlight_positions: Vec, - cx: &Context, - ) -> AnyElement { - let selected = ix == self.selected_index; - let hovered = Some(ix) == self.hovered_index; - let entry_time = entry.updated_at; - let display_text = match (format, entry_time) { - (EntryTimeFormat::DateAndTime, Some(entry_time)) => { - let now = Utc::now(); - let duration = now.signed_duration_since(entry_time); - let days = duration.num_days(); - - format!("{}d", days) - } - (EntryTimeFormat::TimeOnly, Some(entry_time)) => { - format.format_timestamp(entry_time.timestamp(), self.local_timezone) - } - (_, None) => "—".to_string(), - }; - - let title = thread_title(entry).clone(); - let full_date = entry_time - .map(|time| { - EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) - }) - .unwrap_or_else(|| "Unknown".to_string()); - - h_flex() - .w_full() - .pb_1() - .child( - ListItem::new(ix) - .rounded() - .toggle_state(selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child( - HighlightedLabel::new(thread_title(entry), highlight_positions) - .size(LabelSize::Small) - .truncate(), - ) - .child( - Label::new(display_text) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .tooltip(move |_, cx| { - Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) - }) - .on_hover(cx.listener(move |this, is_hovered, _window, cx| { - if *is_hovered { - this.hovered_index = Some(ix); - } else if this.hovered_index == Some(ix) { - this.hovered_index = None; - } - - cx.notify(); - })) - .end_slot::(if hovered && self.supports_delete() { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.remove_thread(ix, cx); - cx.stop_propagation() - })), - ) - } else { - None - }) - .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), - ) - .into_any_element() - } -} - -impl Focusable for ThreadHistory { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.search_editor.focus_handle(cx) - } -} - -impl Render for ThreadHistory { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let has_no_history = self.is_empty(); - - v_flex() - .key_context("ThreadHistory") - .size_full() - .bg(cx.theme().colors().panel_background) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::remove_selected_thread)) - .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { - this.remove_history(window, cx); - })) - .child( - h_flex() - .h(Tab::container_height(cx)) - .w_full() - .py_1() - .px_2() - .gap_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(self.search_editor.clone()), - ) - .child({ - let view = v_flex() - .id("list-container") - .relative() - .overflow_hidden() - .flex_grow(); - - if has_no_history { - view.justify_center().items_center().child( - Label::new("You don't have any past threads yet.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - } else if self.search_produced_no_matches() { - view.justify_center() - .items_center() - .child(Label::new("No threads match your search.").size(LabelSize::Small)) - } else { - view.child( - uniform_list( - "thread-history", - self.visible_items.len(), - cx.processor(|this, range: Range, window, cx| { - this.render_list_items(range, window, cx) - }), - ) - .p_1() - .pr_4() - .track_scroll(&self.scroll_handle) - .flex_grow(), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - } - }) - .when(!has_no_history && self.supports_delete(), |this| { - this.child( - h_flex() - .p_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .when(!self.confirming_delete_history, |this| { - this.child( - Button::new("delete_history", "Delete All History") - .full_width() - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.prompt_delete_history(window, cx); - })), - ) - }) - .when(self.confirming_delete_history, |this| { - this.w_full() - .gap_2() - .flex_wrap() - .justify_between() - .child( - h_flex() - .flex_wrap() - .gap_1() - .child( - Label::new("Delete all threads?") - .size(LabelSize::Small), - ) - .child( - Label::new("You won't be able to recover them later.") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("cancel_delete", "Cancel") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.cancel_delete_history(window, cx); - })), - ) - .child( - Button::new("confirm_delete", "Delete") - .style(ButtonStyle::Tinted(ui::TintColor::Error)) - .color(Color::Error) - .label_size(LabelSize::Small) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action( - Box::new(RemoveHistory), - cx, - ); - })), - ), - ) - }), - ) - }) - } -} - -#[derive(IntoElement)] -pub struct HistoryEntryElement { - entry: AgentSessionInfo, - thread_view: WeakEntity, - selected: bool, - hovered: bool, - supports_delete: bool, - on_hover: Box, -} - -impl HistoryEntryElement { - pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity) -> Self { - Self { - entry, - thread_view, - selected: false, - hovered: false, - supports_delete: false, - on_hover: Box::new(|_, _, _| {}), - } - } - - pub fn supports_delete(mut self, supports_delete: bool) -> Self { - self.supports_delete = supports_delete; - self - } - - pub fn hovered(mut self, hovered: bool) -> Self { - self.hovered = hovered; - self - } - - pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { - self.on_hover = Box::new(on_hover); - self - } -} - -impl RenderOnce for HistoryEntryElement { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let id = ElementId::Name(self.entry.session_id.0.clone().into()); - let title = thread_title(&self.entry).clone(); - let formatted_time = self - .entry - .updated_at - .map(|timestamp| { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(timestamp); - - if duration.num_days() > 0 { - format!("{}d", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h ago", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m ago", duration.num_minutes()) - } else { - "Just now".to_string() - } - }) - .unwrap_or_else(|| "Unknown".to_string()); - - ListItem::new(id) - .rounded() - .toggle_state(self.selected) - .spacing(ListItemSpacing::Sparse) - .start_slot( - h_flex() - .w_full() - .gap_2() - .justify_between() - .child(Label::new(title).size(LabelSize::Small).truncate()) - .child( - Label::new(formatted_time) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) - .on_hover(self.on_hover) - .end_slot::(if (self.hovered || self.selected) && self.supports_delete { - Some( - IconButton::new("delete", IconName::Trash) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .tooltip(move |_window, cx| { - Tooltip::for_action("Delete", &RemoveSelectedThread, cx) - }) - .on_click({ - let thread_view = self.thread_view.clone(); - let session_id = self.entry.session_id.clone(); - - move |_event, _window, cx| { - if let Some(thread_view) = thread_view.upgrade() { - thread_view.update(cx, |thread_view, cx| { - thread_view.delete_history_entry(&session_id, cx); - }); - } - } - }), - ) - } else { - None - }) - .on_click({ - let thread_view = self.thread_view.clone(); - let entry = self.entry; - - move |_event, window, cx| { - if let Some(workspace) = thread_view - .upgrade() - .and_then(|view| view.read(cx).workspace().upgrade()) - { - 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, - ); - }); - } - } - } - }) - } -} - -#[derive(Clone, Copy)] -pub enum EntryTimeFormat { - DateAndTime, - TimeOnly, -} - -impl EntryTimeFormat { - fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { - let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); - - match self { - EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( - timestamp, - OffsetDateTime::now_utc(), - timezone, - time_format::TimestampFormat::EnhancedAbsolute, - ), - EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), - } - } -} - -impl From for EntryTimeFormat { - fn from(bucket: TimeBucket) -> Self { - match bucket { - TimeBucket::Today => EntryTimeFormat::TimeOnly, - TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, - TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, - TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, - TimeBucket::All => EntryTimeFormat::DateAndTime, - } - } -} - -#[derive(PartialEq, Eq, Clone, Copy, Debug)] -enum TimeBucket { - Today, - Yesterday, - ThisWeek, - PastWeek, - All, -} - -impl TimeBucket { - fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { - if date == reference { - return TimeBucket::Today; - } - - if date == reference - TimeDelta::days(1) { - return TimeBucket::Yesterday; - } - - let week = date.iso_week(); - - if reference.iso_week() == week { - return TimeBucket::ThisWeek; - } - - let last_week = (reference - TimeDelta::days(7)).iso_week(); - - if week == last_week { - return TimeBucket::PastWeek; - } - - TimeBucket::All - } -} - -impl Display for TimeBucket { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TimeBucket::Today => write!(f, "Today"), - TimeBucket::Yesterday => write!(f, "Yesterday"), - TimeBucket::ThisWeek => write!(f, "This Week"), - TimeBucket::PastWeek => write!(f, "Past Week"), - TimeBucket::All => write!(f, "All"), + Task::ready(Ok(())) } } } @@ -1073,7 +253,6 @@ impl Display for TimeBucket { mod tests { use super::*; use acp_thread::AgentSessionListResponse; - use chrono::NaiveDate; use gpui::TestAppContext; use std::{ any::Any, @@ -1229,9 +408,10 @@ mod tests { fn test_session(session_id: &str, title: &str) -> AgentSessionInfo { AgentSessionInfo { session_id: acp::SessionId::new(session_id), - cwd: None, + work_dirs: None, title: Some(title.to_string().into()), updated_at: None, + created_at: None, meta: None, } } @@ -1245,9 +425,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, _cx| { @@ -1269,9 +447,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); session_list.clear_requested_cursors(); @@ -1306,9 +482,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1339,9 +513,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1370,9 +542,7 @@ mod tests { vec![test_session("session-2", "Second")], )); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); history.update(cx, |history, cx| history.refresh_full_history(cx)); @@ -1415,9 +585,7 @@ mod tests { .with_async_responses(), ); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); session_list.clear_requested_cursors(); @@ -1440,26 +608,23 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Original Title".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send a title update session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("New Title"), }); cx.run_until_parked(); - // Check that the title was updated history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1476,26 +641,23 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Original Title".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update that clears the title (null) session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title(None::), }); cx.run_until_parked(); - // Check that the title was cleared history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!(session.unwrap().title, None); @@ -1509,26 +671,23 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Original Title".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update with no fields set (all undefined) session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new(), }); cx.run_until_parked(); - // Check that the title is unchanged history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1545,19 +704,17 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: None, updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send multiple updates before the executor runs session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("First Title"), @@ -1568,7 +725,6 @@ mod tests { }); cx.run_until_parked(); - // Check that the final title is "Second Title" (both applied in order) history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1585,19 +741,17 @@ mod tests { let session_id = acp::SessionId::new("test-session"); let sessions = vec![AgentSessionInfo { session_id: session_id.clone(), - cwd: None, + work_dirs: None, title: Some("Server Title".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an info update followed by a refresh session_list.send_update(SessionListUpdate::SessionInfo { session_id: session_id.clone(), update: acp::SessionInfoUpdate::new().title("Local Update"), @@ -1605,7 +759,6 @@ mod tests { session_list.send_update(SessionListUpdate::Refresh); cx.run_until_parked(); - // The refresh should have fetched from server, getting "Server Title" history.update(cx, |history, _cx| { let session = history.sessions.iter().find(|s| s.session_id == session_id); assert_eq!( @@ -1622,26 +775,23 @@ mod tests { let session_id = acp::SessionId::new("known-session"); let sessions = vec![AgentSessionInfo { session_id, - cwd: None, + work_dirs: None, title: Some("Original".into()), updated_at: None, + created_at: None, meta: None, }]; let session_list = Rc::new(TestSessionList::new(sessions)); - let (history, cx) = cx.add_window_view(|window, cx| { - ThreadHistory::new(Some(session_list.clone()), window, cx) - }); + let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx)); cx.run_until_parked(); - // Send an update for an unknown session session_list.send_update(SessionListUpdate::SessionInfo { session_id: acp::SessionId::new("unknown-session"), update: acp::SessionInfoUpdate::new().title("Should Be Ignored"), }); cx.run_until_parked(); - // Check that the known session is unchanged and no crash occurred history.update(cx, |history, _cx| { assert_eq!(history.sessions.len(), 1); assert_eq!( @@ -1650,43 +800,4 @@ mod tests { ); }); } - - #[test] - fn test_time_bucket_from_dates() { - let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); - - let date = today; - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); - - let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); - - let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); - - // All: not in this week or last week - let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); - - // Test year boundary cases - let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); - - let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); - assert_eq!( - TimeBucket::from_dates(new_year, date), - TimeBucket::Yesterday - ); - - let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); - assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); - } } diff --git a/crates/agent_ui/src/thread_history_view.rs b/crates/agent_ui/src/thread_history_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..bfb01d74d534934cbe731bd80403d04f4e454457 --- /dev/null +++ b/crates/agent_ui/src/thread_history_view.rs @@ -0,0 +1,886 @@ +use crate::thread_history::ThreadHistory; +use crate::{AgentPanel, ConversationView, RemoveHistory, RemoveSelectedThread}; +use acp_thread::AgentSessionInfo; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, WeakEntity, Window, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, + WithScrollbar, prelude::*, +}; + +const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread"); + +pub(crate) fn thread_title(entry: &AgentSessionInfo) -> &SharedString { + entry + .title + .as_ref() + .filter(|title| !title.is_empty()) + .unwrap_or(DEFAULT_TITLE) +} + +pub struct ThreadHistoryView { + history: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + confirming_delete_history: bool, + _visible_items_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: AgentSessionInfo, + format: EntryTimeFormat, + }, + SearchResult { + entry: AgentSessionInfo, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&AgentSessionInfo> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +pub enum ThreadHistoryViewEvent { + Open(AgentSessionInfo), +} + +impl EventEmitter for ThreadHistoryView {} + +impl ThreadHistoryView { + pub fn new( + history: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_subscription = cx.observe(&history, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + confirming_delete_history: false, + _subscriptions: vec![search_editor_subscription, history_subscription], + _visible_items_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + pub fn history(&self) -> &Entity { + &self.history + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self.history.read(cx).sessions().to_vec(); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._visible_items_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.session_id == history_entry.session_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_bucket = entry + .updated_at + .map(|timestamp| { + let entry_date = timestamp.with_timezone(&Local).naive_local().date(); + TimeBucket::from_dates(today, entry_date) + }) + .unwrap_or(TimeBucket::All); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, thread_title(entry))); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&AgentSessionInfo> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.len() == 0 { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryViewEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + if !self.history.read(cx).supports_delete() { + return; + } + let session_id = entry.session_id.clone(); + self.history.update(cx, |history, cx| { + history + .delete_session(&session_id, cx) + .detach_and_log_err(cx); + }); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + if !self.history.read(cx).supports_delete() { + return; + } + self.history.update(cx, |history, cx| { + history.delete_sessions(cx).detach_and_log_err(cx); + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &AgentSessionInfo, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let entry_time = entry.updated_at; + let display_text = match (format, entry_time) { + (EntryTimeFormat::DateAndTime, Some(entry_time)) => { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + let days = duration.num_days(); + + format!("{}d", days) + } + (EntryTimeFormat::TimeOnly, Some(entry_time)) => { + format.format_timestamp(entry_time.timestamp(), self.local_timezone) + } + (_, None) => "—".to_string(), + }; + + let title = thread_title(entry).clone(); + let full_date = entry_time + .map(|time| { + EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone) + }) + .unwrap_or_else(|| "Unknown".to_string()); + + let supports_delete = self.history.read(cx).supports_delete(); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(thread_title(entry), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(display_text) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta(title.clone(), None, full_date.clone(), cx) + }) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + + cx.notify(); + })) + .end_slot::(if hovered && supports_delete { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for ThreadHistoryView { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for ThreadHistoryView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history.read(cx).is_empty(); + let supports_delete = self.history.read(cx).supports_delete(); + + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(&self.scroll_handle) + .flex_grow(), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } + }) + .when(!has_no_history && supports_delete, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) + } +} + +#[derive(IntoElement)] +pub struct HistoryEntryElement { + entry: AgentSessionInfo, + conversation_view: WeakEntity, + selected: bool, + hovered: bool, + supports_delete: bool, + on_hover: Box, +} + +impl HistoryEntryElement { + pub fn new(entry: AgentSessionInfo, conversation_view: WeakEntity) -> Self { + Self { + entry, + conversation_view, + selected: false, + hovered: false, + supports_delete: false, + on_hover: Box::new(|_, _, _| {}), + } + } + + pub fn supports_delete(mut self, supports_delete: bool) -> Self { + self.supports_delete = supports_delete; + self + } + + pub fn hovered(mut self, hovered: bool) -> Self { + self.hovered = hovered; + self + } + + pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.on_hover = Box::new(on_hover); + self + } +} + +impl RenderOnce for HistoryEntryElement { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = ElementId::Name(self.entry.session_id.0.clone().into()); + let title = thread_title(&self.entry).clone(); + let formatted_time = self + .entry + .updated_at + .map(|timestamp| { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(timestamp); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h ago", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m ago", duration.num_minutes()) + } else { + "Just now".to_string() + } + }) + .unwrap_or_else(|| "Unknown".to_string()); + + ListItem::new(id) + .rounded() + .toggle_state(self.selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Label::new(title).size(LabelSize::Small).truncate()) + .child( + Label::new(formatted_time) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(self.on_hover) + .end_slot::(if (self.hovered || self.selected) && self.supports_delete { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click({ + let conversation_view = self.conversation_view.clone(); + let session_id = self.entry.session_id.clone(); + + move |_event, _window, cx| { + if let Some(conversation_view) = conversation_view.upgrade() { + conversation_view.update(cx, |conversation_view, cx| { + conversation_view.delete_history_entry(&session_id, cx); + }); + } + } + }), + ) + } else { + None + }) + .on_click({ + let conversation_view = self.conversation_view.clone(); + let entry = self.entry; + + move |_event, window, cx| { + if let Some(workspace) = conversation_view + .upgrade() + .and_then(|view| view.read(cx).workspace().upgrade()) + { + if let Some(panel) = workspace.read(cx).panel::(cx) { + panel.update(cx, |panel, cx| { + if let Some(agent) = panel.selected_agent() { + panel.load_agent_thread( + agent, + entry.session_id.clone(), + entry.work_dirs.clone(), + entry.title.clone(), + true, + window, + cx, + ); + } + }); + } + } + } + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(); + + assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today); + + let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, yesterday), + TimeBucket::Yesterday + ); + + let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, this_week), + TimeBucket::ThisWeek + ); + + let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap(); + assert_eq!( + TimeBucket::from_dates(today, past_week), + TimeBucket::PastWeek + ); + + let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All); + } +} diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..66a9e05fbb294f253b3b25b782b35f3d503304e4 --- /dev/null +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -0,0 +1,528 @@ +use std::{path::Path, sync::Arc}; + +use agent::{ThreadStore, ZED_AGENT_ID}; +use agent_client_protocol as acp; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use collections::HashMap; +use db::{ + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, + sqlez_macros::sql, +}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use gpui::{AppContext as _, Entity, Global, Subscription, Task}; +use project::AgentId; +use ui::{App, Context, SharedString}; +use workspace::PathList; + +pub fn init(cx: &mut App) { + ThreadMetadataStore::init_global(cx); + + if cx.has_flag::() { + migrate_thread_metadata(cx); + } + cx.observe_flag::(|has_flag, cx| { + if has_flag { + migrate_thread_metadata(cx); + } + }) + .detach(); +} + +/// Migrate existing thread metadata from native agent thread store to the new metadata storage. +/// +/// TODO: Remove this after N weeks of shipping the sidebar +fn migrate_thread_metadata(cx: &mut App) { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + let list = store.list(cx); + cx.spawn(async move |this, cx| { + let Ok(list) = list.await else { + return; + }; + if list.is_empty() { + this.update(cx, |this, cx| { + let metadata = ThreadStore::global(cx) + .read(cx) + .entries() + .map(|entry| ThreadMetadata { + session_id: entry.id, + agent_id: None, + title: entry.title, + updated_at: entry.updated_at, + created_at: entry.created_at, + folder_paths: entry.folder_paths, + }) + .collect::>(); + for entry in metadata { + this.save(entry, cx).detach_and_log_err(cx); + } + }) + .ok(); + } + }) + .detach(); + }); +} + +struct GlobalThreadMetadataStore(Entity); +impl Global for GlobalThreadMetadataStore {} + +/// Lightweight metadata for any thread (native or ACP), enough to populate +/// the sidebar list and route to the correct load path when clicked. +#[derive(Debug, Clone)] +pub struct ThreadMetadata { + pub session_id: acp::SessionId, + /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents. + pub agent_id: Option, + pub title: SharedString, + pub updated_at: DateTime, + pub created_at: Option>, + pub folder_paths: PathList, +} + +pub struct ThreadMetadataStore { + db: ThreadMetadataDb, + session_subscriptions: HashMap, +} + +impl ThreadMetadataStore { + #[cfg(not(any(test, feature = "test-support")))] + pub fn init_global(cx: &mut App) { + if cx.has_global::() { + return; + } + + let db = THREAD_METADATA_DB.clone(); + let thread_store = cx.new(|cx| Self::new(db, cx)); + cx.set_global(GlobalThreadMetadataStore(thread_store)); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn init_global(cx: &mut App) { + let thread = std::thread::current(); + let test_name = thread.name().unwrap_or("unknown_test"); + let db_name = format!("THREAD_METADATA_DB_{}", test_name); + let db = smol::block_on(db::open_test_db::(&db_name)); + let thread_store = cx.new(|cx| Self::new(ThreadMetadataDb(db), cx)); + cx.set_global(GlobalThreadMetadataStore(thread_store)); + } + + pub fn try_global(cx: &App) -> Option> { + cx.try_global::() + .map(|store| store.0.clone()) + } + + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub fn list(&self, cx: &App) -> Task>> { + let db = self.db.clone(); + cx.background_spawn(async move { + let s = db.list()?; + Ok(s) + }) + } + + pub fn save(&mut self, metadata: ThreadMetadata, cx: &mut Context) -> Task> { + if !cx.has_flag::() { + return Task::ready(Ok(())); + } + + let db = self.db.clone(); + cx.spawn(async move |this, cx| { + db.save(metadata).await?; + this.update(cx, |_this, cx| cx.notify()) + }) + } + + pub fn delete( + &mut self, + session_id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + if !cx.has_flag::() { + return Task::ready(Ok(())); + } + + let db = self.db.clone(); + cx.spawn(async move |this, cx| { + db.delete(session_id).await?; + this.update(cx, |_this, cx| cx.notify()) + }) + } + + fn new(db: ThreadMetadataDb, cx: &mut Context) -> Self { + let weak_store = cx.weak_entity(); + + cx.observe_new::(move |thread, _window, cx| { + let thread_entity = cx.entity(); + + cx.on_release({ + let weak_store = weak_store.clone(); + move |thread, cx| { + weak_store + .update(cx, |store, _cx| { + store.session_subscriptions.remove(thread.session_id()); + }) + .ok(); + } + }) + .detach(); + + weak_store + .update(cx, |this, cx| { + let subscription = cx.subscribe(&thread_entity, Self::handle_thread_update); + this.session_subscriptions + .insert(thread.session_id().clone(), subscription); + }) + .ok(); + }) + .detach(); + + Self { + db, + session_subscriptions: HashMap::default(), + } + } + + fn handle_thread_update( + &mut self, + thread: Entity, + event: &acp_thread::AcpThreadEvent, + cx: &mut Context, + ) { + match event { + acp_thread::AcpThreadEvent::NewEntry + | acp_thread::AcpThreadEvent::EntryUpdated(_) + | acp_thread::AcpThreadEvent::TitleUpdated => { + let metadata = Self::metadata_for_acp_thread(thread.read(cx), cx); + self.save(metadata, cx).detach_and_log_err(cx); + } + _ => {} + } + } + + fn metadata_for_acp_thread(thread: &acp_thread::AcpThread, cx: &App) -> ThreadMetadata { + let session_id = thread.session_id().clone(); + let title = thread.title(); + let updated_at = Utc::now(); + + let agent_id = thread.connection().agent_id(); + + let agent_id = if agent_id.as_ref() == ZED_AGENT_ID.as_ref() { + None + } else { + Some(agent_id) + }; + + let folder_paths = { + let project = thread.project().read(cx); + let paths: Vec> = project + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path()) + .collect(); + PathList::new(&paths) + }; + + ThreadMetadata { + session_id, + agent_id, + title, + created_at: Some(updated_at), // handled by db `ON CONFLICT` + updated_at, + folder_paths, + } + } +} + +impl Global for ThreadMetadataStore {} + +#[derive(Clone)] +struct ThreadMetadataDb(ThreadSafeConnection); + +impl Domain for ThreadMetadataDb { + const NAME: &str = stringify!(ThreadMetadataDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE IF NOT EXISTS sidebar_threads( + session_id TEXT PRIMARY KEY, + agent_id TEXT, + title TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT + ) STRICT; + )]; +} + +db::static_connection!(THREAD_METADATA_DB, ThreadMetadataDb, []); + +impl ThreadMetadataDb { + /// List all sidebar thread metadata, ordered by updated_at descending. + pub fn list(&self) -> anyhow::Result> { + self.select::( + "SELECT session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order \ + FROM sidebar_threads \ + ORDER BY updated_at DESC" + )?() + } + + /// Upsert metadata for a thread. + pub async fn save(&self, row: ThreadMetadata) -> anyhow::Result<()> { + let id = row.session_id.0.clone(); + let agent_id = row.agent_id.as_ref().map(|id| id.0.to_string()); + let title = row.title.to_string(); + let updated_at = row.updated_at.to_rfc3339(); + let created_at = row.created_at.map(|dt| dt.to_rfc3339()); + let serialized = row.folder_paths.serialize(); + let (folder_paths, folder_paths_order) = if row.folder_paths.is_empty() { + (None, None) + } else { + (Some(serialized.paths), Some(serialized.order)) + }; + + self.write(move |conn| { + let sql = "INSERT INTO sidebar_threads(session_id, agent_id, title, updated_at, created_at, folder_paths, folder_paths_order) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(session_id) DO UPDATE SET \ + agent_id = excluded.agent_id, \ + title = excluded.title, \ + updated_at = excluded.updated_at, \ + folder_paths = excluded.folder_paths, \ + folder_paths_order = excluded.folder_paths_order"; + let mut stmt = Statement::prepare(conn, sql)?; + let mut i = stmt.bind(&id, 1)?; + i = stmt.bind(&agent_id, i)?; + i = stmt.bind(&title, i)?; + i = stmt.bind(&updated_at, i)?; + i = stmt.bind(&created_at, i)?; + i = stmt.bind(&folder_paths, i)?; + stmt.bind(&folder_paths_order, i)?; + stmt.exec() + }) + .await + } + + /// Delete metadata for a single thread. + pub async fn delete(&self, session_id: acp::SessionId) -> anyhow::Result<()> { + let id = session_id.0.clone(); + self.write(move |conn| { + let mut stmt = + Statement::prepare(conn, "DELETE FROM sidebar_threads WHERE session_id = ?")?; + stmt.bind(&id, 1)?; + stmt.exec() + }) + .await + } +} + +impl Column for ThreadMetadata { + fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> { + let (id, next): (Arc, i32) = Column::column(statement, start_index)?; + let (agent_id, next): (Option, i32) = Column::column(statement, next)?; + let (title, next): (String, i32) = Column::column(statement, next)?; + let (updated_at_str, next): (String, i32) = Column::column(statement, next)?; + let (created_at_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_order_str, next): (Option, i32) = + Column::column(statement, next)?; + + let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc); + let created_at = created_at_str + .as_deref() + .map(DateTime::parse_from_rfc3339) + .transpose()? + .map(|dt| dt.with_timezone(&Utc)); + + let folder_paths = folder_paths_str + .map(|paths| { + PathList::deserialize(&util::path_list::SerializedPathList { + paths, + order: folder_paths_order_str.unwrap_or_default(), + }) + }) + .unwrap_or_default(); + + Ok(( + ThreadMetadata { + session_id: acp::SessionId::new(id), + agent_id: agent_id.map(|id| AgentId::new(id)), + title: title.into(), + updated_at, + created_at, + folder_paths, + }, + next, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent::DbThread; + use gpui::TestAppContext; + + fn make_db_thread(title: &str, updated_at: DateTime) -> DbThread { + DbThread { + title: title.to_string().into(), + messages: Vec::new(), + updated_at, + detailed_summary: None, + initial_project_snapshot: None, + cumulative_token_usage: Default::default(), + request_token_usage: Default::default(), + model: None, + profile: None, + imported: false, + subagent_context: None, + speed: None, + thinking_enabled: false, + thinking_effort: None, + draft_prompt: None, + ui_scroll_position: None, + } + } + + #[gpui::test] + async fn test_migrate_thread_metadata(cx: &mut TestAppContext) { + cx.update(|cx| { + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + }); + + // Verify the list is empty before migration + let metadata_list = cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.read(cx).list(cx) + }); + + let list = metadata_list.await.unwrap(); + assert_eq!(list.len(), 0); + + let now = Utc::now(); + + // Populate the native ThreadStore via save_thread + let save1 = cx.update(|cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new("session-1"), + make_db_thread("Thread 1", now), + PathList::default(), + cx, + ) + }) + }); + save1.await.unwrap(); + cx.run_until_parked(); + + let save2 = cx.update(|cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new("session-2"), + make_db_thread("Thread 2", now), + PathList::default(), + cx, + ) + }) + }); + save2.await.unwrap(); + cx.run_until_parked(); + + // Run migration + cx.update(|cx| { + migrate_thread_metadata(cx); + }); + + cx.run_until_parked(); + + // Verify the metadata was migrated + let metadata_list = cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.read(cx).list(cx) + }); + + let list = metadata_list.await.unwrap(); + assert_eq!(list.len(), 2); + + let metadata1 = list + .iter() + .find(|m| m.session_id.0.as_ref() == "session-1") + .expect("session-1 should be in migrated metadata"); + assert_eq!(metadata1.title.as_ref(), "Thread 1"); + assert!(metadata1.agent_id.is_none()); + + let metadata2 = list + .iter() + .find(|m| m.session_id.0.as_ref() == "session-2") + .expect("session-2 should be in migrated metadata"); + assert_eq!(metadata2.title.as_ref(), "Thread 2"); + assert!(metadata2.agent_id.is_none()); + } + + #[gpui::test] + async fn test_migrate_thread_metadata_skips_when_data_exists(cx: &mut TestAppContext) { + cx.update(|cx| { + ThreadStore::init_global(cx); + ThreadMetadataStore::init_global(cx); + }); + + // Pre-populate the metadata store with existing data + let existing_metadata = ThreadMetadata { + session_id: acp::SessionId::new("existing-session"), + agent_id: None, + title: "Existing Thread".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + folder_paths: PathList::default(), + }; + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(existing_metadata, cx).detach(); + }); + }); + + cx.run_until_parked(); + + // Add an entry to native thread store that should NOT be migrated + let save_task = cx.update(|cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| { + store.save_thread( + acp::SessionId::new("native-session"), + make_db_thread("Native Thread", Utc::now()), + PathList::default(), + cx, + ) + }) + }); + save_task.await.unwrap(); + cx.run_until_parked(); + + // Run migration - should skip because metadata store is not empty + cx.update(|cx| { + migrate_thread_metadata(cx); + }); + + cx.run_until_parked(); + + // Verify only the existing metadata is present (migration was skipped) + let metadata_list = cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.read(cx).list(cx) + }); + + let list = metadata_list.await.unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].session_id.0.as_ref(), "existing-session"); + } +} diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..237a6c539c6669df0df535ae91a7ba9fa99acf9f --- /dev/null +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -0,0 +1,770 @@ +use std::sync::Arc; + +use crate::{ + Agent, RemoveSelectedThread, agent_connection_store::AgentConnectionStore, + thread_history::ThreadHistory, +}; +use acp_thread::AgentSessionInfo; +use agent::ThreadStore; +use agent_client_protocol as acp; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc}; +use editor::Editor; +use fs::Fs; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render, + SharedString, Subscription, Task, Window, list, prelude::*, px, +}; +use itertools::Itertools as _; +use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use project::{AgentId, AgentServerStore}; +use theme::ActiveTheme; +use ui::{ + ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, ListItem, + PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, +}; +use util::ResultExt as _; +use zed_actions::editor::{MoveDown, MoveUp}; + +#[derive(Clone)] +enum ArchiveListItem { + BucketSeparator(TimeBucket), + Entry { + session: AgentSessionInfo, + highlight_positions: Vec, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + Older, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + let week = date.iso_week(); + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + let last_week = (reference - TimeDelta::days(7)).iso_week(); + if week == last_week { + return TimeBucket::PastWeek; + } + TimeBucket::Older + } + + fn label(&self) -> &'static str { + match self { + TimeBucket::Today => "Today", + TimeBucket::Yesterday => "Yesterday", + TimeBucket::ThisWeek => "This Week", + TimeBucket::PastWeek => "Past Week", + TimeBucket::Older => "Older", + } + } +} + +fn fuzzy_match_positions(query: &str, text: &str) -> Option> { + let query = query.to_lowercase(); + let text_lower = text.to_lowercase(); + let mut positions = Vec::new(); + let mut query_chars = query.chars().peekable(); + for (i, c) in text_lower.chars().enumerate() { + if query_chars.peek() == Some(&c) { + positions.push(i); + query_chars.next(); + } + } + if query_chars.peek().is_none() { + Some(positions) + } else { + None + } +} + +pub enum ThreadsArchiveViewEvent { + Close, + OpenThread { + agent: Agent, + session_info: AgentSessionInfo, + }, +} + +impl EventEmitter for ThreadsArchiveView {} + +pub struct ThreadsArchiveView { + agent_connection_store: Entity, + agent_server_store: Entity, + thread_store: Entity, + fs: Arc, + history: Option>, + _history_subscription: Subscription, + selected_agent: Agent, + focus_handle: FocusHandle, + list_state: ListState, + items: Vec, + selection: Option, + hovered_index: Option, + filter_editor: Entity, + _subscriptions: Vec, + selected_agent_menu: PopoverMenuHandle, + _refresh_history_task: Task<()>, + is_loading: bool, +} + +impl ThreadsArchiveView { + pub fn new( + agent_connection_store: Entity, + agent_server_store: Entity, + thread_store: Entity, + fs: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let filter_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search archive…", window, cx); + editor + }); + + let filter_editor_subscription = + cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { + if let editor::EditorEvent::BufferEdited = event { + this.update_items(cx); + } + }); + + let mut this = Self { + agent_connection_store, + agent_server_store, + thread_store, + fs, + history: None, + _history_subscription: Subscription::new(|| {}), + selected_agent: Agent::NativeAgent, + focus_handle, + list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), + items: Vec::new(), + selection: None, + hovered_index: None, + filter_editor, + _subscriptions: vec![filter_editor_subscription], + selected_agent_menu: PopoverMenuHandle::default(), + _refresh_history_task: Task::ready(()), + is_loading: true, + }; + this.set_selected_agent(Agent::NativeAgent, window, cx); + this + } + + fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { + self.selected_agent = agent.clone(); + self.is_loading = true; + self.history = None; + self.items.clear(); + self.selection = None; + self.list_state.reset(0); + self.reset_filter_editor_text(window, cx); + + let server = agent.server(self.fs.clone(), self.thread_store.clone()); + let connection = self + .agent_connection_store + .update(cx, |store, cx| store.request_connection(agent, server, cx)); + + let task = connection.read(cx).wait_for_connection(); + self._refresh_history_task = cx.spawn(async move |this, cx| { + if let Some(state) = task.await.log_err() { + this.update(cx, |this, cx| this.set_history(state.history, cx)) + .ok(); + } + }); + + cx.notify(); + } + + fn set_history(&mut self, history: Entity, cx: &mut Context) { + self._history_subscription = cx.observe(&history, |this, _, cx| { + this.update_items(cx); + }); + history.update(cx, |history, cx| { + history.refresh_full_history(cx); + }); + self.history = Some(history); + self.is_loading = false; + self.update_items(cx); + cx.notify(); + } + + fn update_items(&mut self, cx: &mut Context) { + let Some(history) = self.history.as_ref() else { + return; + }; + + let sessions = history.read(cx).sessions().to_vec(); + let query = self.filter_editor.read(cx).text(cx).to_lowercase(); + let today = Local::now().naive_local().date(); + + let mut items = Vec::with_capacity(sessions.len() + 5); + let mut current_bucket: Option = None; + + for session in sessions { + let highlight_positions = if !query.is_empty() { + let title = session.title.as_ref().map(|t| t.as_ref()).unwrap_or(""); + match fuzzy_match_positions(&query, title) { + Some(positions) => positions, + None => continue, + } + } else { + Vec::new() + }; + + let entry_bucket = session + .updated_at + .map(|timestamp| { + let entry_date = timestamp.with_timezone(&Local).naive_local().date(); + TimeBucket::from_dates(today, entry_date) + }) + .unwrap_or(TimeBucket::Older); + + if Some(entry_bucket) != current_bucket { + current_bucket = Some(entry_bucket); + items.push(ArchiveListItem::BucketSeparator(entry_bucket)); + } + + items.push(ArchiveListItem::Entry { + session, + highlight_positions, + }); + } + + self.list_state.reset(items.len()); + self.items = items; + cx.notify(); + } + + fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) { + self.filter_editor.update(cx, |editor, cx| { + editor.set_text("", window, cx); + }); + } + + fn go_back(&mut self, window: &mut Window, cx: &mut Context) { + self.reset_filter_editor_text(window, cx); + cx.emit(ThreadsArchiveViewEvent::Close); + } + + fn open_thread( + &mut self, + session_info: AgentSessionInfo, + window: &mut Window, + cx: &mut Context, + ) { + self.selection = None; + self.reset_filter_editor_text(window, cx); + cx.emit(ThreadsArchiveViewEvent::OpenThread { + agent: self.selected_agent.clone(), + session_info, + }); + } + + fn delete_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { + let Some(history) = &self.history else { + return; + }; + if !history.read(cx).supports_delete() { + return; + } + let session_id = session_id.clone(); + history.update(cx, |history, cx| { + history + .delete_session(&session_id, cx) + .detach_and_log_err(cx); + }); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(ix) = self.selection else { + return; + }; + let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { + return; + }; + let session_id = session.session_id.clone(); + self.delete_thread(&session_id, cx); + } + + fn is_selectable_item(&self, ix: usize) -> bool { + matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. })) + } + + fn find_next_selectable(&self, start: usize) -> Option { + (start..self.items.len()).find(|&i| self.is_selectable_item(i)) + } + + fn find_previous_selectable(&self, start: usize) -> Option { + (0..=start).rev().find(|&i| self.is_selectable_item(i)) + } + + fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { + self.select_next(&SelectNext, window, cx); + } + + fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { + self.select_previous(&SelectPrevious, window, cx); + } + + fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { + let next = match self.selection { + Some(ix) => self.find_next_selectable(ix + 1), + None => self.find_next_selectable(0), + }; + if let Some(next) = next { + self.selection = Some(next); + self.list_state.scroll_to_reveal_item(next); + cx.notify(); + } + } + + fn select_previous( + &mut self, + _: &SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + let prev = match self.selection { + Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1), + None => { + let last = self.items.len().saturating_sub(1); + self.find_previous_selectable(last) + } + _ => return, + }; + if let Some(prev) = prev { + self.selection = Some(prev); + self.list_state.scroll_to_reveal_item(prev); + cx.notify(); + } + } + + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { + if let Some(first) = self.find_next_selectable(0) { + self.selection = Some(first); + self.list_state.scroll_to_reveal_item(first); + cx.notify(); + } + } + + fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + let last = self.items.len().saturating_sub(1); + if let Some(last) = self.find_previous_selectable(last) { + self.selection = Some(last); + self.list_state.scroll_to_reveal_item(last); + cx.notify(); + } + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let Some(ix) = self.selection else { return }; + let Some(ArchiveListItem::Entry { session, .. }) = self.items.get(ix) else { + return; + }; + self.open_thread(session.clone(), window, cx); + } + + fn render_list_entry( + &mut self, + ix: usize, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(item) = self.items.get(ix) else { + return div().into_any_element(); + }; + + match item { + ArchiveListItem::BucketSeparator(bucket) => div() + .w_full() + .px_2() + .pt_3() + .pb_1() + .child( + Label::new(bucket.label()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element(), + ArchiveListItem::Entry { + session, + highlight_positions, + } => { + let is_selected = self.selection == Some(ix); + let hovered = self.hovered_index == Some(ix); + let supports_delete = self + .history + .as_ref() + .map(|h| h.read(cx).supports_delete()) + .unwrap_or(false); + let title: SharedString = + session.title.clone().unwrap_or_else(|| "Untitled".into()); + let session_info = session.clone(); + let session_id_for_delete = session.session_id.clone(); + let focus_handle = self.focus_handle.clone(); + let highlight_positions = highlight_positions.clone(); + + let timestamp = session.created_at.or(session.updated_at).map(|entry_time| { + let now = Utc::now(); + let duration = now.signed_duration_since(entry_time); + + let minutes = duration.num_minutes(); + let hours = duration.num_hours(); + let days = duration.num_days(); + let weeks = days / 7; + let months = days / 30; + + if minutes < 60 { + format!("{}m", minutes.max(1)) + } else if hours < 24 { + format!("{}h", hours) + } else if weeks < 4 { + format!("{}w", weeks.max(1)) + } else { + format!("{}mo", months.max(1)) + } + }); + + let id = SharedString::from(format!("archive-entry-{}", ix)); + + let title_label = if highlight_positions.is_empty() { + Label::new(title) + .size(LabelSize::Small) + .truncate() + .into_any_element() + } else { + HighlightedLabel::new(title, highlight_positions) + .size(LabelSize::Small) + .truncate() + .into_any_element() + }; + + ListItem::new(id) + .toggle_state(is_selected) + .child( + h_flex() + .min_w_0() + .w_full() + .py_1() + .pl_0p5() + .pr_1p5() + .gap_2() + .justify_between() + .child(title_label) + .when(!(hovered && supports_delete), |this| { + this.when_some(timestamp, |this, ts| { + this.child( + Label::new(ts).size(LabelSize::Small).color(Color::Muted), + ) + }) + }), + ) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + cx.notify(); + })) + .end_slot::(if hovered && supports_delete { + Some( + IconButton::new("delete-thread", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.delete_thread(&session_id_for_delete, cx); + cx.stop_propagation(); + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_thread(session_info.clone(), window, cx); + })) + .into_any_element() + } + } + } + + fn render_agent_picker(&self, cx: &mut Context) -> PopoverMenu { + let agent_server_store = self.agent_server_store.clone(); + + let (chevron_icon, icon_color) = if self.selected_agent_menu.is_deployed() { + (IconName::ChevronUp, Color::Accent) + } else { + (IconName::ChevronDown, Color::Muted) + }; + + let selected_agent_icon = if let Agent::Custom { id } = &self.selected_agent { + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&id); + + if let Some(icon) = icon { + Icon::from_external_svg(icon) + } else { + Icon::new(IconName::Sparkle) + } + .color(Color::Muted) + .size(IconSize::Small) + } else { + Icon::new(IconName::ZedAgent) + .color(Color::Muted) + .size(IconSize::Small) + }; + + let this = cx.weak_entity(); + + PopoverMenu::new("agent_history_menu") + .trigger( + ButtonLike::new("selected_agent") + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .child( + h_flex().gap_1().child(selected_agent_icon).child( + Icon::new(chevron_icon) + .color(icon_color) + .size(IconSize::XSmall), + ), + ), + ) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, |menu, _window, cx| { + menu.item( + ContextMenuEntry::new("Zed Agent") + .icon(IconName::ZedAgent) + .icon_color(Color::Muted) + .handler({ + let this = this.clone(); + move |window, cx| { + this.update(cx, |this, cx| { + this.set_selected_agent(Agent::NativeAgent, window, cx) + }) + .ok(); + } + }), + ) + .separator() + .map(|mut menu| { + let agent_server_store = agent_server_store.read(cx); + let registry_store = project::AgentRegistryStore::try_global(cx); + let registry_store_ref = registry_store.as_ref().map(|s| s.read(cx)); + + struct AgentMenuItem { + id: AgentId, + display_name: SharedString, + } + + let agent_items = agent_server_store + .external_agents() + .map(|agent_id| { + let display_name = agent_server_store + .agent_display_name(agent_id) + .or_else(|| { + registry_store_ref + .as_ref() + .and_then(|store| store.agent(agent_id)) + .map(|a| a.name().clone()) + }) + .unwrap_or_else(|| agent_id.0.clone()); + AgentMenuItem { + id: agent_id.clone(), + display_name, + } + }) + .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) + .collect::>(); + + for item in &agent_items { + let mut entry = ContextMenuEntry::new(item.display_name.clone()); + + let icon_path = agent_server_store.agent_icon(&item.id).or_else(|| { + registry_store_ref + .as_ref() + .and_then(|store| store.agent(&item.id)) + .and_then(|a| a.icon_path().cloned()) + }); + + if let Some(icon_path) = icon_path { + entry = entry.custom_icon_svg(icon_path); + } else { + entry = entry.icon(IconName::ZedAgent); + } + + entry = entry.icon_color(Color::Muted).handler({ + let this = this.clone(); + let agent = Agent::Custom { + id: item.id.clone(), + }; + move |window, cx| { + this.update(cx, |this, cx| { + this.set_selected_agent(agent.clone(), window, cx) + }) + .ok(); + } + }); + + menu = menu.item(entry); + } + menu + }) + })) + }) + .with_handle(self.selected_agent_menu.clone()) + .anchor(gpui::Corner::TopRight) + .offset(gpui::Point { + x: px(1.0), + y: px(1.0), + }) + } + + fn render_header(&self, cx: &mut Context) -> impl IntoElement { + let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); + + h_flex() + .h(Tab::container_height(cx)) + .px_1() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .flex_1() + .w_full() + .gap_1p5() + .child( + IconButton::new("back", IconName::ArrowLeft) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Back to Sidebar")) + .on_click(cx.listener(|this, _, window, cx| { + this.go_back(window, cx); + })), + ) + .child(self.filter_editor.clone()) + .when(has_query, |this| { + this.border_r_1().child( + IconButton::new("clear_archive_filter", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_items(cx); + })), + ) + }), + ) + .child(self.render_agent_picker(cx)) + } +} + +impl Focusable for ThreadsArchiveView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ThreadsArchiveView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_empty = self.items.is_empty(); + let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); + + let content = if self.is_loading { + v_flex() + .flex_1() + .justify_center() + .items_center() + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .into_any_element() + } else if is_empty && has_query { + v_flex() + .flex_1() + .justify_center() + .items_center() + .child( + Label::new("No threads match your search.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + } else if is_empty { + v_flex() + .flex_1() + .justify_center() + .items_center() + .child( + Label::new("No archived threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element() + } else { + v_flex() + .flex_1() + .overflow_hidden() + .child( + list( + self.list_state.clone(), + cx.processor(Self::render_list_entry), + ) + .flex_1() + .size_full(), + ) + .vertical_scrollbar_for(&self.list_state, window, cx) + .into_any_element() + }; + + v_flex() + .key_context("ThreadsArchiveView") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::editor_move_down)) + .on_action(cx.listener(Self::editor_move_up)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .size_full() + .bg(cx.theme().colors().surface_background) + .child(self.render_header(cx)) + .child(content) + } +} diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs index 23f3eadc4b259aa854f6c2cbb6bb3a68ec46deb5..7b6a563582abe89022d9d1684275dc850d28b23b 100644 --- a/crates/agent_ui/src/ui/acp_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -2,7 +2,7 @@ use gpui::{ ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, linear_color_stop, linear_gradient, }; -use project::agent_server_store::GEMINI_NAME; +use project::agent_server_store::GEMINI_ID; use ui::{TintColor, Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; @@ -39,7 +39,7 @@ impl AcpOnboardingModal { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: GEMINI_NAME.into(), + id: GEMINI_ID.into(), }, window, cx, @@ -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..c8ae51850325d674ae45eac22891cdcd0c948465 100644 --- a/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs +++ b/crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs @@ -2,7 +2,7 @@ use gpui::{ ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, linear_color_stop, linear_gradient, }; -use project::agent_server_store::CLAUDE_AGENT_NAME; +use project::agent_server_store::CLAUDE_AGENT_ID; use ui::{TintColor, Vector, VectorName, prelude::*}; use workspace::{ModalView, Workspace}; @@ -39,7 +39,7 @@ impl ClaudeCodeOnboardingModal { panel.update(cx, |panel, cx| { panel.new_agent_thread( AgentType::Custom { - name: CLAUDE_AGENT_NAME.into(), + id: CLAUDE_AGENT_ID.into(), }, window, cx, @@ -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/hold_for_default.rs b/crates/agent_ui/src/ui/hold_for_default.rs index 1972f5de4d38fd5ba47ff91709be6ded302b61ae..436ca65ddd93b977a09c8de8eaeb25dc6c0eb1a0 100644 --- a/crates/agent_ui/src/ui/hold_for_default.rs +++ b/crates/agent_ui/src/ui/hold_for_default.rs @@ -4,20 +4,31 @@ use ui::{prelude::*, render_modifiers}; #[derive(IntoElement)] pub struct HoldForDefault { is_default: bool, + more_content: bool, } impl HoldForDefault { pub fn new(is_default: bool) -> Self { - Self { is_default } + Self { + is_default, + more_content: true, + } + } + + pub fn more_content(mut self, more_content: bool) -> Self { + self.more_content = more_content; + self } } impl RenderOnce for HoldForDefault { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { h_flex() - .pt_1() - .border_t_1() - .border_color(cx.theme().colors().border_variant) + .when(self.more_content, |this| { + this.pt_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + }) .gap_0p5() .text_sm() .text_color(Color::Muted.color(cx)) diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 8b813ef7e40c2afe91b98600b9d1146d4751d48b..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, @@ -187,7 +189,8 @@ fn open_mention_uri( | MentionUri::Selection { abs_path: None, .. } | MentionUri::Diagnostics { .. } | MentionUri::TerminalSelection { .. } - | MentionUri::GitDiff { .. } => {} + | MentionUri::GitDiff { .. } + | MentionUri::MergeConflict { .. } => {} }); } @@ -274,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/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 0b1ccb4088e67de332c2bd2940ca5bdf77f1d3df..e05853fa167267c505d4424365c29844e0ce08db 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -266,6 +266,20 @@ impl ZedAiOnboarding { .into_any_element() } + fn render_business_plan_state(&self, _cx: &mut App) -> AnyElement { + v_flex() + .gap_1() + .child(Headline::new("Welcome to Zed Business")) + .child( + Label::new("Here's what you get:") + .color(Color::Muted) + .mb_2(), + ) + .child(PlanDefinitions.business_plan()) + .children(self.render_dismiss_button()) + .into_any_element() + } + fn render_student_plan_state(&self, _cx: &mut App) -> AnyElement { v_flex() .gap_1() @@ -289,6 +303,7 @@ impl RenderOnce for ZedAiOnboarding { Some(Plan::ZedFree) => self.render_free_plan_state(cx), Some(Plan::ZedProTrial) => self.render_trial_state(cx), Some(Plan::ZedPro) => self.render_pro_plan_state(cx), + Some(Plan::ZedBusiness) => self.render_business_plan_state(cx), Some(Plan::ZedStudent) => self.render_student_plan_state(cx), } } else { @@ -353,6 +368,14 @@ impl Component for ZedAiOnboarding { "Pro Plan", onboarding(SignInStatus::SignedIn, Some(Plan::ZedPro), false), ), + single_example( + "Business Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedBusiness), false), + ), + single_example( + "Student Plan", + onboarding(SignInStatus::SignedIn, Some(Plan::ZedStudent), false), + ), ]) .into_any_element(), ) diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs index f1a1c4310def0b9b4dbabbc6a59eae940396fbb9..cbaa9785db9e5471dd76a3add2cb9f19ca1b7ae1 100644 --- a/crates/ai_onboarding/src/ai_upsell_card.rs +++ b/crates/ai_onboarding/src/ai_upsell_card.rs @@ -250,6 +250,15 @@ impl RenderOnce for AiUpsellCard { .mb_2(), ) .child(PlanDefinitions.pro_plan()), + Some(Plan::ZedBusiness) => card + .child(certified_user_stamp) + .child(Label::new("You're in the Zed Business plan").size(LabelSize::Large)) + .child( + Label::new("Here's what you get:") + .color(Color::Muted) + .mb_2(), + ) + .child(PlanDefinitions.business_plan()), Some(Plan::ZedStudent) => card .child(certified_user_stamp) .child(Label::new("You're in the Zed Student plan").size(LabelSize::Large)) @@ -368,6 +377,28 @@ impl Component for AiUpsellCard { } .into_any_element(), ), + single_example( + "Business Plan", + AiUpsellCard { + sign_in_status: SignInStatus::SignedIn, + sign_in: Arc::new(|_, _| {}), + account_too_young: false, + user_plan: Some(Plan::ZedBusiness), + tab_index: Some(1), + } + .into_any_element(), + ), + single_example( + "Student Plan", + AiUpsellCard { + sign_in_status: SignInStatus::SignedIn, + sign_in: Arc::new(|_, _| {}), + account_too_young: false, + user_plan: Some(Plan::ZedStudent), + tab_index: Some(1), + } + .into_any_element(), + ), ], )) .into_any_element(), diff --git a/crates/ai_onboarding/src/plan_definitions.rs b/crates/ai_onboarding/src/plan_definitions.rs index 6d46a598c385b300fa579c69b0c58cfe51610c68..184815bcad9babb1892335c6207a79e1fe193c04 100644 --- a/crates/ai_onboarding/src/plan_definitions.rs +++ b/crates/ai_onboarding/src/plan_definitions.rs @@ -36,6 +36,12 @@ impl PlanDefinitions { .child(ListBulletItem::new("Usage-based billing beyond $5")) } + pub fn business_plan(&self) -> impl IntoElement { + List::new() + .child(ListBulletItem::new("Unlimited edit predictions")) + .child(ListBulletItem::new("Usage-based billing")) + } + pub fn student_plan(&self) -> impl IntoElement { List::new() .child(ListBulletItem::new("Unlimited edit predictions")) diff --git a/crates/anthropic/Cargo.toml b/crates/anthropic/Cargo.toml index f344470475a7603782d3eba9a8c461a92d7b4855..065879bc94b68abe193a1a4fc530142d7695ff49 100644 --- a/crates/anthropic/Cargo.toml +++ b/crates/anthropic/Cargo.toml @@ -27,8 +27,4 @@ settings.workspace = true strum.workspace = true thiserror.workspace = true -[dev-dependencies] -reqwest_client.workspace = true -gpui_tokio.workspace = true -gpui.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index a6509c81fa1ecabac32ff9e8bb0fafdddd9e7414..39ad14390a13b95e94029b9841b99facda3716ba 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -78,23 +78,20 @@ pub enum Model { alias = "claude-opus-4-5-thinking-latest" )] ClaudeOpus4_5Thinking, - #[serde(rename = "claude-opus-4-6", alias = "claude-opus-4-6-latest")] - ClaudeOpus4_6, - #[serde( - rename = "claude-opus-4-6-thinking", - alias = "claude-opus-4-6-thinking-latest" - )] - ClaudeOpus4_6Thinking, #[serde( - rename = "claude-opus-4-6-1m-context", + rename = "claude-opus-4-6", + alias = "claude-opus-4-6-latest", + alias = "claude-opus-4-6-1m-context", alias = "claude-opus-4-6-1m-context-latest" )] - ClaudeOpus4_6_1mContext, + ClaudeOpus4_6, #[serde( - rename = "claude-opus-4-6-1m-context-thinking", + rename = "claude-opus-4-6-thinking", + alias = "claude-opus-4-6-thinking-latest", + alias = "claude-opus-4-6-1m-context-thinking", alias = "claude-opus-4-6-1m-context-thinking-latest" )] - ClaudeOpus4_6_1mContextThinking, + ClaudeOpus4_6Thinking, #[serde(rename = "claude-sonnet-4", alias = "claude-sonnet-4-latest")] ClaudeSonnet4, #[serde( @@ -120,23 +117,20 @@ pub enum Model { )] ClaudeSonnet4_5_1mContextThinking, #[default] - #[serde(rename = "claude-sonnet-4-6", alias = "claude-sonnet-4-6-latest")] - ClaudeSonnet4_6, #[serde( - rename = "claude-sonnet-4-6-thinking", - alias = "claude-sonnet-4-6-thinking-latest" - )] - ClaudeSonnet4_6Thinking, - #[serde( - rename = "claude-sonnet-4-6-1m-context", + rename = "claude-sonnet-4-6", + alias = "claude-sonnet-4-6-latest", + alias = "claude-sonnet-4-6-1m-context", alias = "claude-sonnet-4-6-1m-context-latest" )] - ClaudeSonnet4_6_1mContext, + ClaudeSonnet4_6, #[serde( - rename = "claude-sonnet-4-6-1m-context-thinking", + rename = "claude-sonnet-4-6-thinking", + alias = "claude-sonnet-4-6-thinking-latest", + alias = "claude-sonnet-4-6-1m-context-thinking", alias = "claude-sonnet-4-6-1m-context-thinking-latest" )] - ClaudeSonnet4_6_1mContextThinking, + ClaudeSonnet4_6Thinking, #[serde(rename = "claude-haiku-4-5", alias = "claude-haiku-4-5-latest")] ClaudeHaiku4_5, #[serde( @@ -172,11 +166,11 @@ impl Model { pub fn from_id(id: &str) -> Result { if id.starts_with("claude-opus-4-6-1m-context-thinking") { - return Ok(Self::ClaudeOpus4_6_1mContextThinking); + return Ok(Self::ClaudeOpus4_6Thinking); } if id.starts_with("claude-opus-4-6-1m-context") { - return Ok(Self::ClaudeOpus4_6_1mContext); + return Ok(Self::ClaudeOpus4_6); } if id.starts_with("claude-opus-4-6-thinking") { @@ -212,11 +206,11 @@ impl Model { } if id.starts_with("claude-sonnet-4-6-1m-context-thinking") { - return Ok(Self::ClaudeSonnet4_6_1mContextThinking); + return Ok(Self::ClaudeSonnet4_6Thinking); } if id.starts_with("claude-sonnet-4-6-1m-context") { - return Ok(Self::ClaudeSonnet4_6_1mContext); + return Ok(Self::ClaudeSonnet4_6); } if id.starts_with("claude-sonnet-4-6-thinking") { @@ -276,8 +270,6 @@ impl Model { Self::ClaudeOpus4_5Thinking => "claude-opus-4-5-thinking-latest", Self::ClaudeOpus4_6 => "claude-opus-4-6-latest", Self::ClaudeOpus4_6Thinking => "claude-opus-4-6-thinking-latest", - Self::ClaudeOpus4_6_1mContext => "claude-opus-4-6-1m-context-latest", - Self::ClaudeOpus4_6_1mContextThinking => "claude-opus-4-6-1m-context-thinking-latest", Self::ClaudeSonnet4 => "claude-sonnet-4-latest", Self::ClaudeSonnet4Thinking => "claude-sonnet-4-thinking-latest", Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-latest", @@ -288,10 +280,6 @@ impl Model { } Self::ClaudeSonnet4_6 => "claude-sonnet-4-6-latest", Self::ClaudeSonnet4_6Thinking => "claude-sonnet-4-6-thinking-latest", - Self::ClaudeSonnet4_6_1mContext => "claude-sonnet-4-6-1m-context-latest", - Self::ClaudeSonnet4_6_1mContextThinking => { - "claude-sonnet-4-6-1m-context-thinking-latest" - } Self::ClaudeHaiku4_5 => "claude-haiku-4-5-latest", Self::ClaudeHaiku4_5Thinking => "claude-haiku-4-5-thinking-latest", Self::Claude3Haiku => "claude-3-haiku-20240307", @@ -305,19 +293,13 @@ impl Model { Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking => "claude-opus-4-20250514", Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-20250805", Self::ClaudeOpus4_5 | Self::ClaudeOpus4_5Thinking => "claude-opus-4-5-20251101", - Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking => "claude-opus-4-6", + Self::ClaudeOpus4_6 | Self::ClaudeOpus4_6Thinking => "claude-opus-4-6", Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => "claude-sonnet-4-20250514", Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking | Self::ClaudeSonnet4_5_1mContext | Self::ClaudeSonnet4_5_1mContextThinking => "claude-sonnet-4-5-20250929", - Self::ClaudeSonnet4_6 - | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking => "claude-sonnet-4-6", + Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_6Thinking => "claude-sonnet-4-6", Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking => "claude-haiku-4-5-20251001", Self::Claude3Haiku => "claude-3-haiku-20240307", Self::Custom { name, .. } => name, @@ -334,8 +316,6 @@ impl Model { Self::ClaudeOpus4_5Thinking => "Claude Opus 4.5 Thinking", Self::ClaudeOpus4_6 => "Claude Opus 4.6", Self::ClaudeOpus4_6Thinking => "Claude Opus 4.6 Thinking", - Self::ClaudeOpus4_6_1mContext => "Claude Opus 4.6 (1M context)", - Self::ClaudeOpus4_6_1mContextThinking => "Claude Opus 4.6 Thinking (1M context)", Self::ClaudeSonnet4 => "Claude Sonnet 4", Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking", Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", @@ -344,8 +324,6 @@ impl Model { Self::ClaudeSonnet4_5_1mContextThinking => "Claude Sonnet 4.5 Thinking (1M context)", Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", Self::ClaudeSonnet4_6Thinking => "Claude Sonnet 4.6 Thinking", - Self::ClaudeSonnet4_6_1mContext => "Claude Sonnet 4.6 (1M context)", - Self::ClaudeSonnet4_6_1mContextThinking => "Claude Sonnet 4.6 Thinking (1M context)", Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", Self::ClaudeHaiku4_5Thinking => "Claude Haiku 4.5 Thinking", Self::Claude3Haiku => "Claude 3 Haiku", @@ -365,8 +343,6 @@ impl Model { | Self::ClaudeOpus4_5Thinking | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -375,8 +351,6 @@ impl Model { | Self::ClaudeSonnet4_5_1mContextThinking | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking | Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking | Self::Claude3Haiku => Some(AnthropicModelCacheConfiguration { @@ -399,23 +373,19 @@ impl Model { | Self::ClaudeOpus4_1Thinking | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_5Thinking - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_6Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5Thinking - | Self::ClaudeSonnet4_6 - | Self::ClaudeSonnet4_6Thinking | Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking | Self::Claude3Haiku => 200_000, - Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking + Self::ClaudeOpus4_6 + | Self::ClaudeOpus4_6Thinking | Self::ClaudeSonnet4_5_1mContext | Self::ClaudeSonnet4_5_1mContextThinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking => 1_000_000, + | Self::ClaudeSonnet4_6 + | Self::ClaudeSonnet4_6Thinking => 1_000_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -436,14 +406,9 @@ impl Model { | Self::ClaudeSonnet4_5_1mContextThinking | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking | Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking => 64_000, - Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking => 128_000, + Self::ClaudeOpus4_6 | Self::ClaudeOpus4_6Thinking => 128_000, Self::Claude3Haiku => 4_096, Self::Custom { max_output_tokens, .. @@ -461,8 +426,6 @@ impl Model { | Self::ClaudeOpus4_5Thinking | Self::ClaudeOpus4_6 | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5 @@ -471,8 +434,6 @@ impl Model { | Self::ClaudeSonnet4_5_1mContextThinking | Self::ClaudeSonnet4_6 | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking | Self::ClaudeHaiku4_5 | Self::ClaudeHaiku4_5Thinking | Self::Claude3Haiku => 1.0, @@ -489,24 +450,20 @@ impl Model { | Self::ClaudeOpus4_1 | Self::ClaudeOpus4_5 | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_6_1mContext | Self::ClaudeSonnet4 | Self::ClaudeSonnet4_5 | Self::ClaudeSonnet4_5_1mContext | Self::ClaudeSonnet4_6 - | Self::ClaudeSonnet4_6_1mContext | Self::ClaudeHaiku4_5 | Self::Claude3Haiku => AnthropicModelMode::Default, Self::ClaudeOpus4Thinking | Self::ClaudeOpus4_1Thinking | Self::ClaudeOpus4_5Thinking | Self::ClaudeOpus4_6Thinking - | Self::ClaudeOpus4_6_1mContextThinking | Self::ClaudeSonnet4Thinking | Self::ClaudeSonnet4_5Thinking | Self::ClaudeSonnet4_5_1mContextThinking | Self::ClaudeSonnet4_6Thinking - | Self::ClaudeSonnet4_6_1mContextThinking | Self::ClaudeHaiku4_5Thinking => AnthropicModelMode::Thinking { budget_tokens: Some(4_096), }, @@ -518,12 +475,7 @@ impl Model { let mut headers = vec![]; match self { - Self::ClaudeOpus4_6_1mContext - | Self::ClaudeOpus4_6_1mContextThinking - | Self::ClaudeSonnet4_5_1mContext - | Self::ClaudeSonnet4_5_1mContextThinking - | Self::ClaudeSonnet4_6_1mContext - | Self::ClaudeSonnet4_6_1mContextThinking => { + Self::ClaudeSonnet4_5_1mContext | Self::ClaudeSonnet4_5_1mContextThinking => { headers.push(CONTEXT_1M_BETA_HEADER.to_string()); } Self::Custom { diff --git a/crates/assistant_text_thread/Cargo.toml b/crates/assistant_text_thread/Cargo.toml index 4c3563a7d26dca06282d5f3d15ec2a64c411dfba..bbb5cf4778efd5d74b880b7350a71e72562f4d70 100644 --- a/crates/assistant_text_thread/Cargo.toml +++ b/crates/assistant_text_thread/Cargo.toml @@ -55,7 +55,7 @@ zed_env_vars.workspace = true [dev-dependencies] assistant_slash_commands.workspace = true -indoc.workspace = true + language_model = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true rand.workspace = true diff --git a/crates/assistant_text_thread/src/text_thread.rs b/crates/assistant_text_thread/src/text_thread.rs index 34007868f9f128fa80f09f884ccbaf57ffd103c1..7df6b32e59733086b70ce4dccaa40bbc9cbccf32 100644 --- a/crates/assistant_text_thread/src/text_thread.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -1219,7 +1219,7 @@ impl TextThread { } => cx.emit(TextThreadEvent::Operation( TextThreadOperation::BufferOperation(operation.clone()), )), - language::BufferEvent::Edited => { + language::BufferEvent::Edited { .. } => { self.count_remaining_tokens(cx); self.reparse(cx); cx.emit(TextThreadEvent::MessagesEdited); diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index f9a635a16a2eaf2a4facbd1f25bf6eb0f9fe7a87..650285aa654ac02ae03f41d0af66b33f086a106e 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -1,77 +1,22 @@ -use anyhow::{Context as _, Result}; -use collections::HashMap; -use cpal::{ - DeviceDescription, DeviceId, default_host, - traits::{DeviceTrait, HostTrait}, -}; -use gpui::{App, AsyncApp, BackgroundExecutor, BorrowAppContext, Global}; +use std::time::Duration; -#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] -mod non_windows_and_freebsd_deps { - pub(super) use cpal::Sample; - pub(super) use libwebrtc::native::apm; - pub(super) use parking_lot::Mutex; - pub(super) use rodio::source::LimitSettings; - pub(super) use std::sync::Arc; -} - -#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] -use non_windows_and_freebsd_deps::*; +use rodio::{ChannelCount, SampleRate, nz}; -use rodio::{ - Decoder, DeviceSinkBuilder, MixerDeviceSink, Source, - mixer::Mixer, - nz, - source::{AutomaticGainControlSettings, Buffered}, -}; -use settings::Settings; -use std::{io::Cursor, num::NonZero, path::PathBuf, sync::atomic::Ordering, time::Duration}; -use util::ResultExt; +pub const REPLAY_DURATION: Duration = Duration::from_secs(30); +pub const SAMPLE_RATE: SampleRate = nz!(48000); +pub const CHANNEL_COUNT: ChannelCount = nz!(2); mod audio_settings; -mod replays; -mod rodio_ext; pub use audio_settings::AudioSettings; -pub use rodio_ext::RodioExt; - -use crate::audio_settings::LIVE_SETTINGS; - -// We are migrating to 16kHz sample rate from 48kHz. In the future -// once we are reasonably sure most users have upgraded we will -// remove the LEGACY parameters. -// -// We migrate to 16kHz because it is sufficient for speech and required -// by the denoiser and future Speech to Text layers. -pub const SAMPLE_RATE: NonZero = nz!(16000); -pub const CHANNEL_COUNT: NonZero = nz!(1); -pub const BUFFER_SIZE: usize = // echo canceller and livekit want 10ms of audio - (SAMPLE_RATE.get() as usize / 100) * CHANNEL_COUNT.get() as usize; - -pub const LEGACY_SAMPLE_RATE: NonZero = nz!(48000); -pub const LEGACY_CHANNEL_COUNT: NonZero = nz!(2); - -pub const REPLAY_DURATION: Duration = Duration::from_secs(30); - -pub fn init(cx: &mut App) { - LIVE_SETTINGS.initialize(cx); -} -// TODO(jk): this is currently cached only once - we should observe and react instead -pub fn ensure_devices_initialized(cx: &mut App) { - if cx.has_global::() { - return; - } - cx.default_global::(); - let task = cx - .background_executor() - .spawn(async move { get_available_audio_devices() }); - cx.spawn(async move |cx: &mut AsyncApp| { - let devices = task.await; - cx.update(|cx| cx.set_global(AvailableAudioDevices(devices))); - cx.refresh(); - }) - .detach(); -} +mod audio_pipeline; +pub use audio_pipeline::{Audio, VoipParts}; +pub use audio_pipeline::{AudioDeviceInfo, AvailableAudioDevices}; +pub use audio_pipeline::{ensure_devices_initialized, resolve_device}; +// TODO(audio) replace with input test functionality in the audio crate +pub use audio_pipeline::RodioExt; +pub use audio_pipeline::init; +pub use audio_pipeline::{open_input_stream, open_test_output}; #[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)] pub enum Sound { @@ -99,347 +44,3 @@ impl Sound { } } } - -pub struct Audio { - output_handle: Option, - #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] - pub echo_canceller: Arc>, - source_cache: HashMap>>>>, - replays: replays::Replays, -} - -impl Default for Audio { - fn default() -> Self { - Self { - output_handle: Default::default(), - #[cfg(not(any( - all(target_os = "windows", target_env = "gnu"), - target_os = "freebsd" - )))] - echo_canceller: Arc::new(Mutex::new(apm::AudioProcessingModule::new( - true, false, false, false, - ))), - source_cache: Default::default(), - replays: Default::default(), - } - } -} - -impl Global for Audio {} - -impl Audio { - fn ensure_output_exists(&mut self, output_audio_device: Option) -> Result<&Mixer> { - #[cfg(debug_assertions)] - log::warn!( - "Audio does not sound correct without optimizations. Use a release build to debug audio issues" - ); - - if self.output_handle.is_none() { - let output_handle = open_output_stream(output_audio_device)?; - - // The webrtc apm is not yet compiling for windows & freebsd - #[cfg(not(any( - any(all(target_os = "windows", target_env = "gnu")), - target_os = "freebsd" - )))] - let echo_canceller = Arc::clone(&self.echo_canceller); - - #[cfg(not(any( - any(all(target_os = "windows", target_env = "gnu")), - target_os = "freebsd" - )))] - { - let source = rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE) - .inspect_buffer::(move |buffer| { - let mut buf: [i16; _] = buffer.map(|s| s.to_sample()); - echo_canceller - .lock() - .process_reverse_stream( - &mut buf, - SAMPLE_RATE.get() as i32, - CHANNEL_COUNT.get().into(), - ) - .expect("Audio input and output threads should not panic"); - }); - output_handle.mixer().add(source); - } - - #[cfg(any( - any(all(target_os = "windows", target_env = "gnu")), - target_os = "freebsd" - ))] - { - let source = rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE); - output_handle.mixer().add(source); - } - - self.output_handle = Some(output_handle); - } - - Ok(self - .output_handle - .as_ref() - .map(|h| h.mixer()) - .expect("we only get here if opening the outputstream succeeded")) - } - - pub fn save_replays( - &self, - executor: BackgroundExecutor, - ) -> gpui::Task> { - self.replays.replays_to_tar(executor) - } - - #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] - pub fn open_microphone(voip_parts: VoipParts) -> anyhow::Result { - let stream = open_input_stream(voip_parts.input_audio_device)?; - let stream = stream - .possibly_disconnected_channels_to_mono() - .constant_samplerate(SAMPLE_RATE) - .limit(LimitSettings::live_performance()) - .process_buffer::(move |buffer| { - let mut int_buffer: [i16; _] = buffer.map(|s| s.to_sample()); - if voip_parts - .echo_canceller - .lock() - .process_stream( - &mut int_buffer, - SAMPLE_RATE.get() as i32, - CHANNEL_COUNT.get() as i32, - ) - .context("livekit audio processor error") - .log_err() - .is_some() - { - for (sample, processed) in buffer.iter_mut().zip(&int_buffer) { - *sample = (*processed).to_sample(); - } - } - }) - .denoise() - .context("Could not set up denoiser")? - .automatic_gain_control(AutomaticGainControlSettings { - target_level: 0.90, - attack_time: Duration::from_secs(1), - release_time: Duration::from_secs(0), - absolute_max_gain: 5.0, - }) - .periodic_access(Duration::from_millis(100), move |agc_source| { - agc_source - .set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed)); - let denoise = agc_source.inner_mut(); - denoise.set_enabled(LIVE_SETTINGS.denoise.load(Ordering::Relaxed)); - }); - - let stream = if voip_parts.legacy_audio_compatible { - stream.constant_params(LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE) - } else { - stream.constant_params(CHANNEL_COUNT, SAMPLE_RATE) - }; - - let (replay, stream) = stream.replayable(REPLAY_DURATION)?; - voip_parts - .replays - .add_voip_stream("local microphone".to_string(), replay); - - Ok(stream) - } - - pub fn play_voip_stream( - source: impl rodio::Source + Send + 'static, - speaker_name: String, - is_staff: bool, - cx: &mut App, - ) -> anyhow::Result<()> { - let (replay_source, source) = source - .constant_params(CHANNEL_COUNT, SAMPLE_RATE) - .automatic_gain_control(AutomaticGainControlSettings { - target_level: 0.90, - attack_time: Duration::from_secs(1), - release_time: Duration::from_secs(0), - absolute_max_gain: 5.0, - }) - .periodic_access(Duration::from_millis(100), move |agc_source| { - agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed)); - }) - .replayable(REPLAY_DURATION) - .expect("REPLAY_DURATION is longer than 100ms"); - let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); - - cx.update_default_global(|this: &mut Self, _cx| { - let output_mixer = this - .ensure_output_exists(output_audio_device) - .context("Could not get output mixer")?; - output_mixer.add(source); - if is_staff { - this.replays.add_voip_stream(speaker_name, replay_source); - } - Ok(()) - }) - } - - pub fn play_sound(sound: Sound, cx: &mut App) { - let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); - cx.update_default_global(|this: &mut Self, cx| { - let source = this.sound_source(sound, cx).log_err()?; - let output_mixer = this - .ensure_output_exists(output_audio_device) - .context("Could not get output mixer") - .log_err()?; - - output_mixer.add(source); - Some(()) - }); - } - - pub fn end_call(cx: &mut App) { - cx.update_default_global(|this: &mut Self, _cx| { - this.output_handle.take(); - }); - } - - fn sound_source(&mut self, sound: Sound, cx: &App) -> Result> { - if let Some(wav) = self.source_cache.get(&sound) { - return Ok(wav.clone()); - } - - let path = format!("sounds/{}.wav", sound.file()); - let bytes = cx - .asset_source() - .load(&path)? - .map(anyhow::Ok) - .with_context(|| format!("No asset available for path {path}"))?? - .into_owned(); - let cursor = Cursor::new(bytes); - let source = Decoder::new(cursor)?.buffered(); - - self.source_cache.insert(sound, source.clone()); - - Ok(source) - } -} - -#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] -pub struct VoipParts { - echo_canceller: Arc>, - replays: replays::Replays, - legacy_audio_compatible: bool, - input_audio_device: Option, -} - -#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] -impl VoipParts { - pub fn new(cx: &AsyncApp) -> anyhow::Result { - let (apm, replays) = cx.read_default_global::(|audio, _| { - (Arc::clone(&audio.echo_canceller), audio.replays.clone()) - }); - let legacy_audio_compatible = - AudioSettings::try_read_global(cx, |settings| settings.legacy_audio_compatible) - .unwrap_or(true); - let input_audio_device = - AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) - .flatten(); - - Ok(Self { - legacy_audio_compatible, - echo_canceller: apm, - replays, - input_audio_device, - }) - } -} - -pub fn open_input_stream( - device_id: Option, -) -> anyhow::Result { - let builder = rodio::microphone::MicrophoneBuilder::new(); - let builder = if let Some(id) = device_id { - // TODO(jk): upstream patch - // if let Some(input_device) = default_host().device_by_id(id) { - // builder.device(input_device); - // } - let mut found = None; - for input in rodio::microphone::available_inputs()? { - if input.clone().into_inner().id()? == id { - found = Some(builder.device(input)); - break; - } - } - found.unwrap_or_else(|| builder.default_device())? - } else { - builder.default_device()? - }; - let stream = builder - .default_config()? - .prefer_sample_rates([ - SAMPLE_RATE, - SAMPLE_RATE.saturating_mul(rodio::nz!(2)), - SAMPLE_RATE.saturating_mul(rodio::nz!(3)), - SAMPLE_RATE.saturating_mul(rodio::nz!(4)), - ]) - .prefer_channel_counts([rodio::nz!(1), rodio::nz!(2), rodio::nz!(3), rodio::nz!(4)]) - .prefer_buffer_sizes(512..) - .open_stream()?; - log::info!("Opened microphone: {:?}", stream.config()); - Ok(stream) -} - -pub fn open_output_stream(device_id: Option) -> anyhow::Result { - let output_handle = if let Some(id) = device_id { - if let Some(device) = default_host().device_by_id(&id) { - DeviceSinkBuilder::from_device(device)?.open_stream() - } else { - DeviceSinkBuilder::open_default_sink() - } - } else { - DeviceSinkBuilder::open_default_sink() - }; - let mut output_handle = output_handle.context("Could not open output stream")?; - output_handle.log_on_drop(false); - log::info!("Output stream: {:?}", output_handle); - Ok(output_handle) -} - -#[derive(Clone, Debug)] -pub struct AudioDeviceInfo { - pub id: DeviceId, - pub desc: DeviceDescription, -} - -impl AudioDeviceInfo { - pub fn matches_input(&self, is_input: bool) -> bool { - if is_input { - self.desc.supports_input() - } else { - self.desc.supports_output() - } - } - - pub fn matches(&self, id: &DeviceId, is_input: bool) -> bool { - &self.id == id && self.matches_input(is_input) - } -} - -impl std::fmt::Display for AudioDeviceInfo { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} ({})", self.desc.name(), self.id) - } -} - -fn get_available_audio_devices() -> Vec { - let Some(devices) = default_host().devices().ok() else { - return Vec::new(); - }; - devices - .filter_map(|device| { - let id = device.id().ok()?; - let desc = device.description().ok()?; - Some(AudioDeviceInfo { id, desc }) - }) - .collect() -} - -#[derive(Default, Clone, Debug)] -pub struct AvailableAudioDevices(pub Vec); - -impl Global for AvailableAudioDevices {} diff --git a/crates/audio/src/audio_pipeline.rs b/crates/audio/src/audio_pipeline.rs new file mode 100644 index 0000000000000000000000000000000000000000..3d2a6ae32c381b1cab590946c35fbb68325af5db --- /dev/null +++ b/crates/audio/src/audio_pipeline.rs @@ -0,0 +1,355 @@ +use anyhow::{Context as _, Result}; +use collections::HashMap; +use cpal::{ + DeviceDescription, DeviceId, default_host, + traits::{DeviceTrait, HostTrait}, +}; +use gpui::{App, AsyncApp, BackgroundExecutor, BorrowAppContext, Global}; + +pub(super) use cpal::Sample; +pub(super) use rodio::source::LimitSettings; + +use rodio::{ + Decoder, DeviceSinkBuilder, MixerDeviceSink, Source, + mixer::Mixer, + source::{AutomaticGainControlSettings, Buffered}, +}; +use settings::Settings; +use std::{io::Cursor, path::PathBuf, sync::atomic::Ordering, time::Duration}; +use util::ResultExt; + +mod echo_canceller; +use echo_canceller::EchoCanceller; +mod replays; +mod rodio_ext; +pub use crate::audio_settings::AudioSettings; +pub use rodio_ext::RodioExt; + +use crate::audio_settings::LIVE_SETTINGS; + +use crate::Sound; + +use super::{CHANNEL_COUNT, SAMPLE_RATE}; +pub const BUFFER_SIZE: usize = // echo canceller and livekit want 10ms of audio + (SAMPLE_RATE.get() as usize / 100) * CHANNEL_COUNT.get() as usize; + +pub fn init(cx: &mut App) { + LIVE_SETTINGS.initialize(cx); +} + +// TODO(jk): this is currently cached only once - we should observe and react instead +pub fn ensure_devices_initialized(cx: &mut App) { + if cx.has_global::() { + return; + } + cx.default_global::(); + let task = cx + .background_executor() + .spawn(async move { get_available_audio_devices() }); + cx.spawn(async move |cx: &mut AsyncApp| { + let devices = task.await; + cx.update(|cx| cx.set_global(AvailableAudioDevices(devices))); + cx.refresh(); + }) + .detach(); +} + +#[derive(Default)] +pub struct Audio { + output: Option<(MixerDeviceSink, Mixer)>, + pub echo_canceller: EchoCanceller, + source_cache: HashMap>>>>, + replays: replays::Replays, +} + +impl Global for Audio {} + +impl Audio { + fn ensure_output_exists(&mut self, output_audio_device: Option) -> Result<&Mixer> { + #[cfg(debug_assertions)] + log::warn!( + "Audio does not sound correct without optimizations. Use a release build to debug audio issues" + ); + + if self.output.is_none() { + let (output_handle, output_mixer) = + open_output_stream(output_audio_device, self.echo_canceller.clone())?; + self.output = Some((output_handle, output_mixer)); + } + + Ok(self + .output + .as_ref() + .map(|(_, mixer)| mixer) + .expect("we only get here if opening the outputstream succeeded")) + } + + pub fn save_replays( + &self, + executor: BackgroundExecutor, + ) -> gpui::Task> { + self.replays.replays_to_tar(executor) + } + + pub fn open_microphone(mut voip_parts: VoipParts) -> anyhow::Result { + let stream = open_input_stream(voip_parts.input_audio_device)?; + let stream = stream + .possibly_disconnected_channels_to_mono() + .constant_params(CHANNEL_COUNT, SAMPLE_RATE) + .process_buffer::(move |buffer| { + let mut int_buffer: [i16; _] = buffer.map(|s| s.to_sample()); + if voip_parts + .echo_canceller + .process_stream(&mut int_buffer) + .log_err() + .is_some() + { + for (sample, processed) in buffer.iter_mut().zip(&int_buffer) { + *sample = (*processed).to_sample(); + } + } + }) + .limit(LimitSettings::live_performance()) + .automatic_gain_control(AutomaticGainControlSettings { + target_level: 0.90, + attack_time: Duration::from_secs(1), + release_time: Duration::from_secs(0), + absolute_max_gain: 5.0, + }) + .periodic_access(Duration::from_millis(100), move |agc_source| { + agc_source + .set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed)); + let _ = LIVE_SETTINGS.denoise; // TODO(audio: re-introduce de-noising + }); + + let (replay, stream) = stream.replayable(crate::REPLAY_DURATION)?; + voip_parts + .replays + .add_voip_stream("local microphone".to_string(), replay); + + Ok(stream) + } + + pub fn play_voip_stream( + source: impl rodio::Source + Send + 'static, + speaker_name: String, + is_staff: bool, + cx: &mut App, + ) -> anyhow::Result<()> { + let (replay_source, source) = source + .automatic_gain_control(AutomaticGainControlSettings { + target_level: 0.90, + attack_time: Duration::from_secs(1), + release_time: Duration::from_secs(0), + absolute_max_gain: 5.0, + }) + .periodic_access(Duration::from_millis(100), move |agc_source| { + agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed)); + }) + .replayable(crate::REPLAY_DURATION) + .expect("REPLAY_DURATION is longer than 100ms"); + let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); + + cx.update_default_global(|this: &mut Self, _cx| { + let output_mixer = this + .ensure_output_exists(output_audio_device) + .context("Could not get output mixer")?; + output_mixer.add(source); + if is_staff { + this.replays.add_voip_stream(speaker_name, replay_source); + } + Ok(()) + }) + } + + pub fn play_sound(sound: Sound, cx: &mut App) { + let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); + cx.update_default_global(|this: &mut Self, cx| { + let source = this.sound_source(sound, cx).log_err()?; + let output_mixer = this + .ensure_output_exists(output_audio_device) + .context("Could not get output mixer") + .log_err()?; + + output_mixer.add(source); + Some(()) + }); + } + + pub fn end_call(cx: &mut App) { + cx.update_default_global(|this: &mut Self, _cx| { + this.output.take(); + }); + } + + fn sound_source(&mut self, sound: Sound, cx: &App) -> Result> { + if let Some(wav) = self.source_cache.get(&sound) { + return Ok(wav.clone()); + } + + let path = format!("sounds/{}.wav", sound.file()); + let bytes = cx + .asset_source() + .load(&path)? + .map(anyhow::Ok) + .with_context(|| format!("No asset available for path {path}"))?? + .into_owned(); + let cursor = Cursor::new(bytes); + let source = Decoder::new(cursor)?.buffered(); + + self.source_cache.insert(sound, source.clone()); + + Ok(source) + } +} + +pub struct VoipParts { + echo_canceller: EchoCanceller, + replays: replays::Replays, + input_audio_device: Option, +} + +impl VoipParts { + pub fn new(cx: &AsyncApp) -> anyhow::Result { + let (apm, replays) = cx.read_default_global::(|audio, _| { + (audio.echo_canceller.clone(), audio.replays.clone()) + }); + let input_audio_device = + AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) + .flatten(); + + Ok(Self { + echo_canceller: apm, + replays, + input_audio_device, + }) + } +} + +pub fn open_input_stream( + device_id: Option, +) -> anyhow::Result { + let builder = rodio::microphone::MicrophoneBuilder::new(); + let builder = if let Some(id) = device_id { + // TODO(jk): upstream patch + // if let Some(input_device) = default_host().device_by_id(id) { + // builder.device(input_device); + // } + let mut found = None; + for input in rodio::microphone::available_inputs()? { + if input.clone().into_inner().id()? == id { + found = Some(builder.device(input)); + break; + } + } + found.unwrap_or_else(|| builder.default_device())? + } else { + builder.default_device()? + }; + let stream = builder + .default_config()? + .prefer_sample_rates([ + SAMPLE_RATE, + SAMPLE_RATE.saturating_mul(rodio::nz!(2)), + SAMPLE_RATE.saturating_mul(rodio::nz!(3)), + SAMPLE_RATE.saturating_mul(rodio::nz!(4)), + ]) + .prefer_channel_counts([rodio::nz!(1), rodio::nz!(2), rodio::nz!(3), rodio::nz!(4)]) + .prefer_buffer_sizes(512..) + .open_stream()?; + log::info!("Opened microphone: {:?}", stream.config()); + Ok(stream) +} + +pub fn resolve_device(device_id: Option<&DeviceId>, input: bool) -> anyhow::Result { + if let Some(id) = device_id { + if let Some(device) = default_host().device_by_id(id) { + return Ok(device); + } + log::warn!("Selected audio device not found, falling back to default"); + } + if input { + default_host() + .default_input_device() + .context("no audio input device available") + } else { + default_host() + .default_output_device() + .context("no audio output device available") + } +} + +pub fn open_test_output(device_id: Option) -> anyhow::Result { + let device = resolve_device(device_id.as_ref(), false)?; + DeviceSinkBuilder::from_device(device)? + .open_stream() + .context("Could not open output stream") +} + +pub fn open_output_stream( + device_id: Option, + mut echo_canceller: EchoCanceller, +) -> anyhow::Result<(MixerDeviceSink, Mixer)> { + let device = resolve_device(device_id.as_ref(), false)?; + let mut output_handle = DeviceSinkBuilder::from_device(device)? + .open_stream() + .context("Could not open output stream")?; + output_handle.log_on_drop(false); + log::info!("Output stream: {:?}", output_handle); + + let (output_mixer, source) = rodio::mixer::mixer(CHANNEL_COUNT, SAMPLE_RATE); + // otherwise the mixer ends as it's empty + output_mixer.add(rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE)); + let echo_cancelling_source = source // apply echo cancellation just before output + .inspect_buffer::(move |buffer| { + let mut buf: [i16; _] = buffer.map(|s| s.to_sample()); + echo_canceller.process_reverse_stream(&mut buf) + }); + output_handle.mixer().add(echo_cancelling_source); + + Ok((output_handle, output_mixer)) +} + +#[derive(Clone, Debug)] +pub struct AudioDeviceInfo { + pub id: DeviceId, + pub desc: DeviceDescription, +} + +impl AudioDeviceInfo { + pub fn matches_input(&self, is_input: bool) -> bool { + if is_input { + self.desc.supports_input() + } else { + self.desc.supports_output() + } + } + + pub fn matches(&self, id: &DeviceId, is_input: bool) -> bool { + &self.id == id && self.matches_input(is_input) + } +} + +impl std::fmt::Display for AudioDeviceInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.desc.name(), self.id) + } +} + +fn get_available_audio_devices() -> Vec { + let Some(devices) = default_host().devices().ok() else { + return Vec::new(); + }; + devices + .filter_map(|device| { + let id = device.id().ok()?; + let desc = device.description().ok()?; + Some(AudioDeviceInfo { id, desc }) + }) + .collect() +} + +#[derive(Default, Clone, Debug)] +pub struct AvailableAudioDevices(pub Vec); + +impl Global for AvailableAudioDevices {} diff --git a/crates/audio/src/audio_pipeline/echo_canceller.rs b/crates/audio/src/audio_pipeline/echo_canceller.rs new file mode 100644 index 0000000000000000000000000000000000000000..ec612b1b448bd33871b33638468747b765fc3c1a --- /dev/null +++ b/crates/audio/src/audio_pipeline/echo_canceller.rs @@ -0,0 +1,54 @@ +#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] +mod real_implementation { + use anyhow::Context; + use libwebrtc::native::apm; + use parking_lot::Mutex; + use std::sync::Arc; + + use crate::{CHANNEL_COUNT, SAMPLE_RATE}; + + #[derive(Clone)] + pub struct EchoCanceller(Arc>); + + impl Default for EchoCanceller { + fn default() -> Self { + Self(Arc::new(Mutex::new(apm::AudioProcessingModule::new( + true, false, false, false, + )))) + } + } + + impl EchoCanceller { + pub fn process_reverse_stream(&mut self, buf: &mut [i16]) { + self.0 + .lock() + .process_reverse_stream(buf, SAMPLE_RATE.get() as i32, CHANNEL_COUNT.get().into()) + .expect("Audio input and output threads should not panic"); + } + + pub fn process_stream(&mut self, buf: &mut [i16]) -> anyhow::Result<()> { + self.0 + .lock() + .process_stream(buf, SAMPLE_RATE.get() as i32, CHANNEL_COUNT.get() as i32) + .context("livekit audio processor error") + } + } +} + +#[cfg(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd"))] +mod fake_implementation { + #[derive(Clone, Default)] + pub struct EchoCanceller; + + impl EchoCanceller { + pub fn process_reverse_stream(&mut self, _buf: &mut [i16]) {} + pub fn process_stream(&mut self, _buf: &mut [i16]) -> anyhow::Result<()> { + Ok(()) + } + } +} + +#[cfg(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd"))] +pub use fake_implementation::EchoCanceller; +#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] +pub use real_implementation::EchoCanceller; diff --git a/crates/audio/src/replays.rs b/crates/audio/src/audio_pipeline/replays.rs similarity index 97% rename from crates/audio/src/replays.rs rename to crates/audio/src/audio_pipeline/replays.rs index bb21df51e5642bf633d068d544690cb26a239151..3228700b2df5581e862da6ec71787704985386a2 100644 --- a/crates/audio/src/replays.rs +++ b/crates/audio/src/audio_pipeline/replays.rs @@ -8,7 +8,8 @@ use rodio::Source; use smol::fs::File; use std::{io, path::PathBuf, sync::Arc, time::Duration}; -use crate::{REPLAY_DURATION, rodio_ext::Replay}; +use crate::REPLAY_DURATION; +use crate::audio_pipeline::rodio_ext::Replay; #[derive(Default, Clone)] pub(crate) struct Replays(Arc>>); diff --git a/crates/audio/src/rodio_ext.rs b/crates/audio/src/audio_pipeline/rodio_ext.rs similarity index 100% rename from crates/audio/src/rodio_ext.rs rename to crates/audio/src/audio_pipeline/rodio_ext.rs diff --git a/crates/audio/src/audio_settings.rs b/crates/audio/src/audio_settings.rs index 4f60a6d63aef1d2c2d7fb4761a6fc2e2eaf3d8c7..8425ed5eaa713053f44b26e199a66b76bf9b57a6 100644 --- a/crates/audio/src/audio_settings.rs +++ b/crates/audio/src/audio_settings.rs @@ -42,12 +42,8 @@ pub struct AudioSettings { /// /// You need to rejoin a call for this setting to apply pub legacy_audio_compatible: bool, - /// Requires 'rodio_audio: true' - /// /// Select specific output audio device. pub output_audio_device: Option, - /// Requires 'rodio_audio: true' - /// /// Select specific input audio device. pub input_audio_device: Option, } 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/buffer_diff/Cargo.toml b/crates/buffer_diff/Cargo.toml index 06cb6cfa76c66c2d5a7b3b4197566cdef3e0c18c..da18728ed4da5cafc972eb80d4dd93117bcff6ed 100644 --- a/crates/buffer_diff/Cargo.toml +++ b/crates/buffer_diff/Cargo.toml @@ -34,7 +34,7 @@ ztracing.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } rand.workspace = true -serde_json.workspace = true + settings.workspace = true text = { workspace = true, features = ["test-support"] } unindent.workspace = true diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 82ab2736b8bc207aa30952ae9f79f161eb9db8db..c0f62ed8fc1c990b3bb4aaef5fff5ae23bebff86 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -843,6 +843,16 @@ impl BufferDiffInner> { .end .saturating_sub(prev_unstaged_hunk_buffer_end); let index_end = prev_unstaged_hunk_base_text_end + end_overshoot; + + // Clamp to the index text bounds. The overshoot mapping assumes that + // text between unstaged hunks is identical in the buffer and index. + // When the buffer has been edited since the diff was computed, anchor + // positions shift while diff_base_byte_range values don't, which can + // cause index_end to exceed index_text.len(). + // See `test_stage_all_with_stale_buffer` which would hit an assert + // without these min calls + let index_end = index_end.min(index_text.len()); + let index_start = index_start.min(index_end); let index_byte_range = index_start..index_end; let replacement_text = match new_status { @@ -2678,6 +2688,51 @@ mod tests { }); } + #[gpui::test] + async fn test_stage_all_with_stale_buffer(cx: &mut TestAppContext) { + // Regression test for ZED-5R2: when the buffer is edited after the diff is + // computed but before staging, anchor positions shift while diff_base_byte_range + // values don't. If the primary (HEAD) hunk extends past the unstaged (index) + // hunk, an edit in the extension region shifts the primary hunk end without + // shifting the unstaged hunk end. The overshoot calculation then produces an + // index_end that exceeds index_text.len(). + // + // Setup: + // HEAD: "aaa\nbbb\nccc\n" (primary hunk covers lines 1-2) + // Index: "aaa\nbbb\nCCC\n" (unstaged hunk covers line 1 only) + // Buffer: "aaa\nBBB\nCCC\n" (both lines differ from HEAD) + // + // The primary hunk spans buffer offsets 4..12, but the unstaged hunk only + // spans 4..8. The pending hunk extends 4 bytes past the unstaged hunk. + // An edit at offset 9 (inside "CCC") shifts the primary hunk end from 12 + // to 13 but leaves the unstaged hunk end at 8, making index_end = 13 > 12. + let head_text = "aaa\nbbb\nccc\n"; + let index_text = "aaa\nbbb\nCCC\n"; + let buffer_text = "aaa\nBBB\nCCC\n"; + + let mut buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + buffer_text.to_string(), + ); + + let unstaged_diff = cx.new(|cx| BufferDiff::new_with_base_text(index_text, &buffer, cx)); + let uncommitted_diff = cx.new(|cx| { + let mut diff = BufferDiff::new_with_base_text(head_text, &buffer, cx); + diff.set_secondary_diff(unstaged_diff); + diff + }); + + // Edit the buffer in the region between the unstaged hunk end (offset 8) + // and the primary hunk end (offset 12). This shifts the primary hunk end + // but not the unstaged hunk end. + buffer.edit([(9..9, "Z")]); + + uncommitted_diff.update(cx, |diff, cx| { + diff.stage_or_unstage_all_hunks(true, &buffer, true, cx); + }); + } + #[gpui::test] async fn test_toggling_stage_and_unstage_same_hunk(cx: &mut TestAppContext) { let head_text = " diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index 2e46b58b74b826e8892d1e9da28c3cf06c99aa9b..64f741bd588d2227198fda13c0a8fbf5fdb4337c 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -51,5 +51,5 @@ gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } + livekit_client = { workspace = true, features = ["test-support"] } diff --git a/crates/channel/src/channel_buffer.rs b/crates/channel/src/channel_buffer.rs index 8b6f30a3cd3bf1d61f76a9b39c99a7b51a30ea4f..6145b1cf055fae543d68cd982a496d423d987e80 100644 --- a/crates/channel/src/channel_buffer.rs +++ b/crates/channel/src/channel_buffer.rs @@ -221,7 +221,7 @@ impl ChannelBuffer { }) .log_err(); } - language::BufferEvent::Edited => { + language::BufferEvent::Edited { .. } => { cx.emit(ChannelBufferEvent::BufferEdited); } _ => {} 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/client/src/test.rs b/crates/client/src/test.rs index 5102664a8c08ba336f3ae506aadb68eb2a537935..b506cee822ff9c2e4e31f262886a26ac1acbd134 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -1,4 +1,6 @@ -use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore}; +use std::collections::BTreeMap; +use std::sync::Arc; + use anyhow::{Context as _, Result, anyhow}; use cloud_api_client::{ AuthenticatedUser, GetAuthenticatedUserResponse, KnownOrUnknown, Plan, PlanInfo, @@ -9,7 +11,8 @@ use gpui::{AppContext as _, Entity, TestAppContext}; use http_client::{AsyncBody, Method, Request, http}; use parking_lot::Mutex; use rpc::{ConnectionId, Peer, Receipt, TypedEnvelope, proto}; -use std::sync::Arc; + +use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore}; pub struct FakeServer { peer: Arc, @@ -266,6 +269,7 @@ pub fn make_get_authenticated_user_response( }, feature_flags: vec![], organizations: vec![], + plans_by_organization: BTreeMap::new(), plan: PlanInfo { plan: KnownOrUnknown::Known(Plan::ZedPro), subscription_period: None, diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 5d38569cfd86c38e5b4780621db40d1f2a3b745c..71b05dc58f54379f8dfb2ec46d4c280926a56bea 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -3,7 +3,7 @@ use anyhow::{Context as _, Result}; use chrono::{DateTime, Utc}; use cloud_api_client::websocket_protocol::MessageToClient; use cloud_api_client::{ - GetAuthenticatedUserResponse, Organization, OrganizationId, Plan, PlanInfo, + GetAuthenticatedUserResponse, KnownOrUnknown, Organization, OrganizationId, Plan, PlanInfo, }; use cloud_llm_client::{ EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit, @@ -817,6 +817,21 @@ impl UserStore { self.organizations = response.organizations.into_iter().map(Arc::new).collect(); self.current_organization = self.organizations.first().cloned(); + self.plans_by_organization = response + .plans_by_organization + .into_iter() + .map(|(organization_id, plan)| { + let plan = match plan { + KnownOrUnknown::Known(plan) => plan, + KnownOrUnknown::Unknown(_) => { + // If we get a plan that we don't recognize, fall back to the Free plan. + Plan::ZedFree + } + }; + + (organization_id, plan) + }) + .collect(); self.edit_prediction_usage = Some(EditPredictionUsage(RequestUsage { limit: response.plan.usage.edit_predictions.limit, diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 42d3442bfc016f5cb1a39ba421ccdfe386bcbc65..e2c517edcc78e37bc2eab7055c5ac8d79c9db5b2 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -4,6 +4,7 @@ mod plan; mod timestamp; pub mod websocket_protocol; +use std::collections::BTreeMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -21,6 +22,8 @@ pub struct GetAuthenticatedUserResponse { pub feature_flags: Vec, #[serde(default)] pub organizations: Vec, + #[serde(default)] + pub plans_by_organization: BTreeMap>, pub plan: PlanInfo, } @@ -35,7 +38,7 @@ pub struct AuthenticatedUser { pub accepted_tos_at: Option, } -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] pub struct OrganizationId(pub Arc); #[derive(Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/cloud_api_types/src/plan.rs b/crates/cloud_api_types/src/plan.rs index e4a33e3c1933717f642848acc13dcf19b173e902..1f40d1ddb5f0e72871d5ecaee62b884132c158e4 100644 --- a/crates/cloud_api_types/src/plan.rs +++ b/crates/cloud_api_types/src/plan.rs @@ -9,6 +9,7 @@ pub enum Plan { ZedFree, ZedPro, ZedProTrial, + ZedBusiness, ZedStudent, } diff --git a/crates/cloud_llm_client/Cargo.toml b/crates/cloud_llm_client/Cargo.toml index 0f0f2e77360dab0793f5740a24965711f4d80fda..a7b4f925a9302296e8fe25a14177a583e5f44b33 100644 --- a/crates/cloud_llm_client/Cargo.toml +++ b/crates/cloud_llm_client/Cargo.toml @@ -22,6 +22,4 @@ strum = { workspace = true, features = ["derive"] } uuid = { workspace = true, features = ["serde"] } zeta_prompt.workspace = true -[dev-dependencies] -pretty_assertions.workspace = true -indoc.workspace = true + diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 9ed82365ea910dd910226f70e242d68388b41796..d2d25ff5b84ef524f4e573a13149b26fe32fc4a5 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -144,6 +144,8 @@ pub struct AcceptEditPredictionBody { pub request_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub e2e_latency_ms: Option, } #[derive(Debug, Clone, Deserialize)] @@ -164,6 +166,8 @@ pub struct EditPredictionRejection { pub was_shown: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub e2e_latency_ms: Option, } #[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] diff --git a/crates/codestral/Cargo.toml b/crates/codestral/Cargo.toml index 2addcf110a7c8194538523077d09af9d5104bd0d..0daaee8fb1420c76757ca898655e8dd1a5244d7e 100644 --- a/crates/codestral/Cargo.toml +++ b/crates/codestral/Cargo.toml @@ -22,5 +22,6 @@ log.workspace = true serde.workspace = true serde_json.workspace = true text.workspace = true +zeta_prompt.workspace = true [dev-dependencies] diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 32436ecc374bef86e3e9a7587acab72741264796..3930e2e873a91618bfae456bc188bbd90ffa64b9 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -8,7 +8,7 @@ use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task}; use http_client::HttpClient; use icons::IconName; use language::{ - Anchor, Buffer, BufferSnapshot, EditPreview, ToPoint, language_settings::all_language_settings, + Anchor, Buffer, BufferSnapshot, EditPreview, language_settings::all_language_settings, }; use language_model::{ApiKeyState, AuthenticateError, EnvVar, env_var}; use serde::{Deserialize, Serialize}; @@ -18,7 +18,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use text::{OffsetRangeExt as _, ToOffset}; +use text::ToOffset; pub const CODESTRAL_API_URL: &str = "https://codestral.mistral.ai"; pub const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150); @@ -259,28 +259,31 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate { } let cursor_offset = cursor_position.to_offset(&snapshot); - let cursor_point = cursor_offset.to_point(&snapshot); + const MAX_EDITABLE_TOKENS: usize = 350; const MAX_CONTEXT_TOKENS: usize = 150; - const MAX_REWRITE_TOKENS: usize = 350; - - let (_, context_range) = - cursor_excerpt::editable_and_context_ranges_for_cursor_position( - cursor_point, - &snapshot, - MAX_REWRITE_TOKENS, - MAX_CONTEXT_TOKENS, - ); - - let context_range = context_range.to_offset(&snapshot); - let excerpt_text = snapshot - .text_for_range(context_range.clone()) - .collect::(); - let cursor_within_excerpt = cursor_offset + + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + cursor_excerpt::compute_cursor_excerpt(&snapshot, cursor_offset); + let syntax_ranges = cursor_excerpt::compute_syntax_ranges( + &snapshot, + cursor_offset, + &excerpt_offset_range, + ); + let excerpt_text: String = snapshot.text_for_range(excerpt_point_range).collect(); + let (_, context_range) = zeta_prompt::compute_editable_and_context_ranges( + &excerpt_text, + cursor_offset_in_excerpt, + &syntax_ranges, + MAX_EDITABLE_TOKENS, + MAX_CONTEXT_TOKENS, + ); + let context_text = &excerpt_text[context_range.clone()]; + let cursor_within_excerpt = cursor_offset_in_excerpt .saturating_sub(context_range.start) - .min(excerpt_text.len()); - let prompt = excerpt_text[..cursor_within_excerpt].to_string(); - let suffix = excerpt_text[cursor_within_excerpt..].to_string(); + .min(context_text.len()); + let prompt = context_text[..cursor_within_excerpt].to_string(); + let suffix = context_text[cursor_within_excerpt..].to_string(); let completion_text = match Self::fetch_completion( http_client, diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 5db06ef8e73d3cf276f73fbd8aa53e932e6c75b8..447c2da08e054c9964f3813ac569964173ded5c3 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -75,13 +75,13 @@ uuid.workspace = true [dev-dependencies] agent = { workspace = true, features = ["test-support"] } -agent-client-protocol.workspace = true -agent_settings.workspace = true -agent_ui = { workspace = true, features = ["test-support"] } + + + assistant_text_thread.workspace = true assistant_slash_command.workspace = true async-trait.workspace = true -audio.workspace = true + buffer_diff.workspace = true call = { workspace = true, features = ["test-support"] } channel.workspace = true @@ -90,11 +90,11 @@ collab = { workspace = true, features = ["test-support"] } collab_ui = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } command_palette_hooks.workspace = true -context_server.workspace = true + ctor.workspace = true dap = { workspace = true, features = ["test-support"] } dap_adapters = { workspace = true, features = ["test-support"] } -dap-types.workspace = true + debugger_ui = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } extension.workspace = true @@ -105,7 +105,7 @@ git_hosting_providers.workspace = true git_ui = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } gpui_tokio.workspace = true -hyper.workspace = true + indoc.workspace = true language = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } @@ -131,7 +131,7 @@ smol.workspace = true sqlx = { version = "0.8", features = ["sqlite"] } task.workspace = true theme.workspace = true -title_bar = { workspace = true, features = ["test-support"] } + unindent.workspace = true util.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 3e4b5c2ce211f68ef7e12895b542db5e6e3ea47c..75d7dbf194068f78b3d566e54bb0fa18f66a9878 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -109,6 +109,7 @@ CREATE TABLE "project_repositories" ( "head_commit_details" VARCHAR, "remote_upstream_url" VARCHAR, "remote_origin_url" VARCHAR, + "linked_worktrees" VARCHAR, PRIMARY KEY (project_id, id) ); diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 0f4e4f2d2e3925ea1e4d2b964c5e4f159f393b4f..394deaf2c0d6a80a2ab6ab1b95a333081c816e23 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -1,3 +1,6 @@ +-- This file is auto-generated. Do not modify it by hand. +-- To regenerate, run `cargo xtask db dump-schema app --collab` from the Cloud repository. + CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; CREATE TABLE public.breakpoints ( @@ -304,7 +307,8 @@ CREATE TABLE public.project_repositories ( head_commit_details character varying, merge_message character varying, remote_upstream_url character varying, - remote_origin_url character varying + remote_origin_url character varying, + linked_worktrees text ); CREATE TABLE public.project_repository_statuses ( @@ -315,10 +319,10 @@ CREATE TABLE public.project_repository_statuses ( status_kind integer NOT NULL, first_status integer, second_status integer, - lines_added integer, - lines_deleted integer, scan_id bigint NOT NULL, - is_deleted boolean NOT NULL + is_deleted boolean NOT NULL, + lines_added integer, + lines_deleted integer ); CREATE TABLE public.projects ( @@ -706,6 +710,8 @@ CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name CREATE INDEX trigram_index_users_on_github_login ON public.users USING gin (github_login public.gin_trgm_ops); +CREATE INDEX trigram_index_users_on_name ON public.users USING gin (name public.gin_trgm_ops); + CREATE UNIQUE INDEX uix_channels_parent_path_name ON public.channels USING btree (parent_path, name) WHERE ((parent_path IS NOT NULL) AND (parent_path <> ''::text)); CREATE UNIQUE INDEX uix_users_on_github_user_id ON public.users USING btree (github_user_id); @@ -753,7 +759,7 @@ ALTER TABLE ONLY public.contacts ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES public.users(id) ON DELETE CASCADE; ALTER TABLE ONLY public.contributors - ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; ALTER TABLE ONLY public.extension_versions ADD CONSTRAINT extension_versions_extension_id_fkey FOREIGN KEY (extension_id) REFERENCES public.extensions(id); diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 24cf639a715aa9b88da80375b389debaea0c4295..71365fb3846c1dccbf527d76779ed8816bde243b 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -374,6 +374,9 @@ impl Database { merge_message: ActiveValue::set(update.merge_message.clone()), remote_upstream_url: ActiveValue::set(update.remote_upstream_url.clone()), remote_origin_url: ActiveValue::set(update.remote_origin_url.clone()), + linked_worktrees: ActiveValue::Set(Some( + serde_json::to_string(&update.linked_worktrees).unwrap(), + )), }) .on_conflict( OnConflict::columns([ @@ -388,6 +391,7 @@ impl Database { project_repository::Column::CurrentMergeConflicts, project_repository::Column::HeadCommitDetails, project_repository::Column::MergeMessage, + project_repository::Column::LinkedWorktrees, ]) .to_owned(), ) @@ -883,6 +887,11 @@ impl Database { remote_upstream_url: db_repository_entry.remote_upstream_url.clone(), remote_origin_url: db_repository_entry.remote_origin_url.clone(), original_repo_abs_path: Some(db_repository_entry.abs_path), + linked_worktrees: db_repository_entry + .linked_worktrees + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(), }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index b4cbd83167b227542d8de1022b7e2cf49f5a7645..3197d142cba7a1969e6fdb9423dc94497f6ca53c 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -799,6 +799,11 @@ impl Database { remote_upstream_url: db_repository.remote_upstream_url.clone(), remote_origin_url: db_repository.remote_origin_url.clone(), original_repo_abs_path: Some(db_repository.abs_path), + linked_worktrees: db_repository + .linked_worktrees + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(), }); } } diff --git a/crates/collab/src/db/tables/project_repository.rs b/crates/collab/src/db/tables/project_repository.rs index 190ae8d79c54bb78daef4a1568ec75683eb0b0f2..33b20817e61a137285e27525eb5b2a221d3cfd9e 100644 --- a/crates/collab/src/db/tables/project_repository.rs +++ b/crates/collab/src/db/tables/project_repository.rs @@ -24,6 +24,8 @@ pub struct Model { pub head_commit_details: Option, pub remote_upstream_url: Option, pub remote_origin_url: Option, + // JSON array of linked worktree objects + pub linked_worktrees: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index b521f6b083ae311d98ec46c900ce821fd8042e4a..6c05bd4e535df0235f708af0272b2eae71581fa2 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -439,6 +439,8 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(disallow_guest_request::) + .add_request_handler(disallow_guest_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) @@ -2250,6 +2252,24 @@ where Ok(()) } +async fn disallow_guest_request( + _request: T, + response: Response, + _session: MessageContext, +) -> Result<()> +where + T: RequestMessage, +{ + response.peer.respond_with_error( + response.receipt, + ErrorCode::Forbidden + .message("request is not allowed for guests".to_string()) + .to_proto(), + )?; + response.responded.store(true, SeqCst); + Ok(()) +} + async fn lsp_query( request: proto::LspQuery, response: Response, diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 0d0569182d5a9ff235642d61c39f0b5bc15b6cb0..1590f498308c74125c7672595cb7510b6653e9b1 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -4721,6 +4721,54 @@ async fn test_copy_file_location(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo cx_b.read_from_clipboard().and_then(|item| item.text()), Some(format!("{}:2", path!("src/main.rs"))) ); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(44)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2-3", path!("src/main.rs"))) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(44)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2-3", path!("src/main.rs"))) + ); + + editor_a.update_in(cx_a, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(43)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_a.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2", path!("src/main.rs"))) + ); + + editor_b.update_in(cx_b, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(43)]); + }); + editor.copy_file_location(&CopyFileLocation, window, cx); + }); + + assert_eq!( + cx_b.read_from_clipboard().and_then(|item| item.text()), + Some(format!("{}:2", path!("src/main.rs"))) + ); } #[track_caller] @@ -5643,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(); @@ -5679,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/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index dccc99a07769e66a3eb318a8201d8e14a29ef4f2..fc20150d662b96be9b6ad4f99ae1f33032b6fb7b 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -1,9 +1,10 @@ use std::path::{Path, PathBuf}; use call::ActiveCall; +use client::RECEIVE_TIMEOUT; use collections::HashMap; use git::{ - repository::RepoPath, + repository::{RepoPath, Worktree as GitWorktree}, status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff}; @@ -302,6 +303,297 @@ async fn test_remote_git_worktrees( worktree_directory.join("bugfix-branch") ); assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha"); + + // Client B (guest) attempts to rename a worktree. This should fail + // because worktree renaming is not forwarded through collab + let rename_result = cx_b + .update(|cx| { + repo_b.update(cx, |repository, _| { + repository.rename_worktree( + worktree_directory.join("feature-branch"), + worktree_directory.join("renamed-branch"), + ) + }) + }) + .await + .unwrap(); + assert!( + rename_result.is_err(), + "Guest should not be able to rename worktrees via collab" + ); + + executor.run_until_parked(); + + // Verify worktrees are unchanged — still 3 + let worktrees = cx_b + .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!( + worktrees.len(), + 3, + "Worktree count should be unchanged after failed rename" + ); + + // Client B (guest) attempts to remove a worktree. This should fail + // because worktree removal is not forwarded through collab + let remove_result = cx_b + .update(|cx| { + repo_b.update(cx, |repository, _| { + repository.remove_worktree(worktree_directory.join("feature-branch"), false) + }) + }) + .await + .unwrap(); + assert!( + remove_result.is_err(), + "Guest should not be able to remove worktrees via collab" + ); + + executor.run_until_parked(); + + // Verify worktrees are unchanged — still 3 + let worktrees = cx_b + .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!( + worktrees.len(), + 3, + "Worktree count should be unchanged after failed removal" + ); +} + +#[gpui::test] +async fn test_linked_worktrees_sync( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + // Set up a git repo with two linked worktrees already present. + client_a + .fs() + .insert_tree( + path!("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), true, |state| { + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project")), + ref_name: "refs/heads/main".into(), + sha: "aaa111".into(), + }); + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project/feature-branch")), + ref_name: "refs/heads/feature-branch".into(), + sha: "bbb222".into(), + }); + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project/bugfix-branch")), + ref_name: "refs/heads/bugfix-branch".into(), + sha: "ccc333".into(), + }); + }) + .unwrap(); + + let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await; + + // Wait for git scanning to complete on the host. + executor.run_until_parked(); + + // Verify the host sees 2 linked worktrees (main worktree is filtered out). + let host_linked = project_a.read_with(cx_a, |project, cx| { + let repos = project.repositories(cx); + assert_eq!(repos.len(), 1, "host should have exactly 1 repository"); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + host_linked.len(), + 2, + "host should have 2 linked worktrees (main filtered out)" + ); + assert_eq!( + host_linked[0].path, + PathBuf::from(path!("/project/feature-branch")) + ); + assert_eq!( + host_linked[0].ref_name.as_ref(), + "refs/heads/feature-branch" + ); + assert_eq!(host_linked[0].sha.as_ref(), "bbb222"); + assert_eq!( + host_linked[1].path, + PathBuf::from(path!("/project/bugfix-branch")) + ); + assert_eq!(host_linked[1].ref_name.as_ref(), "refs/heads/bugfix-branch"); + assert_eq!(host_linked[1].sha.as_ref(), "ccc333"); + + // Share the project and have client B join. + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + executor.run_until_parked(); + + // Verify the guest sees the same linked worktrees as the host. + let guest_linked = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + assert_eq!(repos.len(), 1, "guest should have exactly 1 repository"); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked, host_linked, + "guest's linked_worktrees should match host's after initial sync" + ); + + // Now mutate: add a third linked worktree on the host side. + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), true, |state| { + state.worktrees.push(GitWorktree { + path: PathBuf::from(path!("/project/hotfix-branch")), + ref_name: "refs/heads/hotfix-branch".into(), + sha: "ddd444".into(), + }); + }) + .unwrap(); + + // Wait for the host to re-scan and propagate the update. + executor.run_until_parked(); + + // Verify host now sees 3 linked worktrees. + let host_linked_updated = project_a.read_with(cx_a, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + host_linked_updated.len(), + 3, + "host should now have 3 linked worktrees" + ); + assert_eq!( + host_linked_updated[2].path, + PathBuf::from(path!("/project/hotfix-branch")) + ); + + // Verify the guest also received the update. + let guest_linked_updated = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked_updated, host_linked_updated, + "guest's linked_worktrees should match host's after update" + ); + + // Now mutate: remove one linked worktree from the host side. + client_a + .fs() + .with_git_state(Path::new(path!("/project/.git")), true, |state| { + state + .worktrees + .retain(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch"); + }) + .unwrap(); + + executor.run_until_parked(); + + // Verify host now sees 2 linked worktrees (feature-branch and hotfix-branch). + let host_linked_after_removal = project_a.read_with(cx_a, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + host_linked_after_removal.len(), + 2, + "host should have 2 linked worktrees after removal" + ); + assert!( + host_linked_after_removal + .iter() + .all(|wt| wt.ref_name.as_ref() != "refs/heads/bugfix-branch"), + "bugfix-branch should have been removed" + ); + + // Verify the guest also reflects the removal. + let guest_linked_after_removal = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked_after_removal, host_linked_after_removal, + "guest's linked_worktrees should match host's after removal" + ); + + // Test DB roundtrip: client C joins late, getting state from the database. + // This verifies that linked_worktrees are persisted and restored correctly. + let project_c = client_c.join_remote_project(project_id, cx_c).await; + executor.run_until_parked(); + + let late_joiner_linked = project_c.read_with(cx_c, |project, cx| { + let repos = project.repositories(cx); + assert_eq!( + repos.len(), + 1, + "late joiner should have exactly 1 repository" + ); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + late_joiner_linked, host_linked_after_removal, + "late-joining client's linked_worktrees should match host's (DB roundtrip)" + ); + + // Test reconnection: disconnect client B (guest) and reconnect. + // After rejoining, client B should get linked_worktrees back from the DB. + server.disconnect_client(client_b.peer_id().unwrap()); + executor.advance_clock(RECEIVE_TIMEOUT); + executor.run_until_parked(); + + // Client B reconnects automatically. + executor.advance_clock(RECEIVE_TIMEOUT); + executor.run_until_parked(); + + // Verify client B still has the correct linked worktrees after reconnection. + let guest_linked_after_reconnect = project_b.read_with(cx_b, |project, cx| { + let repos = project.repositories(cx); + assert_eq!( + repos.len(), + 1, + "guest should still have exactly 1 repository after reconnect" + ); + let repo = repos.values().next().unwrap(); + repo.read(cx).linked_worktrees().to_vec() + }); + assert_eq!( + guest_linked_after_reconnect, host_linked_after_removal, + "guest's linked_worktrees should survive guest disconnect/reconnect" + ); } #[gpui::test] diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index 6825c468e783ee8d3a2a6107a031accfc108abd0..ceb7db145970b52d23a6ef7ace82cd84acf1e840 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -518,6 +518,122 @@ async fn test_ssh_collaboration_git_worktrees( server_worktrees[1].path, worktree_directory.join("feature-branch") ); + + // Host (client A) renames the worktree via SSH + let repo_a = cx_a.update(|cx| { + project_a + .read(cx) + .repositories(cx) + .values() + .next() + .unwrap() + .clone() + }); + cx_a.update(|cx| { + repo_a.update(cx, |repository, _| { + repository.rename_worktree( + PathBuf::from("/project/feature-branch"), + PathBuf::from("/project/renamed-branch"), + ) + }) + }) + .await + .unwrap() + .unwrap(); + + executor.run_until_parked(); + + let host_worktrees = cx_a + .update(|cx| repo_a.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!( + host_worktrees.len(), + 2, + "Host should still have 2 worktrees after rename" + ); + assert_eq!( + host_worktrees[1].path, + PathBuf::from("/project/renamed-branch") + ); + + let server_worktrees = { + let server_repo = server_cx.update(|cx| { + headless_project.update(cx, |headless_project, cx| { + headless_project + .git_store + .read(cx) + .repositories() + .values() + .next() + .unwrap() + .clone() + }) + }); + server_cx + .update(|cx| server_repo.update(cx, |repo, _| repo.worktrees())) + .await + .unwrap() + .unwrap() + }; + assert_eq!( + server_worktrees.len(), + 2, + "Server should still have 2 worktrees after rename" + ); + assert_eq!( + server_worktrees[1].path, + PathBuf::from("/project/renamed-branch") + ); + + // Host (client A) removes the renamed worktree via SSH + cx_a.update(|cx| { + repo_a.update(cx, |repository, _| { + repository.remove_worktree(PathBuf::from("/project/renamed-branch"), false) + }) + }) + .await + .unwrap() + .unwrap(); + + executor.run_until_parked(); + + let host_worktrees = cx_a + .update(|cx| repo_a.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!( + host_worktrees.len(), + 1, + "Host should only have the main worktree after removal" + ); + + let server_worktrees = { + let server_repo = server_cx.update(|cx| { + headless_project.update(cx, |headless_project, cx| { + headless_project + .git_store + .read(cx) + .repositories() + .values() + .next() + .unwrap() + .clone() + }) + }); + server_cx + .update(|cx| server_repo.update(cx, |repo, _| repo.worktrees())) + .await + .unwrap() + .unwrap() + }; + assert_eq!( + server_worktrees.len(), + 1, + "Server should only have the main worktree after removal" + ); } #[gpui::test] diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index c996e3821fee17dbea99f660304e0b76b6e9bc28..0ac413d1863dbbcdbcd81ad2bb3907f7a370c866 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -24,7 +24,7 @@ test-support = [ "settings/test-support", "util/test-support", "workspace/test-support", - "http_client/test-support", + "title_bar/test-support", ] @@ -67,11 +67,11 @@ collections = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } notifications = { workspace = true, features = ["test-support"] } -pretty_assertions.workspace = true + project = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } -tree-sitter-md.workspace = true + util = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } + workspace = { workspace = true, features = ["test-support"] } 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..308d521832d5f2964a46f32e88329bd15d5358ee 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({ @@ -679,6 +677,9 @@ impl Panel for NotificationPanel { } fn icon_label(&self, _window: &Window, cx: &App) -> Option { + if !NotificationPanelSettings::get_global(cx).show_count_badge { + return None; + } let count = self.notification_store.read(cx).unread_notification_count(); if count == 0 { None diff --git a/crates/collab_ui/src/panel_settings.rs b/crates/collab_ui/src/panel_settings.rs index ebd021be4b56f4051feae01f3fef7a063c3a8214..938d33159e9adb7a9e63ceb73219b70724efee17 100644 --- a/crates/collab_ui/src/panel_settings.rs +++ b/crates/collab_ui/src/panel_settings.rs @@ -15,6 +15,7 @@ pub struct NotificationPanelSettings { pub button: bool, pub dock: DockPosition, pub default_width: Pixels, + pub show_count_badge: bool, } impl Settings for CollaborationPanelSettings { @@ -36,6 +37,7 @@ impl Settings for NotificationPanelSettings { button: panel.button.unwrap(), dock: panel.dock.unwrap().into(), default_width: panel.default_width.map(px).unwrap(), + show_count_badge: panel.show_count_badge.unwrap(), }; } } diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index bd86c10a8071896f0b24ea531d354c0e46114d48..96be6cb9ee2b767bc14503cbae7e2de6838e6724 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -38,14 +38,14 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -ctor.workspace = true + db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } -env_logger.workspace = true + go_to_line.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } menu.workspace = true project = { workspace = true, features = ["test-support"] } -serde_json.workspace = true + workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 236216a8d9a64f736c76399867f0b8766c93c16b..d625c998b034a249cb3f498ae1fdd4e0e179a4cc 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -52,14 +52,10 @@ workspace.workspace = true async-std = { version = "1.12.0", features = ["unstable"] } [dev-dependencies] -client = { workspace = true, features = ["test-support"] } -clock = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } -ctor.workspace = true editor = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } indoc.workspace = true language = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 179e217d207554bcf226ce905aa9226c1c334b72..4a08cf2803aaa51a86d5dc7017c559bee1184c2e 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -949,7 +949,7 @@ impl Copilot { && let Some(registered_buffer) = server.registered_buffers.get_mut(&buffer.entity_id()) { match event { - language::BufferEvent::Edited => { + language::BufferEvent::Edited { .. } => { drop(registered_buffer.report_changes(&buffer, cx)); } language::BufferEvent::Saved => { @@ -1779,6 +1779,7 @@ mod tests { fn disk_state(&self) -> language::DiskState { language::DiskState::Present { mtime: ::fs::MTime::from_seconds_and_nanos(100, 42), + size: 0, } } 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/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index 60af963ee5520addedcfe9abdf41941e77922867..9f18088b0ec2e709ff420b8e107e61dd7424e643 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -350,8 +350,34 @@ impl minidumper::ServerHandler for CrashServer { } } +/// Rust's string-slicing panics embed the user's string content in the message, +/// e.g. "byte index 4 is out of bounds of `a`". Strip that suffix so we +/// don't upload arbitrary user text in crash reports. +fn strip_user_string_from_panic(message: &str) -> String { + const STRING_PANIC_PREFIXES: &[&str] = &[ + // Older rustc (pre-1.95): + "byte index ", + "begin <= end (", + // Newer rustc (1.95+): + // https://github.com/rust-lang/rust/pull/145024 + "start byte index ", + "end byte index ", + "begin > end (", + ]; + + if (message.ends_with('`') || message.ends_with("`[...]")) + && STRING_PANIC_PREFIXES + .iter() + .any(|prefix| message.starts_with(prefix)) + && let Some(open) = message.find('`') + { + return format!("{} ``", &message[..open]); + } + message.to_owned() +} + pub fn panic_hook(info: &PanicHookInfo) { - let message = info.payload_as_str().unwrap_or("Box").to_owned(); + let message = strip_user_string_from_panic(info.payload_as_str().unwrap_or("Box")); let span = info .location() diff --git a/crates/dap/Cargo.toml b/crates/dap/Cargo.toml index d856ae0164ff35236f7a133361cdf28908f8b044..a1b107eb42ac44e95b84f4b5bfd1f0871cfcfc93 100644 --- a/crates/dap/Cargo.toml +++ b/crates/dap/Cargo.toml @@ -58,7 +58,6 @@ async-pipe.workspace = true gpui = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } task = { workspace = true, features = ["test-support"] } -tree-sitter.workspace = true -tree-sitter-go.workspace = true + util = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/dap_adapters/src/go.rs b/crates/dap_adapters/src/go.rs index af81f5cca5390d7e72e1805331e25da0a036d9d8..93d0e8a958568cd7899208daca05a9c1dd2f846b 100644 --- a/crates/dap_adapters/src/go.rs +++ b/crates/dap_adapters/src/go.rs @@ -8,6 +8,7 @@ use dap::{ }, }; use fs::Fs; +use futures::StreamExt; use gpui::{AsyncApp, SharedString}; use language::LanguageName; use log::warn; @@ -71,27 +72,59 @@ impl GoDebugAdapter { return Ok(path); } - let asset = Self::fetch_latest_adapter_version(delegate).await?; - let ty = if consts::OS == "windows" { - DownloadedFileType::Zip - } else { - DownloadedFileType::GzipTar - }; - download_adapter_from_github( - "delve-shim-dap".into(), - asset.clone(), - ty, - delegate.as_ref(), - ) - .await?; + let adapter_dir = paths::debug_adapters_dir().join("delve-shim-dap"); + + match Self::fetch_latest_adapter_version(delegate).await { + Ok(asset) => { + let ty = if consts::OS == "windows" { + DownloadedFileType::Zip + } else { + DownloadedFileType::GzipTar + }; + download_adapter_from_github( + "delve-shim-dap".into(), + asset.clone(), + ty, + delegate.as_ref(), + ) + .await?; + + let path = adapter_dir + .join(format!("delve-shim-dap_{}", asset.tag_name)) + .join(format!("delve-shim-dap{}", consts::EXE_SUFFIX)); + self.shim_path.set(path.clone()).ok(); - let path = paths::debug_adapters_dir() - .join("delve-shim-dap") - .join(format!("delve-shim-dap_{}", asset.tag_name)) - .join(format!("delve-shim-dap{}", std::env::consts::EXE_SUFFIX)); - self.shim_path.set(path.clone()).ok(); + Ok(path) + } + Err(error) => { + let binary_name = format!("delve-shim-dap{}", consts::EXE_SUFFIX); + let mut cached = None; + if let Ok(mut entries) = delegate.fs().read_dir(&adapter_dir).await { + while let Some(entry) = entries.next().await { + if let Ok(version_dir) = entry { + let candidate = version_dir.join(&binary_name); + if delegate + .fs() + .metadata(&candidate) + .await + .is_ok_and(|m| m.is_some()) + { + cached = Some(candidate); + break; + } + } + } + } - Ok(path) + if let Some(path) = cached { + warn!("Failed to fetch latest delve-shim-dap, using cached version: {error:#}"); + self.shim_path.set(path.clone()).ok(); + Ok(path) + } else { + Err(error) + } + } + } } } diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index 96bdde12672cae471edf1d6e603a06413d1f4b21..111eab5a1d1bf4dea5f99ce83c01ce8fdb9e47e3 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -224,16 +224,27 @@ impl PythonDebugAdapter { ) -> Result, String> { self.debugpy_whl_base_path .get_or_init(|| async move { - self.maybe_fetch_new_wheel(toolchain, delegate) - .await - .map_err(|e| format!("{e}"))?; - Ok(Arc::from( - debug_adapters_dir() - .join(Self::ADAPTER_NAME) - .join("debugpy") - .join("adapter") - .as_ref(), - )) + let adapter_path = debug_adapters_dir() + .join(Self::ADAPTER_NAME) + .join("debugpy") + .join("adapter"); + + if let Err(error) = self.maybe_fetch_new_wheel(toolchain, delegate).await { + if delegate + .fs() + .metadata(&adapter_path) + .await + .is_ok_and(|m| m.is_some()) + { + log::warn!( + "Failed to fetch latest debugpy, using cached version: {error:#}" + ); + } else { + return Err(format!("{error}")); + } + } + + Ok(Arc::from(adapter_path.as_ref())) }) .await .clone() 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/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs index c183f8941c3f30cb43ffaa638eae4e6b387e226d..cc407dfd810ceedb11c4d8030c46a6f17065b34b 100644 --- a/crates/debugger_ui/src/tests.rs +++ b/crates/debugger_ui/src/tests.rs @@ -132,7 +132,13 @@ pub fn start_debug_session_with) + 'static>( .workspace() .read(cx) .panel::(cx) - .and_then(|panel| panel.read(cx).active_session()) + .and_then(|panel| { + panel + .read(cx) + .sessions_with_children + .keys() + .max_by_key(|session| session.read(cx).session_id(cx)) + }) .map(|session| session.read(cx).running_state().read(cx).session()) .cloned() .context("Failed to get active session") diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index 207e82b4958941e04ea04fc47c9471141e61a64d..e4c258a8d2af0b865f13c28430c44a66117a11cd 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -27,7 +27,7 @@ use std::{ path::Path, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }, }; use terminal_view::terminal_panel::TerminalPanel; @@ -2481,3 +2481,75 @@ async fn test_adapter_shutdown_with_child_sessions_on_app_quit( "Child session should have received disconnect request" ); } + +#[gpui::test] +async fn test_restart_request_is_not_sent_more_than_once_until_response( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + path!("/project"), + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let session = start_debug_session(&workspace, cx, move |client| { + client.on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_restart_request: Some(true), + ..Default::default() + }) + }); + }) + .unwrap(); + + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + let restart_count = Arc::new(AtomicUsize::new(0)); + + client.on_request::({ + let restart_count = restart_count.clone(); + move |_, _| { + restart_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + }); + + // This works because the restart request sender is on the foreground thread + // so it will start running after the gpui update stack is cleared + session.update(cx, |session, cx| { + session.restart(None, cx); + session.restart(None, cx); + session.restart(None, cx); + }); + + cx.run_until_parked(); + + assert_eq!( + restart_count.load(Ordering::SeqCst), + 1, + "Only one restart request should be sent while a restart is in-flight" + ); + + session.update(cx, |session, cx| { + session.restart(None, cx); + }); + + cx.run_until_parked(); + + assert_eq!( + restart_count.load(Ordering::SeqCst), + 2, + "A second restart should be allowed after the first one completes" + ); +} diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 1f5ac5dea4a19af338feceaa2ee51fd9322fa9a5..9a9a9316fb09def438f78734831c5e560c838fba 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1211,7 +1211,9 @@ async fn test_stack_frame_filter_persistence( cx.run_until_parked(); let workspace_id = workspace - .update(cx, |workspace, _window, cx| workspace.database_id(cx)) + .update(cx, |workspace, _window, cx| { + workspace.active_workspace_database_id(cx) + }) .ok() .flatten() .expect("workspace id has to be some for this test to work properly"); diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml index 7b1574da69729a8ff5ddeb5523a8c249779a721b..e3a67601c3837bd9579a477576e9c837f73c1e75 100644 --- a/crates/dev_container/Cargo.toml +++ b/crates/dev_container/Cargo.toml @@ -29,7 +29,7 @@ gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } -theme.workspace = true + workspace = { workspace = true, features = ["test-support"] } worktree = { workspace = true, features = ["test-support"] } diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index a5328a1a6dd2e492dc4fb38a963b68a84d98cc03..09ee023d57fbb9b9f2c7d828f9b2ea25f73d23d9 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -38,7 +38,7 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -client = { workspace = true, features = ["test-support"] } + editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 920bf4bc880c347c640d3dbf7106f3545bba3444..89cebf8fb237a032866e14c36d3097e18388e6ab 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -297,7 +297,7 @@ impl DiagnosticBlock { return; }; - for (excerpt_id, range) in multibuffer.excerpts_for_buffer(buffer_id, cx) { + for (excerpt_id, _, range) in multibuffer.excerpts_for_buffer(buffer_id, cx) { if range.context.overlaps(&diagnostic.range, &snapshot) { Self::jump_to( editor, diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 57ce6f03d2b56c9441bee763a28dcc7010f8311e..b200d01669a90c1e439338b9b01118cce8b8bb0c 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -583,7 +583,7 @@ impl ProjectDiagnosticsEditor { RetainExcerpts::All | RetainExcerpts::Dirty => multi_buffer .excerpts_for_buffer(buffer_id, cx) .into_iter() - .map(|(_, range)| range) + .map(|(_, _, range)| range) .sorted_by(|a, b| cmp_excerpts(&buffer_snapshot, a, b)) .collect(), } diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index b4ca52ea7239b6e4e76160a475d703ddd2933f44..67a6877bbe95778815d9470c0d9c8360657328f3 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -28,7 +28,7 @@ pub struct DiagnosticIndicator { impl Render for DiagnosticIndicator { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let indicator = h_flex().gap_2(); + let indicator = h_flex().gap_2().min_w_0().overflow_x_hidden(); if !ProjectSettings::get_global(cx).diagnostics.button { return indicator.hidden(); } @@ -67,6 +67,7 @@ impl Render for DiagnosticIndicator { Some( Button::new("diagnostic_message", SharedString::new(message)) .label_size(LabelSize::Small) + .truncate(true) .tooltip(|_window, cx| { Tooltip::for_action( "Next Diagnostic", diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 9f867584b57c8aed86f7003cca3a2b034c184476..d2a23b8b4ec3425072ffbe9d042ff89d26a56778 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -82,5 +82,5 @@ parking_lot.workspace = true project = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } -tree-sitter-rust.workspace = true + zlog.workspace = true diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs index e0df8cf957747256f86fe5d7f0d63d2ec873d9ca..d21df7868162d279cb18aeea3ef04d4ea9d7be7f 100644 --- a/crates/edit_prediction/src/capture_example.rs +++ b/crates/edit_prediction/src/capture_example.rs @@ -1,12 +1,9 @@ -use crate::{ - StoredEvent, cursor_excerpt::editable_and_context_ranges_for_cursor_position, - example_spec::ExampleSpec, -}; +use crate::{StoredEvent, example_spec::ExampleSpec}; use anyhow::Result; use buffer_diff::BufferDiffSnapshot; use collections::HashMap; use gpui::{App, Entity, Task}; -use language::{Buffer, ToPoint as _}; +use language::Buffer; use project::{Project, WorktreeId}; use std::{collections::hash_map, fmt::Write as _, ops::Range, path::Path, sync::Arc}; use text::{BufferSnapshot as TextBufferSnapshot, Point}; @@ -157,17 +154,34 @@ fn compute_cursor_excerpt( cursor_anchor: language::Anchor, ) -> (String, usize, Range) { use text::ToOffset as _; + use text::ToPoint as _; - let cursor_point = cursor_anchor.to_point(snapshot); - let (_editable_range, context_range) = - editable_and_context_ranges_for_cursor_position(cursor_point, snapshot, 100, 50); - let context_start_offset = context_range.start.to_offset(snapshot); let cursor_offset = cursor_anchor.to_offset(snapshot); - let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset); - let excerpt = snapshot - .text_for_range(context_range.clone()) - .collect::(); - (excerpt, cursor_offset_in_excerpt, context_range) + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + crate::cursor_excerpt::compute_cursor_excerpt(snapshot, cursor_offset); + let syntax_ranges = crate::cursor_excerpt::compute_syntax_ranges( + snapshot, + cursor_offset, + &excerpt_offset_range, + ); + let excerpt_text: String = snapshot.text_for_range(excerpt_point_range).collect(); + let (_, context_range) = zeta_prompt::compute_editable_and_context_ranges( + &excerpt_text, + cursor_offset_in_excerpt, + &syntax_ranges, + 100, + 50, + ); + let context_text = excerpt_text[context_range.clone()].to_string(); + let cursor_in_context = cursor_offset_in_excerpt.saturating_sub(context_range.start); + let context_buffer_start = + (excerpt_offset_range.start + context_range.start).to_point(snapshot); + let context_buffer_end = (excerpt_offset_range.start + context_range.end).to_point(snapshot); + ( + context_text, + cursor_in_context, + context_buffer_start..context_buffer_end, + ) } async fn collect_snapshots( diff --git a/crates/edit_prediction/src/cursor_excerpt.rs b/crates/edit_prediction/src/cursor_excerpt.rs index 690e7001bd45ab3d9a995b4dfd43c2e8e297dbe9..2badcab07a90fd1c96634b4de1581758afc95deb 100644 --- a/crates/edit_prediction/src/cursor_excerpt.rs +++ b/crates/edit_prediction/src/cursor_excerpt.rs @@ -1,107 +1,140 @@ -use language::{BufferSnapshot, Point}; +use language::{BufferSnapshot, Point, ToPoint as _}; use std::ops::Range; use text::OffsetRangeExt as _; -use zeta_prompt::ExcerptRanges; -/// Computes all range variants for a cursor position: editable ranges at 150, 180, and 350 -/// token budgets, plus their corresponding context expansions. Returns the full excerpt range -/// (union of all context ranges) and the individual sub-ranges as Points. -pub fn compute_excerpt_ranges( - position: Point, +const CURSOR_EXCERPT_TOKEN_BUDGET: usize = 8192; + +/// Computes a cursor excerpt as the largest linewise symmetric region around +/// the cursor that fits within an 8192-token budget. Returns the point range, +/// byte offset range, and the cursor offset relative to the excerpt start. +pub fn compute_cursor_excerpt( snapshot: &BufferSnapshot, -) -> (Range, Range, ExcerptRanges) { - let editable_150 = compute_editable_range(snapshot, position, 150); - let editable_180 = compute_editable_range(snapshot, position, 180); - let editable_350 = compute_editable_range(snapshot, position, 350); - let editable_512 = compute_editable_range(snapshot, position, 512); - - let editable_150_context_350 = - expand_context_syntactically_then_linewise(snapshot, editable_150.clone(), 350); - let editable_180_context_350 = - expand_context_syntactically_then_linewise(snapshot, editable_180.clone(), 350); - let editable_350_context_150 = - expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 150); - let editable_350_context_512 = - expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 512); - let editable_350_context_1024 = - expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 1024); - let context_4096 = expand_context_syntactically_then_linewise( - snapshot, - editable_350_context_1024.clone(), - 4096 - 1024, - ); - let context_8192 = - expand_context_syntactically_then_linewise(snapshot, context_4096.clone(), 8192 - 4096); - - let full_start_row = context_8192.start.row; - let full_end_row = context_8192.end.row; - - let full_context = - Point::new(full_start_row, 0)..Point::new(full_end_row, snapshot.line_len(full_end_row)); - - let full_context_offset_range = full_context.to_offset(snapshot); - - let to_offset = |range: &Range| -> Range { - let start = range.start.to_offset(snapshot); - let end = range.end.to_offset(snapshot); - (start - full_context_offset_range.start)..(end - full_context_offset_range.start) - }; - - let ranges = ExcerptRanges { - editable_150: to_offset(&editable_150), - editable_180: to_offset(&editable_180), - editable_350: to_offset(&editable_350), - editable_512: Some(to_offset(&editable_512)), - editable_150_context_350: to_offset(&editable_150_context_350), - editable_180_context_350: to_offset(&editable_180_context_350), - editable_350_context_150: to_offset(&editable_350_context_150), - editable_350_context_512: Some(to_offset(&editable_350_context_512)), - editable_350_context_1024: Some(to_offset(&editable_350_context_1024)), - context_4096: Some(to_offset(&context_4096)), - context_8192: Some(to_offset(&context_8192)), - }; - - (full_context, full_context_offset_range, ranges) + cursor_offset: usize, +) -> (Range, Range, usize) { + let cursor_point = cursor_offset.to_point(snapshot); + let cursor_row = cursor_point.row; + let (start_row, end_row, _) = + expand_symmetric_from_cursor(snapshot, cursor_row, CURSOR_EXCERPT_TOKEN_BUDGET); + + let excerpt_range = Point::new(start_row, 0)..Point::new(end_row, snapshot.line_len(end_row)); + let excerpt_offset_range = excerpt_range.to_offset(snapshot); + let cursor_offset_in_excerpt = cursor_offset - excerpt_offset_range.start; + + ( + excerpt_range, + excerpt_offset_range, + cursor_offset_in_excerpt, + ) } -pub fn editable_and_context_ranges_for_cursor_position( - position: Point, +/// Expands symmetrically from cursor, one line at a time, alternating down then up. +/// Returns (start_row, end_row, remaining_tokens). +fn expand_symmetric_from_cursor( snapshot: &BufferSnapshot, - editable_region_token_limit: usize, - context_token_limit: usize, -) -> (Range, Range) { - let editable_range = compute_editable_range(snapshot, position, editable_region_token_limit); + cursor_row: u32, + mut token_budget: usize, +) -> (u32, u32, usize) { + let mut start_row = cursor_row; + let mut end_row = cursor_row; + + let cursor_line_tokens = line_token_count(snapshot, cursor_row); + token_budget = token_budget.saturating_sub(cursor_line_tokens); + + loop { + let can_expand_up = start_row > 0; + let can_expand_down = end_row < snapshot.max_point().row; + + if token_budget == 0 || (!can_expand_up && !can_expand_down) { + break; + } - let context_range = expand_context_syntactically_then_linewise( - snapshot, - editable_range.clone(), - context_token_limit, - ); + if can_expand_down { + let next_row = end_row + 1; + let line_tokens = line_token_count(snapshot, next_row); + if line_tokens <= token_budget { + end_row = next_row; + token_budget = token_budget.saturating_sub(line_tokens); + } else { + break; + } + } - (editable_range, context_range) + if can_expand_up && token_budget > 0 { + let next_row = start_row - 1; + let line_tokens = line_token_count(snapshot, next_row); + if line_tokens <= token_budget { + start_row = next_row; + token_budget = token_budget.saturating_sub(line_tokens); + } else { + break; + } + } + } + + (start_row, end_row, token_budget) +} + +/// Typical number of string bytes per token for the purposes of limiting model input. This is +/// intentionally low to err on the side of underestimating limits. +pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3; + +pub fn guess_token_count(bytes: usize) -> usize { + bytes / BYTES_PER_TOKEN_GUESS } -/// Computes the editable range using a three-phase approach: -/// 1. Expand symmetrically from cursor (75% of budget) -/// 2. Expand to syntax boundaries -/// 3. Continue line-wise in the least-expanded direction -fn compute_editable_range( +fn line_token_count(snapshot: &BufferSnapshot, row: u32) -> usize { + guess_token_count(snapshot.line_len(row) as usize).max(1) +} + +/// Computes the byte offset ranges of all syntax nodes containing the cursor, +/// ordered from innermost to outermost. The offsets are relative to +/// `excerpt_offset_range.start`. +pub fn compute_syntax_ranges( snapshot: &BufferSnapshot, - cursor: Point, - token_limit: usize, -) -> Range { - // Phase 1: Expand symmetrically from cursor using 75% of budget. - let initial_budget = (token_limit * 3) / 4; - let (mut start_row, mut end_row, mut remaining_tokens) = - expand_symmetric_from_cursor(snapshot, cursor.row, initial_budget); + cursor_offset: usize, + excerpt_offset_range: &Range, +) -> Vec> { + let cursor_point = cursor_offset.to_point(snapshot); + let range = cursor_point..cursor_point; + let mut current = snapshot.syntax_ancestor(range); + let mut ranges = Vec::new(); + let mut last_range: Option<(usize, usize)> = None; - // Add remaining budget from phase 1. - remaining_tokens += token_limit.saturating_sub(initial_budget); + while let Some(node) = current.take() { + let node_start = node.start_byte(); + let node_end = node.end_byte(); + let key = (node_start, node_end); - let original_start = start_row; - let original_end = end_row; + current = node.parent(); - // Phase 2: Expand to syntax boundaries that fit within budget. + if last_range == Some(key) { + continue; + } + last_range = Some(key); + + let start = node_start.saturating_sub(excerpt_offset_range.start); + let end = node_end + .min(excerpt_offset_range.end) + .saturating_sub(excerpt_offset_range.start); + ranges.push(start..end); + } + + ranges +} + +/// Expands context by first trying to reach syntax boundaries, +/// then expanding line-wise only if no syntax expansion occurred. +pub fn expand_context_syntactically_then_linewise( + snapshot: &BufferSnapshot, + editable_range: Range, + context_token_limit: usize, +) -> Range { + let mut start_row = editable_range.start.row; + let mut end_row = editable_range.end.row; + let mut remaining_tokens = context_token_limit; + let mut did_syntax_expand = false; + + // Phase 1: Try to expand to containing syntax boundaries, picking the largest that fits. for (boundary_start, boundary_end) in containing_syntax_boundaries(snapshot, start_row, end_row) { let tokens_for_start = if boundary_start < start_row { @@ -125,76 +158,57 @@ fn compute_editable_range( end_row = boundary_end; } remaining_tokens = remaining_tokens.saturating_sub(total_needed); + did_syntax_expand = true; } else { break; } } - // Phase 3: Continue line-wise in the direction we expanded least during syntax phase. - let expanded_up = original_start.saturating_sub(start_row); - let expanded_down = end_row.saturating_sub(original_end); - - (start_row, end_row, _) = expand_linewise_biased( - snapshot, - start_row, - end_row, - remaining_tokens, - expanded_up <= expanded_down, // prefer_up if we expanded less upward - ); + // Phase 2: Only expand line-wise if no syntax expansion occurred. + if !did_syntax_expand { + (start_row, end_row, _) = + expand_linewise_biased(snapshot, start_row, end_row, remaining_tokens, true); + } let start = Point::new(start_row, 0); let end = Point::new(end_row, snapshot.line_len(end_row)); start..end } -/// Expands symmetrically from cursor, one line at a time, alternating down then up. -/// Returns (start_row, end_row, remaining_tokens). -fn expand_symmetric_from_cursor( +/// Returns an iterator of (start_row, end_row) for successively larger syntax nodes +/// containing the given row range. Smallest containing node first. +fn containing_syntax_boundaries( snapshot: &BufferSnapshot, - cursor_row: u32, - mut token_budget: usize, -) -> (u32, u32, usize) { - let mut start_row = cursor_row; - let mut end_row = cursor_row; - - // Account for the cursor's line. - let cursor_line_tokens = line_token_count(snapshot, cursor_row); - token_budget = token_budget.saturating_sub(cursor_line_tokens); + start_row: u32, + end_row: u32, +) -> impl Iterator { + let range = Point::new(start_row, 0)..Point::new(end_row, snapshot.line_len(end_row)); + let mut current = snapshot.syntax_ancestor(range); + let mut last_rows: Option<(u32, u32)> = None; - loop { - let can_expand_up = start_row > 0; - let can_expand_down = end_row < snapshot.max_point().row; + std::iter::from_fn(move || { + while let Some(node) = current.take() { + let node_start_row = node.start_position().row as u32; + let node_end_row = node.end_position().row as u32; + let rows = (node_start_row, node_end_row); - if token_budget == 0 || (!can_expand_up && !can_expand_down) { - break; - } + current = node.parent(); - // Expand down first (slight forward bias for edit prediction). - if can_expand_down { - let next_row = end_row + 1; - let line_tokens = line_token_count(snapshot, next_row); - if line_tokens <= token_budget { - end_row = next_row; - token_budget = token_budget.saturating_sub(line_tokens); - } else { - break; + // Skip nodes that don't extend beyond our range. + if node_start_row >= start_row && node_end_row <= end_row { + continue; } - } - // Then expand up. - if can_expand_up && token_budget > 0 { - let next_row = start_row - 1; - let line_tokens = line_token_count(snapshot, next_row); - if line_tokens <= token_budget { - start_row = next_row; - token_budget = token_budget.saturating_sub(line_tokens); - } else { - break; + // Skip if same as last returned (some nodes have same span). + if last_rows == Some(rows) { + continue; } - } - } - (start_row, end_row, token_budget) + last_rows = Some(rows); + return Some(rows); + } + None + }) } /// Expands line-wise with a bias toward one direction. @@ -265,18 +279,6 @@ fn expand_linewise_biased( (start_row, end_row, remaining_tokens) } -/// Typical number of string bytes per token for the purposes of limiting model input. This is -/// intentionally low to err on the side of underestimating limits. -pub(crate) const BYTES_PER_TOKEN_GUESS: usize = 3; - -pub fn guess_token_count(bytes: usize) -> usize { - bytes / BYTES_PER_TOKEN_GUESS -} - -fn line_token_count(snapshot: &BufferSnapshot, row: u32) -> usize { - guess_token_count(snapshot.line_len(row) as usize).max(1) -} - /// Estimates token count for rows in range [start_row, end_row). fn estimate_tokens_for_rows(snapshot: &BufferSnapshot, start_row: u32, end_row: u32) -> usize { let mut tokens = 0; @@ -286,104 +288,14 @@ fn estimate_tokens_for_rows(snapshot: &BufferSnapshot, start_row: u32, end_row: tokens } -/// Returns an iterator of (start_row, end_row) for successively larger syntax nodes -/// containing the given row range. Smallest containing node first. -fn containing_syntax_boundaries( - snapshot: &BufferSnapshot, - start_row: u32, - end_row: u32, -) -> impl Iterator { - let range = Point::new(start_row, 0)..Point::new(end_row, snapshot.line_len(end_row)); - let mut current = snapshot.syntax_ancestor(range); - let mut last_rows: Option<(u32, u32)> = None; - - std::iter::from_fn(move || { - while let Some(node) = current.take() { - let node_start_row = node.start_position().row as u32; - let node_end_row = node.end_position().row as u32; - let rows = (node_start_row, node_end_row); - - current = node.parent(); - - // Skip nodes that don't extend beyond our range. - if node_start_row >= start_row && node_end_row <= end_row { - continue; - } - - // Skip if same as last returned (some nodes have same span). - if last_rows == Some(rows) { - continue; - } - - last_rows = Some(rows); - return Some(rows); - } - None - }) -} - -/// Expands context by first trying to reach syntax boundaries, -/// then expanding line-wise only if no syntax expansion occurred. -fn expand_context_syntactically_then_linewise( - snapshot: &BufferSnapshot, - editable_range: Range, - context_token_limit: usize, -) -> Range { - let mut start_row = editable_range.start.row; - let mut end_row = editable_range.end.row; - let mut remaining_tokens = context_token_limit; - let mut did_syntax_expand = false; - - // Phase 1: Try to expand to containing syntax boundaries, picking the largest that fits. - for (boundary_start, boundary_end) in containing_syntax_boundaries(snapshot, start_row, end_row) - { - let tokens_for_start = if boundary_start < start_row { - estimate_tokens_for_rows(snapshot, boundary_start, start_row) - } else { - 0 - }; - let tokens_for_end = if boundary_end > end_row { - estimate_tokens_for_rows(snapshot, end_row + 1, boundary_end + 1) - } else { - 0 - }; - - let total_needed = tokens_for_start + tokens_for_end; - - if total_needed <= remaining_tokens { - if boundary_start < start_row { - start_row = boundary_start; - } - if boundary_end > end_row { - end_row = boundary_end; - } - remaining_tokens = remaining_tokens.saturating_sub(total_needed); - did_syntax_expand = true; - } else { - break; - } - } - - // Phase 2: Only expand line-wise if no syntax expansion occurred. - if !did_syntax_expand { - (start_row, end_row, _) = - expand_linewise_biased(snapshot, start_row, end_row, remaining_tokens, true); - } - - let start = Point::new(start_row, 0); - let end = Point::new(end_row, snapshot.line_len(end_row)); - start..end -} - -use language::ToOffset as _; - #[cfg(test)] mod tests { use super::*; - use gpui::{App, AppContext}; + use gpui::{App, AppContext as _}; use indoc::indoc; use language::{Buffer, rust_lang}; use util::test::{TextRangeMarker, marked_text_ranges_by}; + use zeta_prompt::compute_editable_and_context_ranges; struct TestCase { name: &'static str, @@ -400,7 +312,18 @@ mod tests { // [ ] = expected context range let test_cases = vec![ TestCase { - name: "cursor near end of function - expands to syntax boundaries", + name: "small function fits entirely in editable and context", + marked_text: indoc! {r#" + [«fn foo() { + let x = 1;ˇ + let y = 2; + }»] + "#}, + editable_token_limit: 30, + context_token_limit: 60, + }, + TestCase { + name: "cursor near end of function - editable expands to syntax boundaries", marked_text: indoc! {r#" [fn first() { let a = 1; @@ -413,12 +336,11 @@ mod tests { println!("{}", x + y);ˇ }»] "#}, - // 18 tokens - expands symmetrically then to syntax boundaries editable_token_limit: 18, context_token_limit: 35, }, TestCase { - name: "cursor at function start - expands to syntax boundaries", + name: "cursor at function start - editable expands to syntax boundaries", marked_text: indoc! {r#" [fn before() { « let a = 1; @@ -434,12 +356,11 @@ mod tests { let b = 2; }] "#}, - // 25 tokens - expands symmetrically then to syntax boundaries editable_token_limit: 25, context_token_limit: 50, }, TestCase { - name: "tiny budget - just lines around cursor", + name: "tiny budget - just lines around cursor, no syntax expansion", marked_text: indoc! {r#" fn outer() { [ let line1 = 1; @@ -451,22 +372,9 @@ mod tests { let line7 = 7; } "#}, - // 12 tokens (~36 bytes) = just the cursor line with tiny budget editable_token_limit: 12, context_token_limit: 24, }, - TestCase { - name: "small function fits entirely", - marked_text: indoc! {r#" - [«fn foo() { - let x = 1;ˇ - let y = 2; - }»] - "#}, - // Plenty of budget for this small function - editable_token_limit: 30, - context_token_limit: 60, - }, TestCase { name: "context extends beyond editable", marked_text: indoc! {r#" @@ -476,13 +384,11 @@ mod tests { fn fourth() { let d = 4; }» fn fifth() { let e = 5; }] "#}, - // Small editable, larger context editable_token_limit: 25, context_token_limit: 45, }, - // Tests for syntax-aware editable and context expansion TestCase { - name: "cursor in first if-statement - expands to syntax boundaries", + name: "cursor in first if-block - editable expands to syntax boundaries", marked_text: indoc! {r#" [«fn before() { } @@ -503,13 +409,11 @@ mod tests { fn after() { }] "#}, - // 35 tokens allows expansion to include function header and first two if blocks editable_token_limit: 35, - // 60 tokens allows context to include the whole file context_token_limit: 60, }, TestCase { - name: "cursor in middle if-statement - expands to syntax boundaries", + name: "cursor in middle if-block - editable spans surrounding blocks", marked_text: indoc! {r#" [fn before() { } @@ -530,13 +434,11 @@ mod tests { fn after() { }] "#}, - // 40 tokens allows expansion to surrounding if blocks editable_token_limit: 40, - // 60 tokens allows context to include the whole file context_token_limit: 60, }, TestCase { - name: "cursor near bottom of long function - editable expands toward syntax, context reaches function", + name: "cursor near bottom of long function - context reaches function boundary", marked_text: indoc! {r#" [fn other() { } @@ -556,11 +458,30 @@ mod tests { fn another() { }»] "#}, - // 40 tokens for editable - allows several lines plus syntax expansion editable_token_limit: 40, - // 55 tokens - enough for function but not whole file context_token_limit: 55, }, + TestCase { + name: "zero context budget - context equals editable", + marked_text: indoc! {r#" + fn before() { + let p = 1; + let q = 2; + [«} + + fn foo() { + let x = 1;ˇ + let y = 2; + } + »] + fn after() { + let r = 3; + let s = 4; + } + "#}, + editable_token_limit: 15, + context_token_limit: 0, + }, ]; for test_case in test_cases { @@ -580,75 +501,63 @@ mod tests { let cursor_ranges = ranges.remove(&cursor_marker).unwrap_or_default(); let expected_editable = ranges.remove(&editable_marker).unwrap_or_default(); let expected_context = ranges.remove(&context_marker).unwrap_or_default(); - assert_eq!(expected_editable.len(), 1); - assert_eq!(expected_context.len(), 1); + assert_eq!(expected_editable.len(), 1, "{}", test_case.name); + assert_eq!(expected_context.len(), 1, "{}", test_case.name); - cx.new(|cx| { + cx.new(|cx: &mut gpui::Context| { let text = text.trim_end_matches('\n'); let buffer = Buffer::local(text, cx).with_language(rust_lang(), cx); let snapshot = buffer.snapshot(); let cursor_offset = cursor_ranges[0].start; - let cursor_point = snapshot.offset_to_point(cursor_offset); - let expected_editable_start = snapshot.offset_to_point(expected_editable[0].start); - let expected_editable_end = snapshot.offset_to_point(expected_editable[0].end); - let expected_context_start = snapshot.offset_to_point(expected_context[0].start); - let expected_context_end = snapshot.offset_to_point(expected_context[0].end); - - let (actual_editable, actual_context) = - editable_and_context_ranges_for_cursor_position( - cursor_point, - &snapshot, - test_case.editable_token_limit, - test_case.context_token_limit, - ); - - let range_text = |start: Point, end: Point| -> String { - snapshot.text_for_range(start..end).collect() + + let (_, excerpt_offset_range, cursor_offset_in_excerpt) = + compute_cursor_excerpt(&snapshot, cursor_offset); + let excerpt_text: String = snapshot + .text_for_range(excerpt_offset_range.clone()) + .collect(); + let syntax_ranges = + compute_syntax_ranges(&snapshot, cursor_offset, &excerpt_offset_range); + + let (actual_editable, actual_context) = compute_editable_and_context_ranges( + &excerpt_text, + cursor_offset_in_excerpt, + &syntax_ranges, + test_case.editable_token_limit, + test_case.context_token_limit, + ); + + let to_buffer_range = |range: Range| -> Range { + (excerpt_offset_range.start + range.start) + ..(excerpt_offset_range.start + range.end) }; - let editable_match = actual_editable.start == expected_editable_start - && actual_editable.end == expected_editable_end; - let context_match = actual_context.start == expected_context_start - && actual_context.end == expected_context_end; + let actual_editable = to_buffer_range(actual_editable); + let actual_context = to_buffer_range(actual_context); + + let expected_editable_range = expected_editable[0].clone(); + let expected_context_range = expected_context[0].clone(); + + let editable_match = actual_editable == expected_editable_range; + let context_match = actual_context == expected_context_range; if !editable_match || !context_match { + let range_text = |range: &Range| { + snapshot.text_for_range(range.clone()).collect::() + }; + println!("\n=== FAILED: {} ===", test_case.name); if !editable_match { - println!( - "\nExpected editable ({:?}..{:?}):", - expected_editable_start, expected_editable_end - ); - println!( - "---\n{}---", - range_text(expected_editable_start, expected_editable_end) - ); - println!( - "\nActual editable ({:?}..{:?}):", - actual_editable.start, actual_editable.end - ); - println!( - "---\n{}---", - range_text(actual_editable.start, actual_editable.end) - ); + println!("\nExpected editable ({:?}):", expected_editable_range); + println!("---\n{}---", range_text(&expected_editable_range)); + println!("\nActual editable ({:?}):", actual_editable); + println!("---\n{}---", range_text(&actual_editable)); } if !context_match { - println!( - "\nExpected context ({:?}..{:?}):", - expected_context_start, expected_context_end - ); - println!( - "---\n{}---", - range_text(expected_context_start, expected_context_end) - ); - println!( - "\nActual context ({:?}..{:?}):", - actual_context.start, actual_context.end - ); - println!( - "---\n{}---", - range_text(actual_context.start, actual_context.end) - ); + println!("\nExpected context ({:?}):", expected_context_range); + println!("---\n{}---", range_text(&expected_context_range)); + println!("\nActual context ({:?}):", actual_context); + println!("---\n{}---", range_text(&actual_context)); } panic!("Test '{}' failed - see output above", test_case.name); } diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 1f692eff2c062cf703e72117c6fd39c7a4e1efbb..cfc5c7efe348b7238813853bbf3e5fd70047340d 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -23,14 +23,14 @@ use futures::{ use gpui::BackgroundExecutor; use gpui::http_client::Url; use gpui::{ - App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, + App, AsyncApp, Entity, EntityId, Global, SharedString, Task, WeakEntity, actions, http_client::{self, AsyncBody, Method}, prelude::*, }; use language::language_settings::all_language_settings; use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToOffset, ToPoint}; use language::{BufferSnapshot, OffsetRangeExt}; -use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener}; +use language_model::{LlmApiToken, NeedsLlmTokenRefresh}; use project::{DisableAiSettings, Project, ProjectPath, WorktreeId}; use release_channel::AppVersion; use semver::Version; @@ -41,7 +41,7 @@ use settings::{ use std::collections::{VecDeque, hash_map}; use std::env; use text::{AnchorRangeExt, Edit}; -use workspace::Workspace; +use workspace::{AppState, Workspace}; use zeta_prompt::{ZetaFormat, ZetaPromptInput}; use std::mem; @@ -75,6 +75,7 @@ pub mod zeta; #[cfg(test)] mod edit_prediction_tests; +use crate::cursor_excerpt::expand_context_syntactically_then_linewise; use crate::example_spec::ExampleSpec; use crate::license_detection::LicenseDetectionWatcher; use crate::mercury::Mercury; @@ -99,8 +100,9 @@ actions!( ); /// Maximum number of events to track. -const EVENT_COUNT_MAX: usize = 6; +const EVENT_COUNT_MAX: usize = 10; const CHANGE_GROUPING_LINE_SPAN: u32 = 8; +const COLLABORATOR_EDIT_LOCALITY_CONTEXT_TOKENS: usize = 512; const LAST_CHANGE_GROUPING_TIME: Duration = Duration::from_secs(1); const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); @@ -133,7 +135,6 @@ pub struct EditPredictionStore { client: Arc, user_store: Entity, llm_token: LlmApiToken, - _llm_token_subscription: Subscription, _fetch_experiments_task: Task<()>, projects: HashMap, update_required: bool, @@ -243,21 +244,31 @@ pub enum UserActionType { pub struct StoredEvent { pub event: Arc, pub old_snapshot: TextBufferSnapshot, - pub edit_range: Range, + pub new_snapshot_version: clock::Global, + pub total_edit_range: Range, } impl StoredEvent { fn can_merge( &self, - next_old_event: &&&StoredEvent, - new_snapshot: &TextBufferSnapshot, - last_edit_range: &Range, + next_old_event: &StoredEvent, + latest_snapshot: &TextBufferSnapshot, + latest_edit_range: &Range, ) -> bool { - // Events must be for the same buffer + // Events must be for the same buffer and be contiguous across included snapshots to be mergeable. if self.old_snapshot.remote_id() != next_old_event.old_snapshot.remote_id() { return false; } - if self.old_snapshot.remote_id() != new_snapshot.remote_id() { + if self.old_snapshot.remote_id() != latest_snapshot.remote_id() { + return false; + } + if self.new_snapshot_version != next_old_event.old_snapshot.version { + return false; + } + if !latest_snapshot + .version + .observed_all(&next_old_event.new_snapshot_version) + { return false; } @@ -282,9 +293,9 @@ impl StoredEvent { return false; } - let left_range = self.edit_range.to_point(new_snapshot); - let right_range = next_old_event.edit_range.to_point(new_snapshot); - let latest_range = last_edit_range.to_point(&new_snapshot); + let left_range = self.total_edit_range.to_point(latest_snapshot); + let right_range = next_old_event.total_edit_range.to_point(latest_snapshot); + let latest_range = latest_edit_range.to_point(latest_snapshot); // Events near to the latest edit are not merged if their sources differ. if lines_between_ranges(&left_range, &latest_range) @@ -374,6 +385,7 @@ impl ProjectState { EditPredictionRejectReason::Canceled, false, None, + None, cx, ); }) @@ -402,6 +414,7 @@ struct CurrentEditPrediction { pub prediction: EditPrediction, pub was_shown: bool, pub shown_with: Option, + pub e2e_latency: std::time::Duration, } impl CurrentEditPrediction { @@ -495,12 +508,14 @@ impl std::ops::Deref for BufferEditPrediction<'_> { } #[derive(Clone)] + struct PendingSettledPrediction { request_id: EditPredictionId, editable_anchor_range: Range, example: Option, enqueued_at: Instant, last_edit_at: Instant, + e2e_latency: std::time::Duration, } struct RegisteredBuffer { @@ -517,7 +532,9 @@ struct LastEvent { new_snapshot: TextBufferSnapshot, old_file: Option>, new_file: Option>, - edit_range: Option>, + latest_edit_range: Range, + total_edit_range: Range, + total_edit_range_at_last_pause_boundary: Option>, predicted: bool, snapshot_after_last_editing_pause: Option, last_edit_time: Option, @@ -543,8 +560,11 @@ impl LastEvent { }) }); - let (diff, edit_range) = - compute_diff_between_snapshots(&self.old_snapshot, &self.new_snapshot)?; + let (diff, edit_range) = compute_diff_between_snapshots_in_range( + &self.old_snapshot, + &self.new_snapshot, + &self.total_edit_range, + )?; if path == old_path && diff.is_empty() { None @@ -557,9 +577,10 @@ impl LastEvent { in_open_source_repo, predicted: self.predicted, }), - edit_range: self.new_snapshot.anchor_before(edit_range.start) - ..self.new_snapshot.anchor_before(edit_range.end), old_snapshot: self.old_snapshot.clone(), + new_snapshot_version: self.new_snapshot.version.clone(), + total_edit_range: self.new_snapshot.anchor_before(edit_range.start) + ..self.new_snapshot.anchor_before(edit_range.end), }) } } @@ -569,12 +590,28 @@ impl LastEvent { return (self.clone(), None); }; + let total_edit_range_before_pause = self + .total_edit_range_at_last_pause_boundary + .clone() + .unwrap_or_else(|| self.total_edit_range.clone()); + + let Some(total_edit_range_after_pause) = + compute_total_edit_range_between_snapshots(boundary_snapshot, &self.new_snapshot) + else { + return (self.clone(), None); + }; + + let latest_edit_range_before_pause = total_edit_range_before_pause.clone(); + let latest_edit_range_after_pause = total_edit_range_after_pause.clone(); + let before = LastEvent { old_snapshot: self.old_snapshot.clone(), new_snapshot: boundary_snapshot.clone(), old_file: self.old_file.clone(), new_file: self.new_file.clone(), - edit_range: None, + latest_edit_range: latest_edit_range_before_pause, + total_edit_range: total_edit_range_before_pause, + total_edit_range_at_last_pause_boundary: None, predicted: self.predicted, snapshot_after_last_editing_pause: None, last_edit_time: self.last_edit_time, @@ -585,7 +622,9 @@ impl LastEvent { new_snapshot: self.new_snapshot.clone(), old_file: self.old_file.clone(), new_file: self.new_file.clone(), - edit_range: None, + latest_edit_range: latest_edit_range_after_pause, + total_edit_range: total_edit_range_after_pause, + total_edit_range_at_last_pause_boundary: None, predicted: self.predicted, snapshot_after_last_editing_pause: None, last_edit_time: self.last_edit_time, @@ -595,21 +634,78 @@ impl LastEvent { } } -pub(crate) fn compute_diff_between_snapshots( +fn compute_total_edit_range_between_snapshots( old_snapshot: &TextBufferSnapshot, new_snapshot: &TextBufferSnapshot, -) -> Option<(String, Range)> { +) -> Option> { let edits: Vec> = new_snapshot .edits_since::(&old_snapshot.version) .collect(); let (first_edit, last_edit) = edits.first().zip(edits.last())?; - - let old_start_point = old_snapshot.offset_to_point(first_edit.old.start); - let old_end_point = old_snapshot.offset_to_point(last_edit.old.end); let new_start_point = new_snapshot.offset_to_point(first_edit.new.start); let new_end_point = new_snapshot.offset_to_point(last_edit.new.end); + Some(new_snapshot.anchor_before(new_start_point)..new_snapshot.anchor_before(new_end_point)) +} + +fn compute_old_range_for_new_range( + old_snapshot: &TextBufferSnapshot, + new_snapshot: &TextBufferSnapshot, + total_edit_range: &Range, +) -> Option> { + let new_start_offset = total_edit_range.start.to_offset(new_snapshot); + let new_end_offset = total_edit_range.end.to_offset(new_snapshot); + + let edits: Vec> = new_snapshot + .edits_since::(&old_snapshot.version) + .collect(); + let mut old_start_offset = None; + let mut old_end_offset = None; + let mut delta: isize = 0; + + for edit in &edits { + if old_start_offset.is_none() && new_start_offset <= edit.new.end { + old_start_offset = Some(if new_start_offset < edit.new.start { + new_start_offset.checked_add_signed(-delta)? + } else { + edit.old.start + }); + } + + if old_end_offset.is_none() && new_end_offset <= edit.new.end { + old_end_offset = Some(if new_end_offset < edit.new.start { + new_end_offset.checked_add_signed(-delta)? + } else { + edit.old.end + }); + } + + delta += edit.new.len() as isize - edit.old.len() as isize; + } + + let old_start_offset = + old_start_offset.unwrap_or_else(|| new_start_offset.saturating_add_signed(-delta)); + let old_end_offset = + old_end_offset.unwrap_or_else(|| new_end_offset.saturating_add_signed(-delta)); + + Some( + old_snapshot.offset_to_point(old_start_offset) + ..old_snapshot.offset_to_point(old_end_offset), + ) +} + +fn compute_diff_between_snapshots_in_range( + old_snapshot: &TextBufferSnapshot, + new_snapshot: &TextBufferSnapshot, + total_edit_range: &Range, +) -> Option<(String, Range)> { + let new_start_point = total_edit_range.start.to_point(new_snapshot); + let new_end_point = total_edit_range.end.to_point(new_snapshot); + let old_range = compute_old_range_for_new_range(old_snapshot, new_snapshot, total_edit_range)?; + let old_start_point = old_range.start; + let old_end_point = old_range.end; + const CONTEXT_LINES: u32 = 3; let old_context_start_row = old_start_point.row.saturating_sub(CONTEXT_LINES); @@ -674,10 +770,9 @@ impl EditPredictionStore { } pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); let data_collection_choice = Self::load_data_collection_choice(); - let llm_token = LlmApiToken::default(); + let llm_token = LlmApiToken::global(cx); let (reject_tx, reject_rx) = mpsc::unbounded(); cx.background_spawn({ @@ -721,23 +816,6 @@ impl EditPredictionStore { user_store, llm_token, _fetch_experiments_task: fetch_experiments_task, - _llm_token_subscription: cx.subscribe( - &refresh_llm_token_listener, - |this, _listener, _event, cx| { - let client = this.client.clone(); - let llm_token = this.llm_token.clone(); - let organization_id = this - .user_store - .read(cx) - .current_organization() - .map(|organization| organization.id.clone()); - cx.spawn(async move |_this, _cx| { - llm_token.refresh(&client, organization_id).await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }, - ), update_required: false, edit_prediction_model: EditPredictionModel::Zeta, zeta2_raw_config: Self::zeta2_raw_config_from_env(), @@ -893,6 +971,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(); @@ -1217,10 +1299,12 @@ impl EditPredictionStore { cx.subscribe(buffer, { let project = project.downgrade(); move |this, buffer, event, cx| { - if let language::BufferEvent::Edited = event + if let language::BufferEvent::Edited { is_local } = event && let Some(project) = project.upgrade() { - this.report_changes_for_buffer(&buffer, &project, false, cx); + this.report_changes_for_buffer( + &buffer, &project, false, *is_local, cx, + ); } } }), @@ -1242,6 +1326,7 @@ impl EditPredictionStore { buffer: &Entity, project: &Entity, is_predicted: bool, + is_local: bool, cx: &mut Context, ) { let project_state = self.get_or_init_project(project, cx); @@ -1253,7 +1338,6 @@ impl EditPredictionStore { if new_snapshot.version == registered_buffer.snapshot.version { return; } - let old_file = mem::replace(&mut registered_buffer.file, new_file.clone()); let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); let mut num_edits = 0usize; @@ -1286,28 +1370,44 @@ impl EditPredictionStore { } } - let action_type = match (total_deleted, total_inserted, num_edits) { - (0, ins, n) if ins == n => UserActionType::InsertChar, - (0, _, _) => UserActionType::InsertSelection, - (del, 0, n) if del == n => UserActionType::DeleteChar, - (_, 0, _) => UserActionType::DeleteSelection, - (_, ins, n) if ins == n => UserActionType::InsertChar, - (_, _, _) => UserActionType::InsertSelection, - }; + let include_in_history = is_local + || collaborator_edit_overlaps_locality_region( + project_state, + project, + buffer, + &buf.snapshot(), + &edit_range, + cx, + ); - if let Some(offset) = last_offset { - let point = new_snapshot.offset_to_point(offset); - let timestamp_epoch_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - project_state.record_user_action(UserActionRecord { - action_type, - buffer_id: buffer.entity_id(), - line_number: point.row, - offset, - timestamp_epoch_ms, - }); + if is_local { + let action_type = match (total_deleted, total_inserted, num_edits) { + (0, ins, n) if ins == n => UserActionType::InsertChar, + (0, _, _) => UserActionType::InsertSelection, + (del, 0, n) if del == n => UserActionType::DeleteChar, + (_, 0, _) => UserActionType::DeleteSelection, + (_, ins, n) if ins == n => UserActionType::InsertChar, + (_, _, _) => UserActionType::InsertSelection, + }; + + if let Some(offset) = last_offset { + let point = new_snapshot.offset_to_point(offset); + let timestamp_epoch_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + project_state.record_user_action(UserActionRecord { + action_type, + buffer_id: buffer.entity_id(), + line_number: point.row, + offset, + timestamp_epoch_ms, + }); + } + } + + if !include_in_history { + return; } let events = &mut project_state.events; @@ -1321,15 +1421,10 @@ impl EditPredictionStore { let should_coalesce = is_next_snapshot_of_same_buffer && !prediction_source_changed - && last_event - .edit_range - .as_ref() - .is_some_and(|last_edit_range| { - lines_between_ranges( - &edit_range.to_point(&new_snapshot), - &last_edit_range.to_point(&new_snapshot), - ) <= CHANGE_GROUPING_LINE_SPAN - }); + && lines_between_ranges( + &edit_range.to_point(&new_snapshot), + &last_event.latest_edit_range.to_point(&new_snapshot), + ) <= CHANGE_GROUPING_LINE_SPAN; if should_coalesce { let pause_elapsed = last_event @@ -1339,9 +1434,13 @@ impl EditPredictionStore { if pause_elapsed { last_event.snapshot_after_last_editing_pause = Some(last_event.new_snapshot.clone()); + last_event.total_edit_range_at_last_pause_boundary = + Some(last_event.total_edit_range.clone()); } - last_event.edit_range = Some(edit_range); + last_event.latest_edit_range = edit_range.clone(); + last_event.total_edit_range = + merge_anchor_ranges(&last_event.total_edit_range, &edit_range, &new_snapshot); last_event.new_snapshot = new_snapshot; last_event.last_edit_time = Some(now); return; @@ -1364,7 +1463,9 @@ impl EditPredictionStore { new_file, old_snapshot, new_snapshot, - edit_range: Some(edit_range), + latest_edit_range: edit_range.clone(), + total_edit_range: edit_range, + total_edit_range_at_last_pause_boundary: None, predicted: is_predicted, snapshot_after_last_editing_pause: None, last_edit_time: Some(now), @@ -1420,7 +1521,13 @@ impl EditPredictionStore { return; }; - self.report_changes_for_buffer(¤t_prediction.prediction.buffer, project, true, cx); + self.report_changes_for_buffer( + ¤t_prediction.prediction.buffer, + project, + true, + true, + cx, + ); // can't hold &mut project_state ref across report_changes_for_buffer_call let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { @@ -1583,6 +1690,7 @@ impl EditPredictionStore { request_id = pending_prediction.request_id.0.clone(), settled_editable_region, example = pending_prediction.example.take(), + e2e_latency = pending_prediction.e2e_latency.as_millis(), ); return false; @@ -1612,6 +1720,7 @@ impl EditPredictionStore { edited_buffer_snapshot: &BufferSnapshot, editable_offset_range: Range, example: Option, + e2e_latency: std::time::Duration, cx: &mut Context, ) { let this = &mut *self; @@ -1626,6 +1735,7 @@ impl EditPredictionStore { editable_anchor_range: edited_buffer_snapshot .anchor_range_around(editable_offset_range), example, + e2e_latency, enqueued_at: now, last_edit_at: now, }); @@ -1648,6 +1758,7 @@ impl EditPredictionStore { reason, prediction.was_shown, model_version, + Some(prediction.e2e_latency), cx, ); } @@ -1709,6 +1820,7 @@ impl EditPredictionStore { reason: EditPredictionRejectReason, was_shown: bool, model_version: Option, + e2e_latency: Option, cx: &App, ) { match self.edit_prediction_model { @@ -1732,6 +1844,7 @@ impl EditPredictionStore { reason, was_shown, model_version, + e2e_latency_ms: e2e_latency.map(|latency| latency.as_millis()), }, organization_id, }) @@ -1809,6 +1922,10 @@ impl EditPredictionStore { return; } + if currently_following(&project, cx) { + return; + } + let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { return; }; @@ -1901,6 +2018,7 @@ impl EditPredictionStore { EditPredictionResult { id: prediction_result.id, prediction: Err(EditPredictionRejectReason::CurrentPreferred), + e2e_latency: prediction_result.e2e_latency, } }, PredictionRequestedBy::DiagnosticsUpdate, @@ -1945,6 +2063,25 @@ impl EditPredictionStore { pub const THROTTLE_TIMEOUT: Duration = Duration::from_millis(300); } +fn currently_following(project: &Entity, cx: &App) -> bool { + let Some(app_state) = AppState::try_global(cx).and_then(|app_state| app_state.upgrade()) else { + return false; + }; + + app_state + .workspace_store + .read(cx) + .workspaces() + .filter_map(|workspace| workspace.upgrade()) + .any(|workspace| { + workspace.read(cx).project().entity_id() == project.entity_id() + && workspace + .read(cx) + .leader_for_pane(workspace.read(cx).active_pane()) + .is_some() + }) +} + fn is_ep_store_provider(provider: EditPredictionProvider) -> bool { match provider { EditPredictionProvider::Zed @@ -2079,6 +2216,7 @@ impl EditPredictionStore { prediction, was_shown: false, shown_with: None, + e2e_latency: prediction_result.e2e_latency, }; if let Some(current_prediction) = @@ -2099,6 +2237,7 @@ impl EditPredictionStore { EditPredictionRejectReason::CurrentPreferred, false, new_prediction.prediction.model_version, + Some(new_prediction.e2e_latency), cx, ); None @@ -2113,6 +2252,7 @@ impl EditPredictionStore { reject_reason, false, None, + Some(prediction_result.e2e_latency), cx, ); None @@ -2689,6 +2829,32 @@ impl EditPredictionStore { } } +fn collaborator_edit_overlaps_locality_region( + project_state: &ProjectState, + project: &Entity, + buffer: &Entity, + snapshot: &BufferSnapshot, + edit_range: &Range, + cx: &App, +) -> bool { + let Some((active_buffer, Some(position))) = project_state.active_buffer(project, cx) else { + return false; + }; + + if active_buffer.entity_id() != buffer.entity_id() { + return false; + } + + let locality_point_range = expand_context_syntactically_then_linewise( + snapshot, + (position..position).to_point(snapshot), + COLLABORATOR_EDIT_LOCALITY_CONTEXT_TOKENS, + ); + let locality_anchor_range = snapshot.anchor_range_around(locality_point_range); + + edit_range.overlaps(&locality_anchor_range, snapshot) +} + fn merge_trailing_events_if_needed( events: &mut VecDeque, end_snapshot: &TextBufferSnapshot, @@ -2699,13 +2865,19 @@ fn merge_trailing_events_if_needed( if last_event.old_snapshot.remote_id() != latest_snapshot.remote_id() { return; } + if !latest_snapshot + .version + .observed_all(&last_event.new_snapshot_version) + { + return; + } } let mut next_old_event = None; let mut mergeable_count = 0; for old_event in events.iter().rev() { - if let Some(next_old_event) = &next_old_event - && !old_event.can_merge(&next_old_event, latest_snapshot, latest_edit_range) + if let Some(next_old_event) = next_old_event + && !old_event.can_merge(next_old_event, latest_snapshot, latest_edit_range) { break; } @@ -2720,10 +2892,19 @@ fn merge_trailing_events_if_needed( let mut events_to_merge = events.range(events.len() - mergeable_count..).peekable(); let oldest_event = events_to_merge.peek().unwrap(); let oldest_snapshot = oldest_event.old_snapshot.clone(); + let newest_snapshot = end_snapshot; + let mut merged_edit_range = oldest_event.total_edit_range.clone(); - if let Some((diff, edited_range)) = - compute_diff_between_snapshots(&oldest_snapshot, end_snapshot) - { + for event in events.range(events.len() - mergeable_count + 1..) { + merged_edit_range = + merge_anchor_ranges(&merged_edit_range, &event.total_edit_range, latest_snapshot); + } + + if let Some((diff, edit_range)) = compute_diff_between_snapshots_in_range( + &oldest_snapshot, + newest_snapshot, + &merged_edit_range, + ) { let merged_event = match oldest_event.event.as_ref() { zeta_prompt::Event::BufferChange { old_path, @@ -2747,8 +2928,9 @@ fn merge_trailing_events_if_needed( }), }), old_snapshot: oldest_snapshot.clone(), - edit_range: end_snapshot.anchor_before(edited_range.start) - ..end_snapshot.anchor_before(edited_range.end), + new_snapshot_version: newest_snapshot.version.clone(), + total_edit_range: newest_snapshot.anchor_before(edit_range.start) + ..newest_snapshot.anchor_before(edit_range.end), }, }; events.truncate(events.len() - mergeable_count); @@ -2756,6 +2938,24 @@ fn merge_trailing_events_if_needed( } } +fn merge_anchor_ranges( + left: &Range, + right: &Range, + snapshot: &TextBufferSnapshot, +) -> Range { + let start = if left.start.cmp(&right.start, snapshot).is_le() { + left.start + } else { + right.start + }; + let end = if left.end.cmp(&right.end, snapshot).is_ge() { + left.end + } else { + right.end + }; + start..end +} + #[derive(Error, Debug)] #[error( "You must update to Zed version {minimum_version} or higher to continue using edit predictions." diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 1ff77fd900db80894b973e79d8fe69e9d65a1e3b..5daa7ee4a0dea1384e002acefe1fb4b47d0d5f91 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1,12 +1,14 @@ use super::*; -use crate::{compute_diff_between_snapshots, udiff::apply_diff_to_string}; +use crate::udiff::apply_diff_to_string; use client::{UserStore, test::FakeServer}; use clock::FakeSystemClock; +use clock::ReplicaId; use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; use cloud_llm_client::{ EditPredictionRejectReason, EditPredictionRejection, RejectEditPredictionsBody, predict_edits_v3::{PredictEditsV3Request, PredictEditsV3Response}, }; + use futures::{ AsyncReadExt, FutureExt, StreamExt, channel::{mpsc, oneshot}, @@ -17,21 +19,29 @@ use gpui::{ http_client::{FakeHttpClient, Response}, }; use indoc::indoc; -use language::{Anchor, Buffer, CursorShape, Operation, Point, Selection, SelectionGoal}; +use language::{ + Anchor, Buffer, Capability, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, + DiagnosticSeverity, Operation, Point, Selection, SelectionGoal, +}; +use language_model::RefreshLlmTokenListener; use lsp::LanguageServerId; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_matches}; use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; -use std::{path::Path, sync::Arc, time::Duration}; -use util::path; +use std::{ops::Range, path::Path, sync::Arc, time::Duration}; +use util::{ + path, + test::{TextRangeMarker, marked_text_ranges_by}, +}; use uuid::Uuid; +use workspace::{AppState, CollaboratorId, MultiWorkspace}; use zeta_prompt::ZetaPromptInput; use crate::{ BufferEditPrediction, EDIT_PREDICTION_SETTLED_QUIESCENCE, EditPredictionId, - EditPredictionStore, REJECT_REQUEST_DEBOUNCE, + EditPredictionJumpsFeatureFlag, EditPredictionStore, REJECT_REQUEST_DEBOUNCE, }; #[gpui::test] @@ -170,6 +180,172 @@ async fn test_current_state(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + + cx.update(|cx| { + cx.update_flags( + false, + vec![EditPredictionJumpsFeatureFlag::NAME.to_string()], + ); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "1.txt": "Hello!\nHow\nBye\n", + "2.txt": "Hola!\nComo\nAdios\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let app_state = cx.update(|cx| { + let app_state = AppState::test(cx); + AppState::set_global(Arc::downgrade(&app_state), cx); + app_state + }); + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + cx.update(|cx| { + AppState::set_global(Arc::downgrade(workspace.read(cx).app_state()), cx); + }); + let _ = app_state; + + let buffer1 = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/1.txt"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let snapshot1 = buffer1.read_with(cx, |buffer, _cx| buffer.snapshot()); + let position = snapshot1.anchor_before(language::Point::new(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_project(&project, cx); + ep_store.register_buffer(&buffer1, &project, cx); + ep_store.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx + .send(model_response( + &request, + indoc! {r" + --- a/root/1.txt + +++ b/root/1.txt + @@ ... @@ + Hello! + -How + +How are you? + Bye + "}, + )) + .unwrap(); + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.reject_current_prediction(EditPredictionRejectReason::Discarded, &project, cx); + }); + + let _ = multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.start_following(CollaboratorId::Agent, window, cx); + }); + }); + cx.run_until_parked(); + + let diagnostic = lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: "Sentence is incomplete".to_string(), + ..Default::default() + }; + + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), + diagnostics: vec![diagnostic.clone()], + version: None, + }, + None, + language::DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + }); + + cx.run_until_parked(); + assert_no_predict_request_ready(&mut requests.predict); + + let _ = multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.unfollow(CollaboratorId::Agent, window, cx); + }); + }); + cx.run_until_parked(); + + project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/2.txt")).unwrap(), + diagnostics: vec![diagnostic], + version: None, + }, + None, + language::DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + }); + + let (request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx + .send(model_response( + &request, + indoc! {r#" + --- a/root/2.txt + +++ b/root/2.txt + @@ ... @@ + Hola! + -Como + +Como estas? + Adios + "#}, + )) + .unwrap(); + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + let prediction = ep_store + .prediction_at(&buffer1, None, &project, cx) + .unwrap(); + assert_matches!( + prediction, + BufferEditPrediction::Jump { prediction } if prediction.snapshot.file().unwrap().full_path(cx) == Path::new(path!("root/2.txt")) + ); + }); +} + #[gpui::test] async fn test_simple_request(cx: &mut TestAppContext) { let (ep_store, mut requests) = init_test_with_fake_client(cx); @@ -363,6 +539,12 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex ep_store.edit_history_for_project(&project, cx) }); assert_eq!(events.len(), 2); + + let first_total_edit_range = buffer.read_with(cx, |buffer, _| { + events[0].total_edit_range.to_point(&buffer.snapshot()) + }); + assert_eq!(first_total_edit_range, Point::new(1, 0)..Point::new(1, 3)); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].event.as_ref(); assert_eq!( diff.as_str(), @@ -375,6 +557,11 @@ async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContex "} ); + let second_total_edit_range = buffer.read_with(cx, |buffer, _| { + events[1].total_edit_range.to_point(&buffer.snapshot()) + }); + assert_eq!(second_total_edit_range, Point::new(1, 3)..Point::new(1, 13)); + let zeta_prompt::Event::BufferChange { diff, .. } = events[1].event.as_ref(); assert_eq!( diff.as_str(), @@ -591,6 +778,240 @@ fn render_events_with_predicted(events: &[StoredEvent]) -> Vec { .collect() } +fn make_collaborator_replica( + buffer: &Entity, + cx: &mut TestAppContext, +) -> (Entity, clock::Global) { + let (state, version) = + buffer.read_with(cx, |buffer, _cx| (buffer.to_proto(_cx), buffer.version())); + let collaborator = cx.new(|_cx| { + Buffer::from_proto(ReplicaId::new(1), Capability::ReadWrite, state, None).unwrap() + }); + (collaborator, version) +} + +async fn apply_collaborator_edit( + collaborator: &Entity, + buffer: &Entity, + since_version: &mut clock::Global, + edit_range: Range, + new_text: &str, + cx: &mut TestAppContext, +) { + collaborator.update(cx, |collaborator, cx| { + collaborator.edit([(edit_range, new_text)], None, cx); + }); + + let serialize_task = collaborator.read_with(cx, |collaborator, cx| { + collaborator.serialize_ops(Some(since_version.clone()), cx) + }); + let ops = serialize_task.await; + *since_version = collaborator.read_with(cx, |collaborator, _cx| collaborator.version()); + + buffer.update(cx, |buffer, cx| { + buffer.apply_ops( + ops.into_iter() + .map(|op| language::proto::deserialize_operation(op).unwrap()), + cx, + ); + }); +} + +#[gpui::test] +async fn test_nearby_collaborator_edits_are_kept_in_history(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.rs": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _cx| buffer.anchor_before(Point::new(1, 0))); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + let _ = ep_store.prediction_at(&buffer, Some(cursor), &project, cx); + }); + + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(0..6, "LOCAL ZERO")], None, cx); + }); + + let (collaborator, mut collaborator_version) = make_collaborator_replica(&buffer, cx); + + let (line_one_start, line_one_len) = collaborator.read_with(cx, |buffer, _cx| { + (Point::new(1, 0).to_offset(buffer), buffer.line_len(1)) + }); + + apply_collaborator_edit( + &collaborator, + &buffer, + &mut collaborator_version, + line_one_start..line_one_start + line_one_len as usize, + "REMOTE ONE", + cx, + ) + .await; + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + + assert_eq!( + render_events_with_predicted(&events), + vec![indoc! {" + manual + @@ -1,5 +1,5 @@ + -line 0 + -line 1 + +LOCAL ZERO + +REMOTE ONE + line 2 + line 3 + line 4 + "}] + ); +} + +#[gpui::test] +async fn test_distant_collaborator_edits_are_omitted_from_history(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.rs": (0..1000) + .map(|i| format!("line {i}\n")) + .collect::() + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let cursor = buffer.read_with(cx, |buffer, _cx| buffer.anchor_before(Point::new(1, 0))); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + let _ = ep_store.prediction_at(&buffer, Some(cursor), &project, cx); + }); + + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(0..6, "LOCAL ZERO")], None, cx); + }); + + let (collaborator, mut collaborator_version) = make_collaborator_replica(&buffer, cx); + + let far_line_start = buffer.read_with(cx, |buffer, _cx| Point::new(900, 0).to_offset(buffer)); + + apply_collaborator_edit( + &collaborator, + &buffer, + &mut collaborator_version, + far_line_start..far_line_start + 7, + "REMOTE FAR", + cx, + ) + .await; + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + + assert_eq!( + render_events_with_predicted(&events), + vec![indoc! {" + manual + @@ -1,4 +1,4 @@ + -line 0 + +LOCAL ZERO + line 1 + line 2 + line 3 + "}] + ); +} + +#[gpui::test] +async fn test_irrelevant_collaborator_edits_in_different_files_are_omitted_from_history( + cx: &mut TestAppContext, +) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.rs": "line 0\nline 1\nline 2\nline 3\n", + "bar.rs": "line 0\nline 1\nline 2\nline 3\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let foo_buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.rs"), cx).unwrap(); + project.set_active_path(Some(path.clone()), cx); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + let bar_buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/bar.rs"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + let foo_cursor = foo_buffer.read_with(cx, |buffer, _cx| buffer.anchor_before(Point::new(1, 0))); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&foo_buffer, &project, cx); + ep_store.register_buffer(&bar_buffer, &project, cx); + let _ = ep_store.prediction_at(&foo_buffer, Some(foo_cursor), &project, cx); + }); + + let (bar_collaborator, mut bar_version) = make_collaborator_replica(&bar_buffer, cx); + + apply_collaborator_edit( + &bar_collaborator, + &bar_buffer, + &mut bar_version, + 0..6, + "REMOTE BAR", + cx, + ) + .await; + + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + + assert!(events.is_empty()); +} + #[gpui::test] async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) { let (ep_store, _requests) = init_test_with_fake_client(cx); @@ -673,7 +1094,7 @@ async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) { let end = Point::new(2, 6).to_offset(buffer); buffer.edit(vec![(offset..end, "LINE TWO")], None, cx); }); - ep_store.report_changes_for_buffer(&buffer, &project, true, cx); + ep_store.report_changes_for_buffer(&buffer, &project, true, true, cx); }); let events = ep_store.update(cx, |ep_store, cx| { @@ -715,7 +1136,7 @@ async fn test_predicted_flag_coalescing(cx: &mut TestAppContext) { let end = Point::new(3, 6).to_offset(buffer); buffer.edit(vec![(offset..end, "LINE THREE")], None, cx); }); - ep_store.report_changes_for_buffer(&buffer, &project, true, cx); + ep_store.report_changes_for_buffer(&buffer, &project, true, true, cx); }); let events = ep_store.update(cx, |ep_store, cx| { @@ -902,6 +1323,7 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Empty, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), }] ); } @@ -963,6 +1385,7 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::InterpolatedEmpty, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), }] ); } @@ -1056,6 +1479,7 @@ async fn test_replace_current(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Replaced, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), }] ); } @@ -1151,6 +1575,7 @@ async fn test_current_preferred(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::CurrentPreferred, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), }] ); } @@ -1243,6 +1668,7 @@ async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Canceled, was_shown: false, model_version: None, + e2e_latency_ms: None, }] ); } @@ -1374,12 +1800,14 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Canceled, was_shown: false, model_version: None, + e2e_latency_ms: None, }, EditPredictionRejection { request_id: first_id, reason: EditPredictionRejectReason::Replaced, was_shown: false, model_version: None, + e2e_latency_ms: Some(0), } ] ); @@ -1542,6 +1970,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Discarded, false, None, + None, cx, ); ep_store.reject_prediction( @@ -1549,6 +1978,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Canceled, true, None, + None, cx, ); }); @@ -1568,6 +1998,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Discarded, was_shown: false, model_version: None, + e2e_latency_ms: None } ); assert_eq!( @@ -1577,6 +2008,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { reason: EditPredictionRejectReason::Canceled, was_shown: true, model_version: None, + e2e_latency_ms: None } ); @@ -1588,6 +2020,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Discarded, false, None, + None, cx, ); } @@ -1620,6 +2053,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Discarded, false, None, + None, cx, ); }); @@ -1640,6 +2074,7 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { EditPredictionRejectReason::Discarded, false, None, + None, cx, ); }); @@ -1656,97 +2091,172 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { assert_eq!(reject_request.rejections[1].request_id, "retry-2"); } -// Skipped until we start including diagnostics in prompt -// #[gpui::test] -// async fn test_request_diagnostics(cx: &mut TestAppContext) { -// let (ep_store, mut req_rx) = init_test_with_fake_client(cx); -// let fs = FakeFs::new(cx.executor()); -// fs.insert_tree( -// "/root", -// json!({ -// "foo.md": "Hello!\nBye" -// }), -// ) -// .await; -// let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; - -// let path_to_buffer_uri = lsp::Uri::from_file_path(path!("/root/foo.md")).unwrap(); -// let diagnostic = lsp::Diagnostic { -// range: lsp::Range::new(lsp::Position::new(1, 1), lsp::Position::new(1, 5)), -// severity: Some(lsp::DiagnosticSeverity::ERROR), -// message: "\"Hello\" deprecated. Use \"Hi\" instead".to_string(), -// ..Default::default() -// }; - -// project.update(cx, |project, cx| { -// project.lsp_store().update(cx, |lsp_store, cx| { -// // Create some diagnostics -// lsp_store -// .update_diagnostics( -// LanguageServerId(0), -// lsp::PublishDiagnosticsParams { -// uri: path_to_buffer_uri.clone(), -// diagnostics: vec![diagnostic], -// version: None, -// }, -// None, -// language::DiagnosticSourceKind::Pushed, -// &[], -// cx, -// ) -// .unwrap(); -// }); -// }); - -// let buffer = project -// .update(cx, |project, cx| { -// let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); -// project.open_buffer(path, cx) -// }) -// .await -// .unwrap(); - -// let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); -// let position = snapshot.anchor_before(language::Point::new(0, 0)); - -// let _prediction_task = ep_store.update(cx, |ep_store, cx| { -// ep_store.request_prediction(&project, &buffer, position, cx) -// }); - -// let (request, _respond_tx) = req_rx.next().await.unwrap(); - -// assert_eq!(request.diagnostic_groups.len(), 1); -// let value = serde_json::from_str::(request.diagnostic_groups[0].0.get()) -// .unwrap(); -// // We probably don't need all of this. TODO define a specific diagnostic type in predict_edits_v3 -// assert_eq!( -// value, -// json!({ -// "entries": [{ -// "range": { -// "start": 8, -// "end": 10 -// }, -// "diagnostic": { -// "source": null, -// "code": null, -// "code_description": null, -// "severity": 1, -// "message": "\"Hello\" deprecated. Use \"Hi\" instead", -// "markdown": null, -// "group_id": 0, -// "is_primary": true, -// "is_disk_based": false, -// "is_unnecessary": false, -// "source_kind": "Pushed", -// "data": null, -// "underline": true -// } -// }], -// "primary_ix": 0 -// }) -// ); -// } +#[gpui::test] +fn test_active_buffer_diagnostics_fetching(cx: &mut TestAppContext) { + let diagnostic_marker: TextRangeMarker = ('«', '»').into(); + let search_range_marker: TextRangeMarker = ('[', ']').into(); + + let (text, mut ranges) = marked_text_ranges_by( + indoc! {r#" + fn alpha() { + let «first_value» = 1; + } + + [fn beta() { + let «second_value» = 2; + let third_value = second_value + missing_symbol; + }ˇ] + + fn gamma() { + let «fourth_value» = missing_other_symbol; + } + "#}, + vec![diagnostic_marker.clone(), search_range_marker.clone()], + ); + + let diagnostic_ranges = ranges.remove(&diagnostic_marker).unwrap_or_default(); + let search_ranges = ranges.remove(&search_range_marker).unwrap_or_default(); + + let buffer = cx.new(|cx| Buffer::local(&text, cx)); + + buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(); + let diagnostics = DiagnosticSet::new( + diagnostic_ranges + .iter() + .enumerate() + .map(|(index, range)| DiagnosticEntry { + range: snapshot.offset_to_point_utf16(range.start) + ..snapshot.offset_to_point_utf16(range.end), + diagnostic: Diagnostic { + severity: match index { + 0 => DiagnosticSeverity::WARNING, + 1 => DiagnosticSeverity::ERROR, + _ => DiagnosticSeverity::HINT, + }, + message: match index { + 0 => "first warning".to_string(), + 1 => "second error".to_string(), + _ => "third hint".to_string(), + }, + group_id: index + 1, + is_primary: true, + source_kind: language::DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + }), + &snapshot, + ); + buffer.update_diagnostics(LanguageServerId(0), diagnostics, cx); + }); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let search_range = snapshot.offset_to_point(search_ranges[0].start) + ..snapshot.offset_to_point(search_ranges[0].end); + + let active_buffer_diagnostics = zeta::active_buffer_diagnostics(&snapshot, search_range, 100); + + assert_eq!( + active_buffer_diagnostics, + vec![zeta_prompt::ActiveBufferDiagnostic { + severity: Some(1), + message: "second error".to_string(), + snippet: text, + snippet_buffer_row_range: 5..5, + diagnostic_range_in_snippet: 61..73, + }] + ); + + let buffer = cx.new(|cx| { + Buffer::local( + indoc! {" + one + two + three + four + five + "}, + cx, + ) + }); + + buffer.update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(); + let diagnostics = DiagnosticSet::new( + vec![ + DiagnosticEntry { + range: text::PointUtf16::new(0, 0)..text::PointUtf16::new(0, 3), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "row zero".to_string(), + group_id: 1, + is_primary: true, + source_kind: language::DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + }, + DiagnosticEntry { + range: text::PointUtf16::new(2, 0)..text::PointUtf16::new(2, 5), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::WARNING, + message: "row two".to_string(), + group_id: 2, + is_primary: true, + source_kind: language::DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + }, + DiagnosticEntry { + range: text::PointUtf16::new(4, 0)..text::PointUtf16::new(4, 4), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::INFORMATION, + message: "row four".to_string(), + group_id: 3, + is_primary: true, + source_kind: language::DiagnosticSourceKind::Pushed, + ..Diagnostic::default() + }, + }, + ], + &snapshot, + ); + buffer.update_diagnostics(LanguageServerId(0), diagnostics, cx); + }); + + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + + let active_buffer_diagnostics = + zeta::active_buffer_diagnostics(&snapshot, Point::new(2, 0)..Point::new(4, 0), 100); + + assert_eq!( + active_buffer_diagnostics + .iter() + .map(|diagnostic| ( + diagnostic.severity, + diagnostic.message.clone(), + diagnostic.snippet.clone(), + diagnostic.snippet_buffer_row_range.clone(), + diagnostic.diagnostic_range_in_snippet.clone(), + )) + .collect::>(), + vec![ + ( + Some(2), + "row two".to_string(), + "one\ntwo\nthree\nfour\nfive\n".to_string(), + 2..2, + 8..13, + ), + ( + Some(3), + "row four".to_string(), + "one\ntwo\nthree\nfour\nfive\n".to_string(), + 4..4, + 19..23, + ), + ] + ); +} // Generate a model response that would apply the given diff to the active file. fn model_response(request: &PredictEditsV3Request, diff_to_apply: &str) -> PredictEditsV3Response { @@ -1774,6 +2284,7 @@ fn empty_response() -> PredictEditsV3Response { fn prompt_from_request(request: &PredictEditsV3Request) -> String { zeta_prompt::format_zeta_prompt(&request.input, zeta_prompt::ZetaFormat::default()) + .expect("default zeta prompt formatting should succeed in edit prediction tests") } fn assert_no_predict_request_ready( @@ -1885,18 +2396,18 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { inputs: ZetaPromptInput { events: Default::default(), related_files: Default::default(), + active_buffer_diagnostics: vec![], cursor_path: Path::new("").into(), cursor_excerpt: "".into(), cursor_offset_in_excerpt: 0, excerpt_start_row: None, excerpt_ranges: Default::default(), + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, repo_url: None, }, - buffer_snapshotted_at: Instant::now(), - response_received_at: Instant::now(), model_version: None, }; @@ -2336,74 +2847,6 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut ); } -#[gpui::test] -fn test_compute_diff_between_snapshots(cx: &mut TestAppContext) { - let buffer = cx.new(|cx| { - Buffer::local( - indoc! {" - zero - one - two - three - four - five - six - seven - eight - nine - ten - eleven - twelve - thirteen - fourteen - fifteen - sixteen - seventeen - eighteen - nineteen - twenty - twenty-one - twenty-two - twenty-three - twenty-four - "}, - cx, - ) - }); - - let old_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); - - buffer.update(cx, |buffer, cx| { - let point = Point::new(12, 0); - buffer.edit([(point..point, "SECOND INSERTION\n")], None, cx); - let point = Point::new(8, 0); - buffer.edit([(point..point, "FIRST INSERTION\n")], None, cx); - }); - - let new_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); - - let (diff, _) = compute_diff_between_snapshots(&old_snapshot, &new_snapshot).unwrap(); - - assert_eq!( - diff, - indoc! {" - @@ -6,10 +6,12 @@ - five - six - seven - +FIRST INSERTION - eight - nine - ten - eleven - +SECOND INSERTION - twelve - thirteen - fourteen - "} - ); -} - #[gpui::test] async fn test_diagnostic_jump_excludes_collaborator_regions(cx: &mut TestAppContext) { fn set_collaborator_cursor(buffer: &Entity, row: u32, cx: &mut TestAppContext) { @@ -2684,6 +3127,7 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) { &snapshot_a, editable_region_a.clone(), None, + Duration::from_secs(0), cx, ); }); @@ -2747,6 +3191,7 @@ async fn test_edit_prediction_settled(cx: &mut TestAppContext) { &snapshot_b2, editable_region_b.clone(), None, + Duration::from_secs(0), cx, ); }); diff --git a/crates/edit_prediction/src/fim.rs b/crates/edit_prediction/src/fim.rs index 02053aae7154acdfa22a01a4f84d6b732a9ca696..46586eb3796026c764ff8659734c564e368681b9 100644 --- a/crates/edit_prediction/src/fim.rs +++ b/crates/edit_prediction/src/fim.rs @@ -6,12 +6,12 @@ use crate::{ use anyhow::{Context as _, Result, anyhow}; use gpui::{App, AppContext as _, Entity, Task}; use language::{ - Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, ToOffset, ToPoint as _, + Anchor, Buffer, BufferSnapshot, ToOffset, ToPoint as _, language_settings::all_language_settings, }; use settings::EditPredictionPromptFormat; use std::{path::Path, sync::Arc, time::Instant}; -use zeta_prompt::ZetaPromptInput; +use zeta_prompt::{ZetaPromptInput, compute_editable_and_context_ranges}; const FIM_CONTEXT_TOKENS: usize = 512; @@ -19,10 +19,8 @@ struct FimRequestOutput { request_id: String, edits: Vec<(std::ops::Range, Arc)>, snapshot: BufferSnapshot, - response_received_at: Instant, inputs: ZetaPromptInput, buffer: Entity, - buffer_snapshotted_at: Instant, } pub fn request_prediction( @@ -47,7 +45,7 @@ pub fn request_prediction( let http_client = cx.http_client(); let cursor_point = position.to_point(&snapshot); - let buffer_snapshotted_at = Instant::now(); + let request_start = cx.background_executor().now(); let Some(settings) = (match provider { settings::EditPredictionProvider::Ollama => settings.ollama.clone(), @@ -62,34 +60,43 @@ pub fn request_prediction( let api_key = load_open_ai_compatible_api_key_if_needed(provider, cx); let result = cx.background_spawn(async move { - let (excerpt_range, _) = cursor_excerpt::editable_and_context_ranges_for_cursor_position( - cursor_point, - &snapshot, + let cursor_offset = cursor_point.to_offset(&snapshot); + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + cursor_excerpt::compute_cursor_excerpt(&snapshot, cursor_offset); + let cursor_excerpt: Arc = snapshot + .text_for_range(excerpt_point_range.clone()) + .collect::() + .into(); + let syntax_ranges = + cursor_excerpt::compute_syntax_ranges(&snapshot, cursor_offset, &excerpt_offset_range); + let (editable_range, _) = compute_editable_and_context_ranges( + &cursor_excerpt, + cursor_offset_in_excerpt, + &syntax_ranges, FIM_CONTEXT_TOKENS, 0, ); - let excerpt_offset_range = excerpt_range.to_offset(&snapshot); - let cursor_offset = cursor_point.to_offset(&snapshot); let inputs = ZetaPromptInput { events, - related_files: Vec::new(), + related_files: Some(Vec::new()), + active_buffer_diagnostics: Vec::new(), cursor_offset_in_excerpt: cursor_offset - excerpt_offset_range.start, cursor_path: full_path.clone(), - excerpt_start_row: Some(excerpt_range.start.row), - cursor_excerpt: snapshot - .text_for_range(excerpt_range) - .collect::() - .into(), + excerpt_start_row: Some(excerpt_point_range.start.row), + cursor_excerpt, excerpt_ranges: Default::default(), + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, repo_url: None, }; - let prefix = inputs.cursor_excerpt[..inputs.cursor_offset_in_excerpt].to_string(); - let suffix = inputs.cursor_excerpt[inputs.cursor_offset_in_excerpt..].to_string(); + let editable_text = &inputs.cursor_excerpt[editable_range.clone()]; + let cursor_in_editable = cursor_offset_in_excerpt.saturating_sub(editable_range.start); + let prefix = editable_text[..cursor_in_editable].to_string(); + let suffix = editable_text[cursor_in_editable..].to_string(); let prompt = format_fim_prompt(prompt_format, &prefix, &suffix); let stop_tokens = get_fim_stop_tokens(); @@ -110,7 +117,7 @@ pub fn request_prediction( log::debug!( "fim: completion received ({:.2}s)", - (response_received_at - buffer_snapshotted_at).as_secs_f64() + (response_received_at - request_start).as_secs_f64() ); let completion: Arc = clean_fim_completion(&response_text).into(); @@ -126,10 +133,8 @@ pub fn request_prediction( request_id, edits, snapshot, - response_received_at, inputs, buffer, - buffer_snapshotted_at, }) }); @@ -142,10 +147,9 @@ pub fn request_prediction( &output.snapshot, output.edits.into(), None, - output.buffer_snapshotted_at, - output.response_received_at, output.inputs, None, + cx.background_executor().now() - request_start, cx, ) .await, diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index cbb4e027253bb4d69b684c0668ff0da60f4e6aaf..71362f4c873ca7b6f89030392449916cdc297b8e 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -1,40 +1,47 @@ 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::{OffsetRangeExt as _, ToOffset, ToPoint as _}; +use language::{ToOffset, ToPoint as _}; use language_model::{ApiKeyState, EnvVar, env_var}; use release_channel::AppVersion; -use serde::Serialize; -use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; - -use zeta_prompt::{ExcerptRanges, ZetaPromptInput}; +use serde::{Deserialize, Serialize}; +use std::{mem, ops::Range, path::Path, sync::Arc}; +use zeta_prompt::ZetaPromptInput; const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; -const MAX_REWRITE_TOKENS: usize = 150; -const MAX_CONTEXT_TOKENS: usize = 350; 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, @@ -44,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); @@ -60,56 +67,51 @@ impl Mercury { let http_client = cx.http_client(); let cursor_point = position.to_point(&snapshot); - let buffer_snapshotted_at = Instant::now(); + let request_start = cx.background_executor().now(); let active_buffer = buffer.clone(); let result = cx.background_spawn(async move { - let (editable_range, context_range) = - crate::cursor_excerpt::editable_and_context_ranges_for_cursor_position( - cursor_point, - &snapshot, - MAX_CONTEXT_TOKENS, - MAX_REWRITE_TOKENS, - ); + let cursor_offset = cursor_point.to_offset(&snapshot); + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + crate::cursor_excerpt::compute_cursor_excerpt(&snapshot, cursor_offset); let related_files = zeta_prompt::filter_redundant_excerpts( related_files, full_path.as_ref(), - context_range.start.row..context_range.end.row, + excerpt_point_range.start.row..excerpt_point_range.end.row, ); - let context_offset_range = context_range.to_offset(&snapshot); - let context_start_row = context_range.start.row; - - let editable_offset_range = editable_range.to_offset(&snapshot); + let cursor_excerpt: Arc = snapshot + .text_for_range(excerpt_point_range.clone()) + .collect::() + .into(); + let syntax_ranges = crate::cursor_excerpt::compute_syntax_ranges( + &snapshot, + cursor_offset, + &excerpt_offset_range, + ); + let excerpt_ranges = zeta_prompt::compute_legacy_excerpt_ranges( + &cursor_excerpt, + cursor_offset_in_excerpt, + &syntax_ranges, + ); - let editable_range_in_excerpt = (editable_offset_range.start - - context_offset_range.start) - ..(editable_offset_range.end - context_offset_range.start); - let context_range_in_excerpt = - 0..(context_offset_range.end - context_offset_range.start); + let editable_offset_range = (excerpt_offset_range.start + + excerpt_ranges.editable_350.start) + ..(excerpt_offset_range.start + excerpt_ranges.editable_350.end); let inputs = zeta_prompt::ZetaPromptInput { events, - related_files, + related_files: Some(related_files), cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - - context_offset_range.start, + - excerpt_offset_range.start, cursor_path: full_path.clone(), - cursor_excerpt: snapshot - .text_for_range(context_range) - .collect::() - .into(), + cursor_excerpt, experiment: None, - excerpt_start_row: Some(context_start_row), - excerpt_ranges: ExcerptRanges { - editable_150: editable_range_in_excerpt.clone(), - editable_180: editable_range_in_excerpt.clone(), - editable_350: editable_range_in_excerpt.clone(), - editable_150_context_350: context_range_in_excerpt.clone(), - editable_180_context_350: context_range_in_excerpt.clone(), - editable_350_context_150: context_range_in_excerpt.clone(), - ..Default::default() - }, + excerpt_start_row: Some(excerpt_point_range.start.row), + excerpt_ranges, + syntax_ranges: Some(syntax_ranges), + active_buffer_diagnostics: vec![], in_open_source_repo: false, can_collect_data: false, repo_url: None, @@ -169,8 +171,13 @@ impl Mercury { .await .context("Failed to read response body")?; - 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(), @@ -214,12 +221,25 @@ impl Mercury { ); } - anyhow::Ok((id, edits, snapshot, response_received_at, inputs)) + anyhow::Ok((id, edits, snapshot, 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, inputs) = result?; anyhow::Ok(Some( EditPredictionResult::new( EditPredictionId(id.into()), @@ -227,10 +247,9 @@ impl Mercury { &old_snapshot, edits.into(), None, - buffer_snapshotted_at, - response_received_at, inputs, None, + cx.background_executor().now() - request_start, cx, ) .await, @@ -260,7 +279,7 @@ fn build_prompt(inputs: &ZetaPromptInput) -> String { &mut prompt, RECENTLY_VIEWED_SNIPPETS_START..RECENTLY_VIEWED_SNIPPETS_END, |prompt| { - for related_file in inputs.related_files.iter() { + for related_file in inputs.related_files.as_deref().unwrap_or_default().iter() { for related_excerpt in &related_file.excerpts { push_delimited( prompt, @@ -323,6 +342,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/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index 263409043b397e2df1ac32514a0ce76656fbefe1..ef2bf2deafb7309f4871a921061ab114fa280e2f 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -1,8 +1,4 @@ -use std::{ - ops::Range, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{ops::Range, sync::Arc}; use cloud_llm_client::EditPredictionRejectReason; use edit_prediction_types::{PredictedCursorPosition, interpolate_edits}; @@ -29,6 +25,7 @@ impl std::fmt::Display for EditPredictionId { pub struct EditPredictionResult { pub id: EditPredictionId, pub prediction: Result, + pub e2e_latency: std::time::Duration, } impl EditPredictionResult { @@ -38,15 +35,15 @@ impl EditPredictionResult { edited_buffer_snapshot: &BufferSnapshot, edits: Arc<[(Range, Arc)]>, cursor_position: Option, - buffer_snapshotted_at: Instant, - response_received_at: Instant, inputs: ZetaPromptInput, model_version: Option, + e2e_latency: std::time::Duration, cx: &mut AsyncApp, ) -> Self { if edits.is_empty() { return Self { id, + e2e_latency, prediction: Err(EditPredictionRejectReason::Empty), }; } @@ -62,6 +59,7 @@ impl EditPredictionResult { else { return Self { id, + e2e_latency, prediction: Err(EditPredictionRejectReason::InterpolatedEmpty), }; }; @@ -70,6 +68,7 @@ impl EditPredictionResult { Self { id: id.clone(), + e2e_latency, prediction: Ok(EditPrediction { id, edits, @@ -78,8 +77,6 @@ impl EditPredictionResult { edit_preview, inputs, buffer: edited_buffer.clone(), - buffer_snapshotted_at, - response_received_at, model_version, }), } @@ -94,8 +91,6 @@ pub struct EditPrediction { pub snapshot: BufferSnapshot, pub edit_preview: EditPreview, pub buffer: Entity, - pub buffer_snapshotted_at: Instant, - pub response_received_at: Instant, pub inputs: zeta_prompt::ZetaPromptInput, pub model_version: Option, } @@ -111,10 +106,6 @@ impl EditPrediction { pub fn targets_buffer(&self, buffer: &Buffer) -> bool { self.snapshot.remote_id() == buffer.remote_id() } - - pub fn latency(&self) -> Duration { - self.response_received_at - self.buffer_snapshotted_at - } } impl std::fmt::Debug for EditPrediction { @@ -156,19 +147,19 @@ mod tests { model_version: None, inputs: ZetaPromptInput { events: vec![], - related_files: vec![], + related_files: Some(vec![]), + active_buffer_diagnostics: vec![], cursor_path: Path::new("path.txt").into(), cursor_offset_in_excerpt: 0, cursor_excerpt: "".into(), excerpt_start_row: None, excerpt_ranges: Default::default(), + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, repo_url: None, }, - buffer_snapshotted_at: Instant::now(), - response_received_at: Instant::now(), }; cx.update(|cx| { diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index d8ce180801aa8902bfff79044cabaae7570ed05f..93a9a34340cfe0b55e40d35bb4c8980dff983fa5 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -21,7 +21,6 @@ use std::{ ops::Range, path::Path, sync::Arc, - time::Instant, }; const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; @@ -50,6 +49,7 @@ impl SweepAi { .sweep .privacy_mode; let debug_info = self.debug_info.clone(); + let request_start = cx.background_executor().now(); self.api_token.update(cx, |key_state, cx| { _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx); }); @@ -90,8 +90,6 @@ impl SweepAi { .take(3) .collect::>(); - let buffer_snapshotted_at = Instant::now(); - let result = cx.background_spawn(async move { let text = inputs.snapshot.text(); @@ -212,7 +210,8 @@ impl SweepAi { let ep_inputs = zeta_prompt::ZetaPromptInput { events: inputs.events, - related_files: inputs.related_files.clone(), + related_files: Some(inputs.related_files.clone()), + active_buffer_diagnostics: vec![], cursor_path: full_path.clone(), cursor_excerpt: request_body.file_contents.clone().into(), cursor_offset_in_excerpt: request_body.cursor_position, @@ -226,6 +225,7 @@ impl SweepAi { editable_350_context_150: 0..inputs.snapshot.len(), ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -253,7 +253,6 @@ impl SweepAi { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; - let response_received_at = Instant::now(); if !response.status().is_success() { let message = format!( "Request failed with status: {:?}\nBody: {}", @@ -287,19 +286,13 @@ impl SweepAi { }) .collect::>(); - anyhow::Ok(( - response.autocomplete_id, - edits, - inputs.snapshot, - response_received_at, - ep_inputs, - )) + anyhow::Ok((response.autocomplete_id, edits, inputs.snapshot, ep_inputs)) }); let buffer = inputs.buffer.clone(); cx.spawn(async move |cx| { - let (id, edits, old_snapshot, response_received_at, inputs) = result.await?; + let (id, edits, old_snapshot, inputs) = result.await?; anyhow::Ok(Some( EditPredictionResult::new( EditPredictionId(id.into()), @@ -307,10 +300,9 @@ impl SweepAi { &old_snapshot, edits.into(), None, - buffer_snapshotted_at, - response_received_at, inputs, None, + cx.background_executor().now() - request_start, cx, ) .await, diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 93fc6aa99a27f18436bc564fbaa39a15d3be0b44..e7d38df5c8e99b86303ca72a715e10acf22eb9b1 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -1,7 +1,8 @@ use crate::{ CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, StoredEvent, - ZedUpdateRequiredError, cursor_excerpt::compute_excerpt_ranges, + ZedUpdateRequiredError, + cursor_excerpt::{self, compute_cursor_excerpt, compute_syntax_ranges}, prediction::EditPredictionResult, }; use anyhow::Result; @@ -11,20 +12,20 @@ use cloud_llm_client::{ use edit_prediction_types::PredictedCursorPosition; use gpui::{App, AppContext as _, Entity, Task, WeakEntity, prelude::*}; use language::{ - Buffer, BufferSnapshot, ToOffset as _, ToPoint, language_settings::all_language_settings, - text_diff, + Buffer, BufferSnapshot, DiagnosticSeverity, OffsetRangeExt as _, ToOffset as _, + language_settings::all_language_settings, text_diff, }; use release_channel::AppVersion; use settings::EditPredictionPromptFormat; -use text::{Anchor, Bias}; +use text::{Anchor, Bias, Point}; use ui::SharedString; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; use zeta_prompt::{ParsedOutput, ZetaPromptInput}; -use std::{env, ops::Range, path::Path, sync::Arc, time::Instant}; +use std::{env, ops::Range, path::Path, sync::Arc}; use zeta_prompt::{ CURSOR_MARKER, ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, - prompt_input_contains_special_tokens, + prompt_input_contains_special_tokens, stop_tokens_for_format, zeta1::{self, EDITABLE_REGION_END_MARKER}, }; @@ -43,6 +44,7 @@ pub fn request_prediction_with_zeta( debug_tx, trigger, project, + diagnostic_search_range, can_collect_data, is_open_source, .. @@ -61,7 +63,7 @@ pub fn request_prediction_with_zeta( }; let http_client = cx.http_client(); - let buffer_snapshotted_at = Instant::now(); + let request_start = cx.background_executor().now(); let raw_config = store.zeta2_raw_config().cloned(); let preferred_experiment = store.preferred_experiment().map(|s| s.to_owned()); let open_ai_compatible_api_key = load_open_ai_compatible_api_key_if_needed(provider, cx); @@ -98,7 +100,6 @@ pub fn request_prediction_with_zeta( snapshot: BufferSnapshot, edits: Vec<(Range, Arc)>, cursor_position: Option, - received_response_at: Instant, editable_range_in_buffer: Range, model_version: Option, } @@ -115,6 +116,7 @@ pub fn request_prediction_with_zeta( &snapshot, related_files, events, + diagnostic_search_range, excerpt_path, cursor_offset, preferred_experiment, @@ -127,13 +129,14 @@ pub fn request_prediction_with_zeta( return Err(anyhow::anyhow!("prompt contains special tokens")); } + let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_version); + if let Some(debug_tx) = &debug_tx { - let prompt = format_zeta_prompt(&prompt_input, zeta_version); debug_tx .unbounded_send(DebugEvent::EditPredictionStarted( EditPredictionStartedDebugEvent { buffer: buffer.downgrade(), - prompt: Some(prompt), + prompt: formatted_prompt.clone(), position, }, )) @@ -142,11 +145,11 @@ pub fn request_prediction_with_zeta( log::trace!("Sending edit prediction request"); - let (request_id, output, model_version, usage) = - if let Some(custom_settings) = &custom_server_settings { + let Some((request_id, output, model_version, usage)) = + (if let Some(custom_settings) = &custom_server_settings { let max_tokens = custom_settings.max_output_tokens * 4; - match custom_settings.prompt_format { + Some(match custom_settings.prompt_format { EditPredictionPromptFormat::Zeta => { let ranges = &prompt_input.excerpt_ranges; let editable_range_in_excerpt = ranges.editable_350.clone(); @@ -183,7 +186,9 @@ pub fn request_prediction_with_zeta( (request_id, parsed_output, None, None) } EditPredictionPromptFormat::Zeta2 => { - let prompt = format_zeta_prompt(&prompt_input, zeta_version); + let Some(prompt) = formatted_prompt.clone() else { + return Ok((None, None)); + }; let prefill = get_prefill(&prompt_input, zeta_version); let prompt = format!("{prompt}{prefill}"); @@ -192,7 +197,10 @@ pub fn request_prediction_with_zeta( custom_settings, prompt, max_tokens, - vec![], + stop_tokens_for_format(zeta_version) + .iter() + .map(|token| token.to_string()) + .collect(), open_ai_compatible_api_key.clone(), &http_client, ) @@ -213,9 +221,11 @@ pub fn request_prediction_with_zeta( (request_id, output_text, None, None) } _ => anyhow::bail!("unsupported prompt format"), - } + }) } else if let Some(config) = &raw_config { - let prompt = format_zeta_prompt(&prompt_input, config.format); + let Some(prompt) = format_zeta_prompt(&prompt_input, config.format) else { + return Ok((None, None)); + }; let prefill = get_prefill(&prompt_input, config.format); let prompt = format!("{prompt}{prefill}"); let environment = config @@ -226,7 +236,10 @@ pub fn request_prediction_with_zeta( model: config.model_id.clone().unwrap_or_default(), prompt, temperature: None, - stop: vec![], + stop: stop_tokens_for_format(config.format) + .iter() + .map(|token| std::borrow::Cow::Borrowed(*token)) + .collect(), max_tokens: Some(2048), environment, }; @@ -254,7 +267,7 @@ pub fn request_prediction_with_zeta( None }; - (request_id, output, None, usage) + Some((request_id, output, None, usage)) } else { // Use V3 endpoint - server handles model/version selection and suffix stripping let (response, usage) = EditPredictionStore::send_v3_request( @@ -275,10 +288,11 @@ pub fn request_prediction_with_zeta( range_in_excerpt: response.editable_range, }; - (request_id, Some(parsed_output), model_version, usage) - }; - - let received_response_at = Instant::now(); + Some((request_id, Some(parsed_output), model_version, usage)) + }) + else { + return Ok((None, None)); + }; log::trace!("Got edit prediction response"); @@ -287,7 +301,7 @@ pub fn request_prediction_with_zeta( range_in_excerpt: editable_range_in_excerpt, }) = output else { - return Ok(((request_id, None), None)); + return Ok((Some((request_id, None)), None)); }; let editable_range_in_buffer = editable_range_in_excerpt.start @@ -333,7 +347,7 @@ pub fn request_prediction_with_zeta( ); anyhow::Ok(( - ( + Some(( request_id, Some(Prediction { prompt_input, @@ -341,18 +355,20 @@ pub fn request_prediction_with_zeta( snapshot: snapshot.clone(), edits, cursor_position, - received_response_at, editable_range_in_buffer, model_version, }), - ), + )), usage, )) } }); cx.spawn(async move |this, cx| { - let (id, prediction) = handle_api_response(&this, request_task.await, cx)?; + let request_duration = cx.background_executor().now() - request_start; + let Some((id, prediction)) = handle_api_response(&this, request_task.await, cx)? else { + return Ok(None); + }; let Some(Prediction { prompt_input: inputs, @@ -360,13 +376,13 @@ pub fn request_prediction_with_zeta( snapshot: edited_buffer_snapshot, edits, cursor_position, - received_response_at, editable_range_in_buffer, model_version, }) = prediction else { return Ok(Some(EditPredictionResult { id, + e2e_latency: request_duration, prediction: Err(EditPredictionRejectReason::Empty), })); }; @@ -404,6 +420,7 @@ pub fn request_prediction_with_zeta( &edited_buffer_snapshot, editable_range_in_buffer, example_spec, + request_duration, cx, ); }) @@ -419,10 +436,9 @@ pub fn request_prediction_with_zeta( &edited_buffer_snapshot, edits.into(), cursor_position, - buffer_snapshotted_at, - received_response_at, inputs, model_version, + request_duration, cx, ) .await, @@ -473,10 +489,50 @@ fn handle_api_response( } } +pub(crate) fn active_buffer_diagnostics( + snapshot: &language::BufferSnapshot, + diagnostic_search_range: Range, + additional_context_token_count: usize, +) -> Vec { + snapshot + .diagnostics_in_range::(diagnostic_search_range, false) + .map(|entry| { + let severity = match entry.diagnostic.severity { + DiagnosticSeverity::ERROR => Some(1), + DiagnosticSeverity::WARNING => Some(2), + DiagnosticSeverity::INFORMATION => Some(3), + DiagnosticSeverity::HINT => Some(4), + _ => None, + }; + let diagnostic_point_range = entry.range.clone(); + let snippet_point_range = cursor_excerpt::expand_context_syntactically_then_linewise( + snapshot, + diagnostic_point_range.clone(), + additional_context_token_count, + ); + let snippet = snapshot + .text_for_range(snippet_point_range.clone()) + .collect::(); + let snippet_start_offset = snippet_point_range.start.to_offset(snapshot); + let diagnostic_offset_range = diagnostic_point_range.to_offset(snapshot); + zeta_prompt::ActiveBufferDiagnostic { + severity, + message: entry.diagnostic.message.clone(), + snippet, + snippet_buffer_row_range: diagnostic_point_range.start.row + ..diagnostic_point_range.end.row, + diagnostic_range_in_snippet: diagnostic_offset_range.start - snippet_start_offset + ..diagnostic_offset_range.end - snippet_start_offset, + } + }) + .collect() +} + pub fn zeta2_prompt_input( snapshot: &language::BufferSnapshot, related_files: Vec, events: Vec>, + diagnostic_search_range: Range, excerpt_path: Arc, cursor_offset: usize, preferred_experiment: Option, @@ -484,33 +540,39 @@ pub fn zeta2_prompt_input( can_collect_data: bool, repo_url: Option, ) -> (Range, zeta_prompt::ZetaPromptInput) { - let cursor_point = cursor_offset.to_point(snapshot); - - let (full_context, full_context_offset_range, excerpt_ranges) = - compute_excerpt_ranges(cursor_point, snapshot); - - let full_context_start_offset = full_context_offset_range.start; - let full_context_start_row = full_context.start.row; + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + compute_cursor_excerpt(snapshot, cursor_offset); + + let cursor_excerpt: Arc = snapshot + .text_for_range(excerpt_point_range.clone()) + .collect::() + .into(); + let syntax_ranges = compute_syntax_ranges(snapshot, cursor_offset, &excerpt_offset_range); + let excerpt_ranges = zeta_prompt::compute_legacy_excerpt_ranges( + &cursor_excerpt, + cursor_offset_in_excerpt, + &syntax_ranges, + ); - let cursor_offset_in_excerpt = cursor_offset - full_context_start_offset; + let active_buffer_diagnostics = + active_buffer_diagnostics(snapshot, diagnostic_search_range, 100); let prompt_input = zeta_prompt::ZetaPromptInput { cursor_path: excerpt_path, - cursor_excerpt: snapshot - .text_for_range(full_context) - .collect::() - .into(), + cursor_excerpt, cursor_offset_in_excerpt, - excerpt_start_row: Some(full_context_start_row), + excerpt_start_row: Some(excerpt_point_range.start.row), events, - related_files, + related_files: Some(related_files), + active_buffer_diagnostics, excerpt_ranges, + syntax_ranges: Some(syntax_ranges), experiment: preferred_experiment, in_open_source_repo: is_open_source, can_collect_data, repo_url, }; - (full_context_offset_range, prompt_input) + (excerpt_offset_range, prompt_input) } pub(crate) fn edit_prediction_accepted( @@ -525,6 +587,7 @@ pub(crate) fn edit_prediction_accepted( let request_id = current_prediction.prediction.id.to_string(); let model_version = current_prediction.prediction.model_version; + let e2e_latency = current_prediction.e2e_latency; let require_auth = custom_accept_url.is_none(); let client = store.client.clone(); let llm_token = store.llm_token.clone(); @@ -550,6 +613,7 @@ pub(crate) fn edit_prediction_accepted( serde_json::to_string(&AcceptEditPredictionBody { request_id: request_id.clone(), model_version: model_version.clone(), + e2e_latency_ms: Some(e2e_latency.as_millis()), })? .into(), ); diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index fe7dff5935aed035e803b1451c8c06df8f79b810..3a20fe0e9a5f89fa3325c1972721a836d60f7156 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -13,7 +13,7 @@ use std::ops::Range; use std::sync::Arc; use zeta_prompt::{ ZetaFormat, encode_patch_as_output_for_format, excerpt_range_for_format, format_zeta_prompt, - output_end_marker_for_format, resolve_cursor_region, + multi_region, output_end_marker_for_format, resolve_cursor_region, }; pub async fn run_format_prompt( @@ -49,6 +49,24 @@ pub async fn run_format_prompt( provider: args.provider, }); } + PredictionProvider::TeacherMultiRegion(_) + | PredictionProvider::TeacherMultiRegionNonBatching(_) => { + step_progress.set_substatus("formatting teacher multi-region prompt"); + + let zeta_format = ZetaFormat::default(); + let (editable_range, context_range) = + excerpt_range_for_format(zeta_format, &prompt_inputs.excerpt_ranges); + + let prompt = + TeacherMultiRegionPrompt::format_prompt(example, editable_range, context_range); + example.prompt = Some(ExamplePrompt { + input: prompt, + expected_output: String::new(), + rejected_output: None, + prefill: None, + provider: args.provider, + }); + } PredictionProvider::Zeta2(zeta_format) => { step_progress.set_substatus("formatting zeta2 prompt"); @@ -74,7 +92,7 @@ pub async fn run_format_prompt( zeta2_output_for_patch(prompt_inputs, patch, None, zeta_format).ok() }); - example.prompt = Some(ExamplePrompt { + example.prompt = prompt.map(|prompt| ExamplePrompt { input: prompt, expected_output, rejected_output, @@ -108,7 +126,7 @@ pub fn zeta2_output_for_patch( return Ok(encoded_output); } - let (mut result, first_hunk_offset) = + let (result, first_hunk_offset) = udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable_region).with_context( || { format!( @@ -118,6 +136,22 @@ pub fn zeta2_output_for_patch( }, )?; + if version == ZetaFormat::V0306SeedMultiRegions { + let cursor_in_new = cursor_offset.map(|cursor_offset| { + let hunk_start = first_hunk_offset.unwrap_or(0); + result.floor_char_boundary((hunk_start + cursor_offset).min(result.len())) + }); + return multi_region::encode_from_old_and_new( + &old_editable_region, + &result, + cursor_in_new, + zeta_prompt::CURSOR_MARKER, + zeta_prompt::seed_coder::END_MARKER, + zeta_prompt::seed_coder::NO_EDITS, + ); + } + + let mut result = result; if let Some(cursor_offset) = cursor_offset { // The cursor_offset is relative to the start of the hunk's new text (context + additions). // We need to add where the hunk context matched in the editable region to compute @@ -211,7 +245,6 @@ impl TeacherPrompt { .context("editable region not found in prompt content")?; let editable_region_start_line = excerpt[..editable_region_offset].matches('\n').count(); - // Use full context so cursor offset (relative to editable region start) aligns with diff content let editable_region_lines = old_editable_region.lines().count() as u32; let diff = language::unified_diff_with_context( &old_editable_region, @@ -259,7 +292,11 @@ impl TeacherPrompt { } pub fn format_context(example: &Example) -> String { - let related_files = example.prompt_inputs.as_ref().map(|pi| &pi.related_files); + let related_files = example + .prompt_inputs + .as_ref() + .and_then(|pi| pi.related_files.as_deref()); + let Some(related_files) = related_files else { return "(No context)".to_string(); }; @@ -314,6 +351,202 @@ impl TeacherPrompt { } } +pub struct TeacherMultiRegionPrompt; + +impl TeacherMultiRegionPrompt { + pub(crate) const USER_CURSOR_MARKER: &str = "<|user_cursor|>"; + pub(crate) const NO_EDITS: &str = "NO_EDITS"; + + /// Truncate edit history to this number of last lines + const MAX_HISTORY_LINES: usize = 128; + + pub fn format_prompt( + example: &Example, + editable_range: Range, + context_range: Range, + ) -> String { + let edit_history = Self::format_edit_history(&example.spec.edit_history); + let context = Self::format_context(example); + let cursor_excerpt = Self::format_cursor_excerpt(example, editable_range, context_range); + + let prompt_template = crate::prompt_assets::get_prompt("teacher_multi_region.md"); + let prompt = prompt_template + .replace("{{context}}", &context) + .replace("{{edit_history}}", &edit_history) + .replace("{{cursor_excerpt}}", &cursor_excerpt); + + prompt + } + + pub fn parse(example: &Example, response: &str) -> Result<(String, Option)> { + let no_edits = (String::new(), None); + if let Some(last_codeblock) = extract_last_codeblock(&response) { + if last_codeblock.trim() == Self::NO_EDITS { + return Ok(no_edits); + } + } + + if response.trim().ends_with(Self::NO_EDITS) { + return Ok(no_edits); + } + + let prompt_inputs = example + .prompt_inputs + .as_ref() + .context("example is missing prompt inputs")?; + + let zeta_format = ZetaFormat::default(); + let (editable_range, _) = + excerpt_range_for_format(zeta_format, &prompt_inputs.excerpt_ranges); + let excerpt = prompt_inputs.cursor_excerpt.as_ref(); + let old_editable_region = &excerpt[editable_range.clone()]; + let marker_offsets = multi_region::compute_marker_offsets(old_editable_region); + + let codeblock = + extract_last_codeblock(&response).context("no codeblock found in model response")?; + let (start_num, end_num, raw_new_span) = multi_region::extract_marker_span(&codeblock)?; + + let start_idx = start_num + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let end_idx = end_num + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let start_byte = *marker_offsets + .get(start_idx) + .context("start marker number out of range")?; + let end_byte = *marker_offsets + .get(end_idx) + .context("end marker number out of range")?; + + if start_byte > end_byte { + return Err(anyhow!("start marker must come before end marker")); + } + + let cursor_in_span = raw_new_span.find(Self::USER_CURSOR_MARKER); + let new_span = raw_new_span.replace(Self::USER_CURSOR_MARKER, ""); + + let old_span = &old_editable_region[start_byte..end_byte]; + let mut new_span = new_span; + if old_span.ends_with('\n') && !new_span.ends_with('\n') && !new_span.is_empty() { + new_span.push('\n'); + } + if !old_span.ends_with('\n') && new_span.ends_with('\n') { + new_span.pop(); + } + + let mut new_editable_region = String::new(); + new_editable_region.push_str(&old_editable_region[..start_byte]); + new_editable_region.push_str(&new_span); + new_editable_region.push_str(&old_editable_region[end_byte..]); + + let cursor_offset = cursor_in_span.map(|pos| start_byte + pos); + + if old_editable_region.starts_with('\n') && !new_editable_region.starts_with('\n') { + new_editable_region.insert(0, '\n'); + } + + let editable_region_offset = editable_range.start; + let editable_region_start_line = excerpt[..editable_region_offset].matches('\n').count(); + + let editable_region_lines = old_editable_region.lines().count() as u32; + let diff = language::unified_diff_with_context( + old_editable_region, + &new_editable_region, + editable_region_start_line as u32, + editable_region_start_line as u32, + editable_region_lines, + ); + + let diff = indoc::formatdoc! {" + --- a/{path} + +++ b/{path} + {diff}", + path = example.spec.cursor_path.to_string_lossy(), + diff = diff, + }; + + let actual_cursor = cursor_offset.map(|editable_region_cursor_offset| { + ActualCursor::from_editable_region( + &example.spec.cursor_path, + editable_region_cursor_offset, + &new_editable_region, + excerpt, + editable_region_offset, + editable_region_start_line, + ) + }); + + Ok((diff, actual_cursor)) + } + + fn format_edit_history(edit_history: &str) -> String { + let lines: Vec<&str> = edit_history.lines().collect(); + + if lines.is_empty() { + return "(No edit history)".to_string(); + } + + if lines.len() > Self::MAX_HISTORY_LINES { + let truncated = lines[lines.len() - Self::MAX_HISTORY_LINES..].join("\n"); + format!("{truncated}\n[...truncated...]") + } else { + lines.join("\n") + } + } + + pub fn format_context(example: &Example) -> String { + let related_files = example + .prompt_inputs + .as_ref() + .and_then(|pi| pi.related_files.as_deref()); + let Some(related_files) = related_files else { + return "(No context)".to_string(); + }; + + if related_files.is_empty() { + return "(No context)".to_string(); + } + + let prefix = "`````"; + let suffix = "`````\n\n"; + let max_tokens = 1024; + zeta_prompt::format_related_files_within_budget(related_files, &prefix, &suffix, max_tokens) + } + + fn format_cursor_excerpt( + example: &Example, + editable_range: Range, + context_range: Range, + ) -> String { + let mut result = String::new(); + + let prompt_inputs = example.prompt_inputs.as_ref().unwrap(); + let excerpt = prompt_inputs.cursor_excerpt.as_ref(); + let cursor_offset = prompt_inputs.cursor_offset_in_excerpt; + + let editable_text = &excerpt[editable_range.clone()]; + let cursor_in_editable = cursor_offset - editable_range.start; + + let path_str = example.spec.cursor_path.to_string_lossy(); + result.push_str(&format!("`````{path_str}\n")); + + result.push_str(&excerpt[context_range.start..editable_range.start]); + + multi_region::write_editable_with_markers( + &mut result, + editable_text, + cursor_in_editable, + Self::USER_CURSOR_MARKER, + ); + + result.push_str(&excerpt[editable_range.end..context_range.end]); + result.push_str("\n`````"); + + result + } +} + /// Extract the cursor excerpt from an example. /// First tries to extract from an existing prompt, then falls back to constructing from prompt_inputs. pub fn extract_cursor_excerpt_from_example(example: &Example) -> Option { @@ -458,7 +691,7 @@ mod tests { } #[test] - fn test_extract_editable_region() { + fn test_extract_editable_region_old_format() { let text = indoc::indoc! {" some lines are @@ -480,6 +713,38 @@ mod tests { ); } + #[test] + fn test_extract_editable_region_marker_format() { + let text = indoc::indoc! {" + some context + <|marker_1|> + one + two three + <|marker_2|> + more context + "}; + let parsed = multi_region::extract_editable_region_from_markers(text).unwrap(); + assert_eq!(parsed, "one\ntwo three"); + } + + #[test] + fn test_extract_editable_region_multi_markers() { + let text = indoc::indoc! {" + prefix + <|marker_1|> + aaa + bbb + <|marker_2|> + ccc + ddd + <|marker_3|> + suffix + "}; + let parsed = multi_region::extract_editable_region_from_markers(text).unwrap(); + // Intermediate marker and its trailing \n are stripped + assert_eq!(parsed, "aaa\nbbb\nccc\nddd"); + } + #[test] fn test_extract_last_codeblock_nested_bibtex() { let text = indoc::indoc! {r#" diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs index df458770519be5accd72f33a56893bb13c9b88a9..d9138482767b2c49bb21bf7ed7c349ec6c9af3ff 100644 --- a/crates/edit_prediction_cli/src/load_project.rs +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -7,12 +7,12 @@ use crate::{ use anyhow::{Context as _, Result}; use edit_prediction::{ EditPredictionStore, - cursor_excerpt::compute_excerpt_ranges, + cursor_excerpt::{compute_cursor_excerpt, compute_syntax_ranges}, udiff::{OpenedBuffers, refresh_worktree_entries, strip_diff_path_prefix}, }; use futures::AsyncWriteExt as _; use gpui::{AsyncApp, Entity}; -use language::{Anchor, Buffer, LanguageNotFound, ToOffset, ToPoint}; +use language::{Anchor, Buffer, LanguageNotFound, ToOffset}; use project::{Project, ProjectPath, buffer_store::BufferStoreEvent}; use std::{fs, path::PathBuf, sync::Arc}; use zeta_prompt::ZetaPromptInput; @@ -71,37 +71,41 @@ pub async fn run_load_project( let existing_related_files = example .prompt_inputs .take() - .map(|inputs| inputs.related_files) - .unwrap_or_default(); + .and_then(|inputs| inputs.related_files); let (prompt_inputs, language_name) = buffer.read_with(&cx, |buffer, _cx| { let snapshot = buffer.snapshot(); - let cursor_point = cursor_position.to_point(&snapshot); let cursor_offset = cursor_position.to_offset(&snapshot); let language_name = buffer .language() .map(|l| l.name().to_string()) .unwrap_or_else(|| "Unknown".to_string()); - let (full_context_point_range, full_context_offset_range, excerpt_ranges) = - compute_excerpt_ranges(cursor_point, &snapshot); + let (excerpt_point_range, excerpt_offset_range, cursor_offset_in_excerpt) = + compute_cursor_excerpt(&snapshot, cursor_offset); let cursor_excerpt: Arc = buffer - .text_for_range(full_context_offset_range.clone()) + .text_for_range(excerpt_offset_range.clone()) .collect::() .into(); - let cursor_offset_in_excerpt = cursor_offset - full_context_offset_range.start; - let excerpt_start_row = Some(full_context_point_range.start.row); + let syntax_ranges = compute_syntax_ranges(&snapshot, cursor_offset, &excerpt_offset_range); + let excerpt_ranges = zeta_prompt::compute_legacy_excerpt_ranges( + &cursor_excerpt, + cursor_offset_in_excerpt, + &syntax_ranges, + ); ( ZetaPromptInput { cursor_path: example.spec.cursor_path.clone(), cursor_excerpt, cursor_offset_in_excerpt, - excerpt_start_row, + excerpt_start_row: Some(excerpt_point_range.start.row), events, related_files: existing_related_files, + active_buffer_diagnostics: vec![], excerpt_ranges, + syntax_ranges: Some(syntax_ranges), in_open_source_repo: false, can_collect_data: false, experiment: None, diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index afe25c5badcfff03babd5e951ae66839ce0f790b..1dcd1d4aa3ad34df853e9d7b193c246f151a61b2 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -360,7 +360,9 @@ enum PredictionProvider { Zeta2(ZetaFormat), Baseten(ZetaFormat), Teacher(TeacherBackend), + TeacherMultiRegion(TeacherBackend), TeacherNonBatching(TeacherBackend), + TeacherMultiRegionNonBatching(TeacherBackend), Repair, } @@ -379,9 +381,15 @@ impl std::fmt::Display for PredictionProvider { PredictionProvider::Zeta2(format) => write!(f, "zeta2:{format}"), PredictionProvider::Baseten(format) => write!(f, "baseten:{format}"), PredictionProvider::Teacher(backend) => write!(f, "teacher:{backend}"), + PredictionProvider::TeacherMultiRegion(backend) => { + write!(f, "teacher-multi-region:{backend}") + } PredictionProvider::TeacherNonBatching(backend) => { write!(f, "teacher-non-batching:{backend}") } + PredictionProvider::TeacherMultiRegionNonBatching(backend) => { + write!(f, "teacher-multi-region-non-batching:{backend}") + } PredictionProvider::Repair => write!(f, "repair"), } } @@ -409,13 +417,27 @@ impl std::str::FromStr for PredictionProvider { .unwrap_or(TeacherBackend::default()); Ok(PredictionProvider::Teacher(backend)) } - "teacher-non-batching" | "teacher_non_batching" | "teachernonbatching" => { + "teacher-multi-region" | "teacher_multi_region" => { + let backend = arg + .map(|a| a.parse()) + .transpose()? + .unwrap_or(TeacherBackend::default()); + Ok(PredictionProvider::TeacherMultiRegion(backend)) + } + "teacher-non-batching" | "teacher_non_batching" => { let backend = arg .map(|a| a.parse()) .transpose()? .unwrap_or(TeacherBackend::default()); Ok(PredictionProvider::TeacherNonBatching(backend)) } + "teacher-multi-region-non-batching" | "teacher_multi_region_non_batching" => { + let backend = arg + .map(|a| a.parse()) + .transpose()? + .unwrap_or(TeacherBackend::default()); + Ok(PredictionProvider::TeacherMultiRegionNonBatching(backend)) + } "repair" => Ok(PredictionProvider::Repair), "baseten" => { let format = arg @@ -426,9 +448,9 @@ impl std::str::FromStr for PredictionProvider { } _ => { anyhow::bail!( - "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-non-batching, repair\n\ + "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-multi-region, teacher-multi-region:, teacher-non-batching, teacher-multi-region-non-batching, repair\n\ For zeta2, you can optionally specify a version like `zeta2:ordered` or `zeta2:V0113_Ordered`.\n\ - For teacher, you can specify a backend like `teacher:sonnet46` or `teacher:gpt52`.\n\ + For teacher providers, you can specify a backend like `teacher:sonnet46`, `teacher-multi-region:sonnet46`, `teacher-multi-region-non-batching:sonnet46`, or `teacher:gpt52`.\n\ Available zeta versions:\n{}", ZetaFormat::options_as_string() ) @@ -491,6 +513,40 @@ enum BatchProvider { Openai, } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prediction_provider_multi_region_non_batched_round_trips_to_primary_spelling() { + let provider: PredictionProvider = "teacher-multi-region-non-batching:sonnet46" + .parse() + .unwrap(); + assert_eq!( + provider, + PredictionProvider::TeacherMultiRegionNonBatching(TeacherBackend::Sonnet46) + ); + assert_eq!( + provider.to_string(), + "teacher-multi-region-non-batching:sonnet46" + ); + } + + #[test] + fn prediction_provider_multi_region_non_batched_alias_round_trips_to_primary_spelling() { + let provider: PredictionProvider = + "teacher_multi_region_non_batching:gpt52".parse().unwrap(); + assert_eq!( + provider, + PredictionProvider::TeacherMultiRegionNonBatching(TeacherBackend::Gpt52) + ); + assert_eq!( + provider.to_string(), + "teacher-multi-region-non-batching:gpt52" + ); + } +} + impl EpArgs { fn output_path(&self) -> Option { if self.in_place { diff --git a/crates/edit_prediction_cli/src/parse_output.rs b/crates/edit_prediction_cli/src/parse_output.rs index 94058efd92ca4a166ba4976819963ef5d3286f5d..2b41384e176ac7a6cc5c3dc7f93ddbba3cf027ae 100644 --- a/crates/edit_prediction_cli/src/parse_output.rs +++ b/crates/edit_prediction_cli/src/parse_output.rs @@ -1,7 +1,7 @@ use crate::{ PredictionProvider, example::{ActualCursor, Example}, - format_prompt::TeacherPrompt, + format_prompt::{TeacherMultiRegionPrompt, TeacherPrompt}, repair, }; use anyhow::{Context as _, Result}; @@ -41,6 +41,10 @@ pub fn parse_prediction_output( PredictionProvider::Teacher(_) | PredictionProvider::TeacherNonBatching(_) => { TeacherPrompt::parse(example, actual_output) } + PredictionProvider::TeacherMultiRegion(_) + | PredictionProvider::TeacherMultiRegionNonBatching(_) => { + TeacherMultiRegionPrompt::parse(example, actual_output) + } PredictionProvider::Zeta2(version) => parse_zeta2_output(example, actual_output, version), PredictionProvider::Repair => repair::parse(example, actual_output), _ => anyhow::bail!( diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 94e28d00da2d61f63b59364304c3b9b4276e15f7..9f70861b5ef7298141441ec09606fa77e341cbfd 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -2,7 +2,7 @@ use crate::{ FormatPromptArgs, PredictArgs, PredictionProvider, TeacherBackend, anthropic_client::AnthropicClient, example::{Example, ExamplePrediction, ExamplePrompt}, - format_prompt::{TeacherPrompt, run_format_prompt}, + format_prompt::{TeacherMultiRegionPrompt, TeacherPrompt, run_format_prompt}, headless::EpAppState, load_project::run_load_project, openai_client::OpenAiClient, @@ -57,8 +57,10 @@ pub async fn run_prediction( ); }; - if let PredictionProvider::Teacher(backend) | PredictionProvider::TeacherNonBatching(backend) = - provider + if let PredictionProvider::Teacher(backend) + | PredictionProvider::TeacherMultiRegion(backend) + | PredictionProvider::TeacherNonBatching(backend) + | PredictionProvider::TeacherMultiRegionNonBatching(backend) = provider { run_context_retrieval(example, app_state.clone(), example_progress, cx.clone()).await?; run_format_prompt( @@ -71,7 +73,10 @@ pub async fn run_prediction( .await?; let step_progress = example_progress.start(Step::Predict); - let batched = matches!(provider, PredictionProvider::Teacher(..)); + let batched = matches!( + provider, + PredictionProvider::Teacher(..) | PredictionProvider::TeacherMultiRegion(..) + ); return predict_teacher( example, backend, @@ -135,7 +140,9 @@ pub async fn run_prediction( PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, PredictionProvider::Teacher(..) + | PredictionProvider::TeacherMultiRegion(..) | PredictionProvider::TeacherNonBatching(..) + | PredictionProvider::TeacherMultiRegionNonBatching(..) | PredictionProvider::Repair | PredictionProvider::Baseten(_) => { unreachable!() @@ -403,7 +410,29 @@ async fn predict_anthropic( .collect::>() .join("\n"); - let (actual_patch, actual_cursor) = TeacherPrompt::parse(example, &actual_output)?; + let parser_provider = if batched { + example + .prompt + .as_ref() + .map(|prompt| prompt.provider) + .unwrap_or(PredictionProvider::Teacher(backend)) + } else { + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) + | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { + PredictionProvider::TeacherMultiRegionNonBatching(backend) + } + _ => PredictionProvider::TeacherNonBatching(backend), + } + }; + + let (actual_patch, actual_cursor) = match parser_provider { + PredictionProvider::TeacherMultiRegion(_) + | PredictionProvider::TeacherMultiRegionNonBatching(_) => { + TeacherMultiRegionPrompt::parse(example, &actual_output)? + } + _ => TeacherPrompt::parse(example, &actual_output)?, + }; let prediction = ExamplePrediction { actual_patch: Some(actual_patch), @@ -411,9 +440,20 @@ async fn predict_anthropic( actual_cursor, error: None, provider: if batched { - PredictionProvider::Teacher(backend) + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) => { + PredictionProvider::TeacherMultiRegion(backend) + } + _ => PredictionProvider::Teacher(backend), + } } else { - PredictionProvider::TeacherNonBatching(backend) + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) + | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { + PredictionProvider::TeacherMultiRegionNonBatching(backend) + } + _ => PredictionProvider::TeacherNonBatching(backend), + } }, }; @@ -487,7 +527,29 @@ async fn predict_openai( .collect::>() .join("\n"); - let (actual_patch, actual_cursor) = TeacherPrompt::parse(example, &actual_output)?; + let parser_provider = if batched { + example + .prompt + .as_ref() + .map(|prompt| prompt.provider) + .unwrap_or(PredictionProvider::Teacher(backend)) + } else { + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) + | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { + PredictionProvider::TeacherMultiRegionNonBatching(backend) + } + _ => PredictionProvider::TeacherNonBatching(backend), + } + }; + + let (actual_patch, actual_cursor) = match parser_provider { + PredictionProvider::TeacherMultiRegion(_) + | PredictionProvider::TeacherMultiRegionNonBatching(_) => { + TeacherMultiRegionPrompt::parse(example, &actual_output)? + } + _ => TeacherPrompt::parse(example, &actual_output)?, + }; let prediction = ExamplePrediction { actual_patch: Some(actual_patch), @@ -495,9 +557,20 @@ async fn predict_openai( actual_cursor, error: None, provider: if batched { - PredictionProvider::Teacher(backend) + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) => { + PredictionProvider::TeacherMultiRegion(backend) + } + _ => PredictionProvider::Teacher(backend), + } } else { - PredictionProvider::TeacherNonBatching(backend) + match example.prompt.as_ref().map(|prompt| prompt.provider) { + Some(PredictionProvider::TeacherMultiRegion(_)) + | Some(PredictionProvider::TeacherMultiRegionNonBatching(_)) => { + PredictionProvider::TeacherMultiRegionNonBatching(backend) + } + _ => PredictionProvider::TeacherNonBatching(backend), + } }, }; @@ -591,7 +664,8 @@ pub async fn predict_baseten( pub async fn sync_batches(provider: Option<&PredictionProvider>) -> anyhow::Result<()> { match provider { - Some(PredictionProvider::Teacher(backend)) => match backend { + Some(PredictionProvider::Teacher(backend)) + | Some(PredictionProvider::TeacherMultiRegion(backend)) => match backend { TeacherBackend::Sonnet45 | TeacherBackend::Sonnet46 => { let llm_client = ANTHROPIC_CLIENT.get_or_init(|| { AnthropicClient::batch(&crate::paths::LLM_CACHE_DB) diff --git a/crates/edit_prediction_cli/src/prompts/teacher_multi_region.md b/crates/edit_prediction_cli/src/prompts/teacher_multi_region.md new file mode 100644 index 0000000000000000000000000000000000000000..61c5c8f3837a321cb565d5c2b089eec94fcc3dc5 --- /dev/null +++ b/crates/edit_prediction_cli/src/prompts/teacher_multi_region.md @@ -0,0 +1,366 @@ +# Instructions + +You are an edit prediction assistant in a code editor. Your task is to predict the next edit to a given region of code surrounding the user's cursor. + +1. Analyze the edit history to understand what the programmer is trying to achieve +2. Identify any incomplete refactoring or changes that need to be finished +3. Make the remaining edits that a human programmer would logically make next (by rewriting a region of code near their cursor) + +## Focus on + +- Completing any partially-applied changes made +- Ensuring consistency with the programming style and patterns already established +- Making edits that maintain or improve code quality + +## Rules + +- **NEVER undo or revert the user's recent edits.** Examine the diff in the edit history carefully: + - If a line was removed (starts with `-`), do NOT restore that content—even if the code now appears incomplete or broken without it + - If a line was added (starts with `+`), do NOT delete or significantly modify it + - If code appears broken or incomplete after the user's edit, output `NO_EDITS` rather than "fixing" it by reverting + - Only add NEW content that extends the user's work forward; never restore what they removed + - **Key test**: if your prediction would make the code more similar to what it was BEFORE the user's edit, output `NO_EDITS` instead + - **Never assume a deletion was accidental.** Even if removing content breaks the code, breaks a pattern, or leaves text looking "incomplete", respect it. The user may be mid-rewrite. Do NOT "complete" partial text by restoring what was deleted. +- Auto-generated code can be modified: Hunks marked with `// User accepted prediction:` contain code from a previous prediction the user accepted. Unlike user-typed content, these hunks CAN be edited, corrected, or replaced if it improves the code. The "never undo/revert" rule protects the user's *current typing intent*—auto-generated code doesn't carry this protection +- Do not just mechanically apply patterns - reason about what changes make sense given the context and the programmer's apparent goals. +- Do not just fix syntax errors - look for the broader refactoring pattern and apply it systematically throughout the code. +- Keep existing formatting unless it's absolutely necessary +- When edit history and surrounding code suggest different edits, prioritize the most recent edits in the history as they best reflect current intent. +- Treat partial text at or near the cursor as the beginning of something the user is actively typing. Complete the code the user appears to be creating based on context. +- When completing partial code, prefer predictions that save meaningful keystrokes, even if this requires making educated guesses about the user's intent. +- For code, it's better to make a substantive prediction that might be rejected than to make a minimal prediction that saves only a few keystrokes. +- When the user is editing prose or documentation (e.g. Markdown, comments, plain text), predict conservatively. Complete the current fragment or sentence, but do not generate additional lines of free-form content since prose is less constrained than code and more prone to incorrect continuations. + +# Input Format + +You will be provided with: +1. The user's *edit history*, in chronological order. Use this to infer the user's trajectory and predict the next most logical edit. + - Hunks preceded by `// User accepted prediction:` indicate code that was auto-generated by a previous prediction and accepted by the user. These are treated differently than user-typed edits (see Rules). +2. A set of *related excerpts* from the user's codebase. Some of these may be needed for correctly predicting the next edit. + - `…` may appear within a related file to indicate that some code has been skipped. +3. An excerpt from the user's *current file*. + - The excerpt contains numbered *marker* tags (`<|marker_1|>`, `<|marker_2|>`, etc.) placed at block boundaries throughout the code. These markers divide the excerpt into spans that you can target for editing. + - Code that appears before the first marker or after the last marker is read-only context and cannot be edited. + - The `<|user_cursor|>` tag marks the user's current cursor position, as it stands after the last edit in the history. + +# Output Format + +- Briefly explain the user's current intent based on the edit history and their current cursor location. +- Output a markdown codeblock containing your predicted edit as a **marker-bounded span**: + - The codeblock must **start** with a marker tag (e.g. `<|marker_2|>`) and **end** with a marker tag (e.g. `<|marker_4|>`). + - The content between these two markers is the full replacement for that span in the original file. + - Choose the **narrowest** pair of markers that fully contains your predicted edits, to minimize unnecessary output. + - Reproduce any unchanged lines within the chosen span faithfully — do not omit or alter them. + - Do not include any intermediate marker tags in your output — only the start and end markers. +- If no edit is needed (the code is already complete and correct, or there is no clear next edit to make), output a codeblock containing only `NO_EDITS`: + ````` + NO_EDITS + ````` +- If there is a specific place in the predicted output where the user is likely to edit next, indicate it using the `<|user_cursor|>` tag. + +## Example 1 + +There is code missing at the cursor location. The related excerpts includes the definition of a relevant type. You should fill in the missing code. + +### Related Excerpts + +````` +struct Product { + name: String, + price: u32, +} +````` + +### User Edit History + +````` +--- a/src/calculate.rs ++++ b/src/calculate.rs +@@ -100,6 +100,7 @@ + fn calculate_total(products: &[Product]) -> u32 { + let mut total = 0; + for product in products { ++ total += ; + } + total + } +````` + +### Current File + +`````src/calculate.rs +fn calculate_total(products: &[Product]) -> u32 { +<|marker_1|> + let mut total = 0; + for product in products { + total += <|user_cursor|>; + } + total +<|marker_2|> +} +````` + +### Output + +The user is computing a sum based on a list of products. The only numeric field on `Product` is `price`, so they must intend to sum the prices. + +````` +<|marker_1|> + let mut total = 0; + for product in products { + total += product.price; + } + total +<|marker_2|> +````` + +## Example 2 + +The user appears to be in the process of typing an eprintln call. Rather than fixing the spelling issue by deleting the newly-inserted content, you must continue the user's trajectory. It's not clear what data they intend to print. You should fill in as much code as is obviously intended, and position the cursor so that the user can fill in the rest. + +### User Edit History + +````` +--- a/src/modal.rs ++++ b/src/modal.rs +@@ -100,4 +100,4 @@ + fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) { + modal_state.close(); +- modal_state.dismiss(); ++ eprmodal_state.dismiss(); + } +````` + +### Current File + +`````src/modal.rs +<|marker_1|> +// handle the close button click +fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) { +<|marker_2|> + modal_state.close(); + epr<|user_cursor|>modal_state.dismiss(); +} +<|marker_3|> +````` + +### Output + +The user is clearly starting to type `eprintln!()`, however, what they intend to print is not obvious. I should fill in the print call and string literal, with the cursor positioned inside the string literal so the user can print whatever they want. + +````` +<|marker_2|> + modal_state.close(); + eprintln!("<|user_cursor|>"); + modal_state.dismiss(); +} +<|marker_3|> +````` + +## Example 3 + +Here, the user is adding a function. There's no way to tell for sure what the function's name will be. In this situation, you should make a reasonable guess at the function's name and signature, and place the user's cursor in the function body. This way, if you guess correctly, it will save the user a meaningful number of keystrokes, and the file will be left in a coherent state. + +### User Edit History + +````` +--- a/src/modal.rs ++++ b/src/modal.rs +@@ -100,4 +100,4 @@ + fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) { + modal_state.close(); + modal_state.dismiss(); + } ++ ++fn + + fn handle_keystroke(modal_state: &mut ModalState, evt: &Event) { +````` + +### Current File + +`````src/modal.rs +// handle the close button click +fn handle_close_button_click(modal_state: &mut ModalState, evt: &Event) { + modal_state.close(); +<|marker_1|> + modal_state.dismiss(); +} + +fn<|user_cursor|> + +<|marker_2|> +fn handle_keystroke(modal_state: &mut ModalState, evt: &Event) { + modal_state.begin_edit(); +<|marker_3|> +````` + +### Output + +The user is adding a new function. The existing functions I see are `handle_close_button_click` and `handle_keystroke`, which have similar signatures. One possible function they might be adding is `handle_submit`. + +````` +<|marker_1|> + modal_state.dismiss(); +} + +fn handle_submit(modal_state: &mut ModalState, evt: &Event) { + <|user_cursor|> +} + +<|marker_2|> +````` + +## Example 4 + +The code is already complete and there is no clear next edit to make. You should output NO_EDITS. + +### User Edit History + +````` +--- a/src/utils.rs ++++ b/src/utils.rs +@@ -10,7 +10,7 @@ + fn add(a: i32, b: i32) -> i32 { +- a - b ++ a + b + } +````` + +### Current File + +`````src/utils.rs +<|marker_1|> +fn add(a: i32, b: i32) -> i32 { + a + b<|user_cursor|> +} +<|marker_2|> +````` + +### Output + +The user just fixed a bug in the `add` function, changing subtraction to addition. The code is now correct and complete. There is no clear next edit to make. + +````` +NO_EDITS +````` + +## Example 5 + +The user just deleted code, leaving behind what looks incomplete. You must NOT "complete" it by restoring deleted content—that would undo their edit. Output NO_EDITS. **This is the correct response even though the code appears broken.** + +### User Edit History + +````` +--- a/config.nix ++++ b/config.nix +@@ -10,7 +10,7 @@ + # /etc/modular/crashdb needs to be mutable +- ln -s /tmp/crashdb $out/etc/modular/crashdb ++ ln -s /tmp/cr $out/etc/modular/crashdb + ''; +````` + +### Current File + +`````config.nix +<|marker_1|> + # /etc/modular/crashdb needs to be mutable + ln -s /tmp/cr<|user_cursor|> $out/etc/modular/crashdb + ''; +<|marker_2|> +````` + +### Output + +The user deleted `ashdb` from `/tmp/crashdb`, leaving `/tmp/cr`. Although this looks like incomplete text that I could "complete", doing so would restore deleted content. The user intentionally removed that text—I must not undo their deletion. + +````` +NO_EDITS +````` + +## Example 6 + +The user accepted a prediction for a function, then started renaming it. The original arguments were auto-generated (marked with `// User accepted prediction:`), so they CAN be updated to match the new function name. This is NOT reverting user input—it's improving auto-generated scaffolding. + +### User Edit History + +````` +--- a/math_utils.py ++++ b/math_utils.py +@@ -3,3 +3,5 @@ + def calculate_rectangle_area(width, height): + return width * height + + ++de + +// User accepted prediction: +--- a/math_utils.py ++++ b/math_utils.py +@@ -3,5 +3,7 @@ + def calculate_rectangle_area(width, height): + return width * height + +-de ++def calculate_rectangle_perimeter(width, height): ++ + +--- a/math_utils.py ++++ b/math_utils.py +@@ -5,5 +5,5 @@ + return width * height + +-def calculate_rectangle_perimeter(width, height): ++def calculate_sq_perimeter(width, height): + +````` + +### Current File + +`````math_utils.py +<|marker_1|> +def calculate_rectangle_area(width, height): + return width * height + +<|marker_2|> +def calculate_sq<|user_cursor|>_perimeter(width, height): + +<|marker_3|> +````` + +### Output + +The user accepted a prediction for `calculate_rectangle_perimeter(width, height)`, then started renaming `rectangle` to `square`. Since squares have equal sides, the arguments should change from `(width, height)` to `(side)`. The arguments were auto-generated (from an accepted prediction), so modifying them is appropriate. + +````` +<|marker_2|> +def calculate_square_perimeter(side): + <|user_cursor|> +<|marker_3|> +````` + + + +# Your task: + +# 1. User Edit History + +````` +{{edit_history}} +````` + +# 2. Related excerpts + +{{context}} + +# 3. Current File + +{{cursor_excerpt}} + + + + +----- + +Based on the edit history and context above, predict the user's next edit within the marker-bounded spans. diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs index a5fb00b39a67a15a7afcced897b4d109f1f3406f..f02509ceb061db078d2a9a98b4322cf246b87594 100644 --- a/crates/edit_prediction_cli/src/retrieve_context.rs +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -20,18 +20,13 @@ pub async fn run_context_retrieval( example_progress: &ExampleProgress, mut cx: AsyncApp, ) -> anyhow::Result<()> { - if example.prompt_inputs.is_some() { - if example.spec.repository_url.is_empty() { - return Ok(()); - } - - if example - .prompt_inputs - .as_ref() - .is_some_and(|inputs| !inputs.related_files.is_empty()) - { - return Ok(()); - } + if example + .prompt_inputs + .as_ref() + .is_some_and(|inputs| inputs.related_files.is_some()) + || example.spec.repository_url.is_empty() + { + return Ok(()); } run_load_project(example, app_state.clone(), example_progress, cx.clone()).await?; @@ -72,7 +67,7 @@ pub async fn run_context_retrieval( step_progress.set_info(format!("{} excerpts", excerpt_count), InfoStyle::Normal); if let Some(prompt_inputs) = example.prompt_inputs.as_mut() { - prompt_inputs.related_files = context_files; + prompt_inputs.related_files = Some(context_files); } Ok(()) } diff --git a/crates/edit_prediction_cli/src/reversal_tracking.rs b/crates/edit_prediction_cli/src/reversal_tracking.rs index cb955dbdf7dd2375395e8c0ecd52df849e33fb38..60661cea04beae4aba4713ac86b51fab42c91979 100644 --- a/crates/edit_prediction_cli/src/reversal_tracking.rs +++ b/crates/edit_prediction_cli/src/reversal_tracking.rs @@ -668,7 +668,8 @@ mod tests { cursor_offset_in_excerpt: 0, excerpt_start_row, events, - related_files: Vec::new(), + related_files: Some(Vec::new()), + active_buffer_diagnostics: Vec::new(), excerpt_ranges: ExcerptRanges { editable_150: 0..content.len(), editable_180: 0..content.len(), @@ -678,6 +679,7 @@ mod tests { editable_350_context_150: 0..content.len(), ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml index e1c1aed4e35f518258edcec8acd59dd9fcac7338..3a63f16610a6b60d2e5a3d415d87698070e7b3f4 100644 --- a/crates/edit_prediction_context/Cargo.toml +++ b/crates/edit_prediction_context/Cargo.toml @@ -42,4 +42,4 @@ serde_json.workspace = true settings = {workspace= true, features = ["test-support"]} text = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } -zlog.workspace = true + diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index 05afbabd2045e9bca591b6c2edba846e95953a4f..b6b6473bafa0222a670e1c541e03d255ee0d2d5a 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -50,18 +50,12 @@ zed_actions.workspace = true zeta_prompt.workspace = true [dev-dependencies] -clock.workspace = true copilot = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } futures.workspace = true indoc.workspace = true -language_model.workspace = true -lsp = { workspace = true, features = ["test-support"] } -pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } -release_channel.workspace = true -semver.workspace = true -serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } -zlog.workspace = true + + 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 d07dbe9bad72c2252ee2e33c8a014778d1331e96..15cccc777feb0a999724f2b4405fc11df8c5f252 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -13,7 +13,7 @@ use project::{ }; use settings::Settings as _; use std::rc::Rc; -use std::{fmt::Write, sync::Arc, time::Duration}; +use std::{fmt::Write, sync::Arc}; use theme::ThemeSettings; use ui::{ ContextMenu, DropdownMenu, KeyBinding, List, ListItem, ListItemSpacing, PopoverMenuHandle, @@ -402,7 +402,13 @@ impl RatePredictionsModal { write!(&mut formatted_inputs, "## Related files\n\n").unwrap(); - for included_file in prediction.inputs.related_files.iter() { + for included_file in prediction + .inputs + .related_files + .as_deref() + .unwrap_or_default() + .iter() + { write!( &mut formatted_inputs, "### {}\n\n", @@ -759,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( @@ -785,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, @@ -848,30 +850,18 @@ impl RatePredictionsModal { .gap_3() .child(Icon::new(icon_name).color(icon_color).size(IconSize::Small)) .child( - v_flex() - .child( - h_flex() - .gap_1() - .child(Label::new(file_name).size(LabelSize::Small)) - .when_some(file_path, |this, p| { - this.child( - Label::new(p) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }), - ) - .child( - Label::new(format!( - "{} ago, {:.2?}", - format_time_ago( - completion.response_received_at.elapsed() - ), - completion.latency() - )) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), + v_flex().child( + h_flex() + .gap_1() + .child(Label::new(file_name).size(LabelSize::Small)) + .when_some(file_path, |this, p| { + this.child( + Label::new(p) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ), ), ) .tooltip(Tooltip::text(tooltip_text)) @@ -975,23 +965,6 @@ impl Focusable for RatePredictionsModal { impl ModalView for RatePredictionsModal {} -fn format_time_ago(elapsed: Duration) -> String { - let seconds = elapsed.as_secs(); - if seconds < 120 { - "1 minute".to_string() - } else if seconds < 3600 { - format!("{} minutes", seconds / 60) - } else if seconds < 7200 { - "1 hour".to_string() - } else if seconds < 86400 { - format!("{} hours", seconds / 3600) - } else if seconds < 172800 { - "1 day".to_string() - } else { - format!("{} days", seconds / 86400) - } -} - struct FeedbackCompletionProvider; impl FeedbackCompletionProvider { diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 2a8709dea29cf1398a862216e407b973eae41004..22a9b8effbe52caa67812619d254076493210e68 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -119,7 +119,7 @@ release_channel.workspace = true rand.workspace = true semver.workspace = true settings = { workspace = true, features = ["test-support"] } -tempfile.workspace = true + text = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } tree-sitter-c.workspace = true @@ -133,7 +133,7 @@ unicode-width.workspace = true unindent.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } + zlog.workspace = true diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 16fe29a7fa4aa066cf045a63c477fbb569d80334..657f1e1b23d91ca421da6a38fbeaa382a65863db 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -1455,6 +1455,60 @@ mod foo «1{ ); } + #[gpui::test] + // reproduction of #47846 + async fn test_bracket_colorization_with_folds(cx: &mut gpui::TestAppContext) { + init_test(cx, |language_settings| { + language_settings.defaults.colorize_brackets = Some(true); + }); + let mut cx = EditorLspTestContext::new( + Arc::into_inner(rust_lang()).unwrap(), + lsp::ServerCapabilities::default(), + cx, + ) + .await; + + // Generate a large function body. When folded, this collapses + // to a single display line, making small_function visible on screen. + let mut big_body = String::new(); + for i in 0..700 { + big_body.push_str(&format!(" let var_{i:04} = ({i});\n")); + } + let source = format!( + "ˇfn big_function() {{\n{big_body}}}\n\nfn small_function() {{\n let x = (1, (2, 3));\n}}\n" + ); + + cx.set_state(&source); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.fold_ranges( + vec![Point::new(0, 0)..Point::new(701, 1)], + false, + window, + cx, + ); + }); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + indoc! {r#" +⋯1» + +fn small_function«1()1» «1{ + let x = «2(1, «3(2, 3)3»)2»; +}1» + +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +3 hsla(286.00, 51.00%, 75.25%, 1.00) +"#,}, + bracket_colors_markup(&mut cx), + ); + } + fn separate_with_comment_lines(head: &str, tail: &str, comment_lines: usize) -> String { let mut result = head.to_string(); result.push_str("\n"); diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 00a48a9ab3d249850b9749d64267d8274e7eaa79..b11832faa3f9bb8294c6ea054a335292b1422b02 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -107,7 +107,7 @@ use project::{InlayId, lsp_store::LspFoldingRange, lsp_store::TokenType}; use serde::Deserialize; use smallvec::SmallVec; use sum_tree::{Bias, TreeMap}; -use text::{BufferId, LineIndent, Patch, ToOffset as _}; +use text::{BufferId, LineIndent, Patch}; use ui::{SharedString, px}; use unicode_segmentation::UnicodeSegmentation; use ztracing::instrument; @@ -1977,57 +1977,11 @@ impl DisplaySnapshot { /// Returned ranges are 0-based relative to `buffer_range.start`. pub(super) fn combined_highlights( &self, - buffer_id: BufferId, - buffer_range: Range, + multibuffer_range: Range, syntax_theme: &theme::SyntaxTheme, ) -> Vec<(Range, HighlightStyle)> { let multibuffer = self.buffer_snapshot(); - let multibuffer_range = multibuffer - .excerpts() - .find_map(|(excerpt_id, buffer, range)| { - if buffer.remote_id() != buffer_id { - return None; - } - let context_start = range.context.start.to_offset(buffer); - let context_end = range.context.end.to_offset(buffer); - if buffer_range.start < context_start || buffer_range.end > context_end { - return None; - } - let start_anchor = buffer.anchor_before(buffer_range.start); - let end_anchor = buffer.anchor_after(buffer_range.end); - let mb_range = - multibuffer.anchor_range_in_excerpt(excerpt_id, start_anchor..end_anchor)?; - Some(mb_range.start.to_offset(multibuffer)..mb_range.end.to_offset(multibuffer)) - }); - - let Some(multibuffer_range) = multibuffer_range else { - // Range is outside all excerpts (e.g. symbol name not in a - // multi-buffer excerpt). Fall back to buffer-level syntax highlights. - let buffer_snapshot = multibuffer.excerpts().find_map(|(_, buffer, _)| { - (buffer.remote_id() == buffer_id).then(|| buffer.clone()) - }); - let Some(buffer_snapshot) = buffer_snapshot else { - return Vec::new(); - }; - let mut highlights = Vec::new(); - let mut offset = 0usize; - for chunk in buffer_snapshot.chunks(buffer_range, true) { - let chunk_len = chunk.text.len(); - if chunk_len == 0 { - continue; - } - if let Some(style) = chunk - .syntax_highlight_id - .and_then(|id| id.style(syntax_theme)) - { - highlights.push((offset..offset + chunk_len, style)); - } - offset += chunk_len; - } - return highlights; - }; - let chunks = custom_highlights::CustomHighlightsChunks::new( multibuffer_range, true, diff --git a/crates/editor/src/document_colors.rs b/crates/editor/src/document_colors.rs index 579414c7f91c6b2770951a2439599abc4000b27c..a38a0527f0641ef2d622b2f33fa1e932080ad7b5 100644 --- a/crates/editor/src/document_colors.rs +++ b/crates/editor/src/document_colors.rs @@ -145,7 +145,7 @@ impl Editor { _: &Window, cx: &mut Context, ) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } let Some(project) = self.project.as_ref() else { diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index 94d53eb19621cbe4d84734e2e77286180a59adf7..0228bbd917ad96b94778b2fc01d3a66e81224296 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -1,4 +1,4 @@ -use std::{cmp, ops::Range}; +use std::ops::Range; use collections::HashMap; use futures::FutureExt; @@ -6,10 +6,15 @@ use futures::future::join_all; use gpui::{App, Context, HighlightStyle, Task}; use itertools::Itertools as _; use language::language_settings::language_settings; -use language::{Buffer, BufferSnapshot, OutlineItem}; -use multi_buffer::{Anchor, MultiBufferSnapshot}; -use text::{Bias, BufferId, OffsetRangeExt as _, ToOffset as _}; +use language::{Buffer, OutlineItem}; +use multi_buffer::{ + Anchor, AnchorRangeExt as _, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, + ToOffset as _, +}; +use text::BufferId; use theme::{ActiveTheme as _, SyntaxTheme}; +use unicode_segmentation::UnicodeSegmentation as _; +use util::maybe; use crate::display_map::DisplaySnapshot; use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT}; @@ -142,7 +147,7 @@ impl Editor { for_buffer: Option, cx: &mut Context, ) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } let Some(project) = self.project.clone() else { @@ -215,16 +220,13 @@ impl Editor { let display_snapshot = editor.display_map.update(cx, |map, cx| map.snapshot(cx)); let mut highlighted_results = results; - for (buffer_id, items) in &mut highlighted_results { - if let Some(buffer) = editor.buffer.read(cx).buffer(*buffer_id) { - let snapshot = buffer.read(cx).snapshot(); - apply_highlights( - items, - *buffer_id, - &snapshot, - &display_snapshot, - &syntax, - ); + for items in highlighted_results.values_mut() { + for item in items { + if let Some(highlights) = + highlights_from_buffer(&display_snapshot, &item, &syntax) + { + item.highlight_ranges = highlights; + } } } editor.lsp_document_symbols.extend(highlighted_results); @@ -242,34 +244,6 @@ fn lsp_symbols_enabled(buffer: &Buffer, cx: &App) -> bool { .lsp_enabled() } -/// Applies combined syntax + semantic token highlights to LSP document symbol -/// outline items that were built without highlights by the project layer. -fn apply_highlights( - items: &mut [OutlineItem], - buffer_id: BufferId, - buffer_snapshot: &BufferSnapshot, - display_snapshot: &DisplaySnapshot, - syntax_theme: &SyntaxTheme, -) { - for item in items { - let symbol_range = item.range.to_offset(buffer_snapshot); - let selection_start = item.source_range_for_text.start.to_offset(buffer_snapshot); - - if let Some(highlights) = highlights_from_buffer( - &item.text, - 0, - buffer_id, - buffer_snapshot, - display_snapshot, - symbol_range, - selection_start, - syntax_theme, - ) { - item.highlight_ranges = highlights; - } - } -} - /// Finds where the symbol name appears in the buffer and returns combined /// (tree-sitter + semantic token) highlights for those positions. /// @@ -278,117 +252,78 @@ fn apply_highlights( /// to word-by-word matching for cases like `impl Trait for Type` /// where the LSP name doesn't appear verbatim in the buffer. fn highlights_from_buffer( - name: &str, - name_offset_in_text: usize, - buffer_id: BufferId, - buffer_snapshot: &BufferSnapshot, display_snapshot: &DisplaySnapshot, - symbol_range: Range, - selection_start_offset: usize, + item: &OutlineItem, syntax_theme: &SyntaxTheme, ) -> Option, HighlightStyle)>> { - if name.is_empty() { + let outline_text = &item.text; + if outline_text.is_empty() { return None; } - let range_start_offset = symbol_range.start; - let range_end_offset = symbol_range.end; - - // Try to find the name verbatim in the buffer near the selection range. - let search_start = buffer_snapshot.clip_offset( - selection_start_offset - .saturating_sub(name.len()) - .max(range_start_offset), - Bias::Right, - ); - let search_end = buffer_snapshot.clip_offset( - cmp::min(selection_start_offset + name.len() * 2, range_end_offset), - Bias::Left, - ); - - if search_start < search_end { - let buffer_text: String = buffer_snapshot - .text_for_range(search_start..search_end) - .collect(); - if let Some(found_at) = buffer_text.find(name) { - let name_start_offset = search_start + found_at; - let name_end_offset = name_start_offset + name.len(); - let result = highlights_for_buffer_range( - name_offset_in_text, - name_start_offset..name_end_offset, - buffer_id, - display_snapshot, - syntax_theme, + let multi_buffer_snapshot = display_snapshot.buffer(); + let multi_buffer_source_range_anchors = + multi_buffer_snapshot.text_anchors_to_visible_anchors([ + item.source_range_for_text.start, + item.source_range_for_text.end, + ]); + let Some(anchor_range) = maybe!({ + Some( + (*multi_buffer_source_range_anchors.get(0)?)? + ..(*multi_buffer_source_range_anchors.get(1)?)?, + ) + }) else { + return None; + }; + + let selection_point_range = anchor_range.to_point(multi_buffer_snapshot); + let mut search_start = selection_point_range.start; + search_start.column = 0; + let search_start_offset = search_start.to_offset(&multi_buffer_snapshot); + let mut search_end = selection_point_range.end; + search_end.column = multi_buffer_snapshot.line_len(MultiBufferRow(search_end.row)); + + let search_text = multi_buffer_snapshot + .text_for_range(search_start..search_end) + .collect::(); + + let mut outline_text_highlights = Vec::new(); + match search_text.find(outline_text) { + Some(start_index) => { + let multibuffer_start = search_start_offset + MultiBufferOffset(start_index); + let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_text.len()); + outline_text_highlights.extend( + display_snapshot + .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme), ); - if result.is_some() { - return result; - } } - } - - // Fallback: match word-by-word. Split the name on whitespace and find - // each word sequentially in the buffer's symbol range. - let range_start_offset = buffer_snapshot.clip_offset(range_start_offset, Bias::Right); - let range_end_offset = buffer_snapshot.clip_offset(range_end_offset, Bias::Left); - - let mut highlights = Vec::new(); - let mut got_any = false; - let buffer_text: String = buffer_snapshot - .text_for_range(range_start_offset..range_end_offset) - .collect(); - let mut buf_search_from = 0usize; - let mut name_search_from = 0usize; - for word in name.split_whitespace() { - let name_word_start = name[name_search_from..] - .find(word) - .map(|pos| name_search_from + pos) - .unwrap_or(name_search_from); - if let Some(found_in_buf) = buffer_text[buf_search_from..].find(word) { - let buf_word_start = range_start_offset + buf_search_from + found_in_buf; - let buf_word_end = buf_word_start + word.len(); - let text_cursor = name_offset_in_text + name_word_start; - if let Some(mut word_highlights) = highlights_for_buffer_range( - text_cursor, - buf_word_start..buf_word_end, - buffer_id, - display_snapshot, - syntax_theme, - ) { - got_any = true; - highlights.append(&mut word_highlights); + None => { + for (outline_text_word_start, outline_word) in outline_text.split_word_bound_indices() { + if let Some(start_index) = search_text.find(outline_word) { + let multibuffer_start = search_start_offset + MultiBufferOffset(start_index); + let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_word.len()); + outline_text_highlights.extend( + display_snapshot + .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme) + .into_iter() + .map(|(range_in_word, style)| { + ( + outline_text_word_start + range_in_word.start + ..outline_text_word_start + range_in_word.end, + style, + ) + }), + ); + } } - buf_search_from = buf_search_from + found_in_buf + word.len(); } - name_search_from = name_word_start + word.len(); } - got_any.then_some(highlights) -} - -/// Gets combined (tree-sitter + semantic token) highlights for a buffer byte -/// range via the editor's display snapshot, then shifts the returned ranges -/// so they start at `text_cursor_start` (the position in the outline item text). -fn highlights_for_buffer_range( - text_cursor_start: usize, - buffer_range: Range, - buffer_id: BufferId, - display_snapshot: &DisplaySnapshot, - syntax_theme: &SyntaxTheme, -) -> Option, HighlightStyle)>> { - let raw = display_snapshot.combined_highlights(buffer_id, buffer_range, syntax_theme); - if raw.is_empty() { - return None; + if outline_text_highlights.is_empty() { + None + } else { + Some(outline_text_highlights) } - Some( - raw.into_iter() - .map(|(range, style)| { - ( - range.start + text_cursor_start..range.end + text_cursor_start, - style, - ) - }) - .collect(), - ) } #[cfg(test)] diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index a997a5f86dfbd3582c0566b8e3351777e0345219..c82915c686e977178398430948f28f8178f216df 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -4,7 +4,13 @@ use edit_prediction_types::{ use gpui::{Entity, KeyBinding, Modifiers, prelude::*}; use indoc::indoc; use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint}; -use std::{ops::Range, sync::Arc}; +use std::{ + ops::Range, + sync::{ + Arc, + atomic::{self, AtomicUsize}, + }, +}; use text::{Point, ToOffset}; use ui::prelude::*; @@ -12,6 +18,8 @@ use crate::{ AcceptEditPrediction, EditPrediction, MenuEditPredictionsPolicy, editor_tests::init_test, test::editor_test_context::EditorTestContext, }; +use rpc::proto::PeerId; +use workspace::CollaboratorId; #[gpui::test] async fn test_edit_prediction_insert(cx: &mut gpui::TestAppContext) { @@ -359,6 +367,60 @@ async fn test_edit_prediction_jump_disabled_for_non_zed_providers(cx: &mut gpui: }); } +#[gpui::test] +async fn test_edit_prediction_refresh_suppressed_while_following(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new(|_| FakeEditPredictionDelegate::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + cx.set_state("let x = ˇ;"); + + propose_edits(&provider, vec![(8..8, "42")], &mut cx); + + cx.update_editor(|editor, window, cx| { + editor.refresh_edit_prediction(false, false, window, cx); + editor.update_visible_edit_prediction(window, cx); + }); + + assert_eq!( + provider.read_with(&cx.cx, |provider, _| { + provider.refresh_count.load(atomic::Ordering::SeqCst) + }), + 1 + ); + cx.editor(|editor, _, _| { + assert!(editor.active_edit_prediction.is_some()); + }); + + cx.update_editor(|editor, window, cx| { + editor.leader_id = Some(CollaboratorId::PeerId(PeerId::default())); + editor.refresh_edit_prediction(false, false, window, cx); + }); + + assert_eq!( + provider.read_with(&cx.cx, |provider, _| { + provider.refresh_count.load(atomic::Ordering::SeqCst) + }), + 1 + ); + cx.editor(|editor, _, _| { + assert!(editor.active_edit_prediction.is_none()); + }); + + cx.update_editor(|editor, window, cx| { + editor.leader_id = None; + editor.refresh_edit_prediction(false, false, window, cx); + }); + + assert_eq!( + provider.read_with(&cx.cx, |provider, _| { + provider.refresh_count.load(atomic::Ordering::SeqCst) + }), + 2 + ); +} + #[gpui::test] async fn test_edit_prediction_preview_cleanup_on_toggle_off(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -567,6 +629,7 @@ fn assign_editor_completion_provider_non_zed( #[derive(Default, Clone)] pub struct FakeEditPredictionDelegate { pub completion: Option, + pub refresh_count: Arc, } impl FakeEditPredictionDelegate { @@ -619,6 +682,7 @@ impl EditPredictionDelegate for FakeEditPredictionDelegate { _debounce: bool, _cx: &mut gpui::Context, ) { + self.refresh_count.fetch_add(1, atomic::Ordering::SeqCst); } fn accept(&mut self, _cx: &mut gpui::Context) {} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index cb63e5f85d766637f5775bb864d79998ada9c254..204011412ec9b229ffdd49195e907369baa2d97f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -35,13 +35,13 @@ mod lsp_ext; mod mouse_context_menu; pub mod movement; mod persistence; +mod runnables; mod rust_analyzer_ext; pub mod scroll; mod selections_collection; pub mod semantic_tokens; mod split; pub mod split_editor_view; -pub mod tasks; #[cfg(test)] mod code_completion_tests; @@ -133,8 +133,8 @@ use language::{ BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape, DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind, IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, LocalFile, OffsetRangeExt, - OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId, - TreeSitterOptions, WordsQuery, + OutlineItem, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions, + WordsQuery, language_settings::{ self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings, language_settings, @@ -158,7 +158,7 @@ use project::{ BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId, InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project, - ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind, + ProjectItem, ProjectPath, ProjectTransaction, debugger::{ breakpoint_store::{ Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState, @@ -200,7 +200,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables}; +use task::TaskVariables; use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _}; use theme::{ AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, @@ -209,6 +209,7 @@ use theme::{ use ui::{ Avatar, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide, + utils::WithRemSize, }; use ui_input::ErasedEditor; use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc}; @@ -216,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, }; @@ -230,6 +231,7 @@ use crate::{ InlineValueCache, inlay_hints::{LspInlayHintData, inlay_hint_settings}, }, + runnables::{ResolvedTasks, RunnableData, RunnableTasks}, scroll::{ScrollOffset, ScrollPixelOffset}, selections_collection::resolve_selections_wrapping_blocks, semantic_tokens::SemanticTokenState, @@ -856,37 +858,6 @@ impl BufferSerialization { } } -#[derive(Clone, Debug)] -struct RunnableTasks { - templates: Vec<(TaskSourceKind, TaskTemplate)>, - offset: multi_buffer::Anchor, - // We need the column at which the task context evaluation should take place (when we're spawning it via gutter). - column: u32, - // Values of all named captures, including those starting with '_' - extra_variables: HashMap, - // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal. - context_range: Range, -} - -impl RunnableTasks { - fn resolve<'a>( - &'a self, - cx: &'a task::TaskContext, - ) -> impl Iterator + 'a { - self.templates.iter().filter_map(|(kind, template)| { - template - .resolve_task(&kind.to_id_base(), cx) - .map(|task| (kind.clone(), task)) - }) - } -} - -#[derive(Clone)] -pub struct ResolvedTasks { - templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, - position: Anchor, -} - /// Addons allow storing per-editor state in other crates (e.g. Vim) pub trait Addon: 'static { fn extend_key_context(&self, _: &mut KeyContext, _: &App) {} @@ -1294,8 +1265,7 @@ pub struct Editor { last_bounds: Option>, last_position_map: Option>, expect_bounds_change: Option>, - tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, - tasks_update_task: Option>, + runnables: RunnableData, breakpoint_store: Option>, gutter_breakpoint_indicator: (Option, Option>), pub(crate) gutter_diff_review_indicator: (Option, Option>), @@ -2172,16 +2142,9 @@ impl Editor { editor.registered_buffers.clear(); editor.register_visible_buffers(cx); editor.invalidate_semantic_tokens(None); + editor.refresh_runnables(None, window, cx); editor.update_lsp_data(None, window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); - if editor.tasks_update_task.is_none() { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); - } - } - project::Event::LanguageServerAdded(..) => { - if editor.tasks_update_task.is_none() { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); - } } project::Event::SnippetEdit(id, snippet_edits) => { // todo(lw): Non singletons @@ -2209,6 +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(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); @@ -2287,7 +2251,7 @@ impl Editor { &task_inventory, window, |editor, _, window, cx| { - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); + editor.refresh_runnables(None, window, cx); }, )); }; @@ -2528,7 +2492,6 @@ impl Editor { }), blame: None, blame_subscription: None, - tasks: BTreeMap::default(), breakpoint_store, gutter_breakpoint_indicator: (None, None), @@ -2564,7 +2527,7 @@ impl Editor { ] }) .unwrap_or_default(), - tasks_update_task: None, + runnables: RunnableData::new(), pull_diagnostics_task: Task::ready(()), colors: None, refresh_colors_task: Task::ready(()), @@ -2631,7 +2594,6 @@ impl Editor { cx.notify(); })); } - editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); editor._subscriptions.extend(project_subscriptions); editor._subscriptions.push(cx.subscribe_in( @@ -2659,15 +2621,7 @@ impl Editor { .await; editor .update_in(cx, |editor, window, cx| { - editor.register_visible_buffers(cx); - editor.colorize_brackets(false, cx); - editor.refresh_inlay_hints( - InlayHintRefreshReason::NewLinesShown, - cx, - ); - if !editor.buffer().read(cx).is_singleton() { - editor.update_lsp_data(None, window, cx); - } + editor.update_data_on_scroll(window, cx) }) .ok(); }); @@ -5790,18 +5744,11 @@ impl Editor { let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let multi_buffer = self.buffer().read(cx); let multi_buffer_snapshot = multi_buffer.snapshot(cx); - let multi_buffer_visible_start = self - .scroll_manager - .native_anchor(&display_snapshot, cx) - .anchor - .to_point(&multi_buffer_snapshot); - let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( - multi_buffer_visible_start - + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), - Bias::Left, - ); multi_buffer_snapshot - .range_to_buffer_ranges(multi_buffer_visible_start..=multi_buffer_visible_end) + .range_to_buffer_ranges( + self.multi_buffer_visible_range(&display_snapshot, cx) + .to_inclusive(), + ) .into_iter() .filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty()) .filter_map(|(buffer, excerpt_visible_range, excerpt_id)| { @@ -6534,6 +6481,7 @@ impl Editor { .selections .all::(&self.display_snapshot(cx)); let mut ranges = Vec::new(); + let mut all_commit_ranges = Vec::new(); let mut linked_edits = LinkedEdits::new(); let text: Arc = new_text.clone().into(); @@ -6559,10 +6507,12 @@ impl Editor { ranges.push(range.clone()); + let start_anchor = snapshot.anchor_before(range.start); + let end_anchor = snapshot.anchor_after(range.end); + let anchor_range = start_anchor.text_anchor..end_anchor.text_anchor; + all_commit_ranges.push(anchor_range.clone()); + if !self.linked_edit_ranges.is_empty() { - let start_anchor = snapshot.anchor_before(range.start); - let end_anchor = snapshot.anchor_after(range.end); - let anchor_range = start_anchor.text_anchor..end_anchor.text_anchor; linked_edits.push(&self, anchor_range, text.clone(), cx); } } @@ -6649,6 +6599,7 @@ impl Editor { completions_menu.completions.clone(), candidate_id, true, + all_commit_ranges, cx, ); @@ -6736,8 +6687,8 @@ impl Editor { }; let buffer_id = buffer.read(cx).remote_id(); let tasks = self - .tasks - .get(&(buffer_id, buffer_row)) + .runnables + .runnables((buffer_id, buffer_row)) .map(|t| Arc::new(t.to_owned())); if !self.focus_handle.is_focused(window) { @@ -7500,7 +7451,8 @@ impl Editor { let mut read_ranges = Vec::new(); for highlight in highlights { let buffer_id = cursor_buffer.read(cx).remote_id(); - for (excerpt_id, excerpt_range) in buffer.excerpts_for_buffer(buffer_id, cx) + for (excerpt_id, _, excerpt_range) in + buffer.excerpts_for_buffer(buffer_id, cx) { let start = highlight .range @@ -7731,7 +7683,7 @@ impl Editor { #[ztracing::instrument(skip_all)] fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context) { - if !self.mode.is_full() { + if !self.lsp_data_enabled() { return; } let cursor = self.selections.newest_anchor().head(); @@ -7787,24 +7739,13 @@ impl Editor { self.debounced_selection_highlight_complete = false; } if on_buffer_edit || query_changed { - let multi_buffer_visible_start = self - .scroll_manager - .native_anchor(&display_snapshot, cx) - .anchor - .to_point(&multi_buffer_snapshot); - let multi_buffer_visible_end = multi_buffer_snapshot.clip_point( - multi_buffer_visible_start - + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0), - Bias::Left, - ); - let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end; self.quick_selection_highlight_task = Some(( query_range.clone(), self.update_selection_occurrence_highlights( snapshot.buffer.clone(), query_text.clone(), query_range.clone(), - multi_buffer_visible_range, + self.multi_buffer_visible_range(&display_snapshot, cx), false, window, cx, @@ -7839,6 +7780,27 @@ impl Editor { } } + pub fn multi_buffer_visible_range( + &self, + display_snapshot: &DisplaySnapshot, + cx: &App, + ) -> Range { + let visible_start = self + .scroll_manager + .native_anchor(display_snapshot, cx) + .anchor + .to_point(display_snapshot.buffer_snapshot()) + .to_display_point(display_snapshot); + + let mut target_end = visible_start; + *target_end.row_mut() += self.visible_line_count().unwrap_or(0.).ceil() as u32; + + visible_start.to_point(display_snapshot) + ..display_snapshot + .clip_point(target_end, Bias::Right) + .to_point(display_snapshot) + } + pub fn refresh_edit_prediction( &mut self, debounce: bool, @@ -7846,7 +7808,11 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option<()> { - let provider = self.edit_prediction_provider()?; + if self.leader_id.is_some() { + self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); + return None; + } + let cursor = self.selections.newest_anchor().head(); let (buffer, cursor_buffer_position) = self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; @@ -7871,7 +7837,8 @@ impl Editor { return None; } - provider.refresh(buffer, cursor_buffer_position, debounce, cx); + self.edit_prediction_provider()? + .refresh(buffer, cursor_buffer_position, debounce, cx); Some(()) } @@ -7996,7 +7963,7 @@ impl Editor { cx: &App, ) -> bool { maybe!({ - if self.read_only(cx) { + if self.read_only(cx) || self.leader_id.is_some() { return Some(false); } let provider = self.edit_prediction_provider()?; @@ -8422,6 +8389,7 @@ impl Editor { self.update_hovered_link( position_map.point_for_position(mouse_position), + Some(mouse_position), &position_map.snapshot, modifiers, window, @@ -8807,19 +8775,6 @@ impl Editor { Some(self.edit_prediction_provider.as_ref()?.provider.clone()) } - fn clear_tasks(&mut self) { - self.tasks.clear() - } - - fn insert_tasks(&mut self, key: (BufferId, BufferRow), value: RunnableTasks) { - if self.tasks.insert(key, value).is_some() { - // This case should hopefully be rare, but just in case... - log::error!( - "multiple different run targets found on a single line, only the last target will be rendered" - ) - } - } - /// Get all display points of breakpoints that will be rendered within editor /// /// This function is used to handle overlaps between breakpoints and Code action/runner symbol. @@ -9197,156 +9152,6 @@ impl Editor { }) } - pub fn spawn_nearest_task( - &mut self, - action: &SpawnNearestTask, - window: &mut Window, - cx: &mut Context, - ) { - let Some((workspace, _)) = self.workspace.clone() else { - return; - }; - let Some(project) = self.project.clone() else { - return; - }; - - // Try to find a closest, enclosing node using tree-sitter that has a task - let Some((buffer, buffer_row, tasks)) = self - .find_enclosing_node_task(cx) - // Or find the task that's closest in row-distance. - .or_else(|| self.find_closest_task(cx)) - else { - return; - }; - - let reveal_strategy = action.reveal; - let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx); - cx.spawn_in(window, async move |_, cx| { - let context = task_context.await?; - let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?; - - let resolved = &mut resolved_task.resolved; - resolved.reveal = reveal_strategy; - - workspace - .update_in(cx, |workspace, window, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - }) - .ok() - }) - .detach(); - } - - fn find_closest_task( - &mut self, - cx: &mut Context, - ) -> Option<(Entity, u32, Arc)> { - let cursor_row = self - .selections - .newest_adjusted(&self.display_snapshot(cx)) - .head() - .row; - - let ((buffer_id, row), tasks) = self - .tasks - .iter() - .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?; - - let buffer = self.buffer.read(cx).buffer(*buffer_id)?; - let tasks = Arc::new(tasks.to_owned()); - Some((buffer, *row, tasks)) - } - - fn find_enclosing_node_task( - &mut self, - cx: &mut Context, - ) -> Option<(Entity, u32, Arc)> { - let snapshot = self.buffer.read(cx).snapshot(cx); - let offset = self - .selections - .newest::(&self.display_snapshot(cx)) - .head(); - let mut excerpt = snapshot.excerpt_containing(offset..offset)?; - let offset = excerpt.map_offset_to_buffer(offset); - let buffer_id = excerpt.buffer().remote_id(); - - let layer = excerpt.buffer().syntax_layer_at(offset)?; - let mut cursor = layer.node().walk(); - - while cursor.goto_first_child_for_byte(offset.0).is_some() { - if cursor.node().end_byte() == offset.0 { - cursor.goto_next_sibling(); - } - } - - // Ascend to the smallest ancestor that contains the range and has a task. - loop { - let node = cursor.node(); - let node_range = node.byte_range(); - let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row; - - // Check if this node contains our offset - if node_range.start <= offset.0 && node_range.end >= offset.0 { - // If it contains offset, check for task - if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) { - let buffer = self.buffer.read(cx).buffer(buffer_id)?; - return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned()))); - } - } - - if !cursor.goto_parent() { - break; - } - } - None - } - - fn render_run_indicator( - &self, - _style: &EditorStyle, - is_active: bool, - row: DisplayRow, - breakpoint: Option<(Anchor, Breakpoint, Option)>, - cx: &mut Context, - ) -> IconButton { - let color = Color::Muted; - let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor); - - IconButton::new( - ("run_indicator", row.0 as usize), - ui::IconName::PlayOutlined, - ) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .icon_color(color) - .toggle_state(is_active) - .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { - let quick_launch = match e { - ClickEvent::Keyboard(_) => true, - ClickEvent::Mouse(e) => e.down.button == MouseButton::Left, - }; - - window.focus(&editor.focus_handle(cx), cx); - editor.toggle_code_actions( - &ToggleCodeActions { - deployed_from: Some(CodeActionSource::RunMenu(row)), - quick_launch, - }, - window, - cx, - ); - })) - .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { - editor.set_breakpoint_context_menu(row, position, event.position(), window, cx); - })) - } - pub fn context_menu_visible(&self) -> bool { !self.edit_prediction_preview_is_active() && self @@ -12642,9 +12447,7 @@ impl Editor { cx: &mut Context, ) { self.manipulate_text(window, cx, |text| { - text.split('\n') - .map(|line| line.to_case(Case::Title)) - .join("\n") + Self::convert_text_case(text, Case::Title) }) } @@ -12654,7 +12457,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Snake)) + self.manipulate_text(window, cx, |text| { + Self::convert_text_case(text, Case::Snake) + }) } pub fn convert_to_kebab_case( @@ -12663,7 +12468,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Kebab)) + self.manipulate_text(window, cx, |text| { + Self::convert_text_case(text, Case::Kebab) + }) } pub fn convert_to_upper_camel_case( @@ -12673,9 +12480,7 @@ impl Editor { cx: &mut Context, ) { self.manipulate_text(window, cx, |text| { - text.split('\n') - .map(|line| line.to_case(Case::UpperCamel)) - .join("\n") + Self::convert_text_case(text, Case::UpperCamel) }) } @@ -12685,7 +12490,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Camel)) + self.manipulate_text(window, cx, |text| { + Self::convert_text_case(text, Case::Camel) + }) } pub fn convert_to_opposite_case( @@ -12713,7 +12520,9 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - self.manipulate_text(window, cx, |text| text.to_case(Case::Sentence)) + self.manipulate_text(window, cx, |text| { + Self::convert_text_case(text, Case::Sentence) + }) } pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context) { @@ -12744,6 +12553,18 @@ impl Editor { }) } + fn convert_text_case(text: &str, case: Case) -> String { + text.lines() + .map(|line| { + let trimmed_start = line.trim_start(); + let leading = &line[..line.len() - trimmed_start.len()]; + let trimmed = trimmed_start.trim_end(); + let trailing = &trimmed_start[trimmed.len()..]; + format!("{}{}{}", leading, trimmed.to_case(case), trailing) + }) + .join("\n") + } + pub fn convert_to_rot47( &mut self, _: &ConvertToRot47, @@ -14898,6 +14719,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + let stop_at_indent = action.stop_at_indent && !self.mode.is_single_line(); self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); self.change_selections(Default::default(), window, cx, |s| { s.move_cursors_with(&mut |map, head, _| { @@ -14906,7 +14728,7 @@ impl Editor { map, head, action.stop_at_soft_wraps, - action.stop_at_indent, + stop_at_indent, ), SelectionGoal::None, ) @@ -14920,6 +14742,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { + let stop_at_indent = action.stop_at_indent && !self.mode.is_single_line(); self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); self.change_selections(Default::default(), window, cx, |s| { s.move_heads_with(&mut |map, head, _| { @@ -14928,7 +14751,7 @@ impl Editor { map, head, action.stop_at_soft_wraps, - action.stop_at_indent, + stop_at_indent, ), SelectionGoal::None, ) @@ -17151,236 +16974,6 @@ impl Editor { }); } - fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { - if !EditorSettings::get_global(cx).gutter.runnables || !self.enable_runnables { - self.clear_tasks(); - return Task::ready(()); - } - let project = self.project().map(Entity::downgrade); - let task_sources = self.lsp_task_sources(cx); - let multi_buffer = self.buffer.downgrade(); - cx.spawn_in(window, async move |editor, cx| { - cx.background_executor().timer(UPDATE_DEBOUNCE).await; - let Some(project) = project.and_then(|p| p.upgrade()) else { - return; - }; - let Ok(display_snapshot) = editor.update(cx, |this, cx| { - this.display_map.update(cx, |map, cx| map.snapshot(cx)) - }) else { - return; - }; - - let hide_runnables = project.update(cx, |project, _| project.is_via_collab()); - if hide_runnables { - return; - } - let new_rows = - cx.background_spawn({ - let snapshot = display_snapshot.clone(); - async move { - Self::fetch_runnable_ranges(&snapshot, Anchor::min()..Anchor::max()) - } - }) - .await; - let Ok(lsp_tasks) = - cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx)) - else { - return; - }; - let lsp_tasks = lsp_tasks.await; - - let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { - lsp_tasks - .into_iter() - .flat_map(|(kind, tasks)| { - tasks.into_iter().filter_map(move |(location, task)| { - Some((kind.clone(), location?, task)) - }) - }) - .fold(HashMap::default(), |mut acc, (kind, location, task)| { - let buffer = location.target.buffer; - let buffer_snapshot = buffer.read(cx).snapshot(); - let offset = display_snapshot.buffer_snapshot().excerpts().find_map( - |(excerpt_id, snapshot, _)| { - if snapshot.remote_id() == buffer_snapshot.remote_id() { - display_snapshot - .buffer_snapshot() - .anchor_in_excerpt(excerpt_id, location.target.range.start) - } else { - None - } - }, - ); - if let Some(offset) = offset { - let task_buffer_range = - location.target.range.to_point(&buffer_snapshot); - let context_buffer_range = - task_buffer_range.to_offset(&buffer_snapshot); - let context_range = BufferOffset(context_buffer_range.start) - ..BufferOffset(context_buffer_range.end); - - acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row)) - .or_insert_with(|| RunnableTasks { - templates: Vec::new(), - offset, - column: task_buffer_range.start.column, - extra_variables: HashMap::default(), - context_range, - }) - .templates - .push((kind, task.original_task().clone())); - } - - acc - }) - }) else { - return; - }; - - let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| { - buffer.language_settings(cx).tasks.prefer_lsp - }) else { - return; - }; - - let rows = Self::runnable_rows( - project, - display_snapshot, - prefer_lsp && !lsp_tasks_by_rows.is_empty(), - new_rows, - cx.clone(), - ) - .await; - editor - .update(cx, |editor, _| { - editor.clear_tasks(); - for (key, mut value) in rows { - if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&key) { - value.templates.extend(lsp_tasks.templates); - } - - editor.insert_tasks(key, value); - } - for (key, value) in lsp_tasks_by_rows { - editor.insert_tasks(key, value); - } - }) - .ok(); - }) - } - fn fetch_runnable_ranges( - snapshot: &DisplaySnapshot, - range: Range, - ) -> Vec<(Range, language::RunnableRange)> { - snapshot.buffer_snapshot().runnable_ranges(range).collect() - } - - fn runnable_rows( - project: Entity, - snapshot: DisplaySnapshot, - prefer_lsp: bool, - runnable_ranges: Vec<(Range, language::RunnableRange)>, - cx: AsyncWindowContext, - ) -> Task> { - cx.spawn(async move |cx| { - let mut runnable_rows = Vec::with_capacity(runnable_ranges.len()); - for (run_range, mut runnable) in runnable_ranges { - let Some(tasks) = cx - .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) - .ok() - else { - continue; - }; - let mut tasks = tasks.await; - - if prefer_lsp { - tasks.retain(|(task_kind, _)| { - !matches!(task_kind, TaskSourceKind::Language { .. }) - }); - } - if tasks.is_empty() { - continue; - } - - let point = run_range.start.to_point(&snapshot.buffer_snapshot()); - let Some(row) = snapshot - .buffer_snapshot() - .buffer_line_for_row(MultiBufferRow(point.row)) - .map(|(_, range)| range.start.row) - else { - continue; - }; - - let context_range = - BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end); - runnable_rows.push(( - (runnable.buffer_id, row), - RunnableTasks { - templates: tasks, - offset: snapshot.buffer_snapshot().anchor_before(run_range.start), - context_range, - column: point.column, - extra_variables: runnable.extra_captures, - }, - )); - } - runnable_rows - }) - } - - fn templates_with_tags( - project: &Entity, - runnable: &mut Runnable, - cx: &mut App, - ) -> Task> { - let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| { - let (worktree_id, file) = project - .buffer_for_id(runnable.buffer, cx) - .and_then(|buffer| buffer.read(cx).file()) - .map(|file| (file.worktree_id(cx), file.clone())) - .unzip(); - - ( - project.task_store().read(cx).task_inventory().cloned(), - worktree_id, - file, - ) - }); - - let tags = mem::take(&mut runnable.tags); - let language = runnable.language.clone(); - cx.spawn(async move |cx| { - let mut templates_with_tags = Vec::new(); - if let Some(inventory) = inventory { - for RunnableTag(tag) in tags { - let new_tasks = inventory.update(cx, |inventory, cx| { - inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx) - }); - templates_with_tags.extend(new_tasks.await.into_iter().filter( - move |(_, template)| { - template.tags.iter().any(|source_tag| source_tag == &tag) - }, - )); - } - } - templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned()); - - if let Some((leading_tag_source, _)) = templates_with_tags.first() { - // Strongest source wins; if we have worktree tag binding, prefer that to - // global and language bindings; - // if we have a global binding, prefer that to language binding. - let first_mismatch = templates_with_tags - .iter() - .position(|(tag_source, _)| tag_source != leading_tag_source); - if let Some(index) = first_mismatch { - templates_with_tags.truncate(index); - } - } - - templates_with_tags - }) - } - pub fn move_to_enclosing_bracket( &mut self, _: &MoveToEnclosingBracket, @@ -19605,7 +19198,7 @@ impl Editor { } pub fn diagnostics_enabled(&self) -> bool { - self.diagnostics_enabled && self.mode.is_full() + self.diagnostics_enabled && self.lsp_data_enabled() } pub fn inline_diagnostics_enabled(&self) -> bool { @@ -19769,10 +19362,7 @@ impl Editor { // `ActiveDiagnostic::All` is a special mode where editor's diagnostics are managed by the external view, // skip any LSP updates for it. - if self.active_diagnostics == ActiveDiagnostic::All - || !self.mode().is_full() - || !self.diagnostics_enabled() - { + if self.active_diagnostics == ActiveDiagnostic::All || !self.diagnostics_enabled() { return None; } let pull_diagnostics_settings = ProjectSettings::get_global(cx) @@ -20481,7 +20071,7 @@ impl Editor { &mut self, creases: Vec>, auto_scroll: bool, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { if creases.is_empty() { @@ -20497,6 +20087,7 @@ impl Editor { cx.notify(); self.scrollbar_marker_state.dirty = true; + self.update_data_on_scroll(window, cx); self.folds_did_change(cx); } @@ -20539,7 +20130,7 @@ impl Editor { let mut all_folded_excerpt_ids = Vec::new(); for buffer_id in &ids_to_fold { let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(*buffer_id, cx); - all_folded_excerpt_ids.extend(folded_excerpts.into_iter().map(|(id, _)| id)); + all_folded_excerpt_ids.extend(folded_excerpts.into_iter().map(|(id, _, _)| id)); } self.display_map.update(cx, |display_map, cx| { @@ -20569,7 +20160,7 @@ impl Editor { display_map.unfold_buffers([buffer_id], cx); }); cx.emit(EditorEvent::BufferFoldToggled { - ids: unfolded_excerpts.iter().map(|&(id, _)| id).collect(), + ids: unfolded_excerpts.iter().map(|&(id, _, _)| id).collect(), folded: false, }); cx.notify(); @@ -22941,7 +22532,7 @@ impl Editor { .snapshot(); let mut handled = false; - for (id, ExcerptRange { context, .. }) in + for (id, _, ExcerptRange { context, .. }) in self.buffer.read(cx).excerpts_for_buffer(buffer_id, cx) { if context.start.cmp(&position, &snapshot).is_ge() @@ -23274,18 +22865,28 @@ impl Editor { _: &mut Window, cx: &mut Context, ) { - let selection = self - .selections - .newest::(&self.display_snapshot(cx)) - .start - .row - + 1; + let selection = self.selections.newest::(&self.display_snapshot(cx)); + + let start_line = selection.start.row + 1; + let end_line = selection.end.row + 1; + + let end_line = if selection.end.column == 0 && end_line > start_line { + end_line - 1 + } else { + end_line + }; + if let Some(file_location) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { let project = self.project()?.read(cx); let file = buffer.read(cx).file()?; let path = file.path().display(project.path_style(cx)); - Some(format!("{path}:{selection}")) + let location = if start_line == end_line { + format!("{path}:{start_line}") + } else { + format!("{path}:{start_line}-{end_line}") + }; + Some(location) }) { cx.write_to_clipboard(ClipboardItem::new_string(file_location)); } @@ -24063,6 +23664,14 @@ impl Editor { editor.buffer.read(cx).buffer(excerpt.buffer_id()) })?; + if current_execution_position + .text_anchor + .buffer_id + .is_some_and(|id| id != buffer.read(cx).remote_id()) + { + return Some(Task::ready(Ok(Vec::new()))); + } + let range = buffer.read(cx).anchor_before(0)..current_execution_position.text_anchor; @@ -24127,7 +23736,10 @@ impl Editor { cx: &mut Context, ) { match event { - multi_buffer::Event::Edited { edited_buffer } => { + multi_buffer::Event::Edited { + edited_buffer, + is_local, + } => { self.scrollbar_marker_state.dirty = true; self.active_indent_guides_state.dirty = true; self.refresh_active_diagnostics(cx); @@ -24137,7 +23749,7 @@ impl Editor { self.refresh_matching_bracket_highlights(&snapshot, cx); self.refresh_outline_symbols_at_cursor(cx); self.refresh_sticky_headers(&snapshot, cx); - if self.has_active_edit_prediction() { + if *is_local && self.has_active_edit_prediction() { self.update_visible_edit_prediction(window, cx); } @@ -24177,7 +23789,6 @@ impl Editor { predecessor, excerpts, } => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); let buffer_id = buffer.read(cx).remote_id(); if self.buffer.read(cx).diff_for(buffer_id).is_none() && let Some(project) = &self.project @@ -24195,6 +23806,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(None, window, cx); self.colorize_brackets(false, cx); self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx); cx.emit(EditorEvent::ExcerptsAdded { @@ -24213,8 +23825,7 @@ impl Editor { self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); for buffer_id in removed_buffer_ids { self.registered_buffers.remove(buffer_id); - self.tasks - .retain(|(task_buffer_id, _), _| task_buffer_id != buffer_id); + self.clear_runnables(Some(*buffer_id)); self.semantic_token_state.invalidate_buffer(buffer_id); self.display_map.update(cx, |display_map, cx| { display_map.invalidate_semantic_highlights(*buffer_id); @@ -24256,10 +23867,11 @@ impl Editor { } self.colorize_brackets(false, cx); self.update_lsp_data(None, window, cx); + self.refresh_runnables(None, window, cx); cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() }) } multi_buffer::Event::Reparsed(buffer_id) => { - self.tasks_update_task = Some(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); @@ -24267,7 +23879,7 @@ impl Editor { cx.emit(EditorEvent::Reparsed(*buffer_id)); } multi_buffer::Event::DiffHunksToggled => { - self.tasks_update_task = Some(self.refresh_runnables(window, cx)); + self.refresh_runnables(None, window, cx); } multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => { if !is_fresh_language { @@ -24403,7 +24015,7 @@ impl Editor { .unwrap_or(DiagnosticSeverity::Hint); self.set_max_diagnostics_severity(new_severity, cx); } - self.tasks_update_task = Some(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); @@ -25623,13 +25235,17 @@ impl Editor { } } + fn lsp_data_enabled(&self) -> bool { + self.enable_lsp_data && self.mode().is_full() + } + fn update_lsp_data( &mut self, for_buffer: Option, window: &mut Window, cx: &mut Context<'_, Self>, ) { - if !self.enable_lsp_data { + if !self.lsp_data_enabled() { return; } @@ -25643,7 +25259,7 @@ impl Editor { } fn register_visible_buffers(&mut self, cx: &mut Context) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } for (_, (visible_buffer, _, _)) in self.visible_excerpts(true, cx) { @@ -25652,7 +25268,7 @@ impl Editor { } fn register_buffer(&mut self, buffer_id: BufferId, cx: &mut Context) { - if !self.mode().is_full() { + if !self.lsp_data_enabled() { return; } @@ -25723,14 +25339,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(|| { @@ -25751,19 +25366,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) } @@ -25775,6 +25388,16 @@ impl Editor { fn disable_runnables(&mut self) { self.enable_runnables = false; } + + fn update_data_on_scroll(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) { + self.register_visible_buffers(cx); + self.colorize_brackets(false, cx); + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); + if !self.buffer().read(cx).is_singleton() { + self.update_lsp_data(None, window, cx); + self.refresh_runnables(None, window, cx); + } + } } fn edit_for_markdown_paste<'a>( @@ -26964,6 +26587,7 @@ pub trait CompletionProvider { _completions: Rc>>, _completion_index: usize, _push_to_history: bool, + _all_commit_ranges: Vec>, _cx: &mut Context, ) -> Task>> { Task::ready(Ok(None)) @@ -27332,6 +26956,7 @@ impl CompletionProvider for Entity { completions: Rc>>, completion_index: usize, push_to_history: bool, + all_commit_ranges: Vec>, cx: &mut Context, ) -> Task>> { self.update(cx, |project, cx| { @@ -27341,6 +26966,7 @@ impl CompletionProvider for Entity { completions, completion_index, push_to_history, + all_commit_ranges, cx, ) }) @@ -29060,12 +28686,41 @@ impl BreakpointPromptEditor { }, ) } + + fn render_close_button(&self, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.prompt.focus_handle(cx); + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .shape(IconButtonShape::Square) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Cancel", &menu::Cancel, &focus_handle, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel(&menu::Cancel, window, cx); + })) + } + + fn render_confirm_button(&self, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.prompt.focus_handle(cx); + IconButton::new("confirm", IconName::Return) + .icon_color(Color::Muted) + .shape(IconButtonShape::Square) + .tooltip(move |_window, cx| { + Tooltip::for_action_in("Confirm", &menu::Confirm, &focus_handle, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.confirm(&menu::Confirm, window, cx); + })) + } } impl Render for BreakpointPromptEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); let editor_margins = *self.editor_margins.lock(); let gutter_dimensions = editor_margins.gutter; + let left_gutter_width = gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0); + let right_padding = editor_margins.right + px(9.); h_flex() .key_context("Editor") .bg(cx.theme().colors().editor_background) @@ -29073,10 +28728,34 @@ impl Render for BreakpointPromptEditor { .border_color(cx.theme().status().info_border) .size_full() .py(window.line_height() / 2.5) + .pr(right_padding) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::cancel)) - .child(h_flex().w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))) - .child(div().flex_1().child(self.render_prompt_editor(cx))) + .child( + WithRemSize::new(ui_font_size) + .h_full() + .w(left_gutter_width) + .flex() + .flex_row() + .flex_shrink_0() + .items_center() + .justify_center() + .gap_1() + .child(self.render_close_button(cx)), + ) + .child( + h_flex() + .w_full() + .justify_between() + .child(div().flex_1().child(self.render_prompt_editor(cx))) + .child( + WithRemSize::new(ui_font_size) + .flex() + .flex_row() + .items_center() + .child(self.render_confirm_button(cx)), + ), + ) } } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index d3da58733dd0a24622a6dcde87f638069e206cf4..683995e8ff0817e9f11c276fba1e85eef29eee7a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -5,6 +5,7 @@ use crate::{ edit_prediction_tests::FakeEditPredictionDelegate, element::StickyHeader, linked_editing_ranges::LinkedEditingRanges, + runnables::RunnableTasks, scroll::scroll_amount::ScrollAmount, test::{ assert_text_with_selections, build_editor, editor_content_with_blocks, @@ -1867,6 +1868,56 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_beginning_of_line_single_line_editor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|window, cx| Editor::single_line(window, cx)); + + _ = editor.update(cx, |editor, window, cx| { + editor.set_text(" indented text", window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10) + ]); + }); + + editor.move_to_beginning_of_line( + &MoveToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); + + _ = editor.update(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 10) + ]); + }); + + editor.select_to_beginning_of_line( + &SelectToBeginningOfLine { + stop_at_soft_wraps: true, + stop_at_indent: true, + }, + window, + cx, + ); + assert_eq!( + display_ranges(editor, cx), + &[DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 0)] + ); + }); +} + #[gpui::test] fn test_beginning_end_of_line_ignore_soft_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -6217,6 +6268,77 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { «HeLlO, wOrLD!ˇ» "}); + // Test that case conversions backed by `to_case` preserve leading/trailing whitespace. + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); + cx.assert_editor_state(indoc! {" + « Hello Worldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_upper_camel_case(&ConvertToUpperCamelCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + « HelloWorldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_lower_camel_case(&ConvertToLowerCamelCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + « helloWorldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); + cx.assert_editor_state(indoc! {" + « hello_worldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_kebab_case(&ConvertToKebabCase, window, cx)); + cx.assert_editor_state(indoc! {" + « hello-worldˇ» + "}); + + cx.set_state(indoc! {" + « hello worldˇ» + "}); + cx.update_editor(|e, window, cx| { + e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx) + }); + cx.assert_editor_state(indoc! {" + « Hello worldˇ» + "}); + + cx.set_state(indoc! {" + « hello world\t\tˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_title_case(&ConvertToTitleCase, window, cx)); + cx.assert_editor_state(indoc! {" + « Hello World\t\tˇ» + "}); + + cx.set_state(indoc! {" + « hello world\t\tˇ» + "}); + cx.update_editor(|e, window, cx| e.convert_to_snake_case(&ConvertToSnakeCase, window, cx)); + cx.assert_editor_state(indoc! {" + « hello_world\t\tˇ» + "}); + // Test selections with `line_mode() = true`. cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); cx.set_state(indoc! {" @@ -19766,6 +19888,100 @@ async fn test_completions_with_additional_edits(cx: &mut TestAppContext) { cx.assert_editor_state("fn main() { let a = Some(2)ˇ; }"); } +#[gpui::test] +async fn test_completions_with_additional_edits_and_multiple_cursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_typescript( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state( + "import { «Fooˇ» } from './types';\n\nclass Bar {\n method(): «Fooˇ» { return new Foo(); }\n}", + ); + + cx.simulate_keystroke("F"); + cx.simulate_keystroke("o"); + + let completion_item = lsp::CompletionItem { + label: "FooBar".into(), + kind: Some(lsp::CompletionItemKind::CLASS), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 3, + character: 14, + }, + end: lsp::Position { + line: 3, + character: 16, + }, + }, + new_text: "FooBar".to_string(), + })), + additional_text_edits: Some(vec![lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 9, + }, + end: lsp::Position { + line: 0, + character: 11, + }, + }, + new_text: "FooBar".to_string(), + }]), + ..Default::default() + }; + + let closure_completion_item = completion_item.clone(); + let mut request = cx.set_request_handler::(move |_, _, _| { + let task_completion_item = closure_completion_item.clone(); + async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + task_completion_item, + ]))) + } + }); + + request.next().await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + let apply_additional_edits = cx.update_editor(|editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }); + + cx.assert_editor_state( + "import { FooBarˇ } from './types';\n\nclass Bar {\n method(): FooBarˇ { return new Foo(); }\n}", + ); + + cx.set_request_handler::(move |_, _, _| { + let task_completion_item = completion_item.clone(); + async move { Ok(task_completion_item) } + }) + .next() + .await + .unwrap(); + + apply_additional_edits.await.unwrap(); + + cx.assert_editor_state( + "import { FooBarˇ } from './types';\n\nclass Bar {\n method(): FooBarˇ { return new Foo(); }\n}", + ); +} + #[gpui::test] async fn test_completions_resolve_updates_labels_if_filter_text_matches(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -24403,20 +24619,24 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { editor.update_in(cx, |editor, window, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); - editor.tasks.insert( - (buffer.read(cx).remote_id(), 3), + editor.runnables.insert( + buffer.read(cx).remote_id(), + 3, + buffer.read(cx).version(), RunnableTasks { - templates: vec![], + templates: Vec::new(), offset: snapshot.anchor_before(MultiBufferOffset(43)), column: 0, extra_variables: HashMap::default(), context_range: BufferOffset(43)..BufferOffset(85), }, ); - editor.tasks.insert( - (buffer.read(cx).remote_id(), 8), + editor.runnables.insert( + buffer.read(cx).remote_id(), + 8, + buffer.read(cx).version(), RunnableTasks { - templates: vec![], + templates: Vec::new(), offset: snapshot.anchor_before(MultiBufferOffset(86)), column: 0, extra_variables: HashMap::default(), diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index b7207fce71bc71c5bdd5962ca3328030935238ca..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. @@ -1243,7 +1243,7 @@ impl EditorElement { let gutter_hitbox = &position_map.gutter_hitbox; let modifiers = event.modifiers; let text_hovered = text_hitbox.is_hovered(window); - let gutter_hovered = gutter_hitbox.bounds.contains(&event.position); + let gutter_hovered = gutter_hitbox.is_hovered(window); editor.set_gutter_hovered(gutter_hovered, cx); editor.show_mouse_cursor(cx); @@ -1462,6 +1462,7 @@ impl EditorElement { if text_hovered { editor.update_hovered_link( point_for_position, + Some(event.position), &position_map.snapshot, modifiers, window, @@ -1473,12 +1474,13 @@ impl EditorElement { .snapshot .buffer_snapshot() .anchor_before(point.to_offset(&position_map.snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx); + hover_at(editor, Some(anchor), Some(event.position), window, cx); Self::update_visible_cursor(editor, point, position_map, window, cx); } else { editor.update_inlay_link_and_hover_points( &position_map.snapshot, point_for_position, + Some(event.position), modifiers.secondary(), modifiers.shift, window, @@ -1487,7 +1489,7 @@ impl EditorElement { } } else { editor.hide_hovered_link(cx); - hover_at(editor, None, window, cx); + hover_at(editor, None, Some(event.position), window, cx); } } @@ -3275,9 +3277,9 @@ impl EditorElement { snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right); editor - .tasks - .iter() - .filter_map(|(_, tasks)| { + .runnables + .all_runnables() + .filter_map(|tasks| { let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot()); if multibuffer_point < offset_range_start || multibuffer_point > offset_range_end @@ -7911,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, @@ -7931,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; @@ -7958,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() }); @@ -8068,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(); @@ -8444,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/folding_ranges.rs b/crates/editor/src/folding_ranges.rs index 593095b004792be2055b0dc2614d086f114acd5e..745fdcbe30a0aede4f364afd5c58958c74b3da79 100644 --- a/crates/editor/src/folding_ranges.rs +++ b/crates/editor/src/folding_ranges.rs @@ -13,7 +13,7 @@ impl Editor { _window: &Window, cx: &mut Context, ) { - if !self.mode().is_full() || !self.use_document_folding_ranges { + if !self.lsp_data_enabled() || !self.use_document_folding_ranges { return; } let Some(project) = self.project.clone() else { diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 659a383d6b20129909b4c3f2d7bdbfbe5e580f4e..3a6ff4ec0e4fc53d19bfb51a10b1f7790933b175 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -4,7 +4,7 @@ use crate::{ HighlightKey, Navigated, PointForPosition, SelectPhase, editor_settings::GoToDefinitionFallback, scroll::ScrollAmount, }; -use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Task, Window, px}; +use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Pixels, Task, Window, px}; use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; @@ -113,6 +113,7 @@ impl Editor { pub(crate) fn update_hovered_link( &mut self, point_for_position: PointForPosition, + mouse_position: Option>, snapshot: &EditorSnapshot, modifiers: Modifiers, window: &mut Window, @@ -138,6 +139,7 @@ impl Editor { self.update_inlay_link_and_hover_points( snapshot, point_for_position, + mouse_position, hovered_link_modifier, modifiers.shift, window, diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index f5d5e6d5ab69d690bd5f3aee29bf9aa493cf0059..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, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla, + AnyElement, App, AsyncWindowContext, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement, - Window, div, px, + Window, canvas, div, px, }; use itertools::Itertools; use language::{DiagnosticEntry, Language, LanguageRegistry}; @@ -20,7 +20,10 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart}; use settings::Settings; -use std::{borrow::Cow, cell::RefCell}; +use std::{ + borrow::Cow, + cell::{Cell, RefCell}, +}; use std::{ops::Range, sync::Arc, time::Duration}; use std::{path::PathBuf, rc::Rc}; use theme::ThemeSettings; @@ -45,6 +48,7 @@ pub fn hover(editor: &mut Editor, _: &Hover, window: &mut Window, cx: &mut Conte pub fn hover_at( editor: &mut Editor, anchor: Option, + mouse_position: Option>, window: &mut Window, cx: &mut Context, ) { @@ -52,10 +56,32 @@ pub fn hover_at( if show_keyboard_hover(editor, window, cx) { return; } + if let Some(anchor) = anchor { + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; show_hover(editor, anchor, false, window, cx); } else { - hide_hover(editor, cx); + let mut getting_closer = false; + if let Some(mouse_position) = mouse_position { + getting_closer = editor.hover_state.is_mouse_getting_closer(mouse_position); + } + + // If we are moving away and a timer is already running, just let it count down. + if !getting_closer && editor.hover_state.hiding_delay_task.is_some() { + return; + } + + // If we are moving closer, or if no timer is running at all, start/restart the 300ms timer. + 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); } } } @@ -156,6 +182,9 @@ pub fn hover_at_inlay( let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; + let task = cx.spawn_in(window, async move |this, cx| { async move { cx.background_executor() @@ -187,6 +216,7 @@ pub fn hover_at_inlay( scroll_handle, keyboard_grace: Rc::new(RefCell::new(false)), anchor: None, + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }; @@ -216,6 +246,8 @@ pub fn hide_hover(editor: &mut Editor, cx: &mut Context) -> bool { editor.hover_state.info_task = None; editor.hover_state.triggered_from = None; + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; editor.clear_background_highlights(HighlightKey::HoverState, cx); @@ -254,6 +286,9 @@ fn show_hover( .map(|project| project.read(cx).languages().clone()); let provider = editor.semantics_provider.clone()?; + editor.hover_state.hiding_delay_task = None; + editor.hover_state.closest_mouse_distance = None; + if !ignore_timeout { if same_info_hover(editor, &snapshot, anchor) || same_diagnostic_hover(editor, &snapshot, anchor) @@ -398,6 +433,7 @@ fn show_hover( background_color, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor, + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }) } else { @@ -466,6 +502,7 @@ fn show_hover( scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }) } @@ -507,6 +544,7 @@ fn show_hover( scroll_handle, keyboard_grace: Rc::new(RefCell::new(ignore_timeout)), anchor: Some(anchor), + last_bounds: Rc::new(Cell::new(None)), _subscription: subscription, }); } @@ -778,6 +816,8 @@ pub struct HoverState { pub diagnostic_popover: Option, pub triggered_from: Option, pub info_task: Option>>, + pub closest_mouse_distance: Option, + pub hiding_delay_task: Option>, } impl HoverState { @@ -785,6 +825,60 @@ impl HoverState { !self.info_popovers.is_empty() || self.diagnostic_popover.is_some() } + pub fn is_mouse_getting_closer(&mut self, mouse_position: gpui::Point) -> bool { + if !self.visible() { + return false; + } + + let mut popover_bounds = Vec::new(); + for info_popover in &self.info_popovers { + if let Some(bounds) = info_popover.last_bounds.get() { + popover_bounds.push(bounds); + } + } + if let Some(diagnostic_popover) = &self.diagnostic_popover { + if let Some(bounds) = diagnostic_popover.last_bounds.get() { + popover_bounds.push(bounds); + } + } + + if popover_bounds.is_empty() { + return false; + } + + let distance = popover_bounds + .iter() + .map(|bounds| self.distance_from_point_to_bounds(mouse_position, *bounds)) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or(px(f32::MAX)); + + if let Some(closest_distance) = self.closest_mouse_distance { + if distance > closest_distance + px(4.0) { + return false; + } + } + + self.closest_mouse_distance = + Some(distance.min(self.closest_mouse_distance.unwrap_or(distance))); + true + } + + fn distance_from_point_to_bounds( + &self, + point: gpui::Point, + bounds: Bounds, + ) -> Pixels { + let center_x = bounds.origin.x + bounds.size.width / 2.; + let center_y = bounds.origin.y + bounds.size.height / 2.; + let dx: f32 = ((point.x - center_x).abs() - bounds.size.width / 2.) + .max(px(0.0)) + .into(); + let dy: f32 = ((point.y - center_y).abs() - bounds.size.height / 2.) + .max(px(0.0)) + .into(); + px((dx.powi(2) + dy.powi(2)).sqrt()) + } + pub(crate) fn render( &mut self, snapshot: &EditorSnapshot, @@ -887,6 +981,7 @@ pub struct InfoPopover { pub scroll_handle: ScrollHandle, pub keyboard_grace: Rc>, pub anchor: Option, + pub last_bounds: Rc>>>, _subscription: Option, } @@ -898,13 +993,36 @@ impl InfoPopover { cx: &mut Context, ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); + let this = cx.entity().downgrade(); + let bounds_cell = self.last_bounds.clone(); div() .id("info_popover") .occlude() .elevation_2(cx) + .child( + canvas( + { + move |bounds, _window, _cx| { + bounds_cell.set(Some(bounds)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) // Prevent a mouse down/move on the popover from being propagated to the editor, // because that would dismiss the popover. - .on_mouse_move(|_, _, cx| cx.stop_propagation()) + .on_mouse_move({ + move |_, _, cx: &mut App| { + this.update(cx, |editor, _| { + editor.hover_state.closest_mouse_distance = Some(px(0.0)); + editor.hover_state.hiding_delay_task = None; + }) + .ok(); + cx.stop_propagation() + } + }) .on_mouse_down(MouseButton::Left, move |_, _, cx| { let mut keyboard_grace = keyboard_grace.borrow_mut(); *keyboard_grace = false; @@ -957,6 +1075,7 @@ pub struct DiagnosticPopover { background_color: Hsla, pub keyboard_grace: Rc>, pub anchor: Anchor, + pub last_bounds: Rc>>>, _subscription: Subscription, pub scroll_handle: ScrollHandle, } @@ -970,10 +1089,23 @@ impl DiagnosticPopover { ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); let this = cx.entity().downgrade(); + let bounds_cell = self.last_bounds.clone(); div() .id("diagnostic") .occlude() .elevation_2_borderless(cx) + .child( + canvas( + { + move |bounds, _window, _cx| { + bounds_cell.set(Some(bounds)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) // Don't draw the background color if the theme // allows transparent surfaces. .when(theme_is_transparent(cx), |this| { @@ -981,7 +1113,17 @@ impl DiagnosticPopover { }) // Prevent a mouse move on the popover from being propagated to the editor, // because that would dismiss the popover. - .on_mouse_move(|_, _, cx| cx.stop_propagation()) + .on_mouse_move({ + let this = this.clone(); + move |_, _, cx: &mut App| { + this.update(cx, |editor, _| { + editor.hover_state.closest_mouse_distance = Some(px(0.0)); + editor.hover_state.hiding_delay_task = None; + }) + .ok(); + cx.stop_propagation() + } + }) // Prevent a mouse down on the popover from being propagated to the editor, // because that would move the cursor. .on_mouse_down(MouseButton::Left, move |_, _, cx| { @@ -1151,7 +1293,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible())); @@ -1251,7 +1393,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); @@ -1289,7 +1431,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); assert!(!cx.editor(|editor, _window, _cx| editor.hover_state.visible())); @@ -1343,7 +1485,7 @@ mod tests { let anchor = snapshot .buffer_snapshot() .anchor_before(hover_point.to_offset(&snapshot, Bias::Left)); - hover_at(editor, Some(anchor), window, cx) + hover_at(editor, Some(anchor), None, window, cx) }); cx.background_executor .advance_clock(Duration::from_millis(get_hover_popover_delay(&cx) + 100)); @@ -1752,6 +1894,7 @@ mod tests { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, + None, true, false, window, @@ -1822,6 +1965,7 @@ mod tests { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), new_type_hint_part_hover_position, + None, true, false, window, @@ -1877,6 +2021,7 @@ mod tests { editor.update_inlay_link_and_hover_points( &editor.snapshot(window, cx), struct_hint_part_hover_position, + None, true, false, window, diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 0b3f6bda09c2cf86b994682e2ed89c2614d72737..414829dc3bbcd89f5f4e4337a955cfff5bb57fca 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -7,7 +7,7 @@ use std::{ use clock::Global; use collections::{HashMap, HashSet}; use futures::future::join_all; -use gpui::{App, Entity, Task}; +use gpui::{App, Entity, Pixels, Task}; use itertools::Itertools; use language::{ BufferRow, @@ -292,7 +292,7 @@ impl Editor { reason: InlayHintRefreshReason, cx: &mut Context, ) { - if !self.mode().is_full() || self.inlay_hints.is_none() { + if !self.lsp_data_enabled() || self.inlay_hints.is_none() { return; } let Some(semantics_provider) = self.semantics_provider() else { @@ -569,6 +569,7 @@ impl Editor { &mut self, snapshot: &EditorSnapshot, point_for_position: PointForPosition, + mouse_position: Option>, secondary_held: bool, shift_held: bool, window: &mut Window, @@ -748,7 +749,7 @@ impl Editor { self.hide_hovered_link(cx) } if !hover_updated { - hover_popover::hover_at(self, None, window, cx); + hover_popover::hover_at(self, None, mouse_position, window, cx); } } 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/linked_editing_ranges.rs b/crates/editor/src/linked_editing_ranges.rs index 34fc1e97df2b01cb3e35b95ec90d0c8d31f5790a..ccd0e64bd850f6ce84e225fe77f1c0a0d5385dc1 100644 --- a/crates/editor/src/linked_editing_ranges.rs +++ b/crates/editor/src/linked_editing_ranges.rs @@ -50,7 +50,7 @@ pub(super) fn refresh_linked_ranges( window: &mut Window, cx: &mut Context, ) -> Option<()> { - if !editor.mode().is_full() || editor.pending_rename.is_some() { + if !editor.lsp_data_enabled() || editor.pending_rename.is_some() { return None; } let project = editor.project()?.downgrade(); 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 new file mode 100644 index 0000000000000000000000000000000000000000..e36658cf0b160dc2e340f11abe76efa5e895b4ee --- /dev/null +++ b/crates/editor/src/runnables.rs @@ -0,0 +1,1093 @@ +use std::{collections::BTreeMap, mem, ops::Range, sync::Arc}; + +use clock::Global; +use collections::{HashMap, HashSet}; +use gpui::{ + App, AppContext as _, AsyncWindowContext, ClickEvent, Context, Entity, Focusable as _, + MouseButton, Task, Window, +}; +use language::{Buffer, BufferRow, Runnable}; +use lsp::LanguageServerName; +use multi_buffer::{ + Anchor, BufferOffset, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, ToPoint as _, +}; +use project::{ + Location, Project, TaskSourceKind, + debugger::breakpoint_store::{Breakpoint, BreakpointSessionState}, + project_settings::ProjectSettings, +}; +use settings::Settings as _; +use smallvec::SmallVec; +use task::{ResolvedTask, RunnableTag, TaskContext, TaskTemplate, TaskVariables, VariableName}; +use text::{BufferId, OffsetRangeExt as _, ToOffset as _, ToPoint as _}; +use ui::{Clickable as _, Color, IconButton, IconSize, Toggleable as _}; + +use crate::{ + CodeActionSource, Editor, EditorSettings, EditorStyle, RangeToAnchorExt, SpawnNearestTask, + ToggleCodeActions, UPDATE_DEBOUNCE, display_map::DisplayRow, +}; + +#[derive(Debug)] +pub(super) struct RunnableData { + runnables: HashMap)>, + invalidate_buffer_data: HashSet, + runnables_update_task: Task<()>, +} + +impl RunnableData { + pub fn new() -> Self { + Self { + runnables: HashMap::default(), + invalidate_buffer_data: HashSet::default(), + runnables_update_task: Task::ready(()), + } + } + + pub fn runnables( + &self, + (buffer_id, buffer_row): (BufferId, BufferRow), + ) -> Option<&RunnableTasks> { + self.runnables.get(&buffer_id)?.1.get(&buffer_row) + } + + pub fn all_runnables(&self) -> impl Iterator { + self.runnables + .values() + .flat_map(|(_, tasks)| tasks.values()) + } + + pub fn has_cached(&self, buffer_id: BufferId, version: &Global) -> bool { + self.runnables + .get(&buffer_id) + .is_some_and(|(cached_version, _)| !version.changed_since(cached_version)) + } + + #[cfg(test)] + pub fn insert( + &mut self, + buffer_id: BufferId, + buffer_row: BufferRow, + version: Global, + tasks: RunnableTasks, + ) { + self.runnables + .entry(buffer_id) + .or_insert_with(|| (version, BTreeMap::default())) + .1 + .insert(buffer_row, tasks); + } +} + +#[derive(Clone, Debug)] +pub struct RunnableTasks { + pub templates: Vec<(TaskSourceKind, TaskTemplate)>, + pub offset: multi_buffer::Anchor, + // We need the column at which the task context evaluation should take place (when we're spawning it via gutter). + pub column: u32, + // Values of all named captures, including those starting with '_' + pub extra_variables: HashMap, + // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal. + pub context_range: Range, +} + +impl RunnableTasks { + pub fn resolve<'a>( + &'a self, + cx: &'a task::TaskContext, + ) -> impl Iterator + 'a { + self.templates.iter().filter_map(|(kind, template)| { + template + .resolve_task(&kind.to_id_base(), cx) + .map(|task| (kind.clone(), task)) + }) + } +} + +#[derive(Clone)] +pub struct ResolvedTasks { + pub templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, + pub position: Anchor, +} + +impl Editor { + 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 + { + self.clear_runnables(None); + return; + } + if let Some(buffer) = self.buffer().read(cx).as_singleton() { + 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); + let multi_buffer = self.buffer.downgrade(); + self.runnables.runnables_update_task = cx.spawn_in(window, async move |editor, cx| { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + let Some(project) = project.and_then(|p| p.upgrade()) else { + return; + }; + + let hide_runnables = project.update(cx, |project, _| project.is_via_collab()); + if hide_runnables { + return; + } + let lsp_tasks = if lsp_task_sources.is_empty() { + Vec::new() + } else { + let Ok(lsp_tasks) = cx + .update(|_, cx| crate::lsp_tasks(project.clone(), &lsp_task_sources, None, cx)) + else { + return; + }; + lsp_tasks.await + }; + let new_rows = { + let Some((multi_buffer_snapshot, multi_buffer_query_range)) = editor + .update(cx, |editor, cx| { + let multi_buffer = editor.buffer().read(cx); + if multi_buffer.is_singleton() { + Some((multi_buffer.snapshot(cx), Anchor::min()..Anchor::max())) + } else { + let display_snapshot = + editor.display_map.update(cx, |map, cx| map.snapshot(cx)); + let multi_buffer_query_range = + editor.multi_buffer_visible_range(&display_snapshot, cx); + let multi_buffer_snapshot = display_snapshot.buffer(); + Some(( + multi_buffer_snapshot.clone(), + multi_buffer_query_range.to_anchors(&multi_buffer_snapshot), + )) + } + }) + .ok() + .flatten() + else { + return; + }; + cx.background_spawn({ + async move { + multi_buffer_snapshot + .runnable_ranges(multi_buffer_query_range) + .collect() + } + }) + .await + }; + + let Ok(multi_buffer_snapshot) = + editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) + else { + return; + }; + let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| { + lsp_tasks + .into_iter() + .flat_map(|(kind, tasks)| { + tasks.into_iter().filter_map(move |(location, task)| { + Some((kind.clone(), location?, task)) + }) + }) + .fold(HashMap::default(), |mut acc, (kind, location, task)| { + let buffer = location.target.buffer; + let buffer_snapshot = buffer.read(cx).snapshot(); + let offset = multi_buffer_snapshot.excerpts().find_map( + |(excerpt_id, snapshot, _)| { + if snapshot.remote_id() == buffer_snapshot.remote_id() { + multi_buffer_snapshot + .anchor_in_excerpt(excerpt_id, location.target.range.start) + } else { + None + } + }, + ); + if let Some(offset) = offset { + let task_buffer_range = + location.target.range.to_point(&buffer_snapshot); + let context_buffer_range = + task_buffer_range.to_offset(&buffer_snapshot); + let context_range = BufferOffset(context_buffer_range.start) + ..BufferOffset(context_buffer_range.end); + + acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row)) + .or_insert_with(|| RunnableTasks { + templates: Vec::new(), + offset, + column: task_buffer_range.start.column, + extra_variables: HashMap::default(), + context_range, + }) + .templates + .push((kind, task.original_task().clone())); + } + + acc + }) + }) else { + return; + }; + + let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| { + buffer.language_settings(cx).tasks.prefer_lsp + }) else { + return; + }; + + let rows = Self::runnable_rows( + project, + multi_buffer_snapshot, + prefer_lsp && !lsp_tasks_by_rows.is_empty(), + new_rows, + cx.clone(), + ) + .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; + }; + + if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&(buffer_id, row)) { + new_tasks.templates.extend(lsp_tasks.templates); + } + editor.insert_runnables( + buffer_id, + buffer.read(cx).version(), + row, + new_tasks, + ); + } + for ((buffer_id, row), new_tasks) in lsp_tasks_by_rows { + let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else { + continue; + }; + editor.insert_runnables( + buffer_id, + buffer.read(cx).version(), + row, + new_tasks, + ); + } + }) + .ok(); + }); + } + + pub fn spawn_nearest_task( + &mut self, + action: &SpawnNearestTask, + window: &mut Window, + cx: &mut Context, + ) { + let Some((workspace, _)) = self.workspace.clone() else { + return; + }; + let Some(project) = self.project.clone() else { + return; + }; + + // Try to find a closest, enclosing node using tree-sitter that has a task + let Some((buffer, buffer_row, tasks)) = self + .find_enclosing_node_task(cx) + // Or find the task that's closest in row-distance. + .or_else(|| self.find_closest_task(cx)) + else { + return; + }; + + let reveal_strategy = action.reveal; + let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx); + cx.spawn_in(window, async move |_, cx| { + let context = task_context.await?; + let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?; + + let resolved = &mut resolved_task.resolved; + resolved.reveal = reveal_strategy; + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.schedule_resolved_task( + task_source_kind, + resolved_task, + false, + window, + cx, + ); + }) + .ok() + }) + .detach(); + } + + pub fn clear_runnables(&mut self, for_buffer: Option) { + if let Some(buffer_id) = for_buffer { + self.runnables.runnables.remove(&buffer_id); + } else { + self.runnables.runnables.clear(); + } + self.runnables.invalidate_buffer_data.clear(); + self.runnables.runnables_update_task = Task::ready(()); + } + + pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task> { + let Some(project) = self.project.clone() else { + return Task::ready(None); + }; + let (selection, buffer, editor_snapshot) = { + let selection = self.selections.newest_adjusted(&self.display_snapshot(cx)); + let Some((buffer, _)) = self + .buffer() + .read(cx) + .point_to_buffer_offset(selection.start, cx) + else { + return Task::ready(None); + }; + let snapshot = self.snapshot(window, cx); + (selection, buffer, snapshot) + }; + let selection_range = selection.range(); + let start = editor_snapshot + .display_snapshot + .buffer_snapshot() + .anchor_after(selection_range.start) + .text_anchor; + let end = editor_snapshot + .display_snapshot + .buffer_snapshot() + .anchor_after(selection_range.end) + .text_anchor; + let location = Location { + buffer, + range: start..end, + }; + let captured_variables = { + let mut variables = TaskVariables::default(); + let buffer = location.buffer.read(cx); + let buffer_id = buffer.remote_id(); + let snapshot = buffer.snapshot(); + let starting_point = location.range.start.to_point(&snapshot); + let starting_offset = starting_point.to_offset(&snapshot); + for (_, tasks) in self + .runnables + .runnables + .get(&buffer_id) + .into_iter() + .flat_map(|(_, tasks)| tasks.range(0..starting_point.row + 1)) + { + if !tasks + .context_range + .contains(&crate::BufferOffset(starting_offset)) + { + continue; + } + for (capture_name, value) in tasks.extra_variables.iter() { + variables.insert( + VariableName::Custom(capture_name.to_owned().into()), + value.clone(), + ); + } + } + variables + }; + + project.update(cx, |project, cx| { + project.task_store().update(cx, |task_store, cx| { + task_store.task_context_for_location(captured_variables, location, cx) + }) + }) + } + + pub fn lsp_task_sources( + &self, + visible_only: bool, + skip_cached: bool, + cx: &mut Context, + ) -> HashMap> { + if !self.lsp_data_enabled() { + return HashMap::default(); + } + let buffers = if visible_only { + self.visible_excerpts(true, cx) + .into_values() + .map(|(buffer, _, _)| buffer) + .collect() + } else { + self.buffer().read(cx).all_buffers() + }; + + let lsp_settings = &ProjectSettings::get_global(cx).lsp; + + buffers + .into_iter() + .filter_map(|buffer| { + let lsp_tasks_source = buffer + .read(cx) + .language()? + .context_provider()? + .lsp_task_source()?; + if lsp_settings + .get(&lsp_tasks_source) + .is_none_or(|s| s.enable_lsp_tasks) + { + let buffer_id = buffer.read(cx).remote_id(); + if skip_cached + && self + .runnables + .has_cached(buffer_id, &buffer.read(cx).version()) + { + None + } else { + Some((lsp_tasks_source, buffer_id)) + } + } else { + None + } + }) + .fold( + HashMap::default(), + |mut acc, (lsp_task_source, buffer_id)| { + acc.entry(lsp_task_source) + .or_insert_with(Vec::new) + .push(buffer_id); + acc + }, + ) + } + + pub fn find_enclosing_node_task( + &mut self, + cx: &mut Context, + ) -> Option<(Entity, u32, Arc)> { + let snapshot = self.buffer.read(cx).snapshot(cx); + let offset = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); + let mut excerpt = snapshot.excerpt_containing(offset..offset)?; + let offset = excerpt.map_offset_to_buffer(offset); + let buffer_id = excerpt.buffer().remote_id(); + + let layer = excerpt.buffer().syntax_layer_at(offset)?; + let mut cursor = layer.node().walk(); + + while cursor.goto_first_child_for_byte(offset.0).is_some() { + if cursor.node().end_byte() == offset.0 { + cursor.goto_next_sibling(); + } + } + + // Ascend to the smallest ancestor that contains the range and has a task. + loop { + let node = cursor.node(); + let node_range = node.byte_range(); + let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row; + + // Check if this node contains our offset + if node_range.start <= offset.0 && node_range.end >= offset.0 { + // If it contains offset, check for task + if let Some(tasks) = self + .runnables + .runnables + .get(&buffer_id) + .and_then(|(_, tasks)| tasks.get(&symbol_start_row)) + { + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned()))); + } + } + + if !cursor.goto_parent() { + break; + } + } + None + } + + pub fn render_run_indicator( + &self, + _style: &EditorStyle, + is_active: bool, + row: DisplayRow, + breakpoint: Option<(Anchor, Breakpoint, Option)>, + cx: &mut Context, + ) -> IconButton { + let color = Color::Muted; + let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor); + + IconButton::new( + ("run_indicator", row.0 as usize), + ui::IconName::PlayOutlined, + ) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(color) + .toggle_state(is_active) + .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| { + let quick_launch = match e { + ClickEvent::Keyboard(_) => true, + ClickEvent::Mouse(e) => e.down.button == MouseButton::Left, + }; + + window.focus(&editor.focus_handle(cx), cx); + editor.toggle_code_actions( + &ToggleCodeActions { + deployed_from: Some(CodeActionSource::RunMenu(row)), + quick_launch, + }, + window, + cx, + ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu(row, position, event.position(), window, cx); + })) + } + + fn insert_runnables( + &mut self, + buffer: BufferId, + version: Global, + row: BufferRow, + new_tasks: RunnableTasks, + ) { + let (old_version, tasks) = self.runnables.runnables.entry(buffer).or_default(); + if !old_version.changed_since(&version) { + *old_version = version; + tasks.insert(row, new_tasks); + } + } + + fn runnable_rows( + project: Entity, + snapshot: MultiBufferSnapshot, + prefer_lsp: bool, + runnable_ranges: Vec<(Range, language::RunnableRange)>, + cx: AsyncWindowContext, + ) -> Task> { + cx.spawn(async move |cx| { + let mut runnable_rows = Vec::with_capacity(runnable_ranges.len()); + for (run_range, mut runnable) in runnable_ranges { + let Some(tasks) = cx + .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx)) + .ok() + else { + continue; + }; + let mut tasks = tasks.await; + + if prefer_lsp { + tasks.retain(|(task_kind, _)| { + !matches!(task_kind, TaskSourceKind::Language { .. }) + }); + } + if tasks.is_empty() { + continue; + } + + let point = run_range.start.to_point(&snapshot); + let Some(row) = snapshot + .buffer_line_for_row(MultiBufferRow(point.row)) + .map(|(_, range)| range.start.row) + else { + continue; + }; + + let context_range = + BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end); + runnable_rows.push(( + (runnable.buffer_id, row), + RunnableTasks { + templates: tasks, + offset: snapshot.anchor_before(run_range.start), + context_range, + column: point.column, + extra_variables: runnable.extra_captures, + }, + )); + } + runnable_rows + }) + } + + fn templates_with_tags( + project: &Entity, + runnable: &mut Runnable, + cx: &mut App, + ) -> Task> { + let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| { + let (worktree_id, file) = project + .buffer_for_id(runnable.buffer, cx) + .and_then(|buffer| buffer.read(cx).file()) + .map(|file| (file.worktree_id(cx), file.clone())) + .unzip(); + + ( + project.task_store().read(cx).task_inventory().cloned(), + worktree_id, + file, + ) + }); + + let tags = mem::take(&mut runnable.tags); + let language = runnable.language.clone(); + cx.spawn(async move |cx| { + let mut templates_with_tags = Vec::new(); + if let Some(inventory) = inventory { + for RunnableTag(tag) in tags { + let new_tasks = inventory.update(cx, |inventory, cx| { + inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx) + }); + templates_with_tags.extend(new_tasks.await.into_iter().filter( + move |(_, template)| { + template.tags.iter().any(|source_tag| source_tag == &tag) + }, + )); + } + } + templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned()); + + if let Some((leading_tag_source, _)) = templates_with_tags.first() { + // Strongest source wins; if we have worktree tag binding, prefer that to + // global and language bindings; + // if we have a global binding, prefer that to language binding. + let first_mismatch = templates_with_tags + .iter() + .position(|(tag_source, _)| tag_source != leading_tag_source); + if let Some(index) = first_mismatch { + templates_with_tags.truncate(index); + } + } + + templates_with_tags + }) + } + + fn find_closest_task( + &mut self, + cx: &mut Context, + ) -> Option<(Entity, u32, Arc)> { + let cursor_row = self + .selections + .newest_adjusted(&self.display_snapshot(cx)) + .head() + .row; + + let ((buffer_id, row), tasks) = self + .runnables + .runnables + .iter() + .flat_map(|(buffer_id, (_, tasks))| { + tasks.iter().map(|(row, tasks)| ((*buffer_id, *row), tasks)) + }) + .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?; + + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + let tasks = Arc::new(tasks.to_owned()); + Some((buffer, row, tasks)) + } +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use futures::StreamExt as _; + use gpui::{AppContext as _, Task, TestAppContext}; + use indoc::indoc; + use language::{ContextProvider, FakeLspAdapter}; + use languages::rust_lang; + use lsp::LanguageServerName; + use multi_buffer::{MultiBuffer, PathKey}; + use project::{ + FakeFs, Project, + lsp_store::lsp_ext_command::{CargoRunnableArgs, Runnable, RunnableArgs, RunnableKind}, + }; + use serde_json::json; + use task::{TaskTemplate, TaskTemplates}; + use text::Point; + use util::path; + + 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 { + fn associated_tasks( + &self, + _: Option>, + _: &gpui::App, + ) -> Task> { + Task::ready(Some(TaskTemplates(vec![ + TaskTemplate { + label: "Run main".into(), + command: "cargo".into(), + args: vec!["run".into()], + tags: vec!["rust-main".into()], + ..TaskTemplate::default() + }, + TaskTemplate { + label: "Run test".into(), + command: "cargo".into(), + args: vec!["test".into()], + tags: vec!["rust-test".into()], + ..TaskTemplate::default() + }, + ]))) + } + } + + 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()) + .unwrap() + .with_context_provider(Some(Arc::new(TestRustContextProvider))), + ) + } + + 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)> { + let mut result = editor + .runnables + .runnables + .iter() + .flat_map(|(buffer_id, (_, tasks))| { + tasks.iter().map(move |(row, runnable_tasks)| { + let mut labels: Vec = runnable_tasks + .templates + .iter() + .map(|(_, template)| template.label.clone()) + .collect(); + labels.sort(); + (*buffer_id, *row, labels) + }) + }) + .collect::>(); + result.sort_by_key(|(id, row, _)| (*id, *row)); + result + } + + #[gpui::test] + async fn test_multi_buffer_runnables_on_scroll(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let padding_lines = 50; + let mut first_rs = String::from("fn main() {\n println!(\"hello\");\n}\n"); + for _ in 0..padding_lines { + first_rs.push_str("//\n"); + } + let test_one_row = 3 + padding_lines as u32 + 1; + first_rs.push_str("#[test]\nfn test_one() {\n assert!(true);\n}\n"); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "first.rs": first_rs, + "second.rs": indoc! {" + #[test] + fn test_two() { + assert!(true); + } + + #[test] + fn test_three() { + assert!(true); + } + "}, + }), + ) + .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_task_context()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/first.rs"), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/project/second.rs"), cx) + }) + .await + .unwrap(); + + let buffer_1_id = buffer_1.read_with(cx, |buffer, _| buffer.remote_id()); + let buffer_2_id = buffer_2.read_with(cx, |buffer, _| buffer.remote_id()); + + let multi_buffer = cx.new(|cx| { + let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite); + let end = buffer_1.read(cx).max_point(); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(0), + buffer_1.clone(), + [Point::new(0, 0)..end], + 0, + cx, + ); + multi_buffer.set_excerpts_for_path( + PathKey::sorted(1), + buffer_2.clone(), + [Point::new(0, 0)..Point::new(8, 1)], + 0, + cx, + ); + multi_buffer + }); + + let editor = cx.add_window(|window, cx| { + Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx) + }); + cx.executor().advance_clock(Duration::from_millis(500)); + cx.executor().run_until_parked(); + + // Clear stale data from startup events, then refresh. + // first.rs is long enough that second.rs is below the ~47-line viewport. + editor + .update(cx, |editor, window, cx| { + editor.clear_runnables(None); + editor.refresh_runnables(None, window, cx); + }) + .unwrap(); + cx.executor().advance_clock(UPDATE_DEBOUNCE); + cx.executor().run_until_parked(); + assert_eq!( + editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .unwrap(), + vec![(buffer_1_id, 0, vec!["Run main".to_string()])], + "Only fn main from first.rs should be visible before scrolling" + ); + + // Scroll down to bring second.rs excerpts into view. + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.executor().run_until_parked(); + + let after_scroll = editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .unwrap(); + assert_eq!( + after_scroll, + vec![ + (buffer_1_id, 0, vec!["Run main".to_string()]), + (buffer_1_id, test_one_row, vec!["Run test".to_string()]), + (buffer_2_id, 1, vec!["Run test".to_string()]), + (buffer_2_id, 6, vec!["Run test".to_string()]), + ], + "Tree-sitter should detect both #[test] fns in second.rs after scroll" + ); + + // Edit second.rs to invalidate its cache; first.rs data should persist. + buffer_2.update(cx, |buffer, cx| { + buffer.edit([(0..0, "// added comment\n")], None, cx); + }); + editor + .update(cx, |editor, window, cx| { + editor.scroll_screen(&ScrollAmount::Page(-1.0), window, cx); + }) + .unwrap(); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.executor().run_until_parked(); + + assert_eq!( + editor + .update(cx, |editor, _, _| collect_runnable_labels(editor)) + .unwrap(), + vec![ + (buffer_1_id, 0, vec!["Run main".to_string()]), + (buffer_1_id, test_one_row, vec!["Run test".to_string()]), + ], + "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/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index 31a573f04787e3759a6a21ec15f36ec148a80f30..e95b20aed5a6655d6ae4ccd2c6658cfcfecc2ea4 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -119,7 +119,7 @@ impl Editor { for_server: Option, cx: &mut Context, ) { - if !self.mode().is_full() || !self.semantic_token_state.enabled() { + if !self.lsp_data_enabled() || !self.semantic_token_state.enabled() { self.invalidate_semantic_tokens(None); self.display_map.update(cx, |display_map, _| { match Arc::get_mut(&mut display_map.semantic_token_highlights) { diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index cff98f474487b52e55ab3f53bff250de24cf2d80..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}, }; @@ -446,6 +448,9 @@ impl SplittableEditor { let mut editor = Editor::for_multibuffer(rhs_multibuffer.clone(), Some(project.clone()), window, cx); editor.set_expand_all_diff_hunks(cx); + editor.disable_runnables(); + editor.disable_diagnostics(cx); + editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx); editor }); // TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs @@ -1165,8 +1170,8 @@ impl SplittableEditor { let lhs_ranges: Vec> = rhs_multibuffer .excerpts_for_buffer(main_buffer_snapshot.remote_id(), cx) .into_iter() - .filter(|(id, _)| rhs_excerpt_ids.contains(id)) - .map(|(_, excerpt_range)| { + .filter(|(id, _, _)| rhs_excerpt_ids.contains(id)) + .map(|(_, _, excerpt_range)| { let to_base_text = |range: Range| { let start = diff_snapshot .buffer_point_to_base_text_range( @@ -1850,13 +1855,28 @@ 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) } fn pixel_position_of_cursor(&self, cx: &App) -> Option> { self.focused_editor().read(cx).pixel_position_of_cursor(cx) } + + fn act_as_type<'a>( + &'a self, + type_id: std::any::TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == std::any::TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == std::any::TypeId::of::() { + Some(self.rhs_editor.clone().into()) + } else { + None + } + } } impl SearchableItem for SplittableEditor { @@ -2064,7 +2084,7 @@ impl Render for SplittableEditor { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::{any::TypeId, sync::Arc}; use buffer_diff::BufferDiff; use collections::{HashMap, HashSet}; @@ -2080,14 +2100,14 @@ mod tests { use settings::{DiffViewStyle, SettingsStore}; use ui::{VisualContext as _, div, px}; use util::rel_path::rel_path; - use workspace::MultiWorkspace; + use workspace::{Item, MultiWorkspace}; - use crate::SplittableEditor; use crate::display_map::{ BlockPlacement, BlockProperties, BlockStyle, Crease, FoldPlaceholder, }; use crate::inlays::Inlay; use crate::test::{editor_content_with_blocks_and_width, set_block_content_for_tests}; + use crate::{Editor, SplittableEditor}; use multi_buffer::MultiBufferOffset; async fn init_test( @@ -6025,4 +6045,17 @@ mod tests { cx.run_until_parked(); } + + #[gpui::test] + async fn test_act_as_type(cx: &mut gpui::TestAppContext) { + let (splittable_editor, cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await; + let editor = splittable_editor.read_with(cx, |editor, cx| { + editor.act_as_type(TypeId::of::(), &splittable_editor, cx) + }); + + assert!( + editor.is_some(), + "SplittableEditor should be able to act as Editor" + ); + } } diff --git a/crates/editor/src/tasks.rs b/crates/editor/src/tasks.rs deleted file mode 100644 index e39880ddc1f575a7b12f40c5496c75c1f473c6e9..0000000000000000000000000000000000000000 --- a/crates/editor/src/tasks.rs +++ /dev/null @@ -1,110 +0,0 @@ -use crate::Editor; - -use collections::HashMap; -use gpui::{App, Task, Window}; -use lsp::LanguageServerName; -use project::{Location, project_settings::ProjectSettings}; -use settings::Settings as _; -use task::{TaskContext, TaskVariables, VariableName}; -use text::{BufferId, ToOffset, ToPoint}; - -impl Editor { - pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task> { - let Some(project) = self.project.clone() else { - return Task::ready(None); - }; - let (selection, buffer, editor_snapshot) = { - let selection = self.selections.newest_adjusted(&self.display_snapshot(cx)); - let Some((buffer, _)) = self - .buffer() - .read(cx) - .point_to_buffer_offset(selection.start, cx) - else { - return Task::ready(None); - }; - let snapshot = self.snapshot(window, cx); - (selection, buffer, snapshot) - }; - let selection_range = selection.range(); - let start = editor_snapshot - .display_snapshot - .buffer_snapshot() - .anchor_after(selection_range.start) - .text_anchor; - let end = editor_snapshot - .display_snapshot - .buffer_snapshot() - .anchor_after(selection_range.end) - .text_anchor; - let location = Location { - buffer, - range: start..end, - }; - let captured_variables = { - let mut variables = TaskVariables::default(); - let buffer = location.buffer.read(cx); - let buffer_id = buffer.remote_id(); - let snapshot = buffer.snapshot(); - let starting_point = location.range.start.to_point(&snapshot); - let starting_offset = starting_point.to_offset(&snapshot); - for (_, tasks) in self - .tasks - .range((buffer_id, 0)..(buffer_id, starting_point.row + 1)) - { - if !tasks - .context_range - .contains(&crate::BufferOffset(starting_offset)) - { - continue; - } - for (capture_name, value) in tasks.extra_variables.iter() { - variables.insert( - VariableName::Custom(capture_name.to_owned().into()), - value.clone(), - ); - } - } - variables - }; - - project.update(cx, |project, cx| { - project.task_store().update(cx, |task_store, cx| { - task_store.task_context_for_location(captured_variables, location, cx) - }) - }) - } - - pub fn lsp_task_sources(&self, cx: &App) -> HashMap> { - let lsp_settings = &ProjectSettings::get_global(cx).lsp; - - self.buffer() - .read(cx) - .all_buffers() - .into_iter() - .filter_map(|buffer| { - let lsp_tasks_source = buffer - .read(cx) - .language()? - .context_provider()? - .lsp_task_source()?; - if lsp_settings - .get(&lsp_tasks_source) - .is_none_or(|s| s.enable_lsp_tasks) - { - let buffer_id = buffer.read(cx).remote_id(); - Some((lsp_tasks_source, buffer_id)) - } else { - None - } - }) - .fold( - HashMap::default(), - |mut acc, (lsp_task_source, buffer_id)| { - acc.entry(lsp_task_source) - .or_insert_with(Vec::new) - .push(buffer_id); - acc - }, - ) - } -} diff --git a/crates/eval_cli/src/main.rs b/crates/eval_cli/src/main.rs index 0f8dbed7ba12cee934e7631dc7068c83db1dc293..b49cc4d53f50eeb5ea10216867257332c5354cb4 100644 --- a/crates/eval_cli/src/main.rs +++ b/crates/eval_cli/src/main.rs @@ -50,6 +50,7 @@ use gpui::{AppContext as _, AsyncApp, Entity, UpdateGlobal}; use language_model::{LanguageModelRegistry, SelectedModel}; use project::Project; use settings::SettingsStore; +use util::path_list::PathList; use crate::headless::AgentCliAppState; @@ -357,24 +358,24 @@ async fn run_agent( Err(e) => return (Err(e), None), }; - let thread_store = cx.new(|cx| ThreadStore::new(cx)); - let agent = match NativeAgent::new( - project.clone(), - thread_store, - Templates::new(), - None, - app_state.fs.clone(), - cx, - ) - .await - { - Ok(a) => a, - Err(e) => return (Err(e).context("creating agent"), None), - }; + let agent = cx.update(|cx| { + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + NativeAgent::new( + thread_store, + Templates::new(), + None, + app_state.fs.clone(), + cx, + ) + }); let connection = Rc::new(NativeAgentConnection(agent.clone())); let acp_thread = match cx - .update(|cx| connection.clone().new_session(project, workdir, cx)) + .update(|cx| { + connection + .clone() + .new_session(project, PathList::new(&[workdir]), cx) + }) .await { Ok(t) => t, diff --git a/crates/extension_host/Cargo.toml b/crates/extension_host/Cargo.toml index c4d1f6d98c82ee348f4a7453a3bb6e3255924b77..c6f4db47c97d69173242953926c6965c039a6397 100644 --- a/crates/extension_host/Cargo.toml +++ b/crates/extension_host/Cargo.toml @@ -65,7 +65,7 @@ language = { workspace = true, features = ["test-support"] } language_extension.workspace = true parking_lot.workspace = true project = { workspace = true, features = ["test-support"] } -rand.workspace = true + reqwest_client.workspace = true theme = { workspace = true, features = ["test-support"] } theme_extension.workspace = true diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 1458b2104f31f4d987319c87a41bfd5538b2727f..2d0b151a107000e913ba4772d7d3d2bf50474fc1 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -870,9 +870,12 @@ impl ExtensionsPage { ) .child( h_flex() + .min_w_0() + .w_full() .justify_between() .child( h_flex() + .min_w_0() .gap_1() .child( Icon::new(IconName::Person) @@ -889,6 +892,7 @@ impl ExtensionsPage { .child( h_flex() .gap_1() + .flex_shrink_0() .child({ let repo_url_for_tooltip = repository_url.clone(); @@ -1052,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| { @@ -1074,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, @@ -1475,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!( @@ -1516,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/feature_flags/Cargo.toml b/crates/feature_flags/Cargo.toml index a25ca1629a539a87a7356f0419ef074e9546bc52..960834211ff18980675b236cd0cc2893d563d668 100644 --- a/crates/feature_flags/Cargo.toml +++ b/crates/feature_flags/Cargo.toml @@ -12,5 +12,4 @@ workspace = true path = "src/feature_flags.rs" [dependencies] -futures.workspace = true gpui.workspace = true diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 1d1929ed4cf89abfc5304fa111dfc7ee523d5dd8..5b8af1180aae812ed1475810acc1920a8ec708f1 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -3,12 +3,8 @@ mod flags; use std::cell::RefCell; use std::rc::Rc; use std::sync::LazyLock; -use std::time::Duration; -use std::{future::Future, pin::Pin, task::Poll}; -use futures::channel::oneshot; -use futures::{FutureExt, select_biased}; -use gpui::{App, Context, Global, Subscription, Task, Window}; +use gpui::{App, Context, Global, Subscription, Window}; pub use flags::*; @@ -122,11 +118,6 @@ pub struct OnFlagsReady { } pub trait FeatureFlagAppExt { - fn wait_for_flag(&mut self) -> WaitForFlag; - - /// Waits for the specified feature flag to resolve, up to the given timeout. - fn wait_for_flag_or_timeout(&mut self, timeout: Duration) -> Task; - fn update_flags(&mut self, staff: bool, flags: Vec); fn set_staff(&mut self, staff: bool); fn has_flag(&self) -> bool; @@ -192,54 +183,4 @@ impl FeatureFlagAppExt for App { callback(feature_flags.has_flag::(), cx); }) } - - fn wait_for_flag(&mut self) -> WaitForFlag { - let (tx, rx) = oneshot::channel::(); - let mut tx = Some(tx); - let subscription: Option; - - match self.try_global::() { - Some(feature_flags) => { - subscription = None; - tx.take().unwrap().send(feature_flags.has_flag::()).ok(); - } - None => { - subscription = Some(self.observe_global::(move |cx| { - let feature_flags = cx.global::(); - if let Some(tx) = tx.take() { - tx.send(feature_flags.has_flag::()).ok(); - } - })); - } - } - - WaitForFlag(rx, subscription) - } - - fn wait_for_flag_or_timeout(&mut self, timeout: Duration) -> Task { - let wait_for_flag = self.wait_for_flag::(); - - self.spawn(async move |cx| { - let mut wait_for_flag = wait_for_flag.fuse(); - let mut timeout = FutureExt::fuse(cx.background_executor().timer(timeout)); - - select_biased! { - is_enabled = wait_for_flag => is_enabled, - _ = timeout => false, - } - }) - } -} - -pub struct WaitForFlag(oneshot::Receiver, Option); - -impl Future for WaitForFlag { - type Output = bool; - - fn poll(mut self: Pin<&mut Self>, cx: &mut core::task::Context<'_>) -> Poll { - self.0.poll_unpin(cx).map(|result| { - self.1.take(); - result.unwrap_or(false) - }) - } } diff --git a/crates/feedback/Cargo.toml b/crates/feedback/Cargo.toml index 0a53a1b6f38d1af0a6b913d61969d4df105a6a10..c2279d778865cb819a5b0e2e494ad9d1e4470067 100644 --- a/crates/feedback/Cargo.toml +++ b/crates/feedback/Cargo.toml @@ -22,5 +22,3 @@ util.workspace = true workspace.workspace = true zed_actions.workspace = true -[dev-dependencies] -editor = { workspace = true, features = ["test-support"] } diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 8800c7cdcb86735e3b884bd7bd1fbbf5a0522174..80e466ac4c571ede217aa734a7862becd08e72ba 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 @@ -26,7 +28,6 @@ picker.workspace = true project.workspace = true settings.workspace = true serde.workspace = true -text.workspace = true theme.workspace = true ui.workspace = true util.workspace = true @@ -38,10 +39,11 @@ project_panel.workspace = true ctor.workspace = true editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } + picker = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true 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..4302669ddc11c94f7df128534217d00c27ef083a 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, @@ -33,7 +35,6 @@ use std::{ atomic::{self, AtomicBool}, }, }; -use text::Point; use ui::{ ButtonLike, ContextMenu, HighlightedLabel, Indicator, KeyBinding, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*, @@ -45,8 +46,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 +322,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 +335,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 +394,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 +453,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 +473,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 +487,7 @@ impl Match { .read(cx) .absolutize(&path_match.path), ), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } @@ -487,7 +495,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, } } } @@ -554,18 +562,21 @@ impl Matches { .extend(history_items.into_iter().map(path_to_entry)); return; }; - // If several worktress are open we have to set the worktree root names in path prefix - let several_worktrees = worktree_store.read(cx).worktrees().count() > 1; - let worktree_name_by_id = several_worktrees.then(|| { - worktree_store - .read(cx) - .worktrees() - .map(|worktree| { - let snapshot = worktree.read(cx).snapshot(); - (snapshot.id(), snapshot.root_name().into()) - }) - .collect() - }); + + let worktree_name_by_id = if should_hide_root_in_entry_path(&worktree_store, cx) { + None + } else { + Some( + worktree_store + .read(cx) + .worktrees() + .map(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + (snapshot.id(), snapshot.root_name().into()) + }) + .collect(), + ) + }; let new_history_matches = matching_history_items( history_items, currently_opened, @@ -628,7 +639,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 +661,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 @@ -786,6 +799,16 @@ fn matching_history_items<'a>( matching_history_paths } +fn should_hide_root_in_entry_path(worktree_store: &Entity, cx: &App) -> bool { + let multiple_worktrees = worktree_store + .read(cx) + .visible_worktrees(cx) + .filter(|worktree| !worktree.read(cx).is_single_file()) + .nth(1) + .is_some(); + ProjectPanelSettings::get_global(cx).hide_root && !multiple_worktrees +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] struct FoundPath { project: ProjectPath, @@ -833,10 +856,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, @@ -885,14 +914,12 @@ impl FileFinderDelegate { .currently_opened_path .as_ref() .map(|found_path| Arc::clone(&found_path.project.path)); - let worktrees = self - .project - .read(cx) - .worktree_store() + let worktree_store = self.project.read(cx).worktree_store(); + let worktrees = worktree_store .read(cx) .visible_worktrees_and_single_files(cx) .collect::>(); - let include_root_name = worktrees.len() > 1; + let include_root_name = !should_hide_root_in_entry_path(&worktree_store, cx); let candidate_sets = worktrees .into_iter() .map(|worktree| { @@ -971,6 +998,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 @@ -1056,17 +1145,8 @@ impl FileFinderDelegate { if let Some(panel_match) = panel_match { self.labels_for_path_match(&panel_match.0, path_style) } else if let Some(worktree) = worktree { - let multiple_folders_open = self - .project - .read(cx) - .visible_worktrees(cx) - .filter(|worktree| !worktree.read(cx).is_single_file()) - .nth(1) - .is_some(); - - let full_path = if ProjectPanelSettings::get_global(cx).hide_root - && !multiple_folders_open - { + let worktree_store = self.project.read(cx).worktree_store(); + let full_path = if should_hide_root_in_entry_path(&worktree_store, cx) { entry_path.project.path.clone() } else { worktree.read(cx).root_name().join(&entry_path.project.path) @@ -1095,6 +1175,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 +1569,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 +1671,7 @@ impl PickerDelegate for FileFinderDelegate { window, cx, ), + Match::Channel { .. } => unreachable!("handled above"), } }); @@ -1598,7 +1699,12 @@ impl PickerDelegate for FileFinderDelegate { active_editor .downgrade() .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); + let Some(buffer) = editor.buffer().read(cx).as_singleton() else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let point = buffer_snapshot.point_from_external_input(row, col); + editor.go_to_singleton_buffer_point(point, window, cx); }) .log_err(); } @@ -1627,7 +1733,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 +1742,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 +1753,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..3f9d579b03c9aa2abeb408bdf6b77cf5e69de003 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -400,6 +400,18 @@ async fn test_absolute_paths(cx: &mut TestAppContext) { #[gpui::test] async fn test_complex_path(cx: &mut TestAppContext) { let app_state = init_test(cx); + + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -509,6 +521,91 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_row_column_numbers_query_inside_unicode_file(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + let first_file_name = "first.rs"; + let first_file_contents = "aéøbcdef"; + app_state + .fs + .as_fake() + .insert_tree( + path!("/src"), + json!({ + "test": { + first_file_name: first_file_contents, + "second.rs": "// Second Rust file", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; + + let (picker, workspace, cx) = build_find_picker(project, cx); + + let file_query = &first_file_name[..3]; + let file_row = 1; + let file_column = 5; + let query_inside_file = format!("{file_query}:{file_row}:{file_column}"); + picker + .update_in(cx, |finder, window, cx| { + finder + .delegate + .update_matches(query_inside_file.to_string(), window, cx) + }) + .await; + picker.update(cx, |finder, _| { + assert_match_at_position(finder, 1, &query_inside_file.to_string()); + let finder = &finder.delegate; + assert_eq!(finder.matches.len(), 2); + let latest_search_query = finder + .latest_search_query + .as_ref() + .expect("Finder should have a query after the update_matches call"); + assert_eq!(latest_search_query.raw_query, query_inside_file); + assert_eq!(latest_search_query.file_query_end, Some(file_query.len())); + assert_eq!(latest_search_query.path_position.row, Some(file_row)); + assert_eq!(latest_search_query.path_position.column, Some(file_column)); + }); + + cx.dispatch_action(Confirm); + + let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::(cx).unwrap()); + cx.executor().advance_clock(Duration::from_secs(2)); + + let expected_column = first_file_contents + .chars() + .take(file_column as usize - 1) + .map(|character| character.len_utf8()) + .sum::(); + + editor.update(cx, |editor, cx| { + let all_selections = editor.selections.all_adjusted(&editor.display_snapshot(cx)); + assert_eq!( + all_selections.len(), + 1, + "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}" + ); + let caret_selection = all_selections.into_iter().next().unwrap(); + assert_eq!( + caret_selection.start, caret_selection.end, + "Caret selection should have its start and end at the same position" + ); + assert_eq!( + file_row, + caret_selection.start.row + 1, + "Query inside file should get caret with the same focus row" + ); + assert_eq!( + expected_column, + caret_selection.start.column as usize, + "Query inside file should map user-visible columns to byte offsets for Unicode text" + ); + }); +} + #[gpui::test] async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -1413,6 +1510,18 @@ async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppCon #[gpui::test] async fn test_path_distance_ordering(cx: &mut TestAppContext) { let app_state = init_test(cx); + + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -1648,6 +1757,17 @@ async fn test_query_history(cx: &mut gpui::TestAppContext) { async fn test_history_match_positions(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -2148,6 +2268,17 @@ async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) { async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -2253,6 +2384,17 @@ async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) { async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -2736,6 +2878,17 @@ async fn test_selected_history_item_stays_selected_on_worktree_updated(cx: &mut async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -2784,6 +2937,17 @@ async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppCo async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -3183,6 +3347,17 @@ async fn test_history_items_uniqueness_for_multiple_worktree_open_all_files( async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state.fs.as_fake().insert_tree("/src", json!({})).await; app_state @@ -3709,7 +3884,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 +3909,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 +3944,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); @@ -3777,6 +3954,17 @@ fn assert_match_at_position( async fn test_filename_precedence(cx: &mut TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() @@ -3821,6 +4009,18 @@ async fn test_filename_precedence(cx: &mut TestAppContext) { #[gpui::test] async fn test_paths_with_starting_slash(cx: &mut TestAppContext) { let app_state = init_test(cx); + + cx.update(|cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + hide_root: true, + ..settings + }, + cx, + ); + }); + app_state .fs .as_fake() diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 04cae2dd2ad18f85a7c2ed663c1c3482febb22d3..371057c3f8abfd50eea34f0edfcc3e3f7d52df7b 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -48,6 +48,7 @@ cocoa = "0.26" [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true +dunce.workspace = true [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] ashpd.workspace = true diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 85489b6057cd8214ee512fb477428c93cdb32219..0cb610f7dd2d4ccf809d907347bf3b3be2c82444 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -790,7 +790,7 @@ impl GitRepository for FakeGitRepository { } fn diff(&self, _diff: git::repository::DiffType) -> BoxFuture<'_, Result> { - unimplemented!() + future::ready(Ok(String::new())).boxed() } fn diff_stat( diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 0fde444171042eda859edcac7915c456ab91e265..662e5c286315e543e361d16f5bedc9a8d7a3150d 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -15,10 +15,14 @@ use gpui::Global; use gpui::ReadGlobal as _; use gpui::SharedString; use std::borrow::Cow; +#[cfg(unix)] +use std::ffi::CString; use util::command::new_command; #[cfg(unix)] use std::os::fd::{AsFd, AsRawFd}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; @@ -33,7 +37,7 @@ use is_executable::IsExecutable; use rope::Rope; use serde::{Deserialize, Serialize}; use smol::io::AsyncWriteExt; -#[cfg(any(target_os = "windows", feature = "test-support"))] +#[cfg(feature = "test-support")] use std::path::Component; use std::{ io::{self, Write}, @@ -143,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<()>; @@ -427,85 +431,103 @@ impl RealFs { #[cfg(target_os = "windows")] fn canonicalize(path: &Path) -> Result { - let mut strip_prefix = None; + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use windows::Win32::Storage::FileSystem::GetVolumePathNameW; + use windows::core::HSTRING; - let mut new_path = PathBuf::new(); - for component in path.components() { - match component { - std::path::Component::Prefix(_) => { - let component = component.as_os_str(); - let canonicalized = if component - .to_str() - .map(|e| e.ends_with("\\")) - .unwrap_or(false) - { - std::fs::canonicalize(component) - } else { - let mut component = component.to_os_string(); - component.push("\\"); - std::fs::canonicalize(component) - }?; - - let mut strip = PathBuf::new(); - for component in canonicalized.components() { - match component { - Component::Prefix(prefix_component) => { - match prefix_component.kind() { - std::path::Prefix::Verbatim(os_str) => { - strip.push(os_str); - } - std::path::Prefix::VerbatimUNC(host, share) => { - strip.push("\\\\"); - strip.push(host); - strip.push(share); - } - std::path::Prefix::VerbatimDisk(disk) => { - strip.push(format!("{}:", disk as char)); - } - _ => strip.push(component), - }; - } - _ => strip.push(component), - } - } - strip_prefix = Some(strip); - new_path.push(component); - } - std::path::Component::RootDir => { - new_path.push(component); - } - std::path::Component::CurDir => { - if strip_prefix.is_none() { - // unrooted path - new_path.push(component); - } - } - std::path::Component::ParentDir => { - if strip_prefix.is_some() { - // rooted path - new_path.pop(); - } else { - new_path.push(component); - } - } - std::path::Component::Normal(_) => { - if let Ok(link) = std::fs::read_link(new_path.join(component)) { - let link = match &strip_prefix { - Some(e) => link.strip_prefix(e).unwrap_or(&link), - None => &link, - }; - new_path.extend(link); - } else { - new_path.push(component); - } - } - } + // std::fs::canonicalize resolves mapped network paths to UNC paths, which can + // confuse some software. To mitigate this, we canonicalize the input, then rebase + // the result onto the input's original volume root if both paths are on the same + // volume. This keeps the same drive letter or mount point the caller used. + + let abs_path = if path.is_relative() { + std::env::current_dir()?.join(path) + } else { + path.to_path_buf() + }; + + let path_hstring = HSTRING::from(abs_path.as_os_str()); + let mut vol_buf = vec![0u16; abs_path.as_os_str().len() + 2]; + unsafe { GetVolumePathNameW(&path_hstring, &mut vol_buf)? }; + let volume_root = { + let len = vol_buf + .iter() + .position(|&c| c == 0) + .unwrap_or(vol_buf.len()); + PathBuf::from(OsString::from_wide(&vol_buf[..len])) + }; + + let resolved_path = dunce::canonicalize(&abs_path)?; + let resolved_root = dunce::canonicalize(&volume_root)?; + + if let Ok(relative) = resolved_path.strip_prefix(&resolved_root) { + let mut result = volume_root; + result.push(relative); + Ok(result) + } else { + Ok(resolved_path) } + } +} - Ok(new_path) +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn rename_without_replace(source: &Path, target: &Path) -> io::Result<()> { + let source = path_to_c_string(source)?; + let target = path_to_c_string(target)?; + + #[cfg(target_os = "macos")] + let result = unsafe { libc::renamex_np(source.as_ptr(), target.as_ptr(), libc::RENAME_EXCL) }; + + #[cfg(target_os = "linux")] + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + libc::AT_FDCWD, + source.as_ptr(), + libc::AT_FDCWD, + target.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) } } +#[cfg(target_os = "windows")] +fn rename_without_replace(source: &Path, target: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + + use windows::Win32::Storage::FileSystem::{MOVE_FILE_FLAGS, MoveFileExW}; + use windows::core::PCWSTR; + + let source: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); + let target: Vec = target.as_os_str().encode_wide().chain(Some(0)).collect(); + + unsafe { + MoveFileExW( + PCWSTR(source.as_ptr()), + PCWSTR(target.as_ptr()), + MOVE_FILE_FLAGS::default(), + ) + } + .map_err(|_| io::Error::last_os_error()) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn path_to_c_string(path: &Path) -> io::Result { + CString::new(path.as_os_str().as_bytes()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("path contains interior NUL: {}", path.display()), + ) + }) +} + #[async_trait::async_trait] impl Fs for RealFs { async fn create_dir(&self, path: &Path) -> Result<()> { @@ -588,7 +610,56 @@ impl Fs for RealFs { } async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()> { - if !options.overwrite && smol::fs::metadata(target).await.is_ok() { + if options.create_parents { + if let Some(parent) = target.parent() { + self.create_dir(parent).await?; + } + } + + if options.overwrite { + smol::fs::rename(source, target).await?; + return Ok(()); + } + + let use_metadata_fallback = { + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + { + let source = source.to_path_buf(); + let target = target.to_path_buf(); + match self + .executor + .spawn(async move { rename_without_replace(&source, &target) }) + .await + { + Ok(()) => return Ok(()), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + if options.ignore_if_exists { + return Ok(()); + } + return Err(error.into()); + } + Err(error) + if error.raw_os_error().is_some_and(|code| { + code == libc::ENOSYS + || code == libc::ENOTSUP + || code == libc::EOPNOTSUPP + }) => + { + // For case when filesystem or kernel does not support atomic no-overwrite rename. + true + } + Err(error) => return Err(error.into()), + } + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + // For platforms which do not have an atomic no-overwrite rename yet. + true + } + }; + + if use_metadata_fallback && smol::fs::metadata(target).await.is_ok() { if options.ignore_if_exists { return Ok(()); } else { @@ -596,12 +667,6 @@ impl Fs for RealFs { } } - if options.create_parents { - if let Some(parent) = target.parent() { - self.create_dir(parent).await?; - } - } - smol::fs::rename(source, target).await?; Ok(()) } @@ -1045,8 +1110,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()), @@ -2762,9 +2827,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, @@ -2780,7 +2843,6 @@ impl Fs for FakeFs { }) as _ }, ) - .log_err() } async fn git_init( diff --git a/crates/fs/tests/integration/fs.rs b/crates/fs/tests/integration/fs.rs index dd5e694e23c99716a81b27afd487e3a6ea648209..b688d5e2c243ede5eb3f499ad2956feaec01a965 100644 --- a/crates/fs/tests/integration/fs.rs +++ b/crates/fs/tests/integration/fs.rs @@ -523,6 +523,65 @@ async fn test_rename(executor: BackgroundExecutor) { ); } +#[gpui::test] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +async fn test_realfs_parallel_rename_without_overwrite_preserves_losing_source( + executor: BackgroundExecutor, +) { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + let source_a = root.join("dir_a/shared.txt"); + let source_b = root.join("dir_b/shared.txt"); + let target = root.join("shared.txt"); + + std::fs::create_dir_all(source_a.parent().unwrap()).unwrap(); + std::fs::create_dir_all(source_b.parent().unwrap()).unwrap(); + std::fs::write(&source_a, "from a").unwrap(); + std::fs::write(&source_b, "from b").unwrap(); + + let fs = RealFs::new(None, executor); + let (first_result, second_result) = futures::future::join( + fs.rename(&source_a, &target, RenameOptions::default()), + fs.rename(&source_b, &target, RenameOptions::default()), + ) + .await; + + assert_ne!(first_result.is_ok(), second_result.is_ok()); + assert!(target.exists()); + assert_eq!(source_a.exists() as u8 + source_b.exists() as u8, 1); +} + +#[gpui::test] +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +async fn test_realfs_rename_ignore_if_exists_leaves_source_and_target_unchanged( + executor: BackgroundExecutor, +) { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + let source = root.join("source.txt"); + let target = root.join("target.txt"); + + std::fs::write(&source, "from source").unwrap(); + std::fs::write(&target, "from target").unwrap(); + + let fs = RealFs::new(None, executor); + let result = fs + .rename( + &source, + &target, + RenameOptions { + ignore_if_exists: true, + ..Default::default() + }, + ) + .await; + + assert!(result.is_ok()); + + assert_eq!(std::fs::read_to_string(&source).unwrap(), "from source"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "from target"); +} + #[gpui::test] #[cfg(unix)] async fn test_realfs_broken_symlink_metadata(executor: BackgroundExecutor) { diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 4d96312e274b3934e0d1ae8aa1f16f235d30a59f..23a937bf1fa17481eb5e130b3e083274dd3f1d16 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -48,7 +48,6 @@ ztracing.workspace = true pretty_assertions.workspace = true serde_json.workspace = true text = { workspace = true, features = ["test-support"] } -unindent.workspace = true gpui = { workspace = true, features = ["test-support"] } tempfile.workspace = true rand.workspace = true 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/Cargo.toml b/crates/git_graph/Cargo.toml index 386d82389ca3370f071f8733b039f91fc3f21feb..4756c55ac9232631a46056e252021a704d4a25b6 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -43,7 +43,6 @@ git = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } rand.workspace = true -recent_projects = { workspace = true, features = ["test-support"] } serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 90ccf94f5f91720972a52d85bc506d12c1a528cb..b0a4701cd25021e2725ff28b7cc45d1b4f203c8d 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -15,7 +15,7 @@ use gpui::{ px, uniform_list, }; use language::line_diff; -use menu::{Cancel, SelectNext, SelectPrevious}; +use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{ Project, git_store::{ @@ -1171,22 +1171,35 @@ impl GitGraph { cx.notify(); } - fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context) { + fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { + self.select_entry(0, cx); + } + + fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { if let Some(selected_entry_idx) = &self.selected_entry_idx { self.select_entry(selected_entry_idx.saturating_sub(1), cx); } else { - self.select_entry(0, cx); + self.select_first(&SelectFirst, window, cx); } } fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { if let Some(selected_entry_idx) = &self.selected_entry_idx { - self.select_entry(selected_entry_idx.saturating_add(1), cx); + self.select_entry( + selected_entry_idx + .saturating_add(1) + .min(self.graph_data.commits.len().saturating_sub(1)), + cx, + ); } else { self.select_prev(&SelectPrevious, window, cx); } } + fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { + self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx); + } + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { self.open_selected_commit_view(window, cx); } @@ -1481,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) @@ -1529,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) @@ -1589,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) @@ -2260,8 +2270,10 @@ impl Render for GitGraph { this.open_selected_commit_view(window, cx); })) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) .child(content) .children(self.context_menu.as_ref().map(|(menu, position, _)| { diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index a25911d65eb87d176a0a987d996e159e2c43628c..464a71489e2a0ed9118ca672299c9b7310855668 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -26,6 +26,7 @@ collections.workspace = true component.workspace = true db.workspace = true editor.workspace = true +file_icons.workspace = true futures.workspace = true feature_flags.workspace = true fuzzy.workspace = true @@ -73,7 +74,6 @@ windows.workspace = true [dev-dependencies] ctor.workspace = true editor = { workspace = true, features = ["test-support"] } -git_hosting_providers.workspace = true gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true pretty_assertions.workspace = true 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 82571b541e692141f843a4c3ef6e082c72e55e48..96faa8879b38f59133bf3679788a3c24d1201f54 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -1,3 +1,4 @@ +use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; use editor::{ ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, @@ -5,14 +6,22 @@ use editor::{ display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, }; use gpui::{ - App, Context, Entity, InteractiveElement as _, ParentElement as _, Subscription, Task, - WeakEntity, + App, Context, DismissEvent, Entity, InteractiveElement as _, ParentElement as _, Subscription, + Task, WeakEntity, }; use language::{Anchor, Buffer, BufferId}; -use project::{ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _}; -use std::{ops::Range, sync::Arc}; -use ui::{ActiveTheme, Element as _, Styled, Window, prelude::*}; +use project::{ + ConflictRegion, ConflictSet, ConflictSetUpdate, ProjectItem as _, + git_store::{GitStoreEvent, RepositoryEvent}, +}; +use settings::Settings; +use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; +use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*}; use util::{ResultExt as _, debug_panic, maybe}; +use workspace::{Workspace, notifications::simple_message_notification::MessageNotification}; +use zed_actions::agent::{ + ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, +}; pub(crate) struct ConflictAddon { buffers: HashMap, @@ -182,7 +191,7 @@ fn conflicts_updated( let excerpts = multibuffer.excerpts_for_buffer(buffer_id, cx); let Some(buffer_snapshot) = excerpts .first() - .and_then(|(excerpt_id, _)| snapshot.buffer_for_excerpt(*excerpt_id)) + .and_then(|(excerpt_id, _, _)| snapshot.buffer_for_excerpt(*excerpt_id)) else { return; }; @@ -221,7 +230,7 @@ fn conflicts_updated( let mut removed_highlighted_ranges = Vec::new(); let mut removed_block_ids = HashSet::default(); for (conflict_range, block_id) in old_conflicts { - let Some((excerpt_id, _)) = excerpts.iter().find(|(_, range)| { + let Some((excerpt_id, _, _)) = excerpts.iter().find(|(_, _, range)| { let precedes_start = range .context .start @@ -263,7 +272,7 @@ fn conflicts_updated( let new_conflicts = &conflict_set.conflicts[event.new_range.clone()]; let mut blocks = Vec::new(); for conflict in new_conflicts { - let Some((excerpt_id, _)) = excerpts.iter().find(|(_, range)| { + let Some((excerpt_id, _, _)) = excerpts.iter().find(|(_, _, range)| { let precedes_start = range .context .start @@ -368,11 +377,12 @@ fn render_conflict_buttons( editor: WeakEntity, cx: &mut BlockContext, ) -> AnyElement { + let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx); + h_flex() .id(cx.block_id) .h(cx.line_height) .ml(cx.margins.gutter.width) - .items_end() .gap_1() .bg(cx.theme().colors().editor_background) .child( @@ -419,6 +429,7 @@ fn render_conflict_buttons( Button::new("both", "Use Both") .label_size(LabelSize::Small) .on_click({ + let editor = editor.clone(); let conflict = conflict.clone(); let ours = conflict.ours.clone(); let theirs = conflict.theirs.clone(); @@ -435,9 +446,145 @@ fn render_conflict_buttons( } }), ) + .when(is_ai_enabled, |this| { + this.child(Divider::vertical()).child( + Button::new("resolve-with-agent", "Resolve with Agent") + .label_size(LabelSize::Small) + .start_icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ) + .on_click({ + let conflict = conflict.clone(); + move |_, window, cx| { + let content = editor + .update(cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx); + let buffer_id = conflict.ours.end.buffer_id?; + let buffer = multibuffer.buffer(buffer_id)?; + let buffer_read = buffer.read(cx); + let snapshot = buffer_read.snapshot(); + let conflict_text = snapshot + .text_for_range(conflict.range.clone()) + .collect::(); + let file_path = buffer_read + .file() + .and_then(|file| file.as_local()) + .map(|f| f.abs_path(cx).to_string_lossy().to_string()) + .unwrap_or_default(); + Some(ConflictContent { + file_path, + conflict_text, + ours_branch_name: conflict.ours_branch_name.to_string(), + theirs_branch_name: conflict.theirs_branch_name.to_string(), + }) + }) + .ok() + .flatten(); + if let Some(content) = content { + window.dispatch_action( + Box::new(ResolveConflictsWithAgent { + conflicts: vec![content], + }), + cx, + ); + } + } + }), + ) + }) .into_any() } +fn collect_conflicted_file_paths(workspace: &Workspace, cx: &App) -> Vec { + let project = workspace.project().read(cx); + let git_store = project.git_store().read(cx); + let mut paths = Vec::new(); + + for repo in git_store.repositories().values() { + let snapshot = repo.read(cx).snapshot(); + for (repo_path, _) in snapshot.merge.merge_heads_by_conflicted_path.iter() { + if let Some(project_path) = repo.read(cx).repo_path_to_project_path(repo_path, cx) { + paths.push( + project_path + .path + .as_std_path() + .to_string_lossy() + .to_string(), + ); + } + } + } + + paths +} + +pub(crate) fn register_conflict_notification( + workspace: &mut Workspace, + cx: &mut Context, +) { + let git_store = workspace.project().read(cx).git_store().clone(); + + let last_shown_paths: Rc>> = Rc::new(RefCell::new(HashSet::default())); + + cx.subscribe(&git_store, move |workspace, _git_store, event, cx| { + let conflicts_changed = matches!( + event, + GitStoreEvent::ConflictsUpdated + | GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _) + ); + if !AgentSettings::get_global(cx).enabled || !conflicts_changed { + return; + } + + if workspace.is_notification_suppressed(workspace::merge_conflict_notification_id()) { + return; + } + + let paths = collect_conflicted_file_paths(workspace, cx); + let notification_id = workspace::merge_conflict_notification_id(); + let current_paths_set: HashSet = paths.iter().cloned().collect(); + + if paths.is_empty() { + last_shown_paths.borrow_mut().clear(); + workspace.dismiss_notification(¬ification_id, cx); + } else if *last_shown_paths.borrow() != current_paths_set { + // Only show the notification if the set of conflicted paths has changed. + // This prevents re-showing after the user dismisses it while working on the same conflicts. + *last_shown_paths.borrow_mut() = current_paths_set; + let file_count = paths.len(); + workspace.show_notification(notification_id, cx, |cx| { + cx.new(|cx| { + let message = if file_count == 1 { + "1 file has unresolved merge conflicts".to_string() + } else { + format!("{file_count} files have unresolved merge conflicts") + }; + + MessageNotification::new(message, cx) + .primary_message("Resolve with Agent") + .primary_icon(IconName::ZedAssistant) + .primary_icon_color(Color::Muted) + .primary_on_click({ + let paths = paths.clone(); + move |window, cx| { + window.dispatch_action( + Box::new(ResolveConflictedFilesWithAgent { + conflicted_file_paths: paths.clone(), + }), + cx, + ); + cx.emit(DismissEvent); + } + }) + }) + }); + } + }) + .detach(); +} + pub(crate) fn resolve_conflict( editor: WeakEntity, excerpt_id: ExcerptId, diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index 115a53abbc240a37b7d4800c4c7905bed270be91..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, }; @@ -108,7 +108,7 @@ impl FileDiffView { for buffer in [&old_buffer, &new_buffer] { cx.subscribe(buffer, move |this, _, event, _| match event { - language::BufferEvent::Edited + language::BufferEvent::Edited { .. } | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); @@ -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_panel.rs b/crates/git_ui/src/git_panel.rs index 8205f5ee7b6a9966a37a8406331d171d8ca57f1d..7bd4f3b32a6bd1fa2be6e26d5662f51c80e5a0e6 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -20,6 +20,7 @@ use editor::{ actions::ExpandAllDiffHunks, }; use editor::{EditorStyle, RewrapOptions}; +use file_icons::FileIcons; use futures::StreamExt as _; use git::commit::ParsedCommitMessage; use git::repository::{ @@ -714,11 +715,16 @@ impl GitPanel { let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; + let mut was_file_icons = GitPanelSettings::get_global(cx).file_icons; + let mut was_folder_icons = GitPanelSettings::get_global(cx).folder_icons; let mut was_diff_stats = GitPanelSettings::get_global(cx).diff_stats; cx.observe_global_in::(window, move |this, window, cx| { - let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; - let tree_view = GitPanelSettings::get_global(cx).tree_view; - let diff_stats = GitPanelSettings::get_global(cx).diff_stats; + let settings = GitPanelSettings::get_global(cx); + let sort_by_path = settings.sort_by_path; + let tree_view = settings.tree_view; + let file_icons = settings.file_icons; + let folder_icons = settings.folder_icons; + let diff_stats = settings.diff_stats; if tree_view != was_tree_view { this.view_mode = GitPanelViewMode::from_settings(cx); } @@ -731,12 +737,22 @@ impl GitPanel { if (diff_stats != was_diff_stats) || update_entries { this.update_visible_entries(window, cx); } + if file_icons != was_file_icons || folder_icons != was_folder_icons { + cx.notify(); + } was_sort_by_path = sort_by_path; was_tree_view = tree_view; + was_file_icons = file_icons; + was_folder_icons = folder_icons; was_diff_stats = diff_stats; }) .detach(); + cx.observe_global::(|_, cx| { + cx.notify(); + }) + .detach(); + // just to let us render a placeholder editor. // Once the active git repo is set, this buffer will be replaced. let temporary_buffer = cx.new(|cx| Buffer::local("", cx)); @@ -5020,15 +5036,21 @@ impl GitPanel { window: &Window, cx: &Context, ) -> AnyElement { - let tree_view = GitPanelSettings::get_global(cx).tree_view; + let settings = GitPanelSettings::get_global(cx); + let tree_view = settings.tree_view; let path_style = self.project.read(cx).path_style(cx); let git_path_style = ProjectSettings::get_global(cx).git.path_style; let display_name = entry.display_name(path_style); let selected = self.selected_entry == Some(ix); let marked = self.marked_entries.contains(&ix); - let status_style = GitPanelSettings::get_global(cx).status_style; + let status_style = settings.status_style; let status = entry.status; + let file_icon = if settings.file_icons { + FileIcons::get_icon(entry.repo_path.as_std_path(), cx) + } else { + None + }; let has_conflict = status.is_conflicted(); let is_modified = status.is_modified(); @@ -5105,6 +5127,21 @@ impl GitPanel { .min_w_0() .flex_1() .gap_1() + .when(settings.file_icons, |this| { + this.child( + file_icon + .map(|file_icon| { + Icon::from_path(file_icon) + .size(IconSize::Small) + .color(Color::Muted) + }) + .unwrap_or_else(|| { + Icon::new(IconName::File) + .size(IconSize::Small) + .color(Color::Muted) + }), + ) + }) .child(git_status_icon(status)) .map(|this| { if tree_view { @@ -5273,10 +5310,24 @@ impl GitPanel { ) }; - let folder_icon = if entry.expanded { - IconName::FolderOpen + let settings = GitPanelSettings::get_global(cx); + let folder_icon = if settings.folder_icons { + FileIcons::get_folder_icon(entry.expanded, entry.key.path.as_std_path(), cx) } else { - IconName::Folder + FileIcons::get_chevron_icon(entry.expanded, cx) + }; + let fallback_folder_icon = if settings.folder_icons { + if entry.expanded { + IconName::FolderOpen + } else { + IconName::Folder + } + } else { + if entry.expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + } }; let stage_status = if let Some(repo) = &self.active_repository { @@ -5299,9 +5350,17 @@ impl GitPanel { .gap_1() .pl(px(entry.depth as f32 * TREE_INDENT)) .child( - Icon::new(folder_icon) - .size(IconSize::Small) - .color(Color::Muted), + folder_icon + .map(|folder_icon| { + Icon::from_path(folder_icon) + .size(IconSize::Small) + .color(Color::Muted) + }) + .unwrap_or_else(|| { + Icon::new(fallback_folder_icon) + .size(IconSize::Small) + .color(Color::Muted) + }), ) .child(self.entry_label(entry.name.clone(), label_color).truncate()); @@ -5738,6 +5797,14 @@ impl Panel for GitPanel { Some("Git Panel") } + fn icon_label(&self, _: &Window, cx: &App) -> Option { + if !GitPanelSettings::get_global(cx).show_count_badge { + return None; + } + let total = self.changes_count; + (total > 0).then(|| total.to_string()) + } + fn toggle_action(&self) -> Box { Box::new(ToggleFocus) } diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index 2a7480de355a6190494211d823e4aa440d191371..baf453e310c02097da1d11344e79bac31f891d0b 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -20,12 +20,15 @@ pub struct GitPanelSettings { pub dock: DockPosition, pub default_width: Pixels, pub status_style: StatusStyle, + pub file_icons: bool, + pub folder_icons: bool, pub scrollbar: ScrollbarSettings, pub fallback_branch_name: String, pub sort_by_path: bool, pub collapse_untracked_diff: bool, pub tree_view: bool, pub diff_stats: bool, + pub show_count_badge: bool, } impl ScrollbarVisibility for GitPanelSettings { @@ -52,6 +55,8 @@ impl Settings for GitPanelSettings { dock: git_panel.dock.unwrap().into(), default_width: px(git_panel.default_width.unwrap()), status_style: git_panel.status_style.unwrap(), + file_icons: git_panel.file_icons.unwrap(), + folder_icons: git_panel.folder_icons.unwrap(), scrollbar: ScrollbarSettings { show: git_panel.scrollbar.unwrap().show.map(Into::into), }, @@ -60,6 +65,7 @@ impl Settings for GitPanelSettings { collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), tree_view: git_panel.tree_view.unwrap(), diff_stats: git_panel.diff_stats.unwrap(), + show_count_badge: git_panel.show_count_badge.unwrap(), } } } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index e19eb8c21f9bf8a3eb88e4804d2a977ffb97e31c..01375e600392d2b18b34ec3241aff45c5fad6e67 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -62,6 +62,7 @@ pub fn init(cx: &mut App) { git_panel::register(workspace); repository_selector::register(workspace); git_picker::register(workspace); + conflict_view::register_conflict_notification(workspace, cx); let project = workspace.project().read(cx); if project.is_read_only(cx) { @@ -871,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 ad7d6b86befd0b0f4a1ecf6386c030d4294cdf5e..41eff7b23a95ca2d4112d4b95aef67ff7d4a765f 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -2,7 +2,6 @@ use crate::{ conflict_view::ConflictAddon, git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, git_panel_settings::GitPanelSettings, - remote_button::{render_publish_button, render_push_button}, resolve_active_repository, }; use agent_settings::AgentSettings; @@ -18,8 +17,7 @@ use editor::{ use git::repository::DiffType; use git::{ - Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, - repository::{Branch, RepoPath, Upstream, UpstreamTracking, UpstreamTrackingStatus}, + Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext, repository::RepoPath, status::FileStatus, }; use gpui::{ @@ -1594,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, @@ -1688,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( @@ -1719,254 +1721,6 @@ impl Render for BranchDiffToolbar { } } -#[derive(IntoElement, RegisterComponent)] -pub struct ProjectDiffEmptyState { - pub no_repo: bool, - pub can_push_and_pull: bool, - pub focus_handle: Option, - pub current_branch: Option, - // has_pending_commits: bool, - // ahead_of_remote: bool, - // no_git_repository: bool, -} - -impl RenderOnce for ProjectDiffEmptyState { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let status_against_remote = |ahead_by: usize, behind_by: usize| -> bool { - matches!(self.current_branch, Some(Branch { - upstream: - Some(Upstream { - tracking: - UpstreamTracking::Tracked(UpstreamTrackingStatus { - ahead, behind, .. - }), - .. - }), - .. - }) if (ahead > 0) == (ahead_by > 0) && (behind > 0) == (behind_by > 0)) - }; - - let change_count = |current_branch: &Branch| -> (usize, usize) { - match current_branch { - Branch { - upstream: - Some(Upstream { - tracking: - UpstreamTracking::Tracked(UpstreamTrackingStatus { - ahead, behind, .. - }), - .. - }), - .. - } => (*ahead as usize, *behind as usize), - _ => (0, 0), - } - }; - - let not_ahead_or_behind = status_against_remote(0, 0); - let ahead_of_remote = status_against_remote(1, 0); - let branch_not_on_remote = if let Some(branch) = self.current_branch.as_ref() { - branch.upstream.is_none() - } else { - false - }; - - let has_branch_container = |branch: &Branch| { - h_flex() - .max_w(px(420.)) - .bg(cx.theme().colors().text.opacity(0.05)) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_sm() - .gap_8() - .px_6() - .py_4() - .map(|this| { - if ahead_of_remote { - let ahead_count = change_count(branch).0; - let ahead_string = format!("{} Commits Ahead", ahead_count); - this.child( - v_flex() - .child(Headline::new(ahead_string).size(HeadlineSize::Small)) - .child( - Label::new(format!("Push your changes to {}", branch.name())) - .color(Color::Muted), - ), - ) - .child(div().child(render_push_button( - self.focus_handle, - "push".into(), - ahead_count as u32, - ))) - } else if branch_not_on_remote { - this.child( - v_flex() - .child(Headline::new("Publish Branch").size(HeadlineSize::Small)) - .child( - Label::new(format!("Create {} on remote", branch.name())) - .color(Color::Muted), - ), - ) - .child( - div().child(render_publish_button(self.focus_handle, "publish".into())), - ) - } else { - this.child(Label::new("Remote status unknown").color(Color::Muted)) - } - }) - }; - - v_flex().size_full().items_center().justify_center().child( - v_flex() - .gap_1() - .when(self.no_repo, |this| { - this.text_center() - .child(Label::new("No Repository").color(Color::Muted)) - .child( - Button::new("initialize-repo", "Initialize Repository") - .on_click(move |_, _, cx| cx.dispatch_action(&git::Init)), - ) - }) - .map(|this| { - if not_ahead_or_behind && self.current_branch.is_some() { - this.text_center() - .child(Label::new("No Changes").color(Color::Muted)) - } else { - this.when_some(self.current_branch.as_ref(), |this, branch| { - this.child(has_branch_container(branch)) - }) - } - }), - ) - } -} - -mod preview { - use git::repository::{ - Branch, CommitSummary, Upstream, UpstreamTracking, UpstreamTrackingStatus, - }; - use ui::prelude::*; - - use super::ProjectDiffEmptyState; - - // View this component preview using `workspace: open component-preview` - impl Component for ProjectDiffEmptyState { - fn scope() -> ComponentScope { - ComponentScope::VersionControl - } - - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - let unknown_upstream: Option = None; - let ahead_of_upstream: Option = Some( - UpstreamTrackingStatus { - ahead: 2, - behind: 0, - } - .into(), - ); - - let not_ahead_or_behind_upstream: Option = Some( - UpstreamTrackingStatus { - ahead: 0, - behind: 0, - } - .into(), - ); - - fn branch(upstream: Option) -> Branch { - Branch { - is_head: true, - ref_name: "some-branch".into(), - upstream: upstream.map(|tracking| Upstream { - ref_name: "origin/some-branch".into(), - tracking, - }), - most_recent_commit: Some(CommitSummary { - sha: "abc123".into(), - subject: "Modify stuff".into(), - commit_timestamp: 1710932954, - author_name: "John Doe".into(), - has_parent: true, - }), - } - } - - let no_repo_state = ProjectDiffEmptyState { - no_repo: true, - can_push_and_pull: false, - focus_handle: None, - current_branch: None, - }; - - let no_changes_state = ProjectDiffEmptyState { - no_repo: false, - can_push_and_pull: true, - focus_handle: None, - current_branch: Some(branch(not_ahead_or_behind_upstream)), - }; - - let ahead_of_upstream_state = ProjectDiffEmptyState { - no_repo: false, - can_push_and_pull: true, - focus_handle: None, - current_branch: Some(branch(ahead_of_upstream)), - }; - - let unknown_upstream_state = ProjectDiffEmptyState { - no_repo: false, - can_push_and_pull: true, - focus_handle: None, - current_branch: Some(branch(unknown_upstream)), - }; - - let (width, height) = (px(480.), px(320.)); - - Some( - v_flex() - .gap_6() - .children(vec![ - example_group(vec![ - single_example( - "No Repo", - div() - .w(width) - .h(height) - .child(no_repo_state) - .into_any_element(), - ), - single_example( - "No Changes", - div() - .w(width) - .h(height) - .child(no_changes_state) - .into_any_element(), - ), - single_example( - "Unknown Upstream", - div() - .w(width) - .h(height) - .child(unknown_upstream_state) - .into_any_element(), - ), - single_example( - "Ahead of Remote", - div() - .w(width) - .h(height) - .child(ahead_of_upstream_state) - .into_any_element(), - ), - ]) - .vertical(), - ]) - .into_any_element(), - ) - } - } -} - struct BranchDiffAddon { branch_diff: Entity, } diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 1419fa049ee2aae1992dac517aad8371800ac532..9ae1b379471e4921b0ba3e77148ef198991e309b 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -165,7 +165,7 @@ impl TextDiffView { let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(()); cx.subscribe(&source_buffer, move |this, _, event, _| match event { - language::BufferEvent::Edited + language::BufferEvent::Edited { .. } | language::BufferEvent::LanguageChanged(_) | language::BufferEvent::Reparsed => { this.buffer_changes_tx.send(()).ok(); diff --git a/crates/go_to_line/Cargo.toml b/crates/go_to_line/Cargo.toml index 0260cd2d122f83f2c11505be9e6e8a84f69f8569..c07656985380c93355a4c8429dcf1135acf93d56 100644 --- a/crates/go_to_line/Cargo.toml +++ b/crates/go_to_line/Cargo.toml @@ -17,6 +17,7 @@ editor.workspace = true gpui.workspace = true language.workspace = true menu.workspace = true +multi_buffer.workspace = true serde.workspace = true settings.workspace = true text.workspace = true @@ -34,6 +35,4 @@ menu.workspace = true project = { workspace = true, features = ["test-support"] } rope.workspace = true serde_json.workspace = true -tree-sitter-rust.workspace = true -tree-sitter-typescript.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 662bf2a98d84ba434da98aeca71791c028f6018c..a5332e96c731a29027ea6a69288d7d9556cb2da0 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -2,7 +2,7 @@ pub mod cursor_position; use cursor_position::UserCaretPosition; use editor::{ - Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, SelectionEffects, ToOffset, ToPoint, + Anchor, Editor, MultiBufferSnapshot, RowHighlightOptions, SelectionEffects, ToPoint, actions::Tab, scroll::{Autoscroll, ScrollOffset}, }; @@ -11,6 +11,7 @@ use gpui::{ Subscription, div, prelude::*, }; use language::Buffer; +use multi_buffer::MultiBufferRow; use text::{Bias, Point}; use theme::ActiveTheme; use ui::prelude::*; @@ -94,7 +95,9 @@ impl GoToLine { .read(cx) .excerpts_for_buffer(snapshot.remote_id(), cx) .into_iter() - .map(move |(_, range)| text::ToPoint::to_point(&range.context.end, &snapshot).row) + .map(move |(_, _, range)| { + text::ToPoint::to_point(&range.context.end, &snapshot).row + }) .max() .unwrap_or(0); @@ -226,31 +229,14 @@ impl GoToLine { let row = query_row.saturating_sub(1); let character = query_char.unwrap_or(0).saturating_sub(1); - let start_offset = Point::new(row, 0).to_offset(snapshot); - const MAX_BYTES_IN_UTF_8: u32 = 4; - let max_end_offset = snapshot - .clip_point( - Point::new(row, character * MAX_BYTES_IN_UTF_8 + 1), - Bias::Right, - ) - .to_offset(snapshot); - - let mut chars_to_iterate = character; - let mut end_offset = start_offset; - 'outer: for text_chunk in snapshot.text_for_range(start_offset..max_end_offset) { - let mut offset_increment = 0; - for c in text_chunk.chars() { - if chars_to_iterate == 0 { - end_offset += offset_increment; - break 'outer; - } else { - chars_to_iterate -= 1; - offset_increment += c.len_utf8(); - } - } - end_offset += offset_increment; - } - Some(snapshot.anchor_before(snapshot.clip_offset(end_offset, Bias::Left))) + let target_multi_buffer_row = MultiBufferRow(row); + let (buffer_snapshot, target_in_buffer, _) = snapshot.point_to_buffer_point(Point::new( + target_multi_buffer_row.min(snapshot.max_row()).0, + 0, + ))?; + let target_point = + buffer_snapshot.point_from_external_input(target_in_buffer.row, character); + Some(snapshot.anchor_before(target_point)) } fn relative_line_from_query(&self, cx: &App) -> Option { diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 8c475378ff32c16eac9a7254aa740c0c51ce75e6..0bf19a4878ba80eda3eca02f355b2419f022621e 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -147,7 +147,6 @@ collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true gpui_platform.workspace = true lyon = { version = "1.0", features = ["extra"] } -pretty_assertions.workspace = true rand.workspace = true scheduler = { workspace = true, features = ["test-support"] } unicode-segmentation.workspace = true diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 8af0a8923b38a6f711d701730996afca012fb48b..3d22d48a3a808a6f437a5875bfd4e337b7672d80 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -27,9 +27,13 @@ use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; pub use context::*; pub use entity_map::*; use gpui_util::{ResultExt, debug_panic}; +#[cfg(any(test, feature = "test-support"))] +pub use headless_app_context::*; use http_client::{HttpClient, Url}; use smallvec::SmallVec; #[cfg(any(test, feature = "test-support"))] +pub use test_app::*; +#[cfg(any(test, feature = "test-support"))] pub use test_context::*; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test_context::*; @@ -54,6 +58,10 @@ mod async_context; mod context; mod entity_map; #[cfg(any(test, feature = "test-support"))] +mod headless_app_context; +#[cfg(any(test, feature = "test-support"))] +mod test_app; +#[cfg(any(test, feature = "test-support"))] mod test_context; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] mod visual_test_context; diff --git a/crates/gpui/src/app/headless_app_context.rs b/crates/gpui/src/app/headless_app_context.rs new file mode 100644 index 0000000000000000000000000000000000000000..90dc8c8f0c0994e3f118916b2d004f7d90566ea7 --- /dev/null +++ b/crates/gpui/src/app/headless_app_context.rs @@ -0,0 +1,275 @@ +//! Cross-platform headless app context for tests that need real text shaping. +//! +//! This replaces the macOS-only `HeadlessMetalAppContext` with a platform-neutral +//! implementation backed by `TestPlatform`. Tests supply a real `PlatformTextSystem` +//! (e.g. `DirectWriteTextSystem` on Windows, `MacTextSystem` on macOS) to get +//! accurate glyph measurements while keeping everything else deterministic. +//! +//! Optionally, a renderer factory can be provided to enable real GPU rendering +//! and screenshot capture via [`HeadlessAppContext::capture_screenshot`]. + +use crate::{ + AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetSource, BackgroundExecutor, Bounds, + Context, Entity, ForegroundExecutor, Global, Pixels, PlatformHeadlessRenderer, + PlatformTextSystem, Render, Reservation, Size, Task, TestDispatcher, TestPlatform, TextSystem, + Window, WindowBounds, WindowHandle, WindowOptions, + app::{GpuiBorrow, GpuiMode}, +}; +use anyhow::Result; +use image::RgbaImage; +use std::{future::Future, rc::Rc, sync::Arc, time::Duration}; + +/// A cross-platform headless app context for tests that need real text shaping. +/// +/// Unlike the old `HeadlessMetalAppContext`, this works on any platform. It uses +/// `TestPlatform` for deterministic scheduling and accepts a pluggable +/// `PlatformTextSystem` so tests get real glyph measurements. +/// +/// # Usage +/// +/// ```ignore +/// let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new("fallback")); +/// let mut cx = HeadlessAppContext::with_platform( +/// text_system, +/// Arc::new(Assets), +/// || gpui_platform::current_headless_renderer(), +/// ); +/// ``` +pub struct HeadlessAppContext { + /// The underlying app cell. + pub app: Rc, + /// The background executor for running async tasks. + pub background_executor: BackgroundExecutor, + /// The foreground executor for running tasks on the main thread. + pub foreground_executor: ForegroundExecutor, + dispatcher: TestDispatcher, + text_system: Arc, +} + +impl HeadlessAppContext { + /// Creates a new headless app context with the given text system. + pub fn new(platform_text_system: Arc) -> Self { + Self::with_platform(platform_text_system, Arc::new(()), || None) + } + + /// Creates a new headless app context with a custom text system and asset source. + pub fn with_asset_source( + platform_text_system: Arc, + asset_source: Arc, + ) -> Self { + Self::with_platform(platform_text_system, asset_source, || None) + } + + /// Creates a new headless app context with the given text system, asset source, + /// and an optional renderer factory for screenshot support. + pub fn with_platform( + platform_text_system: Arc, + asset_source: Arc, + renderer_factory: impl Fn() -> Option> + 'static, + ) -> Self { + let seed = std::env::var("SEED") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let dispatcher = TestDispatcher::new(seed); + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + + let renderer_factory: Box Option>> = + Box::new(renderer_factory); + let platform = TestPlatform::with_platform( + background_executor.clone(), + foreground_executor.clone(), + platform_text_system.clone(), + Some(renderer_factory), + ); + + let text_system = Arc::new(TextSystem::new(platform_text_system)); + let http_client = http_client::FakeHttpClient::with_404_response(); + let app = App::new_app(platform, asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + + Self { + app, + background_executor, + foreground_executor, + dispatcher, + text_system, + } + } + + /// Opens a window for headless rendering. + pub fn open_window( + &mut self, + size: Size, + build_root: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result> { + use crate::{point, px}; + + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size, + }; + + let mut cx = self.app.borrow_mut(); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: false, + ..Default::default() + }, + build_root, + ) + } + + /// Runs all pending tasks until parked. + pub fn run_until_parked(&self) { + self.dispatcher.run_until_parked(); + } + + /// Advances the simulated clock. + pub fn advance_clock(&self, duration: Duration) { + self.dispatcher.advance_clock(duration); + } + + /// Enables parking mode, allowing blocking on real I/O (e.g., async asset loading). + pub fn allow_parking(&self) { + self.dispatcher.allow_parking(); + } + + /// Disables parking mode, returning to deterministic test execution. + pub fn forbid_parking(&self) { + self.dispatcher.forbid_parking(); + } + + /// Updates app state. + pub fn update(&mut self, f: impl FnOnce(&mut App) -> R) -> R { + let mut app = self.app.borrow_mut(); + f(&mut app) + } + + /// Updates a window and calls draw to render. + pub fn update_window( + &mut self, + window: AnyWindowHandle, + f: impl FnOnce(AnyView, &mut Window, &mut App) -> R, + ) -> Result { + let mut app = self.app.borrow_mut(); + app.update_window(window, f) + } + + /// Captures a screenshot from a window. + /// + /// Requires that the context was created with a renderer factory that + /// returns `Some` via [`HeadlessAppContext::with_platform`]. + pub fn capture_screenshot(&mut self, window: AnyWindowHandle) -> Result { + let mut app = self.app.borrow_mut(); + app.update_window(window, |_, window, _| window.render_to_image())? + } + + /// Returns the text system. + pub fn text_system(&self) -> &Arc { + &self.text_system + } + + /// Returns the background executor. + pub fn background_executor(&self) -> &BackgroundExecutor { + &self.background_executor + } + + /// Returns the foreground executor. + pub fn foreground_executor(&self) -> &ForegroundExecutor { + &self.foreground_executor + } +} + +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(); + app.new(build_entity) + } + + fn reserve_entity(&mut self) -> Reservation { + let mut app = self.app.borrow_mut(); + app.reserve_entity() + } + + fn insert_entity( + &mut self, + reservation: Reservation, + build_entity: impl FnOnce(&mut Context) -> T, + ) -> Entity { + let mut app = self.app.borrow_mut(); + app.insert_entity(reservation, build_entity) + } + + fn update_entity( + &mut self, + handle: &Entity, + update: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + let mut app = self.app.borrow_mut(); + app.update_entity(handle, update) + } + + fn as_mut<'a, T>(&'a mut self, _: &Entity) -> GpuiBorrow<'a, T> + where + T: 'static, + { + panic!("Cannot use as_mut with HeadlessAppContext. Call update() instead.") + } + + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R + where + T: 'static, + { + let app = self.app.borrow(); + app.read_entity(handle, read) + } + + fn update_window(&mut self, window: AnyWindowHandle, f: F) -> Result + where + F: FnOnce(AnyView, &mut Window, &mut App) -> T, + { + let mut lock = self.app.borrow_mut(); + lock.update_window(window, f) + } + + fn read_window( + &self, + window: &WindowHandle, + read: impl FnOnce(Entity, &App) -> R, + ) -> Result + where + T: 'static, + { + let app = self.app.borrow(); + app.read_window(window, read) + } + + fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.background_executor.spawn(future) + } + + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R + where + G: Global, + { + let app = self.app.borrow(); + app.read_global(callback) + } +} diff --git a/crates/gpui/src/app/test_app.rs b/crates/gpui/src/app/test_app.rs new file mode 100644 index 0000000000000000000000000000000000000000..268fa891b563289b85195097d27e06d0b3e15680 --- /dev/null +++ b/crates/gpui/src/app/test_app.rs @@ -0,0 +1,607 @@ +//! A clean testing API for GPUI applications. +//! +//! `TestApp` provides a simpler alternative to `TestAppContext` with: +//! - Automatic effect flushing after updates +//! - Clean window creation and inspection +//! - Input simulation helpers +//! +//! # Example +//! ```ignore +//! #[test] +//! fn test_my_view() { +//! let mut app = TestApp::new(); +//! +//! let mut window = app.open_window(|window, cx| { +//! MyView::new(window, cx) +//! }); +//! +//! window.update(|view, window, cx| { +//! view.do_something(cx); +//! }); +//! +//! // Check rendered state +//! assert_eq!(window.title(), Some("Expected Title")); +//! } +//! ``` + +use crate::{ + AnyWindowHandle, App, AppCell, AppContext, AsyncApp, BackgroundExecutor, BorrowAppContext, + Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, + PlatformTextSystem, Point, Render, Size, Task, TestDispatcher, TestPlatform, TextSystem, + Window, WindowBounds, WindowHandle, WindowOptions, app::GpuiMode, +}; +use std::{future::Future, rc::Rc, sync::Arc, time::Duration}; + +/// A test application context with a clean API. +/// +/// Unlike `TestAppContext`, `TestApp` automatically flushes effects after +/// each update and provides simpler window management. +pub struct TestApp { + app: Rc, + platform: Rc, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + #[allow(dead_code)] + dispatcher: TestDispatcher, + text_system: Arc, +} + +impl TestApp { + /// Create a new test application. + pub fn new() -> Self { + Self::with_seed(0) + } + + /// Create a new test application with a specific random seed. + pub fn with_seed(seed: u64) -> Self { + Self::build(seed, None, Arc::new(())) + } + + /// Create a new test application with a custom text system for real font shaping. + pub fn with_text_system(text_system: Arc) -> Self { + Self::build(0, Some(text_system), Arc::new(())) + } + + /// Create a new test application with a custom text system and asset source. + pub fn with_text_system_and_assets( + text_system: Arc, + asset_source: Arc, + ) -> Self { + Self::build(0, Some(text_system), asset_source) + } + + fn build( + seed: u64, + platform_text_system: Option>, + asset_source: Arc, + ) -> Self { + let dispatcher = TestDispatcher::new(seed); + let arc_dispatcher = Arc::new(dispatcher.clone()); + let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(arc_dispatcher); + let platform = match platform_text_system.clone() { + Some(ts) => TestPlatform::with_text_system( + background_executor.clone(), + foreground_executor.clone(), + ts, + ), + None => TestPlatform::new(background_executor.clone(), foreground_executor.clone()), + }; + let http_client = http_client::FakeHttpClient::with_404_response(); + let text_system = Arc::new(TextSystem::new( + platform_text_system.unwrap_or_else(|| platform.text_system.clone()), + )); + + let app = App::new_app(platform.clone(), asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + + Self { + app, + platform, + background_executor, + foreground_executor, + dispatcher, + text_system, + } + } + + /// Run a closure with mutable access to the App context. + /// Automatically runs until parked after the closure completes. + pub fn update(&mut self, f: impl FnOnce(&mut App) -> R) -> R { + let result = { + let mut app = self.app.borrow_mut(); + app.update(f) + }; + self.run_until_parked(); + result + } + + /// Run a closure with read-only access to the App context. + pub fn read(&self, f: impl FnOnce(&App) -> R) -> R { + let app = self.app.borrow(); + f(&app) + } + + /// Create a new entity in the app. + pub fn new_entity( + &mut self, + build: impl FnOnce(&mut Context) -> T, + ) -> Entity { + self.update(|cx| cx.new(build)) + } + + /// Update an entity. + pub fn update_entity( + &mut self, + entity: &Entity, + f: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + self.update(|cx| entity.update(cx, f)) + } + + /// Read an entity. + pub fn read_entity( + &self, + entity: &Entity, + f: impl FnOnce(&T, &App) -> R, + ) -> R { + self.read(|cx| f(entity.read(cx), cx)) + } + + /// Open a test window with the given root view, using maximized bounds. + pub fn open_window( + &mut self, + build_view: impl FnOnce(&mut Window, &mut Context) -> V, + ) -> TestAppWindow { + let bounds = self.read(|cx| Bounds::maximized(None, cx)); + let handle = self.update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| build_view(window, cx)), + ) + .unwrap() + }); + + TestAppWindow { + handle, + app: self.app.clone(), + platform: self.platform.clone(), + background_executor: self.background_executor.clone(), + } + } + + /// Open a test window with specific options. + pub fn open_window_with_options( + &mut self, + options: WindowOptions, + build_view: impl FnOnce(&mut Window, &mut Context) -> V, + ) -> TestAppWindow { + let handle = self.update(|cx| { + cx.open_window(options, |window, cx| cx.new(|cx| build_view(window, cx))) + .unwrap() + }); + + TestAppWindow { + handle, + app: self.app.clone(), + platform: self.platform.clone(), + background_executor: self.background_executor.clone(), + } + } + + /// Run pending tasks until there's nothing left to do. + pub fn run_until_parked(&self) { + self.background_executor.run_until_parked(); + } + + /// Advance the simulated clock by the given duration. + pub fn advance_clock(&self, duration: Duration) { + self.background_executor.advance_clock(duration); + } + + /// Spawn a future on the foreground executor. + pub fn spawn(&self, f: impl FnOnce(AsyncApp) -> Fut) -> Task + where + Fut: Future + 'static, + R: 'static, + { + self.foreground_executor.spawn(f(self.to_async())) + } + + /// Spawn a future on the background executor. + pub fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.background_executor.spawn(future) + } + + /// Get an async handle to the app. + pub fn to_async(&self) -> AsyncApp { + AsyncApp { + app: Rc::downgrade(&self.app), + background_executor: self.background_executor.clone(), + foreground_executor: self.foreground_executor.clone(), + } + } + + /// Get the background executor. + pub fn background_executor(&self) -> &BackgroundExecutor { + &self.background_executor + } + + /// Get the foreground executor. + pub fn foreground_executor(&self) -> &ForegroundExecutor { + &self.foreground_executor + } + + /// Get the text system. + pub fn text_system(&self) -> &Arc { + &self.text_system + } + + /// Check if a global of the given type exists. + pub fn has_global(&self) -> bool { + self.read(|cx| cx.has_global::()) + } + + /// Set a global value. + pub fn set_global(&mut self, global: G) { + self.update(|cx| cx.set_global(global)); + } + + /// Read a global value. + pub fn read_global(&self, f: impl FnOnce(&G, &App) -> R) -> R { + self.read(|cx| f(cx.global(), cx)) + } + + /// Update a global value. + pub fn update_global(&mut self, f: impl FnOnce(&mut G, &mut App) -> R) -> R { + self.update(|cx| cx.update_global(f)) + } + + // Platform simulation methods + + /// Write text to the simulated clipboard. + pub fn write_to_clipboard(&self, item: ClipboardItem) { + self.platform.write_to_clipboard(item); + } + + /// Read from the simulated clipboard. + pub fn read_from_clipboard(&self) -> Option { + self.platform.read_from_clipboard() + } + + /// Get URLs that have been opened via `cx.open_url()`. + pub fn opened_url(&self) -> Option { + self.platform.opened_url.borrow().clone() + } + + /// Check if a file path prompt is pending. + pub fn did_prompt_for_new_path(&self) -> bool { + self.platform.did_prompt_for_new_path() + } + + /// Simulate answering a path selection dialog. + pub fn simulate_new_path_selection( + &self, + select: impl FnOnce(&std::path::Path) -> Option, + ) { + self.platform.simulate_new_path_selection(select); + } + + /// Check if a prompt dialog is pending. + pub fn has_pending_prompt(&self) -> bool { + self.platform.has_pending_prompt() + } + + /// Simulate answering a prompt dialog. + pub fn simulate_prompt_answer(&self, button: &str) { + self.platform.simulate_prompt_answer(button); + } + + /// Get all open windows. + pub fn windows(&self) -> Vec { + self.read(|cx| cx.windows()) + } +} + +impl Default for TestApp { + fn default() -> Self { + Self::new() + } +} + +/// A test window with inspection and simulation capabilities. +pub struct TestAppWindow { + handle: WindowHandle, + app: Rc, + platform: Rc, + background_executor: BackgroundExecutor, +} + +impl TestAppWindow { + /// Get the window handle. + pub fn handle(&self) -> WindowHandle { + self.handle + } + + /// Get the root view entity. + pub fn root(&self) -> Entity { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |root_view, _, _| { + root_view.downcast::().expect("root view type mismatch") + }) + .expect("window not found") + } + + /// Update the root view. + pub fn update(&mut self, f: impl FnOnce(&mut V, &mut Window, &mut Context) -> R) -> R { + let result = { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |root_view, window, cx| { + let view = root_view.downcast::().expect("root view type mismatch"); + view.update(cx, |view, cx| f(view, window, cx)) + }) + .expect("window not found") + }; + self.background_executor.run_until_parked(); + result + } + + /// Read the root view. + pub fn read(&self, f: impl FnOnce(&V, &App) -> R) -> R { + let app = self.app.borrow(); + let view = self + .app + .borrow() + .windows + .get(self.handle.window_id()) + .and_then(|w| w.as_ref()) + .and_then(|w| w.root.clone()) + .and_then(|r| r.downcast::().ok()) + .expect("window or root view not found"); + f(view.read(&app), &app) + } + + /// Get the window title. + pub fn title(&self) -> Option { + let app = self.app.borrow(); + app.read_window(&self.handle, |_, _cx| { + // TODO: expose title through Window API + None + }) + .unwrap() + } + + /// Simulate a keystroke. + pub fn simulate_keystroke(&mut self, keystroke: &str) { + let keystroke = Keystroke::parse(keystroke).unwrap(); + { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |_, window, cx| { + window.dispatch_keystroke(keystroke, cx); + }) + .unwrap(); + } + self.background_executor.run_until_parked(); + } + + /// Simulate multiple keystrokes (space-separated). + pub fn simulate_keystrokes(&mut self, keystrokes: &str) { + for keystroke in keystrokes.split(' ') { + self.simulate_keystroke(keystroke); + } + } + + /// Simulate typing text. + pub fn simulate_input(&mut self, input: &str) { + for char in input.chars() { + self.simulate_keystroke(&char.to_string()); + } + } + + /// Simulate a mouse move. + pub fn simulate_mouse_move(&mut self, position: Point) { + self.simulate_event(MouseMoveEvent { + position, + modifiers: Default::default(), + pressed_button: None, + }); + } + + /// Simulate a mouse down event. + pub fn simulate_mouse_down(&mut self, position: Point, button: MouseButton) { + self.simulate_event(MouseDownEvent { + position, + button, + modifiers: Default::default(), + click_count: 1, + first_mouse: false, + }); + } + + /// Simulate a mouse up event. + pub fn simulate_mouse_up(&mut self, position: Point, button: MouseButton) { + self.simulate_event(MouseUpEvent { + position, + button, + modifiers: Default::default(), + click_count: 1, + }); + } + + /// Simulate a click at the given position. + pub fn simulate_click(&mut self, position: Point, button: MouseButton) { + self.simulate_mouse_down(position, button); + self.simulate_mouse_up(position, button); + } + + /// Simulate a scroll event. + pub fn simulate_scroll(&mut self, position: Point, delta: Point) { + self.simulate_event(crate::ScrollWheelEvent { + position, + delta: crate::ScrollDelta::Pixels(delta), + modifiers: Default::default(), + touch_phase: crate::TouchPhase::Moved, + }); + } + + /// Simulate an input event. + pub fn simulate_event(&mut self, event: E) { + let platform_input = event.to_platform_input(); + { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |_, window, cx| { + window.dispatch_event(platform_input, cx); + }) + .unwrap(); + } + self.background_executor.run_until_parked(); + } + + /// Simulate resizing the window. + pub fn simulate_resize(&mut self, size: Size) { + let window_id = self.handle.window_id(); + let mut app = self.app.borrow_mut(); + if let Some(Some(window)) = app.windows.get_mut(window_id) { + if let Some(test_window) = window.platform_window.as_test() { + test_window.simulate_resize(size); + } + } + drop(app); + self.background_executor.run_until_parked(); + } + + /// Force a redraw of the window. + pub fn draw(&mut self) { + let mut app = self.app.borrow_mut(); + let any_handle: AnyWindowHandle = self.handle.into(); + app.update_window(any_handle, |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + } +} + +impl Clone for TestAppWindow { + fn clone(&self) -> Self { + Self { + handle: self.handle, + app: self.app.clone(), + platform: self.platform.clone(), + background_executor: self.background_executor.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FocusHandle, Focusable, div, prelude::*}; + + struct Counter { + count: usize, + focus_handle: FocusHandle, + } + + impl Counter { + fn new(_window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + Self { + count: 0, + focus_handle, + } + } + + fn increment(&mut self, _cx: &mut Context) { + self.count += 1; + } + } + + impl Focusable for Counter { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } + } + + impl Render for Counter { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().child(format!("Count: {}", self.count)) + } + } + + #[test] + fn test_basic_usage() { + let mut app = TestApp::new(); + + let mut window = app.open_window(Counter::new); + + window.update(|counter, _window, cx| { + counter.increment(cx); + }); + + window.read(|counter, _| { + assert_eq!(counter.count, 1); + }); + + drop(window); + app.update(|cx| cx.shutdown()); + } + + #[test] + fn test_entity_creation() { + let mut app = TestApp::new(); + + let entity = app.new_entity(|cx| Counter { + count: 42, + focus_handle: cx.focus_handle(), + }); + + app.read_entity(&entity, |counter, _| { + assert_eq!(counter.count, 42); + }); + + app.update_entity(&entity, |counter, _cx| { + counter.count += 1; + }); + + app.read_entity(&entity, |counter, _| { + assert_eq!(counter.count, 43); + }); + } + + #[test] + fn test_globals() { + let mut app = TestApp::new(); + + struct MyGlobal(String); + impl Global for MyGlobal {} + + assert!(!app.has_global::()); + + app.set_global(MyGlobal("hello".into())); + + assert!(app.has_global::()); + + app.read_global::(|global, _| { + assert_eq!(global.0, "hello"); + }); + + app.update_global::(|global, _| { + global.0 = "world".into(); + }); + + app.read_global::(|global, _| { + assert_eq!(global.0, "world"); + }); + } +} diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 0f0f0e14fbd8565d8f948579ed1ab23381c80108..d8f459df3c54200f07b4584eeb8e1ffa8415554b 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -22,7 +22,8 @@ pub struct TestAppContext { pub background_executor: BackgroundExecutor, #[doc(hidden)] pub foreground_executor: ForegroundExecutor, - dispatcher: TestDispatcher, + #[doc(hidden)] + pub dispatcher: TestDispatcher, test_platform: Rc, text_system: Arc, fn_name: Option<&'static str>, @@ -231,6 +232,33 @@ impl TestAppContext { .unwrap() } + /// Opens a new window with a specific size. + /// + /// Unlike `add_window` which uses maximized bounds, this allows controlling + /// the window dimensions, which is important for layout-sensitive tests. + pub fn open_window( + &mut self, + window_size: Size, + build_window: F, + ) -> WindowHandle + where + F: FnOnce(&mut Window, &mut Context) -> V, + V: 'static + Render, + { + let mut cx = self.app.borrow_mut(); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Point::default(), + size: window_size, + })), + ..Default::default() + }, + |window, cx| cx.new(|cx| build_window(window, cx)), + ) + .unwrap() + } + /// Adds a new window with no content. pub fn add_empty_window(&mut self) -> &mut VisualTestContext { let mut cx = self.app.borrow_mut(); diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index bb41a2f996e250b8c73377922f81170bb432321f..75585bcd90881513d835d28d260319d08acf9c4d 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -820,6 +820,15 @@ impl LinearColorStop { } impl Background { + /// Returns the solid color if this is a solid background, None otherwise. + pub fn as_solid(&self) -> Option { + if self.tag == BackgroundTag::Solid { + Some(self.solid) + } else { + None + } + } + /// Use specified color space for color interpolation. /// /// diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 8cc4d08fc2e4534bbc79eb64ff9be80b6b59e318..8af2c5e9ca9c6b83bfd7e508a618888c7c1fb2d7 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -15,6 +15,8 @@ //! and Tailwind-like styling that you can use to build your own custom elements. Div is //! constructed by combining these two systems into an all-in-one element. +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::PinchEvent; use crate::{ AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, @@ -353,6 +355,43 @@ impl Interactivity { })); } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) { + self.pinch_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx); + } + })); + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn capture_pinch( + &mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) { + self.pinch_listeners + .push(Box::new(move |event, phase, _hitbox, window, cx| { + if phase == DispatchPhase::Capture { + (listener)(event, window, cx); + } else { + cx.propagate(); + } + })); + } + /// Bind the given callback to an action dispatch during the capture phase. /// The imperative API equivalent to [`InteractiveElement::capture_action`]. /// @@ -635,6 +674,16 @@ impl Interactivity { pub fn block_mouse_except_scroll(&mut self) { self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll; } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn has_pinch_listeners(&self) -> bool { + !self.pinch_listeners.is_empty() + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + fn has_pinch_listeners(&self) -> bool { + false + } } /// A trait for elements that want to use the standard GPUI event handlers that don't @@ -905,6 +954,34 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self { + self.interactivity().on_pinch(listener); + self + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// The fluent API equivalent to [`Interactivity::capture_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn capture_pinch( + mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_pinch(listener); + self + } /// Capture the given action, before normal action dispatch can fire. /// The fluent API equivalent to [`Interactivity::capture_action`]. /// @@ -1399,6 +1476,10 @@ pub(crate) type MouseMoveListener = pub(crate) type ScrollWheelListener = Box; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) type PinchListener = + Box; + pub(crate) type ClickListener = Rc; pub(crate) type DragListener = @@ -1813,6 +1894,8 @@ pub struct Interactivity { pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, pub(crate) modifiers_changed_listeners: Vec, @@ -1832,22 +1915,37 @@ pub struct Interactivity { // a11y fields pub(crate) override_role: Option, + pub(crate) aria_label: Option, + pub(crate) aria_selected: Option, + pub(crate) aria_expanded: Option, + pub(crate) aria_toggled: Option, + pub(crate) aria_numeric_value: Option, + pub(crate) aria_min_numeric_value: Option, + pub(crate) aria_max_numeric_value: Option, + pub(crate) aria_orientation: Option, + pub(crate) aria_level: Option, + pub(crate) aria_position_in_set: Option, + pub(crate) aria_size_of_set: Option, + pub(crate) aria_row_index: Option, + pub(crate) aria_column_index: Option, + pub(crate) aria_row_count: Option, + pub(crate) aria_column_count: Option, - + #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -2041,6 +2139,7 @@ impl Interactivity { || !self.click_listeners.is_empty() || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() + || self.has_pinch_listeners() || self.drag_listener.is_some() || !self.drop_listeners.is_empty() || self.tooltip_builder.is_some() @@ -2409,6 +2508,14 @@ impl Interactivity { }) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + for listener in self.pinch_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + if self.hover_style.is_some() || self.base_style.mouse_cursor.is_some() || cx.active_drag.is_some() && !self.drag_over_styles.is_empty() diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 92b5389fecf219c0c113f682463498902df4c07d..b84241e9e0f79fe5cf8a24514cbf57982247a76b 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -1103,6 +1103,7 @@ impl Element for List { ); state.items = new_items; + state.measuring_behavior.reset(); } let padding = style @@ -1348,6 +1349,41 @@ mod test { assert_eq!(offset.offset_in_item, px(0.)); } + #[gpui::test] + fn test_measure_all_after_width_change(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + // First draw at width 100: all 10 items measured (total 500px). + // Viewport is 200px, so max scroll offset should be 300px. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); + + // Second draw at a different width: items get invalidated. + // Without the fix, max_offset would drop because unmeasured items + // contribute 0 height. + cx.draw(point(px(0.), px(0.)), size(px(200.), px(200.)), |_, _| { + view.into_any_element() + }); + assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); + } + #[gpui::test] fn test_remeasure(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 023fe4ca8a4ad4e58e12183709993fed725ed7e4..36d90c5bec37a03ae276a26abe08a5b9bb17da1f 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -247,7 +247,12 @@ impl StyledText { pub fn with_runs(mut self, runs: Vec) -> Self { let mut text = &**self.text; for run in &runs { - text = text.get(run.len..).expect("invalid text run"); + text = text.get(run.len..).unwrap_or_else(|| { + #[cfg(debug_assertions)] + panic!("invalid text run. Text: '{text}', run: {run:?}"); + #[cfg(not(debug_assertions))] + panic!("invalid text run"); + }); } assert!(text.is_empty(), "invalid text run"); self.runs = Some(runs); diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index cb65f758d5a521f15f77e7be266b1b4ed0480d03..f66f58447879afb86b721a9d6d7d2c59c65a8953 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -129,6 +129,13 @@ impl BackgroundExecutor { } } + /// Returns the underlying scheduler::BackgroundExecutor. + /// + /// This is used by Ex to pass the executor to thread/worktree code. + pub fn scheduler_executor(&self) -> scheduler::BackgroundExecutor { + self.inner.clone() + } + /// Enqueues the given future to be run to completion on a background thread. #[track_caller] pub fn spawn(&self, future: impl Future + Send + 'static) -> Task diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 5316a5992bb41d11ef5b6518555a9a20795f894c..3d3ddb49f70b2f96772627d085c93ce31b6dc0b5 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -17,6 +17,9 @@ pub trait KeyEvent: InputEvent {} /// A mouse event from the platform. pub trait MouseEvent: InputEvent {} +/// A gesture event from the platform. +pub trait GestureEvent: InputEvent {} + /// The key down event equivalent for the platform. #[derive(Clone, Debug, Eq, PartialEq)] pub struct KeyDownEvent { @@ -467,6 +470,51 @@ impl Default for ScrollDelta { } } +/// A pinch gesture event from the platform, generated when the user performs +/// a pinch-to-zoom gesture (typically on a trackpad). +/// +/// Note: This event is only available on macOS and Wayland (Linux). +/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. +#[derive(Clone, Debug, Default)] +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub struct PinchEvent { + /// The position of the pinch center on the window. + pub position: Point, + + /// The zoom delta for this event. + /// Positive values indicate zooming in, negative values indicate zooming out. + /// For example, 0.1 represents a 10% zoom increase. + pub delta: f32, + + /// The modifiers that were held down during the pinch gesture. + pub modifiers: Modifiers, + + /// The phase of the pinch gesture. + pub phase: TouchPhase, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Sealed for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl InputEvent for PinchEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::Pinch(self) + } +} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl GestureEvent for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl MouseEvent for PinchEvent {} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Deref for PinchEvent { + type Target = Modifiers; + + fn deref(&self) -> &Self::Target { + &self.modifiers + } +} + impl ScrollDelta { /// Returns true if this is a precise scroll delta in pixels. pub fn precise(&self) -> bool { @@ -626,6 +674,9 @@ pub enum PlatformInput { MouseExited(MouseExitEvent), /// The scroll wheel was used. ScrollWheel(ScrollWheelEvent), + /// A pinch gesture was performed. + #[cfg(any(target_os = "linux", target_os = "macos"))] + Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), } @@ -642,6 +693,8 @@ impl PlatformInput { PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), } } @@ -657,6 +710,8 @@ impl PlatformInput { PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, } } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index ac12680d78580045012c9bf82f3f1b3b53398ace..9a64cd86a9e6632eceaacbd00f50c5a7b3ea8b54 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -583,25 +583,21 @@ pub struct TrivialActivationHandler(pub Box Option + Sen pub struct TrivialActionHandler(pub Box); /// Trivial implementor of [`accesskit::DeactivationHandler`] pub struct TrivialDeactivationHandler(pub Box); - impl accesskit::ActivationHandler for TrivialActivationHandler { fn request_initial_tree(&mut self) -> Option { (self.0)() } } - impl accesskit::ActionHandler for TrivialActionHandler { fn do_action(&mut self, request: ActionRequest) { (self.0)(request) } } - impl accesskit::DeactivationHandler for TrivialDeactivationHandler { fn deactivate_accessibility(&mut self) { (self.0)() } } - /// Callbacks required by accesskit adapters pub struct A11yCallbacks { /// See [`accesskit::ActivationHandler`] @@ -611,6 +607,19 @@ pub struct A11yCallbacks { /// See [`accesskit::DeactivationHandler`] pub deactivation: TrivialDeactivationHandler, } +/// A renderer for headless windows that can produce real rendered output. +#[cfg(any(test, feature = "test-support"))] +pub trait PlatformHeadlessRenderer { + /// Render a scene and return the result as an RGBA image. + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> Result; + + /// Returns the sprite atlas used by this renderer. + fn sprite_atlas(&self) -> Arc; +} /// Type alias for runnables with metadata. /// Previously an enum with a single variant, now simplified to a direct type alias. @@ -630,6 +639,7 @@ pub trait PlatformDispatcher: Send + Sync { fn dispatch(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant); + fn spawn_realtime(&self, f: Box); fn now(&self) -> Instant { @@ -649,19 +659,29 @@ pub trait PlatformDispatcher: Send + Sync { #[expect(missing_docs)] pub trait PlatformTextSystem: Send + Sync { fn add_fonts(&self, fonts: Vec>) -> Result<()>; + /// Get all available font names. fn all_font_names(&self) -> Vec; + /// Get the font ID for a font descriptor. fn font_id(&self, descriptor: &Font) -> Result; + /// Get metrics for a font. fn font_metrics(&self, font_id: FontId) -> FontMetrics; + /// Get typographic bounds for a glyph. fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result>; + /// Get the advance width for a glyph. fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result>; + /// Get the glyph ID for a character. fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option; + /// Get raster bounds for a glyph. fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result>; + /// Rasterize a glyph. fn rasterize_glyph( &self, params: &RenderGlyphParams, raster_bounds: Bounds, ) -> Result<(Size, Vec)>; + /// Layout a line of text with the given font runs. fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout; + /// Returns the recommended text rendering mode for the given font and size. fn recommended_rendering_mode(&self, _font_id: FontId, _font_size: Pixels) -> TextRenderingMode; } diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index c40ec8f669d1e2e58f8af3bcf0fbd64fbddbe4d8..29aff84ff9d07f3a558ab68f2ac3117835688cc8 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -30,11 +30,12 @@ impl TestDispatcher { .map_or(false, |var| var == "1" || var == "true"), timeout_ticks: 0..=1000, })); + Self::from_scheduler(scheduler) + } - let session_id = scheduler.allocate_session_id(); - + pub fn from_scheduler(scheduler: Arc) -> Self { TestDispatcher { - session_id, + session_id: scheduler.allocate_session_id(), scheduler, num_cpus_override: Arc::new(AtomicUsize::new(0)), } @@ -76,6 +77,14 @@ impl TestDispatcher { while self.tick(false) {} } + pub fn allow_parking(&self) { + self.scheduler.allow_parking(); + } + + pub fn forbid_parking(&self) { + self.scheduler.forbid_parking(); + } + /// Override the value returned by `BackgroundExecutor::num_cpus()` in tests. /// A value of 0 means no override (the default of 4 is used). pub fn set_num_cpus(&self, count: usize) { diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 1da42f5742215f9001dcbd09cc42977ea28623ea..a59b21f038a01b48686ee211919afd7c647b7331 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,9 +1,9 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, - ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task, - TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, + PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, + Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, }; use anyhow::Result; use collections::VecDeque; @@ -34,6 +34,7 @@ pub(crate) struct TestPlatform { pub opened_url: RefCell>, pub text_system: Arc, pub expect_restart: RefCell>>>, + headless_renderer_factory: Option Option>>>, weak: Weak, } @@ -88,8 +89,30 @@ pub(crate) struct TestPrompts { impl TestPlatform { pub fn new(executor: BackgroundExecutor, foreground_executor: ForegroundExecutor) -> Rc { - let text_system = Arc::new(NoopTextSystem); - + Self::with_platform( + executor, + foreground_executor, + Arc::new(NoopTextSystem), + None, + ) + } + + pub fn with_text_system( + executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + ) -> Rc { + Self::with_platform(executor, foreground_executor, text_system, None) + } + + pub fn with_platform( + executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + headless_renderer_factory: Option< + Box Option>>, + >, + ) -> Rc { Rc::new_cyclic(|weak| TestPlatform { background_executor: executor, foreground_executor, @@ -107,6 +130,7 @@ impl TestPlatform { weak: weak.clone(), opened_url: Default::default(), text_system, + headless_renderer_factory, }) } @@ -299,11 +323,13 @@ impl Platform for TestPlatform { handle: AnyWindowHandle, params: WindowParams, ) -> anyhow::Result> { + let renderer = self.headless_renderer_factory.as_ref().and_then(|f| f()); let window = TestWindow::new( handle, params, self.weak.clone(), self.active_display.clone(), + renderer, ); Ok(Box::new(window)) } diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 6965b86b83dd57ef45854eed3cc5b99a6c1fc536..8e0ffaddf41d73fedad14fd7cde8daddcc2689a8 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -1,10 +1,12 @@ use crate::{ - AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DispatchEventResult, GpuSpecs, - Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, - Point, PromptButton, RequestFrameOptions, Size, TestPlatform, TileId, WindowAppearance, + AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, + DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, + PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, + PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, }; use collections::HashMap; +use image::RgbaImage; use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use std::{ @@ -21,6 +23,7 @@ pub(crate) struct TestWindowState { platform: Weak, // TODO: Replace with `Rc` sprite_atlas: Arc, + renderer: Option>, pub(crate) should_close_handler: Option bool>>, hit_test_window_control_callback: Option Option>>, input_callback: Option DispatchEventResult>>, @@ -57,13 +60,19 @@ impl TestWindow { params: WindowParams, platform: Weak, display: Rc, + renderer: Option>, ) -> Self { + let sprite_atlas: Arc = match &renderer { + Some(r) => r.sprite_atlas(), + None => Arc::new(TestAtlas::new()), + }; Self(Rc::new(Mutex::new(TestWindowState { bounds: params.bounds, display, platform, handle, - sprite_atlas: Arc::new(TestAtlas::new()), + sprite_atlas, + renderer, title: Default::default(), edited: false, should_close_handler: None, @@ -81,10 +90,11 @@ impl TestWindow { pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); + // Always update bounds, even if no callback is registered + lock.bounds.size = size; let Some(mut callback) = lock.resize_callback.take() else { return; }; - lock.bounds.size = size; drop(lock); callback(size, scale_factor); self.0.lock().resize_callback = Some(callback); @@ -275,12 +285,25 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} - fn draw(&self, _scene: &crate::Scene) {} + fn draw(&self, _scene: &Scene) {} fn sprite_atlas(&self) -> sync::Arc { self.0.lock().sprite_atlas.clone() } + #[cfg(any(test, feature = "test-support"))] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + let mut state = self.0.lock(); + let size = state.bounds.size; + if let Some(renderer) = &mut state.renderer { + let scale_factor = 2.0; + let device_size: Size = size.to_device_pixels(scale_factor); + renderer.render_scene_to_image(scene, device_size) + } else { + anyhow::bail!("render_to_image not available: no HeadlessRenderer configured") + } + } + fn as_test(&mut self) -> Option<&mut TestWindow> { Some(self) } diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 7e0ffe017024cc7914885df9ea713a3ec3db820e..22b1bb468d84b2897b312c6fc8af00ee5c8523db 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -657,7 +657,7 @@ impl Default for TransformationMatrix { #[expect(missing_docs)] pub struct MonochromeSprite { pub order: DrawOrder, - pub pad: u32, // align to 8 bytes + pub pad: u32, pub bounds: Bounds, pub content_mask: ContentMask, pub color: Hsla, @@ -695,7 +695,7 @@ impl From for Primitive { #[expect(missing_docs)] pub struct PolychromeSprite { pub order: DrawOrder, - pub pad: u32, // align to 8 bytes + pub pad: u32, pub grayscale: bool, pub opacity: f32, pub bounds: Bounds, diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 3d0b86a9523f5ac05e51941c826e32379368c464..bc394271585f1e392353187692b1b25df198d130 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -1,5 +1,5 @@ use crate::{ - self as gpui, AbsoluteLength, AlignContent, AlignItems, BorderStyle, CursorStyle, + self as gpui, AbsoluteLength, AlignContent, AlignItems, AlignSelf, BorderStyle, CursorStyle, DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle, FontWeight, GridPlacement, Hsla, JustifyContent, Length, SharedString, StrikethroughStyle, StyleRefinement, TextAlign, TextOverflow, TextStyleRefinement, UnderlineStyle, WhiteSpace, px, @@ -278,6 +278,55 @@ pub trait Styled: Sized { self } + /// Sets how this specific element is aligned along the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#start) + fn self_start(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Start); + self + } + + /// Sets this element to align against the end of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#end) + fn self_end(mut self) -> Self { + self.style().align_self = Some(AlignSelf::End); + self + } + + /// Sets this element to align against the start of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#start) + fn self_flex_start(mut self) -> Self { + self.style().align_self = Some(AlignSelf::FlexStart); + self + } + + /// Sets this element to align against the end of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#end) + fn self_flex_end(mut self) -> Self { + self.style().align_self = Some(AlignSelf::FlexEnd); + self + } + + /// Sets this element to align along the center of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#center) + fn self_center(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Center); + self + } + + /// Sets this element to align along the baseline of the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#baseline) + fn self_baseline(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Baseline); + self + } + + /// Sets this element to stretch to fill the available space along the container's cross axis. + /// [Docs](https://tailwindcss.com/docs/align-self#stretch) + fn self_stretch(mut self) -> Self { + self.style().align_self = Some(AlignSelf::Stretch); + self + } + /// Sets the element to justify flex items against the start of the container's main axis. /// [Docs](https://tailwindcss.com/docs/justify-content#start) fn justify_start(mut self) -> Self { @@ -384,6 +433,20 @@ pub trait Styled: Sized { self } + /// Sets the aspect ratio of the element. + /// [Docs](https://tailwindcss.com/docs/aspect-ratio) + fn aspect_ratio(mut self, ratio: f32) -> Self { + self.style().aspect_ratio = Some(ratio); + self + } + + /// Sets the aspect ratio of the element to 1/1 – equal width and height. + /// [Docs](https://tailwindcss.com/docs/aspect-ratio) + fn aspect_square(mut self) -> Self { + self.style().aspect_ratio = Some(1.0); + self + } + /// Sets the background color of the element. fn bg(mut self, fill: F) -> Self where diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 43982b2666bde8210f770419623cc0b9afd6e2af..b62a0ad6fd4f885b127144bd66e8e3e41747d889 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -63,7 +63,8 @@ pub struct TextSystem { } impl TextSystem { - pub(crate) fn new(platform_text_system: Arc) -> Self { + /// Create a new TextSystem with the given platform text system. + pub fn new(platform_text_system: Arc) -> Self { TextSystem { platform_text_system, font_metrics: RwLock::default(), @@ -372,7 +373,8 @@ pub struct WindowTextSystem { } impl WindowTextSystem { - pub(crate) fn new(text_system: Arc) -> Self { + /// Create a new WindowTextSystem with the given TextSystem. + pub fn new(text_system: Arc) -> Self { Self { line_layout_cache: LineLayoutCache::new(text_system.platform_text_system.clone()), text_system, @@ -438,6 +440,74 @@ impl WindowTextSystem { } } + /// Shape the given line using a caller-provided content hash as the cache key. + /// + /// This enables cache hits without materializing a contiguous `SharedString` for the text. + /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + /// + /// Like [`Self::shape_line`], this must be used only for single-line text (no `\n`). + pub fn shape_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[TextRun], + force_width: Option, + materialize_text: impl FnOnce() -> SharedString, + ) -> ShapedLine { + let mut decoration_runs = SmallVec::<[DecorationRun; 32]>::new(); + for run in runs { + if let Some(last_run) = decoration_runs.last_mut() + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + && last_run.background_color == run.background_color + { + last_run.len += run.len as u32; + continue; + } + decoration_runs.push(DecorationRun { + len: run.len as u32, + color: run.color, + background_color: run.background_color, + underline: run.underline, + strikethrough: run.strikethrough, + }); + } + + let mut used_force_width = force_width; + let layout = self.layout_line_by_hash( + text_hash, + text_len, + font_size, + runs, + used_force_width, + || { + let text = materialize_text(); + debug_assert!( + text.find('\n').is_none(), + "text argument should not contain newlines" + ); + text + }, + ); + + // We only materialize actual text on cache miss; on hit we avoid allocations. + // Since `ShapedLine` carries a `SharedString`, use an empty placeholder for hits. + // NOTE: Callers must not rely on `ShapedLine.text` for content when using this API. + let text: SharedString = SharedString::new_static(""); + + ShapedLine { + layout, + text, + decoration_runs, + } + } + /// Shape a multi line string of text, at the given font_size, for painting to the screen. /// Subsets of the text can be styled independently with the `runs` parameter. /// If `wrap_width` is provided, the line breaks will be adjusted to fit within the given width. @@ -627,6 +697,130 @@ impl WindowTextSystem { layout } + + /// Probe the line layout cache using a caller-provided content hash, without allocating. + /// + /// Returns `Some(layout)` if the layout is already cached in either the current frame + /// or the previous frame. Returns `None` if it is not cached. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn try_layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[TextRun], + force_width: Option, + ) -> Option> { + let mut last_run = None::<&TextRun>; + let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); + font_runs.clear(); + + for run in runs.iter() { + let decoration_changed = if let Some(last_run) = last_run + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + // we do not consider differing background color relevant, as it does not affect glyphs + // && last_run.background_color == run.background_color + { + false + } else { + last_run = Some(run); + true + }; + + let font_id = self.resolve_font(&run.font); + if let Some(font_run) = font_runs.last_mut() + && font_id == font_run.font_id + && !decoration_changed + { + font_run.len += run.len; + } else { + font_runs.push(FontRun { + len: run.len, + font_id, + }); + } + } + + let layout = self.line_layout_cache.try_layout_line_by_hash( + text_hash, + text_len, + font_size, + &font_runs, + force_width, + ); + + self.font_runs_pool.lock().push(font_runs); + + layout + } + + /// Layout the given line of text using a caller-provided content hash as the cache key. + /// + /// This enables cache hits without materializing a contiguous `SharedString` for the text. + /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[TextRun], + force_width: Option, + materialize_text: impl FnOnce() -> SharedString, + ) -> Arc { + let mut last_run = None::<&TextRun>; + let mut font_runs = self.font_runs_pool.lock().pop().unwrap_or_default(); + font_runs.clear(); + + for run in runs.iter() { + let decoration_changed = if let Some(last_run) = last_run + && last_run.color == run.color + && last_run.underline == run.underline + && last_run.strikethrough == run.strikethrough + // we do not consider differing background color relevant, as it does not affect glyphs + // && last_run.background_color == run.background_color + { + false + } else { + last_run = Some(run); + true + }; + + let font_id = self.resolve_font(&run.font); + if let Some(font_run) = font_runs.last_mut() + && font_id == font_run.font_id + && !decoration_changed + { + font_run.len += run.len; + } else { + font_runs.push(FontRun { + len: run.len, + font_id, + }); + } + } + + let layout = self.line_layout_cache.layout_line_by_hash( + text_hash, + text_len, + font_size, + &font_runs, + force_width, + materialize_text, + ); + + self.font_runs_pool.lock().push(font_runs); + + layout + } } #[derive(Hash, Eq, PartialEq)] @@ -802,6 +996,11 @@ impl TextRun { #[repr(C)] pub struct GlyphId(pub u32); +/// Parameters for rendering a glyph, used as cache keys for raster bounds. +/// +/// This struct identifies a specific glyph rendering configuration including +/// font, size, subpixel positioning, and scale factor. It's used to look up +/// cached raster bounds and sprite atlas entries. #[derive(Clone, Debug, PartialEq)] #[expect(missing_docs)] pub struct RenderGlyphParams { diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index c87e051ad3b4e5fc86d17ad0e6168553108175fa..7b5714188ff97d0169806ac5da9f039f9be2c16a 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -1,12 +1,24 @@ use crate::{ - App, Bounds, Half, Hsla, LineLayout, Pixels, Point, Result, SharedString, StrikethroughStyle, - TextAlign, UnderlineStyle, Window, WrapBoundary, WrappedLineLayout, black, fill, point, px, - size, + App, Bounds, DevicePixels, Half, Hsla, LineLayout, Pixels, Point, RenderGlyphParams, Result, + ShapedGlyph, ShapedRun, SharedString, StrikethroughStyle, TextAlign, UnderlineStyle, Window, + WrapBoundary, WrappedLineLayout, black, fill, point, px, size, }; use derive_more::{Deref, DerefMut}; use smallvec::SmallVec; use std::sync::Arc; +/// Pre-computed glyph data for efficient painting without per-glyph cache lookups. +/// +/// This is produced by `ShapedLine::compute_glyph_raster_data` during prepaint +/// and consumed by `ShapedLine::paint_with_raster_data` during paint. +#[derive(Clone, Debug)] +pub struct GlyphRasterData { + /// The raster bounds for each glyph, in paint order. + pub bounds: Vec>, + /// The render params for each glyph (needed for sprite atlas lookup). + pub params: Vec, +} + /// Set the text decoration for a run of text. #[derive(Debug, Clone)] pub struct DecorationRun { @@ -44,6 +56,14 @@ impl ShapedLine { self.layout.len } + /// The width of the shaped line in pixels. + /// + /// This is the glyph advance width computed by the text shaping system and is useful for + /// incrementally advancing a "pen" when painting multiple fragments on the same row. + pub fn width(&self) -> Pixels { + self.layout.width + } + /// Override the len, useful if you're rendering text a /// as text b (e.g. rendering invisibles). pub fn with_len(mut self, len: usize) -> Self { @@ -108,6 +128,120 @@ impl ShapedLine { Ok(()) } + + /// Split this shaped line at a byte index, returning `(prefix, suffix)`. + /// + /// - `prefix` contains glyphs for bytes `[0, byte_index)` with original positions. + /// Its width equals the x-advance up to the split point. + /// - `suffix` contains glyphs for bytes `[byte_index, len)` with positions + /// shifted left so the first glyph starts at x=0, and byte indices rebased to 0. + /// - Decoration runs are partitioned at the boundary; a run that straddles it is + /// split into two with adjusted lengths. + /// - `font_size`, `ascent`, and `descent` are copied to both halves. + pub fn split_at(&self, byte_index: usize) -> (ShapedLine, ShapedLine) { + let x_offset = self.layout.x_for_index(byte_index); + + // Partition glyph runs. A single run may contribute glyphs to both halves. + let mut left_runs = Vec::new(); + let mut right_runs = Vec::new(); + + for run in &self.layout.runs { + let split_pos = run.glyphs.partition_point(|g| g.index < byte_index); + + if split_pos > 0 { + left_runs.push(ShapedRun { + font_id: run.font_id, + glyphs: run.glyphs[..split_pos].to_vec(), + }); + } + + if split_pos < run.glyphs.len() { + let right_glyphs = run.glyphs[split_pos..] + .iter() + .map(|g| ShapedGlyph { + id: g.id, + position: point(g.position.x - x_offset, g.position.y), + index: g.index - byte_index, + is_emoji: g.is_emoji, + }) + .collect(); + right_runs.push(ShapedRun { + font_id: run.font_id, + glyphs: right_glyphs, + }); + } + } + + // Partition decoration runs. A run straddling the boundary is split into two. + let mut left_decorations = SmallVec::new(); + let mut right_decorations = SmallVec::new(); + let mut decoration_offset = 0u32; + let split_point = byte_index as u32; + + for decoration in &self.decoration_runs { + let run_end = decoration_offset + decoration.len; + + if run_end <= split_point { + left_decorations.push(decoration.clone()); + } else if decoration_offset >= split_point { + right_decorations.push(decoration.clone()); + } else { + let left_len = split_point - decoration_offset; + let right_len = run_end - split_point; + left_decorations.push(DecorationRun { + len: left_len, + color: decoration.color, + background_color: decoration.background_color, + underline: decoration.underline, + strikethrough: decoration.strikethrough, + }); + right_decorations.push(DecorationRun { + len: right_len, + color: decoration.color, + background_color: decoration.background_color, + underline: decoration.underline, + strikethrough: decoration.strikethrough, + }); + } + + decoration_offset = run_end; + } + + // Split text + let left_text = SharedString::new(self.text[..byte_index].to_string()); + let right_text = SharedString::new(self.text[byte_index..].to_string()); + + let left_width = x_offset; + let right_width = self.layout.width - left_width; + + let left = ShapedLine { + layout: Arc::new(LineLayout { + font_size: self.layout.font_size, + width: left_width, + ascent: self.layout.ascent, + descent: self.layout.descent, + runs: left_runs, + len: byte_index, + }), + text: left_text, + decoration_runs: left_decorations, + }; + + let right = ShapedLine { + layout: Arc::new(LineLayout { + font_size: self.layout.font_size, + width: right_width, + ascent: self.layout.ascent, + descent: self.layout.descent, + runs: right_runs, + len: self.layout.len - byte_index, + }), + text: right_text, + decoration_runs: right_decorations, + }; + + (left, right) + } } /// A line of text that has been shaped, decorated, and wrapped by the text layout system. @@ -594,3 +728,268 @@ fn aligned_origin_x( TextAlign::Right => origin.x + align_width - line_width, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FontId, GlyphId}; + + /// Helper: build a ShapedLine from glyph descriptors without the platform text system. + /// Each glyph is described as (byte_index, x_position). + fn make_shaped_line( + text: &str, + glyphs: &[(usize, f32)], + width: f32, + decorations: &[DecorationRun], + ) -> ShapedLine { + let shaped_glyphs: Vec = glyphs + .iter() + .map(|&(index, x)| ShapedGlyph { + id: GlyphId(0), + position: point(px(x), px(0.0)), + index, + is_emoji: false, + }) + .collect(); + + ShapedLine { + layout: Arc::new(LineLayout { + font_size: px(16.0), + width: px(width), + ascent: px(12.0), + descent: px(4.0), + runs: vec![ShapedRun { + font_id: FontId(0), + glyphs: shaped_glyphs, + }], + len: text.len(), + }), + text: SharedString::new(text.to_string()), + decoration_runs: SmallVec::from(decorations.to_vec()), + } + } + + #[test] + fn test_split_at_invariants() { + // Split "abcdef" at every possible byte index and verify structural invariants. + let line = make_shaped_line( + "abcdef", + &[ + (0, 0.0), + (1, 10.0), + (2, 20.0), + (3, 30.0), + (4, 40.0), + (5, 50.0), + ], + 60.0, + &[], + ); + + for i in 0..=6 { + let (left, right) = line.split_at(i); + + assert_eq!( + left.width() + right.width(), + line.width(), + "widths must sum at split={i}" + ); + assert_eq!( + left.len() + right.len(), + line.len(), + "lengths must sum at split={i}" + ); + assert_eq!( + format!("{}{}", left.text.as_ref(), right.text.as_ref()), + "abcdef", + "text must concatenate at split={i}" + ); + assert_eq!(left.font_size, line.font_size, "font_size at split={i}"); + assert_eq!(right.ascent, line.ascent, "ascent at split={i}"); + assert_eq!(right.descent, line.descent, "descent at split={i}"); + } + + // Edge: split at 0 produces no left runs, full content on right + let (left, right) = line.split_at(0); + assert_eq!(left.runs.len(), 0); + assert_eq!(right.runs[0].glyphs.len(), 6); + + // Edge: split at end produces full content on left, no right runs + let (left, right) = line.split_at(6); + assert_eq!(left.runs[0].glyphs.len(), 6); + assert_eq!(right.runs.len(), 0); + } + + #[test] + fn test_split_at_glyph_rebasing() { + // Two font runs (simulating a font fallback boundary at byte 3): + // run A (FontId 0): glyphs at bytes 0,1,2 positions 0,10,20 + // run B (FontId 1): glyphs at bytes 3,4,5 positions 30,40,50 + // Successive splits simulate the incremental splitting done during wrap. + let line = ShapedLine { + layout: Arc::new(LineLayout { + font_size: px(16.0), + width: px(60.0), + ascent: px(12.0), + descent: px(4.0), + runs: vec![ + ShapedRun { + font_id: FontId(0), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(0), + position: point(px(0.0), px(0.0)), + index: 0, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(10.0), px(0.0)), + index: 1, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(20.0), px(0.0)), + index: 2, + is_emoji: false, + }, + ], + }, + ShapedRun { + font_id: FontId(1), + glyphs: vec![ + ShapedGlyph { + id: GlyphId(0), + position: point(px(30.0), px(0.0)), + index: 3, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(40.0), px(0.0)), + index: 4, + is_emoji: false, + }, + ShapedGlyph { + id: GlyphId(0), + position: point(px(50.0), px(0.0)), + index: 5, + is_emoji: false, + }, + ], + }, + ], + len: 6, + }), + text: SharedString::new("abcdef".to_string()), + decoration_runs: SmallVec::new(), + }; + + // First split at byte 2 — mid-run in run A + let (first, remainder) = line.split_at(2); + assert_eq!(first.text.as_ref(), "ab"); + assert_eq!(first.runs.len(), 1); + assert_eq!(first.runs[0].font_id, FontId(0)); + + // Remainder "cdef" should have two runs: tail of A (1 glyph) + all of B (3 glyphs) + assert_eq!(remainder.text.as_ref(), "cdef"); + assert_eq!(remainder.runs.len(), 2); + assert_eq!(remainder.runs[0].font_id, FontId(0)); + assert_eq!(remainder.runs[0].glyphs.len(), 1); + assert_eq!(remainder.runs[0].glyphs[0].index, 0); + assert_eq!(remainder.runs[0].glyphs[0].position.x, px(0.0)); + assert_eq!(remainder.runs[1].font_id, FontId(1)); + assert_eq!(remainder.runs[1].glyphs[0].index, 1); + assert_eq!(remainder.runs[1].glyphs[0].position.x, px(10.0)); + + // Second split at byte 2 within remainder — crosses the run boundary + let (second, final_part) = remainder.split_at(2); + assert_eq!(second.text.as_ref(), "cd"); + assert_eq!(final_part.text.as_ref(), "ef"); + assert_eq!(final_part.runs[0].glyphs[0].index, 0); + assert_eq!(final_part.runs[0].glyphs[0].position.x, px(0.0)); + + // Widths must sum across all three pieces + assert_eq!( + first.width() + second.width() + final_part.width(), + line.width() + ); + } + + #[test] + fn test_split_at_decorations() { + // Three decoration runs: red [0..2), green [2..5), blue [5..6). + // Split at byte 3 — red goes entirely left, green straddles, blue goes entirely right. + let red = Hsla { + h: 0.0, + s: 1.0, + l: 0.5, + a: 1.0, + }; + let green = Hsla { + h: 0.3, + s: 1.0, + l: 0.5, + a: 1.0, + }; + let blue = Hsla { + h: 0.6, + s: 1.0, + l: 0.5, + a: 1.0, + }; + + let line = make_shaped_line( + "abcdef", + &[ + (0, 0.0), + (1, 10.0), + (2, 20.0), + (3, 30.0), + (4, 40.0), + (5, 50.0), + ], + 60.0, + &[ + DecorationRun { + len: 2, + color: red, + background_color: None, + underline: None, + strikethrough: None, + }, + DecorationRun { + len: 3, + color: green, + background_color: None, + underline: None, + strikethrough: None, + }, + DecorationRun { + len: 1, + color: blue, + background_color: None, + underline: None, + strikethrough: None, + }, + ], + ); + + let (left, right) = line.split_at(3); + + // Left: red(2) + green(1) — green straddled, left portion has len 1 + assert_eq!(left.decoration_runs.len(), 2); + assert_eq!(left.decoration_runs[0].len, 2); + assert_eq!(left.decoration_runs[0].color, red); + assert_eq!(left.decoration_runs[1].len, 1); + assert_eq!(left.decoration_runs[1].color, green); + + // Right: green(2) + blue(1) — green straddled, right portion has len 2 + assert_eq!(right.decoration_runs.len(), 2); + assert_eq!(right.decoration_runs[0].len, 2); + assert_eq!(right.decoration_runs[0].color, green); + assert_eq!(right.decoration_runs[1].len, 1); + assert_eq!(right.decoration_runs[1].color, blue); + } +} diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 78ab21b3d324674b0f34d9ab418893430df70f2a..8f3d7563d068979defa8b3f93367a2c9b7102cc1 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -401,12 +401,25 @@ struct FrameCache { wrapped_lines: FxHashMap, Arc>, used_lines: Vec>, used_wrapped_lines: Vec>, + + // Content-addressable caches keyed by caller-provided text hash + layout params. + // These allow cache hits without materializing a contiguous `SharedString`. + // + // IMPORTANT: To support allocation-free lookups, we store these maps using a key type + // (`HashedCacheKeyRef`) that can be computed without building a contiguous `&str`/`SharedString`. + // On miss, we allocate once and store under an owned `HashedCacheKey`. + lines_by_hash: FxHashMap, Arc>, + wrapped_lines_by_hash: FxHashMap, Arc>, + used_lines_by_hash: Vec>, + used_wrapped_lines_by_hash: Vec>, } #[derive(Clone, Default)] pub(crate) struct LineLayoutIndex { lines_index: usize, wrapped_lines_index: usize, + lines_by_hash_index: usize, + wrapped_lines_by_hash_index: usize, } impl LineLayoutCache { @@ -423,6 +436,8 @@ impl LineLayoutCache { LineLayoutIndex { lines_index: frame.used_lines.len(), wrapped_lines_index: frame.used_wrapped_lines.len(), + lines_by_hash_index: frame.used_lines_by_hash.len(), + wrapped_lines_by_hash_index: frame.used_wrapped_lines_by_hash.len(), } } @@ -445,6 +460,24 @@ impl LineLayoutCache { } current_frame.used_wrapped_lines.push(key.clone()); } + + for key in &previous_frame.used_lines_by_hash + [range.start.lines_by_hash_index..range.end.lines_by_hash_index] + { + if let Some((key, line)) = previous_frame.lines_by_hash.remove_entry(key) { + current_frame.lines_by_hash.insert(key, line); + } + current_frame.used_lines_by_hash.push(key.clone()); + } + + for key in &previous_frame.used_wrapped_lines_by_hash + [range.start.wrapped_lines_by_hash_index..range.end.wrapped_lines_by_hash_index] + { + if let Some((key, line)) = previous_frame.wrapped_lines_by_hash.remove_entry(key) { + current_frame.wrapped_lines_by_hash.insert(key, line); + } + current_frame.used_wrapped_lines_by_hash.push(key.clone()); + } } pub fn truncate_layouts(&self, index: LineLayoutIndex) { @@ -453,6 +486,12 @@ impl LineLayoutCache { current_frame .used_wrapped_lines .truncate(index.wrapped_lines_index); + current_frame + .used_lines_by_hash + .truncate(index.lines_by_hash_index); + current_frame + .used_wrapped_lines_by_hash + .truncate(index.wrapped_lines_by_hash_index); } pub fn finish_frame(&self) { @@ -463,6 +502,11 @@ impl LineLayoutCache { curr_frame.wrapped_lines.clear(); curr_frame.used_lines.clear(); curr_frame.used_wrapped_lines.clear(); + + curr_frame.lines_by_hash.clear(); + curr_frame.wrapped_lines_by_hash.clear(); + curr_frame.used_lines_by_hash.clear(); + curr_frame.used_wrapped_lines_by_hash.clear(); } pub fn layout_wrapped_line( @@ -590,6 +634,165 @@ impl LineLayoutCache { layout } } + + /// Try to retrieve a previously-shaped line layout using a caller-provided content hash. + /// + /// This is a *non-allocating* cache probe: it does not materialize any text. If the layout + /// is not already cached in either the current frame or previous frame, returns `None`. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn try_layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[FontRun], + force_width: Option, + ) -> Option> { + let key_ref = HashedCacheKeyRef { + text_hash, + text_len, + font_size, + runs, + wrap_width: None, + force_width, + }; + + let current_frame = self.current_frame.read(); + if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) { + return Some(layout.clone()); + } + + let previous_frame = self.previous_frame.lock(); + if let Some((_, layout)) = previous_frame.lines_by_hash.iter().find(|(key, _)| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) { + return Some(layout.clone()); + } + + None + } + + /// Layout a line of text using a caller-provided content hash as the cache key. + /// + /// This enables cache hits without materializing a contiguous `SharedString` for `text`. + /// If the cache misses, `materialize_text` is invoked to produce the `SharedString` for shaping. + /// + /// Contract (caller enforced): + /// - Same `text_hash` implies identical text content (collision risk accepted by caller). + /// - `text_len` should be the UTF-8 byte length of the text (helps reduce accidental collisions). + pub fn layout_line_by_hash( + &self, + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &[FontRun], + force_width: Option, + materialize_text: impl FnOnce() -> SharedString, + ) -> Arc { + let key_ref = HashedCacheKeyRef { + text_hash, + text_len, + font_size, + runs, + wrap_width: None, + force_width, + }; + + // Fast path: already cached (no allocation). + let current_frame = self.current_frame.upgradable_read(); + if let Some((_, layout)) = current_frame.lines_by_hash.iter().find(|(key, _)| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) { + return layout.clone(); + } + + let mut current_frame = RwLockUpgradableReadGuard::upgrade(current_frame); + + // Try to reuse from previous frame without allocating; do a linear scan to find a matching key. + // (We avoid `drain()` here because it would eagerly move all entries.) + let mut previous_frame = self.previous_frame.lock(); + if let Some(existing_key) = previous_frame + .used_lines_by_hash + .iter() + .find(|key| { + HashedCacheKeyRef { + text_hash: key.text_hash, + text_len: key.text_len, + font_size: key.font_size, + runs: key.runs.as_slice(), + wrap_width: key.wrap_width, + force_width: key.force_width, + } == key_ref + }) + .cloned() + { + if let Some((key, layout)) = previous_frame.lines_by_hash.remove_entry(&existing_key) { + current_frame + .lines_by_hash + .insert(key.clone(), layout.clone()); + current_frame.used_lines_by_hash.push(key); + return layout; + } + } + + let text = materialize_text(); + let mut layout = self + .platform_text_system + .layout_line(&text, font_size, runs); + + if let Some(force_width) = force_width { + let mut glyph_pos = 0; + for run in layout.runs.iter_mut() { + for glyph in run.glyphs.iter_mut() { + if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) { + glyph.position.x = glyph_pos * force_width; + } + glyph_pos += 1; + } + } + } + + let key = Arc::new(HashedCacheKey { + text_hash, + text_len, + font_size, + runs: SmallVec::from(runs), + wrap_width: None, + force_width, + }); + let layout = Arc::new(layout); + current_frame + .lines_by_hash + .insert(key.clone(), layout.clone()); + current_frame.used_lines_by_hash.push(key); + layout + } } /// A run of text with a single font. @@ -622,12 +825,80 @@ struct CacheKeyRef<'a> { force_width: Option, } +#[derive(Clone, Debug)] +struct HashedCacheKey { + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: SmallVec<[FontRun; 1]>, + wrap_width: Option, + force_width: Option, +} + +#[derive(Copy, Clone)] +struct HashedCacheKeyRef<'a> { + text_hash: u64, + text_len: usize, + font_size: Pixels, + runs: &'a [FontRun], + wrap_width: Option, + force_width: Option, +} + impl PartialEq for dyn AsCacheKeyRef + '_ { fn eq(&self, other: &dyn AsCacheKeyRef) -> bool { self.as_cache_key_ref() == other.as_cache_key_ref() } } +impl PartialEq for HashedCacheKey { + fn eq(&self, other: &Self) -> bool { + self.text_hash == other.text_hash + && self.text_len == other.text_len + && self.font_size == other.font_size + && self.runs.as_slice() == other.runs.as_slice() + && self.wrap_width == other.wrap_width + && self.force_width == other.force_width + } +} + +impl Eq for HashedCacheKey {} + +impl Hash for HashedCacheKey { + fn hash(&self, state: &mut H) { + self.text_hash.hash(state); + self.text_len.hash(state); + self.font_size.hash(state); + self.runs.as_slice().hash(state); + self.wrap_width.hash(state); + self.force_width.hash(state); + } +} + +impl PartialEq for HashedCacheKeyRef<'_> { + fn eq(&self, other: &Self) -> bool { + self.text_hash == other.text_hash + && self.text_len == other.text_len + && self.font_size == other.font_size + && self.runs == other.runs + && self.wrap_width == other.wrap_width + && self.force_width == other.force_width + } +} + +impl Eq for HashedCacheKeyRef<'_> {} + +impl Hash for HashedCacheKeyRef<'_> { + fn hash(&self, state: &mut H) { + self.text_hash.hash(state); + self.text_len.hash(state); + self.font_size.hash(state); + self.runs.hash(state); + self.wrap_width.hash(state); + self.force_width.hash(state); + } +} + impl Eq for dyn AsCacheKeyRef + '_ {} impl Hash for dyn AsCacheKeyRef + '_ { diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 07df35472b0bd3f91b8096439ed82cf811b45c77..9a7d10133bb9bd57b86c3e08e1a21e47fec38b96 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -236,6 +236,9 @@ impl LineWrapper { matches!(c, '\u{1E00}'..='\u{1EFF}') || // Latin Extended Additional matches!(c, '\u{0300}'..='\u{036F}') || // Combining Diacritical Marks + // Bengali (https://en.wikipedia.org/wiki/Bengali_(Unicode_block)) + matches!(c, '\u{0980}'..='\u{09FF}') || + // Some other known special characters that should be treated as word characters, // e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, // `2^3`, `a~b`, `a=1`, `Self::new`, etc. @@ -856,6 +859,10 @@ mod tests { assert_word("АБВГДЕЖЗИЙКЛМНОП"); // Vietnamese (https://github.com/zed-industries/zed/issues/23245) assert_word("ThậmchíđếnkhithuachạychúngcònnhẫntâmgiếtnốtsốđôngtùchínhtrịởYênBáivàCaoBằng"); + // Bengali + assert_word("গিয়েছিলেন"); + assert_word("ছেলে"); + assert_word("হচ্ছিল"); // non-word characters assert_not_word("你好"); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index c313656786c4fa4b1c558ae8fd7218d63155dffa..32542f6a03e6d254799bf32d5305d1717dda247c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -570,6 +570,10 @@ impl HitboxId { /// /// See [`Hitbox::is_hovered`] for details. pub fn is_hovered(self, window: &Window) -> bool { + // If this hitbox has captured the pointer, it's always considered hovered + if window.captured_hitbox == Some(self) { + return true; + } let hit_test = &window.mouse_hit_test; for id in hit_test.ids.iter().take(hit_test.hover_hitbox_count) { if self == *id { @@ -826,6 +830,11 @@ impl Frame { self.tab_stops.clear(); self.focus = None; + #[cfg(any(test, feature = "test-support"))] + { + self.debug_bounds.clear(); + } + #[cfg(any(feature = "inspector", debug_assertions))] { self.next_inspector_instance_ids.clear(); @@ -959,6 +968,9 @@ pub struct Window { pub(crate) a11y_nodes: A11yNodes, pub(crate) a11y_click_listeners: FxHashMap>, pub(crate) a11y_focus_ids: FxHashMap, + /// The hitbox that has captured the pointer, if any. + /// While captured, mouse events route to this hitbox regardless of hit testing. + captured_hitbox: Option, #[cfg(any(feature = "inspector", debug_assertions))] inspector: Option>, } @@ -1589,6 +1601,7 @@ impl Window { a11y_click_listeners: FxHashMap::default(), a11y_focus_ids: FxHashMap::default(), image_cache_stack: Vec::new(), + captured_hitbox: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector: None, }) @@ -2038,7 +2051,12 @@ impl Window { }) } - fn bounds_changed(&mut self, cx: &mut App) { + /// Notify the window that its bounds have changed. + /// + /// This updates internal state like `viewport_size` and `scale_factor` from + /// the platform window, then notifies observers. Normally called automatically + /// by the platform's resize callback, but exposed publicly for test infrastructure. + pub fn bounds_changed(&mut self, cx: &mut App) { self.scale_factor = self.platform_window.scale_factor(); self.viewport_size = self.platform_window.content_size(); self.display_id = self.platform_window.display().map(|display| display.id()); @@ -2296,6 +2314,26 @@ impl Window { self.mouse_position } + /// Captures the pointer for the given hitbox. While captured, all mouse move and mouse up + /// events will be routed to listeners that check this hitbox's `is_hovered` status, + /// regardless of actual hit testing. This enables drag operations that continue + /// even when the pointer moves outside the element's bounds. + /// + /// The capture is automatically released on mouse up. + pub fn capture_pointer(&mut self, hitbox_id: HitboxId) { + self.captured_hitbox = Some(hitbox_id); + } + + /// Releases any active pointer capture. + pub fn release_pointer(&mut self) { + self.captured_hitbox = None; + } + + /// Returns the hitbox that has captured the pointer, if any. + pub fn captured_hitbox(&self) -> Option { + self.captured_hitbox + } + /// The current state of the keyboard's modifiers pub fn modifiers(&self) -> Modifiers { self.modifiers @@ -3494,6 +3532,100 @@ impl Window { Ok(()) } + /// Paints a monochrome glyph with pre-computed raster bounds. + /// + /// This is faster than `paint_glyph` because it skips the per-glyph cache lookup. + /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint. + pub fn paint_glyph_with_raster_bounds( + &mut self, + origin: Point, + _font_id: FontId, + _glyph_id: GlyphId, + _font_size: Pixels, + color: Hsla, + raster_bounds: Bounds, + params: &RenderGlyphParams, + ) -> Result<()> { + self.invalidator.debug_assert_paint(); + + let element_opacity = self.element_opacity(); + let scale_factor = self.scale_factor(); + let glyph_origin = origin.scale(scale_factor); + + if !raster_bounds.is_zero() { + let tile = self + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + let (size, bytes) = self.text_system().rasterize_glyph(params)?; + Ok(Some((size, Cow::Owned(bytes)))) + })? + .expect("Callback above only errors or returns Some"); + let bounds = Bounds { + origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }; + let content_mask = self.content_mask().scale(scale_factor); + self.next_frame.scene.insert_primitive(MonochromeSprite { + order: 0, + pad: 0, + bounds, + content_mask, + color: color.opacity(element_opacity), + tile, + transformation: TransformationMatrix::unit(), + }); + } + Ok(()) + } + + /// Paints an emoji glyph with pre-computed raster bounds. + /// + /// This is faster than `paint_emoji` because it skips the per-glyph cache lookup. + /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint. + pub fn paint_emoji_with_raster_bounds( + &mut self, + origin: Point, + _font_id: FontId, + _glyph_id: GlyphId, + _font_size: Pixels, + raster_bounds: Bounds, + params: &RenderGlyphParams, + ) -> Result<()> { + self.invalidator.debug_assert_paint(); + + let scale_factor = self.scale_factor(); + let glyph_origin = origin.scale(scale_factor); + + if !raster_bounds.is_zero() { + let tile = self + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + let (size, bytes) = self.text_system().rasterize_glyph(params)?; + Ok(Some((size, Cow::Owned(bytes)))) + })? + .expect("Callback above only errors or returns Some"); + + let bounds = Bounds { + origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }; + let content_mask = self.content_mask().scale(scale_factor); + let opacity = self.element_opacity(); + + self.next_frame.scene.insert_primitive(PolychromeSprite { + order: 0, + pad: 0, + grayscale: false, + bounds, + corner_radii: Default::default(), + content_mask, + tile, + opacity, + }); + } + Ok(()) + } + fn should_use_subpixel_rendering(&self, font_id: FontId, font_size: Pixels) -> bool { if self.platform_window.background_appearance() != WindowBackgroundAppearance::Opaque { return false; @@ -4149,6 +4281,12 @@ impl Window { self.modifiers = scroll_wheel.modifiers; PlatformInput::ScrollWheel(scroll_wheel) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(pinch) => { + self.mouse_position = pinch.position; + self.modifiers = pinch.modifiers; + PlatformInput::Pinch(pinch) + } // Translate dragging and dropping of external files from the operating system // to internal drag and drop events. PlatformInput::FileDrop(file_drop) => match file_drop { @@ -4261,6 +4399,11 @@ impl Window { self.refresh(); } } + + // Auto-release pointer capture on mouse up + if event.is::() && self.captured_hitbox.is_some() { + self.captured_hitbox = None; + } } fn dispatch_key_event(&mut self, event: &dyn Any, cx: &mut App) { diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 8dd48b878cc1ffcb87201e9b1b252966bfce5efb..ce49fca37232f256e570f584272519d8d6f34dd8 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -36,6 +36,9 @@ use wayland_client::{ wl_shm_pool, wl_surface, }, }; +use wayland_protocols::wp::pointer_gestures::zv1::client::{ + zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1, +}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{ self, ZwpPrimarySelectionOfferV1, }; @@ -124,6 +127,7 @@ pub struct Globals { pub layer_shell: Option, pub blur_manager: Option, pub text_input_manager: Option, + pub gesture_manager: Option, pub dialog: Option, pub executor: ForegroundExecutor, } @@ -164,6 +168,7 @@ impl Globals { layer_shell: globals.bind(&qh, 1..=5, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), + gesture_manager: globals.bind(&qh, 1..=3, ()).ok(), dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), executor, qh, @@ -208,6 +213,8 @@ pub(crate) struct WaylandClientState { pub compositor_gpu: Option, wl_seat: wl_seat::WlSeat, // TODO: Multi seat support wl_pointer: Option, + pinch_gesture: Option, + pinch_scale: f32, wl_keyboard: Option, cursor_shape_device: Option, data_device: Option, @@ -584,6 +591,8 @@ impl WaylandClient { wl_seat: seat, wl_pointer: None, wl_keyboard: None, + pinch_gesture: None, + pinch_scale: 1.0, cursor_shape_device: None, data_device, primary_selection, @@ -1325,6 +1334,12 @@ impl Dispatch for WaylandClientStatePtr { .as_ref() .map(|cursor_shape_manager| cursor_shape_manager.get_pointer(&pointer, qh, ())); + state.pinch_gesture = state.globals.gesture_manager.as_ref().map( + |gesture_manager: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1| { + gesture_manager.get_pinch_gesture(&pointer, qh, ()) + }, + ); + if let Some(wl_pointer) = &state.wl_pointer { wl_pointer.release(); } @@ -1998,6 +2013,91 @@ impl Dispatch for WaylandClientStatePtr { } } +impl Dispatch for WaylandClientStatePtr { + fn event( + _this: &mut Self, + _: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + // The gesture manager doesn't generate events + } +} + +impl Dispatch + for WaylandClientStatePtr +{ + fn event( + this: &mut Self, + _: &zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1, + event: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use gpui::PinchEvent; + + let client = this.get_client(); + let mut state = client.borrow_mut(); + + let Some(window) = state.mouse_focused_window.clone() else { + return; + }; + + match event { + zwp_pointer_gesture_pinch_v1::Event::Begin { + serial: _, + time: _, + surface: _, + fingers: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Started, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::Update { time: _, scale, .. } => { + let new_absolute_scale = scale as f32; + let previous_scale = state.pinch_scale; + let zoom_delta = new_absolute_scale - previous_scale; + state.pinch_scale = new_absolute_scale; + + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: zoom_delta, + modifiers: state.modifiers, + phase: TouchPhase::Moved, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::End { + serial: _, + time: _, + cancelled: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Ended, + }); + drop(state); + window.handle_input(input); + } + _ => {} + } + } +} + impl Dispatch for WaylandClientStatePtr { fn event( this: &mut Self, 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/gpui_macos/src/events.rs b/crates/gpui_macos/src/events.rs index 5970488a17fbf9395f4ba29f5b98a135f6d55f7f..71bcb105e8aa8c6c43fd5b7864881535454c5ec3 100644 --- a/crates/gpui_macos/src/events.rs +++ b/crates/gpui_macos/src/events.rs @@ -1,8 +1,8 @@ use gpui::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, - NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent, - TouchPhase, point, px, + NavigationDirection, PinchEvent, Pixels, PlatformInput, PressureStage, ScrollDelta, + ScrollWheelEvent, TouchPhase, point, px, }; use crate::{ @@ -234,6 +234,27 @@ pub(crate) unsafe fn platform_input_from_native( _ => None, } } + NSEventType::NSEventTypeMagnify => window_height.map(|window_height| { + let phase = match native_event.phase() { + NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { + TouchPhase::Started + } + NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended, + _ => TouchPhase::Moved, + }; + + let magnification = native_event.magnification() as f32; + + PlatformInput::Pinch(PinchEvent { + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + delta: magnification, + modifiers: read_modifiers(native_event), + phase, + }) + }), NSEventType::NSScrollWheel => window_height.map(|window_height| { let phase = match native_event.phase() { NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 93e039019b1ca639118b5453ff8f9de0d30e4f99..e96d14b15691bec1da54aa9d46e3e765218292b2 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -110,10 +110,12 @@ impl InstanceBufferPool { pub(crate) struct MetalRenderer { device: metal::Device, - layer: metal::MetalLayer, + layer: Option, is_apple_gpu: bool, is_unified_memory: bool, presents_with_transaction: bool, + /// For headless rendering, tracks whether output should be opaque + opaque: bool, command_queue: CommandQueue, paths_rasterization_pipeline_state: metal::RenderPipelineState, path_sprites_pipeline_state: metal::RenderPipelineState, @@ -142,26 +144,9 @@ pub struct PathRasterizationVertex { } impl MetalRenderer { + /// Creates a new MetalRenderer with a CAMetalLayer for window-based rendering. pub fn new(instance_buffer_pool: Arc>, transparent: bool) -> Self { - // Prefer low‐power integrated GPUs on Intel Mac. On Apple - // Silicon, there is only ever one GPU, so this is equivalent to - // `metal::Device::system_default()`. - let device = if let Some(d) = metal::Device::all() - .into_iter() - .min_by_key(|d| (d.is_removable(), !d.is_low_power())) - { - d - } else { - // For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689 - // In that case, we fall back to the system default device. - log::error!( - "Unable to enumerate Metal devices; attempting to use system default device" - ); - metal::Device::system_default().unwrap_or_else(|| { - log::error!("unable to access a compatible graphics device"); - std::process::exit(1); - }) - }; + let device = Self::create_device(); let layer = metal::MetalLayer::new(); layer.set_device(&device); @@ -182,6 +167,48 @@ impl MetalRenderer { | AutoresizingMask::HEIGHT_SIZABLE ]; } + + Self::new_internal(device, Some(layer), !transparent, instance_buffer_pool) + } + + /// Creates a new headless MetalRenderer for offscreen rendering without a window. + /// + /// This renderer can render scenes to images without requiring a CAMetalLayer, + /// window, or AppKit. Use `render_scene_to_image()` to render scenes. + #[cfg(any(test, feature = "test-support"))] + pub fn new_headless(instance_buffer_pool: Arc>) -> Self { + let device = Self::create_device(); + Self::new_internal(device, None, true, instance_buffer_pool) + } + + fn create_device() -> metal::Device { + // Prefer low‐power integrated GPUs on Intel Mac. On Apple + // Silicon, there is only ever one GPU, so this is equivalent to + // `metal::Device::system_default()`. + if let Some(d) = metal::Device::all() + .into_iter() + .min_by_key(|d| (d.is_removable(), !d.is_low_power())) + { + d + } else { + // For some reason `all()` can return an empty list, see https://github.com/zed-industries/zed/issues/37689 + // In that case, we fall back to the system default device. + log::error!( + "Unable to enumerate Metal devices; attempting to use system default device" + ); + metal::Device::system_default().unwrap_or_else(|| { + log::error!("unable to access a compatible graphics device"); + std::process::exit(1); + }) + } + } + + fn new_internal( + device: metal::Device, + layer: Option, + opaque: bool, + instance_buffer_pool: Arc>, + ) -> Self { #[cfg(feature = "runtime_shaders")] let library = device .new_library_with_source(&SHADERS_SOURCE_FILE, &metal::CompileOptions::new()) @@ -303,6 +330,7 @@ impl MetalRenderer { presents_with_transaction: false, is_apple_gpu, is_unified_memory, + opaque, command_queue, paths_rasterization_pipeline_state, path_sprites_pipeline_state, @@ -322,12 +350,15 @@ impl MetalRenderer { } } - pub fn layer(&self) -> &metal::MetalLayerRef { - &self.layer + pub fn layer(&self) -> Option<&metal::MetalLayerRef> { + self.layer.as_ref().map(|l| l.as_ref()) } pub fn layer_ptr(&self) -> *mut CAMetalLayer { - self.layer.as_ptr() + self.layer + .as_ref() + .map(|l| l.as_ptr()) + .unwrap_or(ptr::null_mut()) } pub fn sprite_atlas(&self) -> &Arc { @@ -336,26 +367,25 @@ impl MetalRenderer { pub fn set_presents_with_transaction(&mut self, presents_with_transaction: bool) { self.presents_with_transaction = presents_with_transaction; - self.layer - .set_presents_with_transaction(presents_with_transaction); + if let Some(layer) = &self.layer { + layer.set_presents_with_transaction(presents_with_transaction); + } } pub fn update_drawable_size(&mut self, size: Size) { - let size = NSSize { - width: size.width.0 as f64, - height: size.height.0 as f64, - }; - unsafe { - let _: () = msg_send![ - self.layer(), - setDrawableSize: size - ]; + if let Some(layer) = &self.layer { + let ns_size = NSSize { + width: size.width.0 as f64, + height: size.height.0 as f64, + }; + unsafe { + let _: () = msg_send![ + layer.as_ref(), + setDrawableSize: ns_size + ]; + } } - let device_pixels_size = Size { - width: DevicePixels(size.width as i32), - height: DevicePixels(size.height as i32), - }; - self.update_path_intermediate_textures(device_pixels_size); + self.update_path_intermediate_textures(size); } fn update_path_intermediate_textures(&mut self, size: Size) { @@ -396,8 +426,11 @@ impl MetalRenderer { } } - pub fn update_transparency(&self, transparent: bool) { - self.layer.set_opaque(!transparent); + pub fn update_transparency(&mut self, transparent: bool) { + self.opaque = !transparent; + if let Some(layer) = &self.layer { + layer.set_opaque(!transparent); + } } pub fn destroy(&self) { @@ -405,7 +438,15 @@ impl MetalRenderer { } pub fn draw(&mut self, scene: &Scene) { - let layer = self.layer.clone(); + let layer = match &self.layer { + Some(l) => l.clone(), + None => { + log::error!( + "draw() called on headless renderer - use render_scene_to_image() instead" + ); + return; + } + }; let viewport_size = layer.drawable_size(); let viewport_size: Size = size( (viewport_size.width.ceil() as i32).into(), @@ -476,9 +517,15 @@ impl MetalRenderer { /// Renders the scene to a texture and returns the pixel data as an RGBA image. /// This does not present the frame to screen - useful for visual testing /// where we want to capture what would be rendered without displaying it. + /// + /// Note: This requires a layer-backed renderer. For headless rendering, + /// use `render_scene_to_image()` instead. #[cfg(any(test, feature = "test-support"))] pub fn render_to_image(&mut self, scene: &Scene) -> Result { - let layer = self.layer.clone(); + let layer = self + .layer + .clone() + .ok_or_else(|| anyhow::anyhow!("render_to_image requires a layer-backed renderer"))?; let viewport_size = layer.drawable_size(); let viewport_size: Size = size( (viewport_size.width.ceil() as i32).into(), @@ -567,21 +614,146 @@ impl MetalRenderer { } } + /// Renders a scene to an image without requiring a window or CAMetalLayer. + /// + /// This is the primary method for headless rendering. It creates an offscreen + /// texture, renders the scene to it, and returns the pixel data as an RGBA image. + #[cfg(any(test, feature = "test-support"))] + pub fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> Result { + if size.width.0 <= 0 || size.height.0 <= 0 { + anyhow::bail!("Invalid size for render_scene_to_image: {:?}", size); + } + + // Update path intermediate textures for this size + self.update_path_intermediate_textures(size); + + // Create an offscreen texture as render target + let texture_descriptor = metal::TextureDescriptor::new(); + texture_descriptor.set_width(size.width.0 as u64); + texture_descriptor.set_height(size.height.0 as u64); + texture_descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm); + texture_descriptor + .set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead); + texture_descriptor.set_storage_mode(metal::MTLStorageMode::Managed); + let target_texture = self.device.new_texture(&texture_descriptor); + + loop { + let mut instance_buffer = self + .instance_buffer_pool + .lock() + .acquire(&self.device, self.is_unified_memory); + + let command_buffer = + self.draw_primitives_to_texture(scene, &mut instance_buffer, &target_texture, size); + + match command_buffer { + Ok(command_buffer) => { + let instance_buffer_pool = self.instance_buffer_pool.clone(); + let instance_buffer = Cell::new(Some(instance_buffer)); + let block = ConcreteBlock::new(move |_| { + if let Some(instance_buffer) = instance_buffer.take() { + instance_buffer_pool.lock().release(instance_buffer); + } + }); + let block = block.copy(); + command_buffer.add_completed_handler(&block); + + // On discrete GPUs (non-unified memory), Managed textures + // require an explicit blit synchronize before the CPU can + // read back the rendered data. Without this, get_bytes + // returns stale zeros. + if !self.is_unified_memory { + let blit = command_buffer.new_blit_command_encoder(); + blit.synchronize_resource(&target_texture); + blit.end_encoding(); + } + + // Commit and wait for completion + command_buffer.commit(); + command_buffer.wait_until_completed(); + + // Read pixels from the texture + let width = size.width.0 as u32; + let height = size.height.0 as u32; + let bytes_per_row = width as usize * 4; + let buffer_size = height as usize * bytes_per_row; + + let mut pixels = vec![0u8; buffer_size]; + + let region = metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: width as u64, + height: height as u64, + depth: 1, + }, + }; + + target_texture.get_bytes( + pixels.as_mut_ptr() as *mut std::ffi::c_void, + bytes_per_row as u64, + region, + 0, + ); + + // Convert BGRA to RGBA (swap B and R channels) + for chunk in pixels.chunks_exact_mut(4) { + chunk.swap(0, 2); + } + + return RgbaImage::from_raw(width, height, pixels).ok_or_else(|| { + anyhow::anyhow!("Failed to create RgbaImage from pixel data") + }); + } + Err(err) => { + log::error!( + "failed to render: {}. retrying with larger instance buffer size", + err + ); + let mut instance_buffer_pool = self.instance_buffer_pool.lock(); + let buffer_size = instance_buffer_pool.buffer_size; + if buffer_size >= 256 * 1024 * 1024 { + anyhow::bail!("instance buffer size grew too large: {}", buffer_size); + } + instance_buffer_pool.reset(buffer_size * 2); + log::info!( + "increased instance buffer size to {}", + instance_buffer_pool.buffer_size + ); + } + } + } + } + fn draw_primitives( &mut self, scene: &Scene, instance_buffer: &mut InstanceBuffer, drawable: &metal::MetalDrawableRef, viewport_size: Size, + ) -> Result { + self.draw_primitives_to_texture(scene, instance_buffer, drawable.texture(), viewport_size) + } + + fn draw_primitives_to_texture( + &mut self, + scene: &Scene, + instance_buffer: &mut InstanceBuffer, + texture: &metal::TextureRef, + viewport_size: Size, ) -> Result { let command_queue = self.command_queue.clone(); let command_buffer = command_queue.new_command_buffer(); - let alpha = if self.layer.is_opaque() { 1. } else { 0. }; + let alpha = if self.opaque { 1. } else { 0. }; let mut instance_offset = 0; - let mut command_encoder = new_command_encoder( + let mut command_encoder = new_command_encoder_for_texture( command_buffer, - drawable, + texture, viewport_size, |color_attachment| { color_attachment.set_load_action(metal::MTLLoadAction::Clear); @@ -617,9 +789,9 @@ impl MetalRenderer { command_buffer, ); - command_encoder = new_command_encoder( + command_encoder = new_command_encoder_for_texture( command_buffer, - drawable, + texture, viewport_size, |color_attachment| { color_attachment.set_load_action(metal::MTLLoadAction::Load); @@ -1309,9 +1481,9 @@ impl MetalRenderer { } } -fn new_command_encoder<'a>( +fn new_command_encoder_for_texture<'a>( command_buffer: &'a metal::CommandBufferRef, - drawable: &'a metal::MetalDrawableRef, + texture: &'a metal::TextureRef, viewport_size: Size, configure_color_attachment: impl Fn(&RenderPassColorAttachmentDescriptorRef), ) -> &'a metal::RenderCommandEncoderRef { @@ -1320,7 +1492,7 @@ fn new_command_encoder<'a>( .color_attachments() .object_at(0) .unwrap(); - color_attachment.set_texture(Some(drawable.texture())); + color_attachment.set_texture(Some(texture)); color_attachment.set_store_action(metal::MTLStoreAction::Store); configure_color_attachment(color_attachment); @@ -1506,3 +1678,32 @@ pub struct SurfaceBounds { pub bounds: Bounds, pub content_mask: ContentMask, } + +#[cfg(any(test, feature = "test-support"))] +pub struct MetalHeadlessRenderer { + renderer: MetalRenderer, +} + +#[cfg(any(test, feature = "test-support"))] +impl MetalHeadlessRenderer { + pub fn new() -> Self { + let instance_buffer_pool = Arc::new(Mutex::new(InstanceBufferPool::default())); + let renderer = MetalRenderer::new_headless(instance_buffer_pool); + Self { renderer } + } +} + +#[cfg(any(test, feature = "test-support"))] +impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer { + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.renderer.render_scene_to_image(scene, size) + } + + fn sprite_atlas(&self) -> Arc { + self.renderer.sprite_atlas().clone() + } +} diff --git a/crates/gpui_macos/src/text_system.rs b/crates/gpui_macos/src/text_system.rs index 2511bcf12dc240bf11d2c050579a6c06ebb155ed..e0f8a010eadf422ce588d8a7d30b3db6f9a4dcee 100644 --- a/crates/gpui_macos/src/text_system.rs +++ b/crates/gpui_macos/src/text_system.rs @@ -53,7 +53,8 @@ use crate::open_type::apply_features_and_fallbacks; #[allow(non_upper_case_globals)] const kCGImageAlphaOnly: u32 = 7; -pub(crate) struct MacTextSystem(RwLock); +/// macOS text system using CoreText for font shaping. +pub struct MacTextSystem(RwLock); #[derive(Clone, PartialEq, Eq, Hash)] struct FontKey { @@ -73,7 +74,8 @@ struct MacTextSystemState { } impl MacTextSystem { - pub(crate) fn new() -> Self { + /// Create a new MacTextSystem. + pub fn new() -> Self { Self(RwLock::new(MacTextSystemState { memory_source: MemSource::empty(), system_source: SystemSource::new(), diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 456ee31ac3b03780e68267621d66435b1ceab4a9..b783a4d083131fac70095d22718796ef761adee3 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -172,6 +172,10 @@ unsafe fn build_classes() { sel!(mouseExited:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(magnifyWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(mouseDragged:), handle_view_event as extern "C" fn(&Object, Sel, id), @@ -1795,10 +1799,13 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: // may need them even if there is no marked text; // however we skip keys with control or the input handler adds control-characters to the buffer. // and keys with function, as the input handler swallows them. + // and keys with platform (Cmd), so that Cmd+key events (e.g. Cmd+`) are not + // consumed by the IME on non-QWERTY / dead-key layouts. if is_composing || (key_down_event.keystroke.key_char.is_none() && !key_down_event.keystroke.modifiers.control - && !key_down_event.keystroke.modifiers.function) + && !key_down_event.keystroke.modifiers.function + && !key_down_event.keystroke.modifiers.platform) { { let mut lock = window_state.as_ref().lock(); @@ -2063,11 +2070,13 @@ fn update_window_scale_factor(window_state: &Arc>) { let scale_factor = lock.scale_factor(); let size = lock.content_size(); let drawable_size = size.to_device_pixels(scale_factor); - unsafe { - let _: () = msg_send![ - lock.renderer.layer(), - setContentsScale: scale_factor as f64 - ]; + if let Some(layer) = lock.renderer.layer() { + unsafe { + let _: () = msg_send![ + layer, + setContentsScale: scale_factor as f64 + ]; + } } lock.renderer.update_drawable_size(drawable_size); diff --git a/crates/gpui_platform/src/gpui_platform.rs b/crates/gpui_platform/src/gpui_platform.rs index 7dac5498a652f7a7fe68b9f6d7ea23dffabdfb22..1d2fea90b477542031dfbf591f458b2427ec6e01 100644 --- a/crates/gpui_platform/src/gpui_platform.rs +++ b/crates/gpui_platform/src/gpui_platform.rs @@ -59,6 +59,22 @@ pub fn current_platform(headless: bool) -> Rc { } } +/// Returns a new [`HeadlessRenderer`] for the current platform, if available. +#[cfg(feature = "test-support")] +pub fn current_headless_renderer() -> Option> { + #[cfg(target_os = "macos")] + { + Some(Box::new( + gpui_macos::metal_renderer::MetalHeadlessRenderer::new(), + )) + } + + #[cfg(not(target_os = "macos"))] + { + None + } +} + #[cfg(all(test, target_os = "macos"))] mod tests { use super::*; diff --git a/crates/http_client/src/github_download.rs b/crates/http_client/src/github_download.rs index 642bbf11c11ce8816a1506c3c4989dce434552d8..2ef615ff64c2b564e5c254b9c6ef21413d18bcf2 100644 --- a/crates/http_client/src/github_download.rs +++ b/crates/http_client/src/github_download.rs @@ -155,6 +155,7 @@ async fn cleanup_staging_path(staging_path: &Path, asset_kind: AssetKind) { } async fn finalize_download(staging_path: &Path, destination_path: &Path) -> Result<()> { + _ = async_fs::remove_dir_all(destination_path).await; async_fs::rename(staging_path, destination_path) .await .with_context(|| format!("renaming {staging_path:?} to {destination_path:?}"))?; diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 3536e73a9db6247a798145f186ae20d2efe29da5..70bc0fc52784c4e50c715ddafab533beeccf3f93 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -27,6 +27,7 @@ pub enum IconName { AiVZero, AiXAi, AiZed, + Archive, ArrowCircle, ArrowDown, ArrowDown10, @@ -113,6 +114,7 @@ pub enum IconName { ExpandUp, ExpandVertical, Eye, + EyeOff, FastForward, FastForwardOff, File, @@ -147,6 +149,7 @@ pub enum IconName { GitBranchPlus, GitCommit, GitGraph, + GitMergeConflict, Github, Hash, HistoryRerun, @@ -243,6 +246,10 @@ pub enum IconName { ThinkingModeOff, Thread, ThreadFromSummary, + ThreadsSidebarLeftClosed, + ThreadsSidebarLeftOpen, + ThreadsSidebarRightClosed, + ThreadsSidebarRightOpen, ThumbsDown, ThumbsUp, TodoComplete, @@ -271,8 +278,6 @@ pub enum IconName { UserRoundPen, Warning, WholeWord, - WorkspaceNavClosed, - WorkspaceNavOpen, XCircle, XCircleFilled, ZedAgent, diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index c223494bd709217439bdff9f6a7ba17e1a65494e..729a2d9ce31cbe2165f0f66c15921e566d6878b4 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -6,12 +6,14 @@ use std::path::Path; use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; use file_icons::FileIcons; +#[cfg(any(target_os = "linux", target_os = "macos"))] +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; @@ -24,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::*; @@ -260,6 +262,12 @@ impl ImageView { cx.notify(); } } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context) { + let zoom_factor = 1.0 + event.delta; + self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); + } } struct ImageContentElement { @@ -522,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); + let font = ThemeSettings::get_global(cx).buffer_font.clone(); - Some(vec![BreadcrumbText { - text, - highlights: None, - font: Some(settings.buffer_font.clone()), - }]) + Some(( + vec![HighlightedText { + text: text.into(), + highlights: vec![], + }], + Some(font), + )) } fn can_split(&self) -> bool { @@ -679,8 +689,9 @@ impl Render for ImageView { .size_full() .relative() .bg(cx.theme().colors().editor_background) - .child( - div() + .child({ + #[cfg(any(target_os = "linux", target_os = "macos"))] + let container = div() .id("image-container") .size_full() .overflow_hidden() @@ -690,13 +701,34 @@ impl Render for ImageView { gpui::CursorStyle::OpenHand }) .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) + .on_pinch(cx.listener(Self::handle_pinch)) .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) .on_mouse_move(cx.listener(Self::handle_mouse_move)) - .child(ImageContentElement::new(cx.entity())), - ) + .child(ImageContentElement::new(cx.entity())); + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let container = div() + .id("image-container") + .size_full() + .overflow_hidden() + .cursor(if self.is_dragging() { + gpui::CursorStyle::ClosedHand + } else { + gpui::CursorStyle::OpenHand + }) + .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) + .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) + .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) + .on_mouse_move(cx.listener(Self::handle_mouse_move)) + .child(ImageContentElement::new(cx.entity())); + + container + }) } } diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index ba97bcf66a77659fb3196ba45ebb3f831452e008..b8028c79b3d5da415a52d946d7601d8cbb40f738 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -9,7 +9,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use workspace::{AppState, OpenVisible, Workspace}; +use workspace::{AppState, OpenResult, OpenVisible, Workspace}; actions!( journal, @@ -107,7 +107,10 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap .spawn(cx, async move |cx| { let (journal_dir, entry_path) = create_entry.await?; let opened = if open_new_workspace { - let (new_workspace, _) = cx + let OpenResult { + window: new_workspace, + .. + } = cx .update(|_window, cx| { workspace::open_paths( &[journal_dir], 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/src/buffer.rs b/crates/language/src/buffer.rs index d183615317ecaa481cda45d780c64b2ddf7ec833..6724b5b1c2e6b666b7f0295685e40427279a0b30 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -359,7 +359,7 @@ pub enum BufferEvent { is_local: bool, }, /// The buffer was edited. - Edited, + Edited { is_local: bool }, /// The buffer's `dirty` bit changed. DirtyChanged, /// The buffer was saved. @@ -435,7 +435,7 @@ pub enum DiskState { /// File created in Zed that has not been saved. New, /// File present on the filesystem. - Present { mtime: MTime }, + Present { mtime: MTime, size: u64 }, /// Deleted file that was previously present. Deleted, /// An old version of a file that was previously present @@ -448,7 +448,17 @@ impl DiskState { pub fn mtime(self) -> Option { match self { DiskState::New => None, - DiskState::Present { mtime } => Some(mtime), + DiskState::Present { mtime, .. } => Some(mtime), + DiskState::Deleted => None, + DiskState::Historic { .. } => None, + } + } + + /// Returns the file's size on disk in bytes. + pub fn size(self) -> Option { + match self { + DiskState::New => None, + DiskState::Present { size, .. } => Some(size), DiskState::Deleted => None, DiskState::Historic { .. } => None, } @@ -2377,7 +2387,7 @@ impl Buffer { }; match file.disk_state() { DiskState::New => false, - DiskState::Present { mtime } => match self.saved_mtime { + DiskState::Present { mtime, .. } => match self.saved_mtime { Some(saved_mtime) => { mtime.bad_is_greater_than(saved_mtime) && self.has_unsaved_edits() } @@ -2457,7 +2467,7 @@ impl Buffer { false }; if let Some((transaction_id, start_version)) = self.text.end_transaction_at(now) { - self.did_edit(&start_version, was_dirty, cx); + self.did_edit(&start_version, was_dirty, true, cx); Some(transaction_id) } else { None @@ -2844,7 +2854,13 @@ impl Buffer { Some(edit_id) } - fn did_edit(&mut self, old_version: &clock::Global, was_dirty: bool, cx: &mut Context) { + fn did_edit( + &mut self, + old_version: &clock::Global, + was_dirty: bool, + is_local: bool, + cx: &mut Context, + ) { self.was_changed(); if self.edits_since::(old_version).next().is_none() { @@ -2852,10 +2868,20 @@ impl Buffer { } self.reparse(cx, true); - cx.emit(BufferEvent::Edited); - if was_dirty != self.is_dirty() { + cx.emit(BufferEvent::Edited { is_local }); + let is_dirty = self.is_dirty(); + if was_dirty != is_dirty { cx.emit(BufferEvent::DirtyChanged); } + if was_dirty && !is_dirty { + if let Some(file) = self.file.as_ref() { + if matches!(file.disk_state(), DiskState::Present { .. }) + && file.disk_state().mtime() != self.saved_mtime + { + cx.emit(BufferEvent::ReloadNeeded); + } + } + } cx.notify(); } @@ -2964,7 +2990,7 @@ impl Buffer { self.text.apply_ops(buffer_ops); self.deferred_ops.insert(deferred_ops); self.flush_deferred_ops(cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, false, cx); // Notify independently of whether the buffer was edited as the operations could include a // selection update. cx.notify(); @@ -3119,7 +3145,7 @@ impl Buffer { if let Some((transaction_id, operation)) = self.text.undo() { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); self.restore_encoding_for_transaction(transaction_id, was_dirty); Some(transaction_id) } else { @@ -3137,7 +3163,7 @@ impl Buffer { let old_version = self.version.clone(); if let Some(operation) = self.text.undo_transaction(transaction_id) { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); true } else { false @@ -3159,7 +3185,7 @@ impl Buffer { self.send_operation(Operation::Buffer(operation), true, cx); } if undone { - self.did_edit(&old_version, was_dirty, cx) + self.did_edit(&old_version, was_dirty, true, cx) } undone } @@ -3169,7 +3195,7 @@ impl Buffer { let operation = self.text.undo_operations(counts); let old_version = self.version.clone(); self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); } /// Manually redoes a specific transaction in the buffer's redo history. @@ -3179,7 +3205,7 @@ impl Buffer { if let Some((transaction_id, operation)) = self.text.redo() { self.send_operation(Operation::Buffer(operation), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); self.restore_encoding_for_transaction(transaction_id, was_dirty); Some(transaction_id) } else { @@ -3220,7 +3246,7 @@ impl Buffer { self.send_operation(Operation::Buffer(operation), true, cx); } if redone { - self.did_edit(&old_version, was_dirty, cx) + self.did_edit(&old_version, was_dirty, true, cx) } redone } @@ -3330,7 +3356,7 @@ impl Buffer { if !ops.is_empty() { for op in ops { self.send_operation(Operation::Buffer(op), true, cx); - self.did_edit(&old_version, was_dirty, cx); + self.did_edit(&old_version, was_dirty, true, cx); } } } diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 49d871cc860bb6df892b80ac433fb70264788664..a47578faa2037e5f17a0e2be4ce5329e61d0fa84 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -458,15 +458,18 @@ fn test_edit_events(cx: &mut gpui::App) { assert_eq!( mem::take(&mut *buffer_1_events.lock()), vec![ - BufferEvent::Edited, + BufferEvent::Edited { is_local: true }, BufferEvent::DirtyChanged, - BufferEvent::Edited, - BufferEvent::Edited, + BufferEvent::Edited { is_local: true }, + BufferEvent::Edited { is_local: true }, ] ); assert_eq!( mem::take(&mut *buffer_2_events.lock()), - vec![BufferEvent::Edited, BufferEvent::DirtyChanged] + vec![ + BufferEvent::Edited { is_local: false }, + BufferEvent::DirtyChanged + ] ); buffer1.update(cx, |buffer, cx| { @@ -481,11 +484,17 @@ fn test_edit_events(cx: &mut gpui::App) { }); assert_eq!( mem::take(&mut *buffer_1_events.lock()), - vec![BufferEvent::Edited, BufferEvent::DirtyChanged,] + vec![ + BufferEvent::Edited { is_local: true }, + BufferEvent::DirtyChanged, + ] ); assert_eq!( mem::take(&mut *buffer_2_events.lock()), - vec![BufferEvent::Edited, BufferEvent::DirtyChanged] + vec![ + BufferEvent::Edited { is_local: false }, + BufferEvent::DirtyChanged + ] ); } diff --git a/crates/language_model/src/model/mod.rs b/crates/language_model/src/model.rs similarity index 100% rename from crates/language_model/src/model/mod.rs rename to crates/language_model/src/model.rs diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index e64cc43edd8eef6cfaf0c6c966365c81d37b611c..527d24ec18c0f9ef08576a71fe92562dd94d4afd 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -30,6 +30,13 @@ impl fmt::Display for PaymentRequiredError { pub struct LlmApiToken(Arc>>); impl LlmApiToken { + pub fn global(cx: &App) -> Self { + RefreshLlmTokenListener::global(cx) + .read(cx) + .llm_api_token + .clone() + } + pub async fn acquire( &self, client: &Arc, @@ -102,13 +109,16 @@ struct GlobalRefreshLlmTokenListener(Entity); impl Global for GlobalRefreshLlmTokenListener {} -pub struct RefreshLlmTokenEvent; +pub struct LlmTokenRefreshedEvent; pub struct RefreshLlmTokenListener { + client: Arc, + user_store: Entity, + llm_api_token: LlmApiToken, _subscription: Subscription, } -impl EventEmitter for RefreshLlmTokenListener {} +impl EventEmitter for RefreshLlmTokenListener {} impl RefreshLlmTokenListener { pub fn register(client: Arc, user_store: Entity, cx: &mut App) { @@ -128,21 +138,39 @@ impl RefreshLlmTokenListener { } }); - let subscription = cx.subscribe(&user_store, |_this, _user_store, event, cx| { + let subscription = cx.subscribe(&user_store, |this, _user_store, event, cx| { if matches!(event, client::user::Event::OrganizationChanged) { - cx.emit(RefreshLlmTokenEvent); + this.refresh(cx); } }); Self { + client, + user_store, + llm_api_token: LlmApiToken::default(), _subscription: subscription, } } + fn refresh(&self, cx: &mut Context) { + let client = self.client.clone(); + let llm_api_token = self.llm_api_token.clone(); + let organization_id = self + .user_store + .read(cx) + .current_organization() + .map(|organization| organization.id.clone()); + cx.spawn(async move |this, cx| { + llm_api_token.refresh(&client, organization_id).await?; + this.update(cx, |_this, cx| cx.emit(LlmTokenRefreshedEvent)) + }) + .detach_and_log_err(cx); + } + fn handle_refresh_llm_token(this: Entity, message: &MessageToClient, cx: &mut App) { match message { MessageToClient::UserUpdated => { - this.update(cx, |_this, cx| cx.emit(RefreshLlmTokenEvent)); + this.update(cx, |this, cx| this.refresh(cx)); } } } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index ece0d68152a20cbf77d0c082746959684816f115..f9dc4266d69ae9164f6b187162ed32069de5c10c 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -20,7 +20,6 @@ aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] aws_http_client.workspace = true base64.workspace = true bedrock = { workspace = true, features = ["schemars"] } -chrono.workspace = true client.workspace = true cloud_api_types.workspace = true cloud_llm_client.workspace = true @@ -68,7 +67,7 @@ vercel = { workspace = true, features = ["schemars"] } x_ai = { workspace = true, features = ["schemars"] } [dev-dependencies] -editor = { workspace = true, features = ["test-support"] } + language_model = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } + 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 d8ffdf8762e2360231deaf835b63f7e4f065af1a..8f2b6c10f3434ed51e3908d0f9de93e54a12dae6 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,7 +1,6 @@ use ai_onboarding::YoungAccountBanner; use anthropic::AnthropicModelMode; use anyhow::{Context as _, Result, anyhow}; -use chrono::{DateTime, Utc}; use client::{Client, UserStore, zed_urls}; use cloud_api_types::{OrganizationId, Plan}; use cloud_llm_client::{ @@ -109,9 +108,10 @@ impl State { cx: &mut Context, ) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let llm_api_token = LlmApiToken::global(cx); Self { client: client.clone(), - llm_api_token: LlmApiToken::default(), + llm_api_token, user_store: user_store.clone(), status, models: Vec::new(), @@ -156,11 +156,8 @@ impl State { .user_store .read(cx) .current_organization() - .map(|o| o.id.clone()); + .map(|organization| organization.id.clone()); cx.spawn(async move |this, cx| { - llm_api_token - .refresh(&client, organization_id.clone()) - .await?; let response = Self::fetch_models(client, llm_api_token, organization_id).await?; this.update(cx, |this, cx| { @@ -707,7 +704,7 @@ impl LanguageModel for CloudLanguageModel { .user_store .read(cx) .current_organization() - .map(|o| o.id.clone()); + .map(|organization| organization.id.clone()); let model_id = self.model.id.to_string(); let generate_content_request = into_google(request, model_id.clone(), GoogleModelMode::Default); @@ -779,7 +776,7 @@ impl LanguageModel for CloudLanguageModel { user_store .read(cx) .current_organization() - .map(|o| o.id.clone()) + .map(|organization| organization.id.clone()) }); let thinking_allowed = request.thinking_allowed; let enable_thinking = thinking_allowed && self.model.supports_thinking; @@ -866,7 +863,10 @@ impl LanguageModel for CloudLanguageModel { ); if enable_thinking && let Some(effort) = effort { - request.reasoning = Some(open_ai::responses::ReasoningConfig { effort }); + request.reasoning = Some(open_ai::responses::ReasoningConfig { + effort, + summary: Some(open_ai::responses::ReasoningSummaryMode::Auto), + }); } let future = self.request_limiter.stream(async move { @@ -1090,7 +1090,6 @@ fn response_lines( struct ZedAiConfiguration { is_connected: bool, plan: Option, - subscription_period: Option<(DateTime, DateTime)>, eligible_for_trial: bool, account_too_young: bool, sign_in_callback: Arc, @@ -1098,33 +1097,37 @@ struct ZedAiConfiguration { impl RenderOnce for ZedAiConfiguration { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let is_pro = self.plan.is_some_and(|plan| plan == Plan::ZedPro); - let subscription_text = match (self.plan, self.subscription_period) { - (Some(Plan::ZedPro), Some(_)) => { - "You have access to Zed's hosted models through your Pro subscription." - } - (Some(Plan::ZedProTrial), Some(_)) => { - "You have access to Zed's hosted models through your Pro trial." - } - (Some(Plan::ZedFree), Some(_)) => { - if self.eligible_for_trial { - "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." - } else { - "Subscribe for access to Zed's hosted models." - } - } - _ => { + let (subscription_text, has_paid_plan) = match self.plan { + Some(Plan::ZedPro) => ( + "You have access to Zed's hosted models through your Pro subscription.", + true, + ), + Some(Plan::ZedProTrial) => ( + "You have access to Zed's hosted models through your Pro trial.", + false, + ), + Some(Plan::ZedStudent) => ( + "You have access to Zed's hosted models through your Student subscription.", + true, + ), + Some(Plan::ZedBusiness) => ( + "You have access to Zed's hosted models through your Organization.", + true, + ), + Some(Plan::ZedFree) | None => ( if self.eligible_for_trial { "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." } else { "Subscribe for access to Zed's hosted models." - } - } + }, + false, + ), }; - let manage_subscription_buttons = if is_pro { + let manage_subscription_buttons = if has_paid_plan { 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() @@ -1148,10 +1151,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(); @@ -1208,7 +1208,6 @@ impl Render for ConfigurationView { ZedAiConfiguration { is_connected: !state.is_signed_out(cx), plan: user_store.plan(), - subscription_period: user_store.subscription_period(), eligible_for_trial: user_store.trial_started_at().is_none(), account_too_young: user_store.account_too_young(), sign_in_callback: self.sign_in_callback.clone(), @@ -1239,9 +1238,6 @@ impl Component for ZedAiConfiguration { ZedAiConfiguration { is_connected, plan, - subscription_period: plan - .is_some() - .then(|| (Utc::now(), Utc::now() + chrono::Duration::days(7))), eligible_for_trial, account_too_young, sign_in_callback: Arc::new(|_, _| {}), 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 9f4c6b4c5409406e6606250a847037a8543feb20..c1ebf76e0b0678d35a5e013e87f9efd9488a4e8d 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -602,7 +602,10 @@ pub fn into_open_ai_response( } else { None }, - reasoning: reasoning_effort.map(|effort| open_ai::responses::ReasoningConfig { effort }), + reasoning: reasoning_effort.map(|effort| open_ai::responses::ReasoningConfig { + effort, + summary: Some(open_ai::responses::ReasoningSummaryMode::Auto), + }), } } @@ -963,10 +966,20 @@ impl OpenAiResponseEventMapper { self.function_calls_by_item.insert(item_id, entry); } } - ResponseOutputItem::Unknown => {} + ResponseOutputItem::Reasoning(_) | ResponseOutputItem::Unknown => {} } events } + ResponsesStreamEvent::ReasoningSummaryTextDelta { delta, .. } => { + if delta.is_empty() { + Vec::new() + } else { + vec![Ok(LanguageModelCompletionEvent::Thinking { + text: delta, + signature: None, + })] + } + } ResponsesStreamEvent::OutputTextDelta { delta, .. } => { if delta.is_empty() { Vec::new() @@ -1075,10 +1088,22 @@ impl OpenAiResponseEventMapper { error.message )))] } - ResponsesStreamEvent::OutputTextDone { .. } => Vec::new(), - ResponsesStreamEvent::OutputItemDone { .. } + ResponsesStreamEvent::ReasoningSummaryPartAdded { summary_index, .. } => { + if summary_index > 0 { + vec![Ok(LanguageModelCompletionEvent::Thinking { + text: "\n\n".to_string(), + signature: None, + })] + } else { + Vec::new() + } + } + ResponsesStreamEvent::OutputTextDone { .. } + | ResponsesStreamEvent::OutputItemDone { .. } | ResponsesStreamEvent::ContentPartAdded { .. } | ResponsesStreamEvent::ContentPartDone { .. } + | ResponsesStreamEvent::ReasoningSummaryTextDone { .. } + | ResponsesStreamEvent::ReasoningSummaryPartDone { .. } | ResponsesStreamEvent::Created { .. } | ResponsesStreamEvent::InProgress { .. } | ResponsesStreamEvent::Unknown => Vec::new(), @@ -1390,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") }), @@ -1416,8 +1443,9 @@ mod tests { use gpui::TestAppContext; use language_model::{LanguageModelRequestMessage, LanguageModelRequestTool}; use open_ai::responses::{ - ResponseFunctionToolCall, ResponseOutputItem, ResponseOutputMessage, ResponseStatusDetails, - ResponseSummary, ResponseUsage, StreamEvent as ResponsesStreamEvent, + ReasoningSummaryPart, ResponseFunctionToolCall, ResponseOutputItem, ResponseOutputMessage, + ResponseReasoningItem, ResponseStatusDetails, ResponseSummary, ResponseUsage, + StreamEvent as ResponsesStreamEvent, }; use pretty_assertions::assert_eq; use serde_json::json; @@ -1675,7 +1703,7 @@ mod tests { } ], "prompt_cache_key": "thread-123", - "reasoning": { "effort": "low" } + "reasoning": { "effort": "low", "summary": "auto" } }); assert_eq!(serialized, expected); @@ -2114,4 +2142,166 @@ mod tests { }) )); } + + #[test] + fn responses_stream_maps_reasoning_summary_deltas() { + let events = vec![ + ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: ResponseOutputItem::Reasoning(ResponseReasoningItem { + id: Some("rs_123".into()), + summary: vec![], + }), + }, + ResponsesStreamEvent::ReasoningSummaryPartAdded { + item_id: "rs_123".into(), + output_index: 0, + summary_index: 0, + }, + ResponsesStreamEvent::ReasoningSummaryTextDelta { + item_id: "rs_123".into(), + output_index: 0, + delta: "Thinking about".into(), + }, + ResponsesStreamEvent::ReasoningSummaryTextDelta { + item_id: "rs_123".into(), + output_index: 0, + delta: " the answer".into(), + }, + ResponsesStreamEvent::ReasoningSummaryTextDone { + item_id: "rs_123".into(), + output_index: 0, + text: "Thinking about the answer".into(), + }, + ResponsesStreamEvent::ReasoningSummaryPartDone { + item_id: "rs_123".into(), + output_index: 0, + summary_index: 0, + }, + ResponsesStreamEvent::ReasoningSummaryPartAdded { + item_id: "rs_123".into(), + output_index: 0, + summary_index: 1, + }, + ResponsesStreamEvent::ReasoningSummaryTextDelta { + item_id: "rs_123".into(), + output_index: 0, + delta: "Second part".into(), + }, + ResponsesStreamEvent::ReasoningSummaryTextDone { + item_id: "rs_123".into(), + output_index: 0, + text: "Second part".into(), + }, + ResponsesStreamEvent::ReasoningSummaryPartDone { + item_id: "rs_123".into(), + output_index: 0, + summary_index: 1, + }, + ResponsesStreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: ResponseOutputItem::Reasoning(ResponseReasoningItem { + id: Some("rs_123".into()), + summary: vec![ + ReasoningSummaryPart::SummaryText { + text: "Thinking about the answer".into(), + }, + ReasoningSummaryPart::SummaryText { + text: "Second part".into(), + }, + ], + }), + }, + ResponsesStreamEvent::OutputItemAdded { + output_index: 1, + sequence_number: None, + item: response_item_message("msg_456"), + }, + ResponsesStreamEvent::OutputTextDelta { + item_id: "msg_456".into(), + output_index: 1, + content_index: Some(0), + delta: "The answer is 42".into(), + }, + ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }, + ]; + + let mapped = map_response_events(events); + + let thinking_events: Vec<_> = mapped + .iter() + .filter(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. })) + .collect(); + assert_eq!( + thinking_events.len(), + 4, + "expected 4 thinking events (2 deltas + separator + second delta), got {:?}", + thinking_events, + ); + + assert!(matches!( + &thinking_events[0], + LanguageModelCompletionEvent::Thinking { text, .. } if text == "Thinking about" + )); + assert!(matches!( + &thinking_events[1], + LanguageModelCompletionEvent::Thinking { text, .. } if text == " the answer" + )); + assert!( + matches!( + &thinking_events[2], + LanguageModelCompletionEvent::Thinking { text, .. } if text == "\n\n" + ), + "expected separator between summary parts" + ); + assert!(matches!( + &thinking_events[3], + LanguageModelCompletionEvent::Thinking { text, .. } if text == "Second part" + )); + + assert!(mapped.iter().any(|e| matches!( + e, + LanguageModelCompletionEvent::Text(t) if t == "The answer is 42" + ))); + } + + #[test] + fn responses_stream_maps_reasoning_from_done_only() { + let events = vec![ + ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: ResponseOutputItem::Reasoning(ResponseReasoningItem { + id: Some("rs_789".into()), + summary: vec![], + }), + }, + ResponsesStreamEvent::OutputItemDone { + output_index: 0, + sequence_number: None, + item: ResponseOutputItem::Reasoning(ResponseReasoningItem { + id: Some("rs_789".into()), + summary: vec![ReasoningSummaryPart::SummaryText { + text: "Summary without deltas".into(), + }], + }), + }, + ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }, + ]; + + let mapped = map_response_events(events); + + assert!( + !mapped + .iter() + .any(|e| matches!(e, LanguageModelCompletionEvent::Thinking { .. })), + "OutputItemDone reasoning should not produce Thinking events (no delta/done text events)" + ); + } } 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_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 17a39d4979a1321a4b0e612bff228f186098babf..e5e6a2e264dbb923390e05b283fe341a3336af97 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -280,20 +280,28 @@ impl PickerDelegate for LanguageSelectorDelegate { }; this.update_in(cx, |this, window, cx| { - let delegate = &mut this.delegate; - delegate.matches = matches; - delegate.selected_index = delegate - .selected_index - .min(delegate.matches.len().saturating_sub(1)); - - if query_is_empty { - if let Some(index) = delegate - .current_language_candidate_index - .and_then(|ci| delegate.matches.iter().position(|m| m.candidate_id == ci)) - { - this.set_selected_index(index, None, false, window, cx); - } + if matches.is_empty() { + this.delegate.matches = matches; + this.delegate.selected_index = 0; + cx.notify(); + return; } + + let selected_index = if query_is_empty { + this.delegate + .current_language_candidate_index + .and_then(|current_language_candidate_index| { + matches.iter().position(|mat| { + mat.candidate_id == current_language_candidate_index + }) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.delegate.matches = matches; + this.set_selected_index(selected_index, None, false, window, cx); cx.notify(); }) .log_err(); @@ -345,28 +353,25 @@ mod tests { fn register_test_languages(project: &Entity, cx: &mut VisualTestContext) { project.read_with(cx, |project, _| { let language_registry = project.languages(); - language_registry.add(Arc::new(Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - None, - ))); - language_registry.add(Arc::new(Language::new( - LanguageConfig { - name: "TypeScript".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["ts".to_string()], + for (language_name, path_suffix) in [ + ("C", "c"), + ("Go", "go"), + ("Ruby", "rb"), + ("Rust", "rs"), + ("TypeScript", "ts"), + ] { + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: language_name.into(), + matcher: LanguageMatcher { + path_suffixes: vec![path_suffix.to_string()], + ..Default::default() + }, ..Default::default() }, - ..Default::default() - }, - None, - ))); + None, + ))); + } }); } @@ -406,6 +411,24 @@ mod tests { workspace: &Entity, project: &Entity, cx: &mut VisualTestContext, + ) -> Entity { + let editor = open_new_buffer_editor(workspace, project, cx).await; + // Ensure the buffer has no language after the editor is created + let (_, buffer, _) = editor.read_with(cx, |editor, cx| { + editor + .active_excerpt(cx) + .expect("editor should have an active excerpt") + }); + buffer.update(cx, |buffer, cx| { + buffer.set_language(None, cx); + }); + editor + } + + async fn open_new_buffer_editor( + workspace: &Entity, + project: &Entity, + cx: &mut VisualTestContext, ) -> Entity { let create_buffer = project.update(cx, |project, cx| project.create_buffer(None, true, cx)); let buffer = create_buffer.await.expect("empty buffer should be created"); @@ -415,10 +438,6 @@ mod tests { workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_center(Box::new(editor.clone()), window, cx); }); - // Ensure the buffer has no language after the editor is created - buffer.update(cx, |buffer, cx| { - buffer.set_language(None, cx); - }); editor } @@ -559,15 +578,86 @@ mod tests { assert_selected_language_for_editor(&workspace, &rust_editor, Some("Rust"), cx); assert_selected_language_for_editor(&workspace, &typescript_editor, Some("TypeScript"), cx); - // Ensure the empty editor's buffer has no language before asserting - let (_, buffer, _) = empty_editor.read_with(cx, |editor, cx| { - editor - .active_excerpt(cx) - .expect("editor should have an active excerpt") + assert_selected_language_for_editor(&workspace, &empty_editor, None, cx); + } + + #[gpui::test] + async fn test_language_selector_selects_first_match_after_querying_new_buffer( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree(path!("/test"), json!({})) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], 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, |multi_workspace, _| multi_workspace.workspace().clone()); + register_test_languages(&project, cx); + + let editor = open_new_buffer_editor(&workspace, &project, cx).await; + workspace.update_in(cx, |workspace, window, cx| { + let was_activated = workspace.activate_item(&editor, true, true, window, cx); + assert!( + was_activated, + "editor should be activated before opening the modal" + ); }); - buffer.update(cx, |buffer, cx| { - buffer.set_language(None, cx); + cx.run_until_parked(); + + let picker = open_selector(&workspace, cx); + picker.read_with(cx, |picker, _| { + let selected_match = picker + .delegate + .matches + .get(picker.delegate.selected_index) + .expect("selected index should point to a match"); + let selected_candidate = picker + .delegate + .candidates + .get(selected_match.candidate_id) + .expect("selected match should map to a candidate"); + + assert_eq!(selected_candidate.string, "Plain Text"); + assert!( + picker + .delegate + .current_language_candidate_index + .is_some_and(|current_language_candidate_index| { + current_language_candidate_index > 1 + }), + "test setup should place Plain Text after at least two earlier languages", + ); + }); + + picker.update_in(cx, |picker, window, cx| { + picker.update_matches("ru".to_string(), window, cx) + }); + cx.run_until_parked(); + + picker.read_with(cx, |picker, _| { + assert!( + picker.delegate.matches.len() > 1, + "query should return multiple matches" + ); + assert_eq!(picker.delegate.selected_index, 0); + + let first_match = picker + .delegate + .matches + .first() + .expect("query should produce at least one match"); + let selected_match = picker + .delegate + .matches + .get(picker.delegate.selected_index) + .expect("selected index should point to a match"); + + assert_eq!(selected_match.candidate_id, first_match.candidate_id); }); - assert_selected_language_for_editor(&workspace, &empty_editor, None, cx); } } 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/languages/Cargo.toml b/crates/languages/Cargo.toml index 8529bdb82ace33d6f3c747ed707b9aac9d319627..b66f661b5e8782a7a072332141e4e2246ab1a2b9 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -98,7 +98,6 @@ util.workspace = true [dev-dependencies] pretty_assertions.workspace = true -text.workspace = true theme = { workspace = true, features = ["test-support"] } tree-sitter-bash.workspace = true tree-sitter-c.workspace = true @@ -109,4 +108,3 @@ tree-sitter-python.workspace = true tree-sitter-typescript.workspace = true tree-sitter.workspace = true unindent.workspace = true -workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/languages/src/cpp.rs b/crates/languages/src/cpp.rs index 85a3fb5045275648282c7a8cbad58779491ad7dc..3207b492f4b11be345cd67a989f9667d025d6660 100644 --- a/crates/languages/src/cpp.rs +++ b/crates/languages/src/cpp.rs @@ -1,3 +1,15 @@ +use settings::SemanticTokenRules; + +use crate::LanguageDir; + +pub(crate) fn semantic_token_rules() -> SemanticTokenRules { + let content = LanguageDir::get("cpp/semantic_token_rules.json") + .expect("missing cpp/semantic_token_rules.json"); + let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); + settings::parse_json_with_comments::(json) + .expect("failed to parse cpp semantic_token_rules.json") +} + #[cfg(test)] mod tests { use gpui::{AppContext as _, BorrowAppContext, TestAppContext}; diff --git a/crates/languages/src/cpp/semantic_token_rules.json b/crates/languages/src/cpp/semantic_token_rules.json new file mode 100644 index 0000000000000000000000000000000000000000..627a5c5f187b47918e6a56069c5ed1bda8583aa6 --- /dev/null +++ b/crates/languages/src/cpp/semantic_token_rules.json @@ -0,0 +1,7 @@ +[ + { + "token_type": "variable", + "token_modifiers": ["readonly"], + "style": ["constant"] + } +] diff --git a/crates/languages/src/gitcommit/config.toml b/crates/languages/src/gitcommit/config.toml index c2421ce00613e5848aacab5d1230ab839c8b1388..83cd6f550e3f18c5d8cb61efa4d632ece6c1ad4d 100644 --- a/crates/languages/src/gitcommit/config.toml +++ b/crates/languages/src/gitcommit/config.toml @@ -7,7 +7,7 @@ path_suffixes = [ "NOTES_EDITMSG", "EDIT_DESCRIPTION", ] -line_comments = ["#"] +line_comments = ["# "] brackets = [ { start = "(", end = ")", close = true, newline = false }, { start = "`", end = "`", close = true, newline = false }, diff --git a/crates/languages/src/gomod/config.toml b/crates/languages/src/gomod/config.toml index e70c9358bfc6f467b69897fa6d20dd9ae0082f9a..d151db961106591c07850034f669304db7edb650 100644 --- a/crates/languages/src/gomod/config.toml +++ b/crates/languages/src/gomod/config.toml @@ -2,7 +2,7 @@ name = "Go Mod" code_fence_block_name = "go.mod" grammar = "gomod" path_suffixes = ["mod"] -line_comments = ["//"] +line_comments = ["// "] autoclose_before = ")" brackets = [ { start = "(", end = ")", close = true, newline = true} diff --git a/crates/languages/src/gowork/config.toml b/crates/languages/src/gowork/config.toml index 68beb073ab64df4761bf3f87a88f28a0608656f7..90e62f0cf102306b258e9efd56bb9ae9838f0f27 100644 --- a/crates/languages/src/gowork/config.toml +++ b/crates/languages/src/gowork/config.toml @@ -2,7 +2,7 @@ name = "Go Work" code_fence_block_name = "gowork" grammar = "gowork" path_suffixes = ["work"] -line_comments = ["//"] +line_comments = ["// "] autoclose_before = ")" brackets = [ { start = "(", end = ")", close = true, newline = true} diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 275b8c58ecde831c8f89ae688dc236583b135c07..240935d2f817b43b2aae03dfdff4321de6522bf3 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -125,6 +125,7 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime LanguageInfo { name: "cpp", adapters: vec![c_lsp_adapter], + semantic_token_rules: Some(cpp::semantic_token_rules()), ..Default::default() }, LanguageInfo { diff --git a/crates/languages/src/markdown/highlights.scm b/crates/languages/src/markdown/highlights.scm index 1a471a848dfe0c9457ab23ba9dbf3fd9e8438f7d..76254c2472d98dc58a6efdccef41d9ec677a1b77 100644 --- a/crates/languages/src/markdown/highlights.scm +++ b/crates/languages/src/markdown/highlights.scm @@ -21,7 +21,10 @@ (list_marker_parenthesis) ] @punctuation.list_marker.markup -(block_quote_marker) @punctuation.markup +[ + (block_quote_marker) + (block_continuation) +] @punctuation.markup (pipe_table_header "|" @punctuation.markup) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 95bfc798414f5d3629e1ea46f54d14a7ed58a8d4..e109d2685efaac6aaacddb7f467180ae48ba54e4 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -159,6 +159,75 @@ fn process_pyright_completions(items: &mut [lsp::CompletionItem]) { } } +fn label_for_pyright_completion( + item: &lsp::CompletionItem, + language: &Arc, +) -> Option { + let label = &item.label; + let label_len = label.len(); + let grammar = language.grammar()?; + let highlight_id = match item.kind? { + lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"), + lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"), + lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"), + lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"), + lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"), + _ => { + return None; + } + }; + let mut text = label.clone(); + if let Some(completion_details) = item + .label_details + .as_ref() + .and_then(|details| details.description.as_ref()) + { + write!(&mut text, " {}", completion_details).ok(); + } + Some(language::CodeLabel::filtered( + text, + label_len, + item.filter_text.as_deref(), + highlight_id + .map(|id| (0..label_len, id)) + .into_iter() + .collect(), + )) +} + +fn label_for_python_symbol( + symbol: &Symbol, + language: &Arc, +) -> Option { + let name = &symbol.name; + let (text, filter_range, display_range) = match symbol.kind { + lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { + let text = format!("def {}():\n", name); + let filter_range = 4..4 + name.len(); + let display_range = 0..filter_range.end; + (text, filter_range, display_range) + } + lsp::SymbolKind::CLASS => { + let text = format!("class {}:", name); + let filter_range = 6..6 + name.len(); + let display_range = 0..filter_range.end; + (text, filter_range, display_range) + } + lsp::SymbolKind::CONSTANT => { + let text = format!("{} = 0", name); + let filter_range = 0..name.len(); + let display_range = 0..filter_range.end; + (text, filter_range, display_range) + } + _ => return None, + }; + Some(language::CodeLabel::new( + text[display_range.clone()].to_string(), + filter_range, + language.highlight_text(&text.as_str().into(), display_range), + )) +} + pub struct TyLspAdapter { fs: Arc, } @@ -255,6 +324,14 @@ impl LspAdapter for TyLspAdapter { )) } + async fn label_for_symbol( + &self, + symbol: &language::Symbol, + language: &Arc, + ) -> Option { + label_for_python_symbol(symbol, language) + } + async fn workspace_configuration( self: Arc, delegate: &Arc, @@ -531,36 +608,7 @@ impl LspAdapter for PyrightLspAdapter { item: &lsp::CompletionItem, language: &Arc, ) -> Option { - let label = &item.label; - let label_len = label.len(); - let grammar = language.grammar()?; - let highlight_id = match item.kind? { - lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"), - lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"), - lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"), - lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"), - lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"), - _ => { - return None; - } - }; - let mut text = label.clone(); - if let Some(completion_details) = item - .label_details - .as_ref() - .and_then(|details| details.description.as_ref()) - { - write!(&mut text, " {}", completion_details).ok(); - } - Some(language::CodeLabel::filtered( - text, - label_len, - item.filter_text.as_deref(), - highlight_id - .map(|id| (0..label_len, id)) - .into_iter() - .collect(), - )) + label_for_pyright_completion(item, language) } async fn label_for_symbol( @@ -568,34 +616,7 @@ impl LspAdapter for PyrightLspAdapter { symbol: &language::Symbol, language: &Arc, ) -> Option { - let name = &symbol.name; - let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { - let text = format!("def {}():\n", name); - let filter_range = 4..4 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CLASS => { - let text = format!("class {}:", name); - let filter_range = 6..6 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CONSTANT => { - let text = format!("{} = 0", name); - let filter_range = 0..name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - _ => return None, - }; - - Some(language::CodeLabel::new( - text[display_range.clone()].to_string(), - filter_range, - language.highlight_text(&text.as_str().into(), display_range), - )) + label_for_python_symbol(symbol, language) } async fn workspace_configuration( @@ -1738,33 +1759,7 @@ impl LspAdapter for PyLspAdapter { symbol: &language::Symbol, language: &Arc, ) -> Option { - let name = &symbol.name; - let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { - let text = format!("def {}():\n", name); - let filter_range = 4..4 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CLASS => { - let text = format!("class {}:", name); - let filter_range = 6..6 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CONSTANT => { - let text = format!("{} = 0", name); - let filter_range = 0..name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - _ => return None, - }; - Some(language::CodeLabel::new( - text[display_range.clone()].to_string(), - filter_range, - language.highlight_text(&text.as_str().into(), display_range), - )) + label_for_python_symbol(symbol, language) } async fn workspace_configuration( @@ -1846,6 +1841,17 @@ impl LspInstaller for PyLspAdapter { ) -> Option { if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await { let env = delegate.shell_env().await; + delegate + .try_exec(LanguageServerBinary { + path: pylsp_bin.clone(), + arguments: vec!["--version".into()], + env: Some(env.clone()), + }) + .await + .inspect_err(|err| { + log::warn!("failed to validate user-installed pylsp at {pylsp_bin:?}: {err:#}") + }) + .ok()?; Some(LanguageServerBinary { path: pylsp_bin, env: Some(env), @@ -1854,7 +1860,21 @@ impl LspInstaller for PyLspAdapter { } else { let toolchain = toolchain?; let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp"); - pylsp_path.exists().then(|| LanguageServerBinary { + if !pylsp_path.exists() { + return None; + } + delegate + .try_exec(LanguageServerBinary { + path: toolchain.path.to_string().into(), + arguments: vec![pylsp_path.clone().into(), "--version".into()], + env: None, + }) + .await + .inspect_err(|err| { + log::warn!("failed to validate toolchain pylsp at {pylsp_path:?}: {err:#}") + }) + .ok()?; + Some(LanguageServerBinary { path: toolchain.path.to_string().into(), arguments: vec![pylsp_path.into()], env: None, @@ -1994,36 +2014,7 @@ impl LspAdapter for BasedPyrightLspAdapter { item: &lsp::CompletionItem, language: &Arc, ) -> Option { - let label = &item.label; - let label_len = label.len(); - let grammar = language.grammar()?; - let highlight_id = match item.kind? { - lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method"), - lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function"), - lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type"), - lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant"), - lsp::CompletionItemKind::VARIABLE => grammar.highlight_id_for_name("variable"), - _ => { - return None; - } - }; - let mut text = label.clone(); - if let Some(completion_details) = item - .label_details - .as_ref() - .and_then(|details| details.description.as_ref()) - { - write!(&mut text, " {}", completion_details).ok(); - } - Some(language::CodeLabel::filtered( - text, - label_len, - item.filter_text.as_deref(), - highlight_id - .map(|id| (0..label.len(), id)) - .into_iter() - .collect(), - )) + label_for_pyright_completion(item, language) } async fn label_for_symbol( @@ -2031,33 +2022,7 @@ impl LspAdapter for BasedPyrightLspAdapter { symbol: &Symbol, language: &Arc, ) -> Option { - let name = &symbol.name; - let (text, filter_range, display_range) = match symbol.kind { - lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { - let text = format!("def {}():\n", name); - let filter_range = 4..4 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CLASS => { - let text = format!("class {}:", name); - let filter_range = 6..6 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - lsp::SymbolKind::CONSTANT => { - let text = format!("{} = 0", name); - let filter_range = 0..name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - _ => return None, - }; - Some(language::CodeLabel::new( - text[display_range.clone()].to_string(), - filter_range, - language.highlight_text(&text.as_str().into(), display_range), - )) + label_for_python_symbol(symbol, language) } async fn workspace_configuration( diff --git a/crates/languages/src/rust/injections.scm b/crates/languages/src/rust/injections.scm index 89d839282d3388f450f9ebdb923167f0986f349c..c50694dc9e0b90d3e31bc1147e59eea7ff402efa 100644 --- a/crates/languages/src/rust/injections.scm +++ b/crates/languages/src/rust/injections.scm @@ -10,7 +10,7 @@ (scoped_identifier (identifier) @_macro_name .) ] - (#not-any-of? @_macro_name "view" "html") + (#not-any-of? @_macro_name "view" "html" "bsn") (token_tree) @injection.content (#set! injection.language "rust")) diff --git a/crates/languages/src/tsx/brackets.scm b/crates/languages/src/tsx/brackets.scm index d72fcb26005a0021907558bbbee7471cfeaec603..cd59d553783f685775e45ba883210272b168c3b8 100644 --- a/crates/languages/src/tsx/brackets.scm +++ b/crates/languages/src/tsx/brackets.scm @@ -7,14 +7,17 @@ ("{" @open "}" @close) -("<" @open +(("<" @open ">" @close) + (#set! rainbow.exclude)) -("<" @open +(("<" @open "/>" @close) + (#set! rainbow.exclude)) -("" @close) + (#set! rainbow.exclude)) (("\"" @open "\"" @close) diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml index 66511da9daa943628e71000a2009b2026eeace6c..df1024aa99e15e322c7dff5ee7933db2a9df80b4 100644 --- a/crates/livekit_client/Cargo.toml +++ b/crates/livekit_client/Cargo.toml @@ -61,7 +61,6 @@ objc.workspace = true collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } gpui_platform.workspace = true -sha2.workspace = true simplelog.workspace = true [build-dependencies] diff --git a/crates/livekit_client/src/lib.rs b/crates/livekit_client/src/lib.rs index be008d8db5108fb087415edb9d2de91bad19ab97..352776cf6bbe02381957a197eca9a64fff094892 100644 --- a/crates/livekit_client/src/lib.rs +++ b/crates/livekit_client/src/lib.rs @@ -1,8 +1,8 @@ use anyhow::Context as _; use collections::HashMap; +use cpal::DeviceId; mod remote_video_track_view; -use cpal::traits::HostTrait as _; pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent}; use rodio::DeviceTrait as _; @@ -192,24 +192,18 @@ pub enum RoomEvent { pub(crate) fn default_device( input: bool, + device_id: Option<&DeviceId>, ) -> anyhow::Result<(cpal::Device, cpal::SupportedStreamConfig)> { - let device; - let config; - if input { - device = cpal::default_host() - .default_input_device() - .context("no audio input device available")?; - config = device + let device = audio::resolve_device(device_id, input)?; + let config = if input { + device .default_input_config() - .context("failed to get default input config")?; + .context("failed to get default input config")? } else { - device = cpal::default_host() - .default_output_device() - .context("no audio output device available")?; - config = device + device .default_output_config() - .context("failed to get default output config")?; - } + .context("failed to get default output config")? + }; Ok((device, config)) } diff --git a/crates/livekit_client/src/livekit_client.rs b/crates/livekit_client/src/livekit_client.rs index 1db9a12ef2b7f3b4f3de1cba6c61a30db12a5bd9..863cf0dc527300f1e85df6867d99e367b5c7fa15 100644 --- a/crates/livekit_client/src/livekit_client.rs +++ b/crates/livekit_client/src/livekit_client.rs @@ -150,7 +150,10 @@ impl Room { info!("Using experimental.rodio_audio audio pipeline for output"); playback::play_remote_audio_track(&track.0, speaker, cx) } else if speaker.sends_legacy_audio { - Ok(self.playback.play_remote_audio_track(&track.0)) + let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); + Ok(self + .playback + .play_remote_audio_track(&track.0, output_audio_device)) } else { Err(anyhow!("Client version too old to play audio in call")) } diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index df62479f022be5295a3de44f40fabf48aed515f2..4b3c55109a297c888ac64d5742a1df91163d77e0 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -1,8 +1,9 @@ use anyhow::{Context as _, Result}; -use audio::{AudioSettings, CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE}; +use audio::{AudioSettings, CHANNEL_COUNT, SAMPLE_RATE}; +use cpal::DeviceId; use cpal::traits::{DeviceTrait, StreamTrait as _}; -use futures::channel::mpsc::UnboundedSender; +use futures::channel::mpsc::Sender; use futures::{Stream, StreamExt as _}; use gpui::{ AsyncApp, BackgroundExecutor, Priority, ScreenCaptureFrame, ScreenCaptureSource, @@ -28,7 +29,7 @@ use std::cell::RefCell; use std::sync::Weak; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::time::Duration; -use std::{borrow::Cow, collections::VecDeque, sync::Arc, thread}; +use std::{borrow::Cow, collections::VecDeque, sync::Arc}; use util::{ResultExt as _, maybe}; mod source; @@ -91,14 +92,15 @@ impl AudioStack { pub(crate) fn play_remote_audio_track( &self, track: &livekit::track::RemoteAudioTrack, + output_audio_device: Option, ) -> AudioStream { - let output_task = self.start_output(); + let output_task = self.start_output(output_audio_device); let next_ssrc = self.next_ssrc.fetch_add(1, Ordering::Relaxed); let source = AudioMixerSource { ssrc: next_ssrc, - sample_rate: LEGACY_SAMPLE_RATE.get(), - num_channels: LEGACY_CHANNEL_COUNT.get() as u32, + sample_rate: SAMPLE_RATE.get(), + num_channels: CHANNEL_COUNT.get() as u32, buffer: Arc::default(), }; self.mixer.lock().add_source(source.clone()); @@ -109,7 +111,7 @@ impl AudioStack { source.num_channels as i32, ); - let receive_task = self.executor.spawn({ + let receive_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, { let source = source.clone(); async move { while let Some(frame) = stream.next().await { @@ -130,19 +132,22 @@ impl AudioStack { } } - fn start_output(&self) -> Arc> { + fn start_output(&self, output_audio_device: Option) -> Arc> { if let Some(task) = self._output_task.borrow().upgrade() { return task; } let task = Arc::new(self.executor.spawn({ let apm = self.apm.clone(); let mixer = self.mixer.clone(); + let executor = self.executor.clone(); async move { Self::play_output( + executor, apm, mixer, - LEGACY_SAMPLE_RATE.get(), - LEGACY_CHANNEL_COUNT.get().into(), + SAMPLE_RATE.get(), + CHANNEL_COUNT.get().into(), + output_audio_device, ) .await .log_err(); @@ -166,8 +171,9 @@ impl AudioStack { NativeAudioSource::new( // n.b. this struct's options are always ignored, noise cancellation is provided by apm. AudioSourceOptions::default(), - LEGACY_SAMPLE_RATE.get(), - LEGACY_CHANNEL_COUNT.get().into(), + SAMPLE_RATE.get(), // TODO(audio): this was legacy params, + // removed for now for simplicity + CHANNEL_COUNT.get().into(), 10, ) } else { @@ -196,8 +202,8 @@ impl AudioStack { let apm = self.apm.clone(); - let (frame_tx, mut frame_rx) = futures::channel::mpsc::unbounded(); - let transmit_task = self.executor.spawn({ + let (frame_tx, mut frame_rx) = futures::channel::mpsc::channel(1); + let transmit_task = self.executor.spawn_with_priority(Priority::RealtimeAudio, { async move { while let Some(frame) = frame_rx.next().await { source.capture_frame(&frame).await.log_err(); @@ -219,12 +225,18 @@ impl AudioStack { Ok(()) }) } else { + let input_audio_device = + AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) + .flatten(); + let executor = self.executor.clone(); self.executor.spawn(async move { Self::capture_input( + executor, apm, frame_tx, - LEGACY_SAMPLE_RATE.get(), - LEGACY_CHANNEL_COUNT.get().into(), + SAMPLE_RATE.get(), // TODO(audio): was legacy removed for now + CHANNEL_COUNT.get().into(), + input_audio_device, ) .await }) @@ -243,10 +255,12 @@ impl AudioStack { } async fn play_output( + executor: BackgroundExecutor, apm: Arc>, mixer: Arc>, sample_rate: u32, - num_channels: u32, + _num_channels: u32, + output_audio_device: Option, ) -> Result<()> { // Prevent App Nap from throttling audio playback on macOS. // This guard is held for the entire duration of audio output. @@ -255,16 +269,17 @@ impl AudioStack { loop { let mut device_change_listener = DeviceChangeListener::new(false)?; - let (output_device, output_config) = crate::default_device(false)?; + let (output_device, output_config) = + crate::default_device(false, output_audio_device.as_ref())?; + info!("Output config: {output_config:?}"); let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>(); let mixer = mixer.clone(); let apm = apm.clone(); let mut resampler = audio_resampler::AudioResampler::default(); let mut buf = Vec::new(); - thread::Builder::new() - .name("AudioPlayback".to_owned()) - .spawn(move || { + executor + .spawn_with_priority(Priority::RealtimeAudio, async move { let output_stream = output_device.build_output_stream( &output_config.config(), { @@ -287,7 +302,12 @@ impl AudioStack { let sampled = resampler.remix_and_resample( mixed, sample_rate / 100, - num_channels, + // We need to assume output number of channels as otherwise we will + // crash in process_reverse_stream otherwise as livekit's audio resampler + // does not seem to support non-matching channel counts. + // NOTE: you can verify this by debug printing buf.len() after this stage. + // For 2->4 channel upmix, we should see buf.len=1920, buf we get only 960. + output_config.channels() as u32, sample_rate, output_config.channels() as u32, output_config.sample_rate(), @@ -315,7 +335,7 @@ impl AudioStack { // Block forever to keep the output stream alive end_on_drop_rx.recv().ok(); }) - .unwrap(); + .detach(); device_change_listener.next().await; drop(end_on_drop_tx) @@ -323,22 +343,23 @@ impl AudioStack { } async fn capture_input( + executor: BackgroundExecutor, apm: Arc>, - frame_tx: UnboundedSender>, + frame_tx: Sender>, sample_rate: u32, num_channels: u32, + input_audio_device: Option, ) -> Result<()> { loop { let mut device_change_listener = DeviceChangeListener::new(true)?; - let (device, config) = crate::default_device(true)?; + let (device, config) = crate::default_device(true, input_audio_device.as_ref())?; let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>(); let apm = apm.clone(); - let frame_tx = frame_tx.clone(); + let mut frame_tx = frame_tx.clone(); let mut resampler = audio_resampler::AudioResampler::default(); - thread::Builder::new() - .name("AudioCapture".to_owned()) - .spawn(move || { + executor + .spawn_with_priority(Priority::RealtimeAudio, async move { maybe!({ if let Some(desc) = device.description().ok() { log::info!("Using microphone: {}", desc.name()) @@ -388,7 +409,7 @@ impl AudioStack { .log_err(); buf.clear(); frame_tx - .unbounded_send(AudioFrame { + .try_send(AudioFrame { data: Cow::Owned(sampled), sample_rate, num_channels, @@ -410,7 +431,7 @@ impl AudioStack { }) .log_err(); }) - .unwrap(); + .detach(); device_change_listener.next().await; drop(end_on_drop_tx) @@ -425,7 +446,7 @@ pub struct Speaker { pub sends_legacy_audio: bool, } -fn send_to_livekit(frame_tx: UnboundedSender>, mut microphone: impl Source) { +fn send_to_livekit(mut frame_tx: Sender>, mut microphone: impl Source) { use cpal::Sample; let sample_rate = microphone.sample_rate().get(); let num_channels = microphone.channels().get() as u32; @@ -438,17 +459,19 @@ fn send_to_livekit(frame_tx: UnboundedSender>, mut microphon .map(|s| s.to_sample()) .collect(); - if frame_tx - .unbounded_send(AudioFrame { - sample_rate, - num_channels, - samples_per_channel: sampled.len() as u32 / num_channels, - data: Cow::Owned(sampled), - }) - .is_err() - { - // must rx has dropped or is not consuming - break; + match frame_tx.try_send(AudioFrame { + sample_rate, + num_channels, + samples_per_channel: sampled.len() as u32 / num_channels, + data: Cow::Owned(sampled), + }) { + Ok(_) => {} + Err(err) => { + if !err.is_full() { + // must rx has dropped or is not consuming + break; + } + } } } } diff --git a/crates/livekit_client/src/livekit_client/playback/source.rs b/crates/livekit_client/src/livekit_client/playback/source.rs index b90c3613f8215481a4a535eb81c665fccae80e5c..2738109ff8fc972e9ab53768fd212d6f5ff5f194 100644 --- a/crates/livekit_client/src/livekit_client/playback/source.rs +++ b/crates/livekit_client/src/livekit_client/playback/source.rs @@ -7,7 +7,7 @@ use rodio::{ ChannelCount, SampleRate, Source, buffer::SamplesBuffer, conversions::SampleTypeConverter, }; -use audio::{CHANNEL_COUNT, LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE, SAMPLE_RATE}; +use audio::{CHANNEL_COUNT, SAMPLE_RATE}; fn frame_to_samplesbuffer(frame: AudioFrame) -> SamplesBuffer { let samples = frame.data.iter().copied(); @@ -35,7 +35,8 @@ impl LiveKitStream { legacy: bool, ) -> Self { let (channel_count, sample_rate) = if legacy { - (LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE) + // (LEGACY_CHANNEL_COUNT, LEGACY_SAMPLE_RATE) TODO(audio): do this or remove + (CHANNEL_COUNT, SAMPLE_RATE) } else { (CHANNEL_COUNT, SAMPLE_RATE) }; diff --git a/crates/livekit_client/src/record.rs b/crates/livekit_client/src/record.rs index c23ab2b938178e9b634f8e0d4d298f2c86450b51..c0fe9eb7218ad8550f7b63042d0e11c2cb53ee20 100644 --- a/crates/livekit_client/src/record.rs +++ b/crates/livekit_client/src/record.rs @@ -7,20 +7,22 @@ use std::{ }; use anyhow::{Context, Result}; +use cpal::DeviceId; use cpal::traits::{DeviceTrait, StreamTrait}; use rodio::{buffer::SamplesBuffer, conversions::SampleTypeConverter}; use util::ResultExt; pub struct CaptureInput { pub name: String, + pub input_device: Option, config: cpal::SupportedStreamConfig, samples: Arc>>, _stream: cpal::Stream, } impl CaptureInput { - pub fn start() -> anyhow::Result { - let (device, config) = crate::default_device(true)?; + pub fn start(input_device: Option) -> anyhow::Result { + let (device, config) = crate::default_device(true, input_device.as_ref())?; let name = device .description() .map(|desc| desc.name().to_string()) @@ -32,6 +34,7 @@ impl CaptureInput { Ok(Self { name, + input_device, _stream: stream, config, samples, diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 4baa308f1088341aada1eb2917c2133b8df8c143..c72de7274a407c168e7a3cdd7a253070cc6f858a 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -30,6 +30,7 @@ markup5ever_rcdom.workspace = true pretty_assertions.workspace = true pulldown-cmark.workspace = true settings.workspace = true +stacksafe.workspace = true theme.workspace = true ui.workspace = true urlencoding.workspace = true diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index ffd697d0e1bafc2feeccf3a3a7836a224d983860..29ea273f49578bd6ad408a8d57b891f572705c07 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -10,6 +10,7 @@ use language::LanguageRegistry; use markdown::parser::PARSE_OPTIONS; use markup5ever_rcdom::RcDom; use pulldown_cmark::{Alignment, Event, Parser, Tag, TagEnd}; +use stacksafe::stacksafe; use std::{ cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec, }; @@ -907,6 +908,7 @@ impl<'a> MarkdownParser<'a> { elements } + #[stacksafe] fn parse_html_node( &self, source_range: Range, @@ -1013,6 +1015,7 @@ impl<'a> MarkdownParser<'a> { } } + #[stacksafe] fn parse_paragraph( &self, source_range: Range, diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index d6e4a78fd8a5366bb05ad88dcd95cc822eb86629..7cf4cd844548ba9a67cd660c3b296f48e11d2937 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -277,6 +277,7 @@ impl MarkdownPreviewView { |this, editor, event: &EditorEvent, window, cx| { match event { EditorEvent::Edited { .. } + | EditorEvent::BufferEdited { .. } | EditorEvent::DirtyChanged | EditorEvent::ExcerptsEdited { .. } => { this.parse_markdown_from_active_editor(true, window, cx); diff --git a/crates/multi_buffer/Cargo.toml b/crates/multi_buffer/Cargo.toml index 524c916682f4d17b4e4b598a9af158e259b40ffc..66c23101ab26ac6be58d482c752f366522bb9305 100644 --- a/crates/multi_buffer/Cargo.toml +++ b/crates/multi_buffer/Cargo.toml @@ -52,7 +52,6 @@ gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true language = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true -project = { workspace = true, features = ["test-support"] } rand.workspace = true settings = { workspace = true, features = ["test-support"] } text = { workspace = true, features = ["test-support"] } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index c991fd9a5cbfe451b3f86ff016f8467395373564..e3b578c9d5ff274f35ddced24a49f4b31d819bf1 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -119,6 +119,7 @@ pub enum Event { DiffHunksToggled, Edited { edited_buffer: Option>, + is_local: bool, }, TransactionUndone { transaction_id: TransactionId, @@ -1912,6 +1913,7 @@ impl MultiBuffer { cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsAdded { buffer, @@ -1974,6 +1976,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsRemoved { ids, @@ -1987,7 +1990,7 @@ impl MultiBuffer { &self, buffer_id: BufferId, cx: &App, - ) -> Vec<(ExcerptId, ExcerptRange)> { + ) -> Vec<(ExcerptId, Arc, ExcerptRange)> { let mut excerpts = Vec::new(); let snapshot = self.read(cx); let mut cursor = snapshot.excerpts.cursor::>(()); @@ -1997,7 +2000,7 @@ impl MultiBuffer { if let Some(excerpt) = cursor.item() && excerpt.locator == *locator { - excerpts.push((excerpt.id, excerpt.range.clone())); + excerpts.push((excerpt.id, excerpt.buffer.clone(), excerpt.range.clone())); } } } @@ -2128,7 +2131,7 @@ impl MultiBuffer { ) -> Option { let mut found = None; let snapshot = buffer.read(cx).snapshot(); - for (excerpt_id, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) { + for (excerpt_id, _, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) { let start = range.context.start.to_point(&snapshot); let end = range.context.end.to_point(&snapshot); if start <= point && point < end { @@ -2138,7 +2141,7 @@ impl MultiBuffer { if point < start { found = Some((start, excerpt_id)); } - if point > end { + if point >= end { found = Some((end, excerpt_id)); } } @@ -2157,7 +2160,7 @@ impl MultiBuffer { cx: &App, ) -> Option { let snapshot = buffer.read(cx).snapshot(); - for (excerpt_id, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) { + for (excerpt_id, _, range) in self.excerpts_for_buffer(snapshot.remote_id(), cx) { if range.context.start.cmp(&anchor, &snapshot).is_le() && range.context.end.cmp(&anchor, &snapshot).is_ge() { @@ -2330,6 +2333,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsRemoved { ids, @@ -2394,8 +2398,9 @@ impl MultiBuffer { use language::BufferEvent; let buffer_id = buffer.read(cx).remote_id(); cx.emit(match event { - BufferEvent::Edited => Event::Edited { + &BufferEvent::Edited { is_local } => Event::Edited { edited_buffer: Some(buffer), + is_local, }, BufferEvent::DirtyChanged => Event::DirtyChanged, BufferEvent::Saved => Event::Saved, @@ -2484,6 +2489,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } @@ -2530,6 +2536,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } @@ -2769,6 +2776,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } @@ -2885,6 +2893,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } @@ -2952,6 +2961,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsExpanded { ids: vec![id] }); cx.notify(); @@ -3059,6 +3069,7 @@ impl MultiBuffer { } cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); cx.emit(Event::ExcerptsExpanded { ids }); cx.notify(); @@ -3702,6 +3713,7 @@ impl MultiBuffer { cx.emit(Event::DiffHunksToggled); cx.emit(Event::Edited { edited_buffer: None, + is_local: true, }); } } diff --git a/crates/multi_buffer/src/multi_buffer_tests.rs b/crates/multi_buffer/src/multi_buffer_tests.rs index 7e27786a76a14783f54e42c73850a888e87a3ac7..8b708968f21b103ee3c7882c01cd1edf6884af03 100644 --- a/crates/multi_buffer/src/multi_buffer_tests.rs +++ b/crates/multi_buffer/src/multi_buffer_tests.rs @@ -72,6 +72,30 @@ fn test_singleton(cx: &mut App) { assert_consistent_line_numbers(&snapshot); } +#[gpui::test] +fn test_buffer_point_to_anchor_at_end_of_singleton_buffer(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("abc", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + + let excerpt_id = multibuffer + .read(cx) + .excerpt_ids() + .into_iter() + .next() + .unwrap(); + let anchor = multibuffer + .read(cx) + .buffer_point_to_anchor(&buffer, Point::new(0, 3), cx); + + assert_eq!( + anchor, + Some(Anchor::in_buffer( + excerpt_id, + buffer.read(cx).snapshot().anchor_after(Point::new(0, 3)), + )) + ); +} + #[gpui::test] fn test_remote(cx: &mut App) { let host_buffer = cx.new(|cx| Buffer::local("a", cx)); @@ -171,12 +195,15 @@ fn test_excerpt_boundaries_and_clipping(cx: &mut App) { &[ Event::Edited { edited_buffer: None, + is_local: true, }, Event::Edited { edited_buffer: None, + is_local: true, }, Event::Edited { edited_buffer: None, + is_local: true, } ] ); @@ -1285,7 +1312,7 @@ fn test_resolving_anchors_after_replacing_their_excerpts(cx: &mut App) { let mut ids = multibuffer .excerpts_for_buffer(buffer_2.read(cx).remote_id(), cx) .into_iter() - .map(|(id, _)| id); + .map(|(id, _, _)| id); (ids.next().unwrap(), ids.next().unwrap()) }); let snapshot_2 = multibuffer.read(cx).snapshot(cx); diff --git a/crates/notifications/Cargo.toml b/crates/notifications/Cargo.toml index 8304c788fdd1ca840d68dbb4eb24bf5e3e79abdc..e0640c67cc55b3c2ba742e762d0e7a1e9d414c40 100644 --- a/crates/notifications/Cargo.toml +++ b/crates/notifications/Cargo.toml @@ -15,7 +15,7 @@ doctest = false [features] test-support = [ "channel/test-support", - "collections/test-support", + "gpui/test-support", "rpc/test-support", ] @@ -37,8 +37,6 @@ zed_actions.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } -collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } 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_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 9196b4a11fbaeeabb9ebe7e59cf106c4d260c267..fe97a438859e920313faa8cba0d335b7faeb75e0 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -78,6 +78,16 @@ pub enum ResponseInputContent { #[derive(Serialize, Debug)] pub struct ReasoningConfig { pub effort: ReasoningEffort, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ReasoningSummaryMode { + Auto, + Concise, + Detailed, } #[derive(Serialize, Debug)] @@ -150,6 +160,30 @@ pub enum StreamEvent { content_index: Option, text: String, }, + #[serde(rename = "response.reasoning_summary_part.added")] + ReasoningSummaryPartAdded { + item_id: String, + output_index: usize, + summary_index: usize, + }, + #[serde(rename = "response.reasoning_summary_text.delta")] + ReasoningSummaryTextDelta { + item_id: String, + output_index: usize, + delta: String, + }, + #[serde(rename = "response.reasoning_summary_text.done")] + ReasoningSummaryTextDone { + item_id: String, + output_index: usize, + text: String, + }, + #[serde(rename = "response.reasoning_summary_part.done")] + ReasoningSummaryPartDone { + item_id: String, + output_index: usize, + summary_index: usize, + }, #[serde(rename = "response.function_call_arguments.delta")] FunctionCallArgumentsDelta { item_id: String, @@ -219,6 +253,25 @@ pub struct ResponseUsage { pub enum ResponseOutputItem { Message(ResponseOutputMessage), FunctionCall(ResponseFunctionToolCall), + Reasoning(ResponseReasoningItem), + #[serde(other)] + Unknown, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct ResponseReasoningItem { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub summary: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ReasoningSummaryPart { + SummaryText { + text: String, + }, #[serde(other)] Unknown, } @@ -356,6 +409,21 @@ pub async fn stream_response( }); } } + ResponseOutputItem::Reasoning(reasoning) => { + if let Some(ref item_id) = reasoning.id { + for part in &reasoning.summary { + if let ReasoningSummaryPart::SummaryText { text } = part { + all_events.push( + StreamEvent::ReasoningSummaryTextDelta { + item_id: item_id.clone(), + output_index, + delta: text.clone(), + }, + ); + } + } + } + } ResponseOutputItem::Unknown => {} } 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/outline/Cargo.toml b/crates/outline/Cargo.toml index 905f323624437d988ff9a9eb3bde4f9a7becaa91..79559e03e8b2339fd8b4473d9e06ca6ff47b2b8c 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -38,6 +38,4 @@ project = { workspace = true, features = ["test-support"] } rope.workspace = true serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } -tree-sitter-rust.workspace = true -tree-sitter-typescript.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 454f6f0b578ce25785f0a356251c8af64776772f..4fb30cec9898534c8c72a83eb7634588ab78f73f 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -1,8 +1,5 @@ use std::ops::Range; -use std::{ - cmp::{self, Reverse}, - sync::Arc, -}; +use std::{cmp, sync::Arc}; use editor::scroll::ScrollOffset; use editor::{Anchor, AnchorRangeExt, Editor, scroll::Autoscroll}; @@ -183,11 +180,10 @@ impl OutlineView { struct OutlineViewDelegate { outline_view: WeakEntity, active_editor: Entity, - outline: Outline, + outline: Arc>, selected_match_index: usize, prev_scroll_position: Option>, matches: Vec, - last_query: String, } enum OutlineRowHighlights {} @@ -202,12 +198,11 @@ impl OutlineViewDelegate { ) -> Self { Self { outline_view, - last_query: Default::default(), matches: Default::default(), selected_match_index: 0, prev_scroll_position: Some(editor.update(cx, |editor, cx| editor.scroll_position(cx))), active_editor: editor, - outline, + outline: Arc::new(outline), } } @@ -280,67 +275,73 @@ impl PickerDelegate for OutlineViewDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { - let selected_index; - if query.is_empty() { + let is_query_empty = query.is_empty(); + if is_query_empty { self.restore_active_editor(window, cx); - self.matches = self - .outline - .items - .iter() - .enumerate() - .map(|(index, _)| StringMatch { - candidate_id: index, - score: Default::default(), - positions: Default::default(), - string: Default::default(), - }) - .collect(); - - let (buffer, cursor_offset) = self.active_editor.update(cx, |editor, cx| { - let buffer = editor.buffer().read(cx).snapshot(cx); - let cursor_offset = editor - .selections - .newest::(&editor.display_snapshot(cx)) - .head(); - (buffer, cursor_offset) - }); - selected_index = self - .outline - .items - .iter() - .enumerate() - .map(|(ix, item)| { - let range = item.range.to_offset(&buffer); - let distance_to_closest_endpoint = cmp::min( - (range.start.0 as isize - cursor_offset.0 as isize).abs(), - (range.end.0 as isize - cursor_offset.0 as isize).abs(), - ); - let depth = if range.contains(&cursor_offset) { - Some(item.depth) - } else { - None - }; - (ix, depth, distance_to_closest_endpoint) - }) - .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance))) - .map(|(ix, _, _)| ix) - .unwrap_or(0); - } else { - self.matches = smol::block_on( - self.outline - .search(&query, cx.background_executor().clone()), - ); - selected_index = self - .matches - .iter() - .enumerate() - .max_by_key(|(_, m)| OrderedFloat(m.score)) - .map(|(ix, _)| ix) - .unwrap_or(0); } - self.last_query = query; - self.set_selected_index(selected_index, !self.last_query.is_empty(), cx); - Task::ready(()) + + let outline = self.outline.clone(); + cx.spawn_in(window, async move |this, cx| { + let matches = if is_query_empty { + outline + .items + .iter() + .enumerate() + .map(|(index, _)| StringMatch { + candidate_id: index, + score: Default::default(), + positions: Default::default(), + string: Default::default(), + }) + .collect() + } else { + outline + .search(&query, cx.background_executor().clone()) + .await + }; + + let _ = this.update(cx, |this, cx| { + this.delegate.matches = matches; + let selected_index = if is_query_empty { + let (buffer, cursor_offset) = + this.delegate.active_editor.update(cx, |editor, cx| { + let snapshot = editor.display_snapshot(cx); + let cursor_offset = editor + .selections + .newest::(&snapshot) + .head(); + (snapshot.buffer().clone(), cursor_offset) + }); + this.delegate + .matches + .iter() + .enumerate() + .filter_map(|(ix, m)| { + let item = &this.delegate.outline.items[m.candidate_id]; + let range = item.range.to_offset(&buffer); + range.contains(&cursor_offset).then_some((ix, item.depth)) + }) + .max_by_key(|(ix, depth)| (*depth, cmp::Reverse(*ix))) + .map(|(ix, _)| ix) + .unwrap_or(0) + } else { + this.delegate + .matches + .iter() + .enumerate() + .max_by(|(ix_a, a), (ix_b, b)| { + OrderedFloat(a.score) + .cmp(&OrderedFloat(b.score)) + .then(ix_b.cmp(ix_a)) + }) + .map(|(ix, _)| ix) + .unwrap_or(0) + }; + + this.delegate + .set_selected_index(selected_index, !is_query_empty, cx); + }); + }) } fn confirm( @@ -586,6 +587,246 @@ mod tests { assert_single_caret_at_row(&editor, expected_first_highlighted_row, cx); } + #[gpui::test] + async fn test_outline_empty_query_prefers_deepest_containing_symbol_else_first( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.rs": indoc! {" + // display line 0 + struct Outer { // display line 1 + fn top(&self) {// display line 2 + let _x = 1;// display line 3 + } // display line 4 + } // display line 5 + + struct Another; // display line 7 + "} + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + project.read_with(cx, |project, _| { + project.languages().add(language::rust_lang()) + }); + + let (workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = cx.read(|cx| workspace.read(cx).workspace().clone()); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + set_single_caret_at_row(&editor, 3, cx); + let outline_view = open_outline_view(&workspace, cx); + cx.run_until_parked(); + let (selected_candidate_id, expected_deepest_containing_candidate_id) = outline_view + .update(cx, |outline_view, cx| { + let delegate = &outline_view.delegate; + let selected_candidate_id = + delegate.matches[delegate.selected_match_index].candidate_id; + let (buffer, cursor_offset) = delegate.active_editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx).snapshot(cx); + let cursor_offset = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + (buffer, cursor_offset) + }); + let deepest_containing_candidate_id = delegate + .outline + .items + .iter() + .enumerate() + .filter_map(|(ix, item)| { + item.range + .to_offset(&buffer) + .contains(&cursor_offset) + .then_some((ix, item.depth)) + }) + .max_by(|(ix_a, depth_a), (ix_b, depth_b)| { + depth_a.cmp(depth_b).then(ix_b.cmp(ix_a)) + }) + .map(|(ix, _)| ix) + .unwrap(); + (selected_candidate_id, deepest_containing_candidate_id) + }); + assert_eq!( + selected_candidate_id, expected_deepest_containing_candidate_id, + "Empty query should select the deepest symbol containing the cursor" + ); + + cx.dispatch_action(menu::Cancel); + cx.run_until_parked(); + + set_single_caret_at_row(&editor, 0, cx); + let outline_view = open_outline_view(&workspace, cx); + cx.run_until_parked(); + let selected_candidate_id = outline_view.read_with(cx, |outline_view, _| { + let delegate = &outline_view.delegate; + delegate.matches[delegate.selected_match_index].candidate_id + }); + assert_eq!( + selected_candidate_id, 0, + "Empty query should fall back to the first symbol when cursor is outside all symbol ranges" + ); + } + + #[gpui::test] + async fn test_outline_filtered_selection_prefers_first_match_on_score_ties( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.rs": indoc! {" + struct A; + impl A { + fn f(&self) {} + fn g(&self) {} + } + + struct B; + impl B { + fn f(&self) {} + fn g(&self) {} + } + + struct C; + impl C { + fn f(&self) {} + fn g(&self) {} + } + "} + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + project.read_with(cx, |project, _| { + project.languages().add(language::rust_lang()) + }); + + let (workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = cx.read(|cx| workspace.read(cx).workspace().clone()); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + assert_single_caret_at_row(&editor, 0, cx); + let outline_view = open_outline_view(&workspace, cx); + let match_ids = |outline_view: &Entity>, + cx: &mut VisualTestContext| { + outline_view.read_with(cx, |outline_view, _| { + let delegate = &outline_view.delegate; + let selected_match = &delegate.matches[delegate.selected_match_index]; + let scored_ids = delegate + .matches + .iter() + .filter(|m| m.score > 0.0) + .map(|m| m.candidate_id) + .collect::>(); + ( + selected_match.candidate_id, + *scored_ids.first().unwrap(), + *scored_ids.last().unwrap(), + scored_ids.len(), + ) + }) + }; + + outline_view + .update_in(cx, |outline_view, window, cx| { + outline_view + .delegate + .update_matches("f".to_string(), window, cx) + }) + .await; + let (selected_id, first_scored_id, last_scored_id, scored_match_count) = + match_ids(&outline_view, cx); + + assert!( + scored_match_count > 1, + "Expected multiple scored matches for `f` in outline filtering" + ); + assert_eq!( + selected_id, first_scored_id, + "Filtered query should pick the first scored match when scores tie" + ); + assert_ne!( + selected_id, last_scored_id, + "Selection should not default to the last scored match" + ); + + set_single_caret_at_row(&editor, 12, cx); + outline_view + .update_in(cx, |outline_view, window, cx| { + outline_view + .delegate + .update_matches("f".to_string(), window, cx) + }) + .await; + let (selected_id, first_scored_id, last_scored_id, scored_match_count) = + match_ids(&outline_view, cx); + + assert!( + scored_match_count > 1, + "Expected multiple scored matches for `f` in outline filtering" + ); + assert_eq!( + selected_id, first_scored_id, + "Filtered selection should stay score-ordered and not switch based on cursor proximity" + ); + assert_ne!( + selected_id, last_scored_id, + "Selection should not default to the last scored match" + ); + } + fn open_outline_view( workspace: &Entity, cx: &mut VisualTestContext, @@ -634,6 +875,18 @@ mod tests { }) } + fn set_single_caret_at_row( + editor: &Entity, + buffer_row: u32, + cx: &mut VisualTestContext, + ) { + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([rope::Point::new(buffer_row, 0)..rope::Point::new(buffer_row, 0)]) + }); + }); + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 445f63fa1cdc38cb358cf033cc49f404aa6e6d94..ec85fc14a2eefe280afd0d44ed92b4b8502f460c 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -1143,7 +1143,7 @@ impl OutlinePanel { .excerpts_for_buffer(buffer.read(cx).remote_id(), cx) }) .and_then(|excerpts| { - let (excerpt_id, excerpt_range) = excerpts.first()?; + let (excerpt_id, _, excerpt_range) = excerpts.first()?; multi_buffer_snapshot .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start) }) 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/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index 7053fe89e7fdc6ece9ad50fdd8facaf31dba3086..1db29b0f53d9e7b185e6c3cd3029ed2e6077753e 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -31,8 +31,6 @@ pub struct PlatformTitleBar { children: SmallVec<[AnyElement; 2]>, should_move: bool, system_window_tabs: Entity, - workspace_sidebar_open: bool, - sidebar_has_notifications: bool, } impl PlatformTitleBar { @@ -46,8 +44,6 @@ impl PlatformTitleBar { children: SmallVec::new(), should_move: false, system_window_tabs, - workspace_sidebar_open: false, - sidebar_has_notifications: false, } } @@ -74,28 +70,6 @@ impl PlatformTitleBar { SystemWindowTabs::init(cx); } - pub fn is_workspace_sidebar_open(&self) -> bool { - self.workspace_sidebar_open - } - - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); - } - - pub fn sidebar_has_notifications(&self) -> bool { - self.sidebar_has_notifications - } - - pub fn set_sidebar_has_notifications( - &mut self, - has_notifications: bool, - cx: &mut Context, - ) { - self.sidebar_has_notifications = has_notifications; - cx.notify(); - } - pub fn is_multi_workspace_enabled(cx: &App) -> bool { cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai } @@ -110,9 +84,6 @@ impl Render for PlatformTitleBar { let close_action = Box::new(workspace::CloseWindow); let children = mem::take(&mut self.children); - let is_multiworkspace_sidebar_open = - PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open(); - let title_bar = h_flex() .window_control_area(WindowControlArea::Drag) .w_full() @@ -161,9 +132,7 @@ impl Render for PlatformTitleBar { .map(|this| { if window.is_fullscreen() { this.pl_2() - } else if self.platform_style == PlatformStyle::Mac - && !is_multiworkspace_sidebar_open - { + } else if self.platform_style == PlatformStyle::Mac { this.pl(px(TRAFFIC_LIGHT_PADDING)) } else { this.pl_2() @@ -175,10 +144,9 @@ impl Render for PlatformTitleBar { .when(!(tiling.top || tiling.right), |el| { el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING) }) - .when( - !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open, - |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING), - ) + .when(!(tiling.top || tiling.left), |el| { + el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING) + }) // this border is to avoid a transparent gap in the rounded corners .mt(px(-1.)) .mb(px(-1.)) diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index cbcd5481ee3c48655fc78e17d5cf65d2ec978a09..dfcc8faf64a7e66cce7b9f07f2daa12eae984fa5 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -31,7 +31,6 @@ test-support = [ "worktree/test-support", "gpui/test-support", "dap/test-support", - "dap_adapters/test-support", ] [dependencies] @@ -105,12 +104,10 @@ tracing.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } encoding_rs.workspace = true -db = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } context_server = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } dap = { workspace = true, features = ["test-support"] } -dap_adapters = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } git2.workspace = true gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 155badc4ac7da22921b121428cc34a0d46f5b982..b0a7e965f093afead16e2e9f2b5f7df44298a314 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -11,14 +11,14 @@ use http_client::{AsyncBody, HttpClient}; use serde::Deserialize; use settings::Settings as _; -use crate::DisableAiSettings; +use crate::{AgentId, DisableAiSettings}; const REGISTRY_URL: &str = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; const REFRESH_THROTTLE_DURATION: Duration = Duration::from_secs(60 * 60); #[derive(Clone, Debug)] pub struct RegistryAgentMetadata { - pub id: SharedString, + pub id: AgentId, pub name: SharedString, pub description: SharedString, pub version: SharedString, @@ -55,7 +55,7 @@ impl RegistryAgent { } } - pub fn id(&self) -> &SharedString { + pub fn id(&self) -> &AgentId { &self.metadata().id } @@ -147,12 +147,28 @@ impl AgentRegistryStore { .map(|store| store.0.clone()) } + #[cfg(any(test, feature = "test-support"))] + pub fn init_test_global(cx: &mut App, agents: Vec) -> Entity { + let fs: Arc = fs::FakeFs::new(cx.background_executor().clone()); + let store = cx.new(|_cx| Self { + fs, + http_client: http_client::FakeHttpClient::with_404_response(), + agents, + is_fetching: false, + fetch_error: None, + pending_refresh: None, + last_refresh: None, + }); + cx.set_global(GlobalAgentRegistryStore(store.clone())); + store + } + pub fn agents(&self) -> &[RegistryAgent] { &self.agents } - pub fn agent(&self, id: &str) -> Option<&RegistryAgent> { - self.agents.iter().find(|agent| agent.id().as_ref() == id) + pub fn agent(&self, id: &AgentId) -> Option<&RegistryAgent> { + self.agents.iter().find(|agent| agent.id() == id) } pub fn is_fetching(&self) -> bool { @@ -348,7 +364,7 @@ async fn build_registry_agents( .await?; let metadata = RegistryAgentMetadata { - id: entry.id.into(), + id: AgentId::new(entry.id), name: entry.name.into(), description: entry.description.into(), version: entry.version.into(), diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index b1dbefa15a3dcaf64c36d027d68060d18f533def..d5acacb912d085121c4c370046c9c7bd734c817c 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -61,28 +61,43 @@ impl std::fmt::Debug for AgentServerCommand { } } -#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ExternalAgentServerName(pub SharedString); +#[derive( + Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(transparent)] +pub struct AgentId(pub SharedString); + +impl AgentId { + pub fn new(id: impl Into) -> Self { + AgentId(id.into()) + } +} -impl std::fmt::Display for ExternalAgentServerName { +impl std::fmt::Display for AgentId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } -impl From<&'static str> for ExternalAgentServerName { +impl From<&'static str> for AgentId { fn from(value: &'static str) -> Self { - ExternalAgentServerName(value.into()) + AgentId(value.into()) } } -impl From for SharedString { - fn from(value: ExternalAgentServerName) -> Self { +impl From for SharedString { + fn from(value: AgentId) -> Self { value.0 } } -impl std::borrow::Borrow for ExternalAgentServerName { +impl AsRef for AgentId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::borrow::Borrow for AgentId { fn borrow(&self) -> &str { &self.0 } @@ -100,7 +115,6 @@ pub trait ExternalAgentServer { fn get_command( &mut self, extra_env: HashMap, - status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task>; @@ -164,7 +178,7 @@ impl ExternalAgentEntry { pub struct AgentServerStore { state: AgentServerStoreState, - pub external_agents: HashMap, + pub external_agents: HashMap, } pub struct AgentServersUpdated; @@ -229,7 +243,7 @@ impl AgentServerStore { .as_ref() .map(|path| SharedString::from(path.clone())); let icon = icon_path; - let agent_server_name = ExternalAgentServerName(agent_name.clone().into()); + let agent_server_name = AgentId(agent_name.clone().into()); self.external_agents .entry(agent_server_name.clone()) .and_modify(|entry| { @@ -243,7 +257,6 @@ impl AgentServerStore { project_id: *project_id, upstream_client: upstream_client.clone(), name: agent_server_name.clone(), - status_tx: None, new_version_available_tx: None, }) as Box, @@ -287,13 +300,13 @@ impl AgentServerStore { cx.emit(AgentServersUpdated); } - pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option { + pub fn agent_icon(&self, name: &AgentId) -> Option { self.external_agents .get(name) .and_then(|entry| entry.icon.clone()) } - pub fn agent_source(&self, name: &ExternalAgentServerName) -> Option { + pub fn agent_source(&self, name: &AgentId) -> Option { self.external_agents.get(name).map(|entry| entry.source) } } @@ -339,7 +352,7 @@ pub fn resolve_extension_icon_path( } impl AgentServerStore { - pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option { + pub fn agent_display_name(&self, name: &AgentId) -> Option { self.external_agents .get(name) .and_then(|entry| entry.display_name.clone()) @@ -347,7 +360,6 @@ impl AgentServerStore { pub fn init_remote(session: &AnyProtoClient) { session.add_entity_message_handler(Self::handle_external_agents_updated); - session.add_entity_message_handler(Self::handle_loading_status_updated); session.add_entity_message_handler(Self::handle_new_version_available); } @@ -427,7 +439,7 @@ impl AgentServerStore { // Insert extension agents before custom/registry so registry entries override extensions. for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() { - let name = ExternalAgentServerName(agent_name.clone().into()); + let name = AgentId(agent_name.clone().into()); let mut env = env.clone(); if let Some(settings_env) = new_settings @@ -466,7 +478,7 @@ impl AgentServerStore { for (name, settings) in new_settings.iter() { match settings { CustomAgentServerSettings::Custom { command, .. } => { - let agent_name = ExternalAgentServerName(name.clone().into()); + let agent_name = AgentId(name.clone().into()); self.external_agents.insert( agent_name.clone(), ExternalAgentEntry::new( @@ -488,7 +500,7 @@ impl AgentServerStore { continue; }; - let agent_name = ExternalAgentServerName(name.clone().into()); + let agent_name = AgentId(name.clone().into()); match agent { RegistryAgent::Binary(agent) => { if !agent.supports_current_platform { @@ -653,7 +665,7 @@ impl AgentServerStore { pub fn get_external_agent( &mut self, - name: &ExternalAgentServerName, + name: &AgentId, ) -> Option<&mut (dyn ExternalAgentServer + 'static)> { self.external_agents .get_mut(name) @@ -671,7 +683,7 @@ impl AgentServerStore { } } - pub fn external_agents(&self) -> impl Iterator { + pub fn external_agents(&self) -> impl Iterator { self.external_agents.keys() } @@ -695,57 +707,38 @@ impl AgentServerStore { .get_mut(&*envelope.payload.name) .map(|entry| entry.server.as_mut()) .with_context(|| format!("agent `{}` not found", envelope.payload.name))?; - let (status_tx, new_version_available_tx) = downstream_client - .clone() - .map(|(project_id, downstream_client)| { - let (status_tx, mut status_rx) = watch::channel(SharedString::from("")); - let (new_version_available_tx, mut new_version_available_rx) = - watch::channel(None); - cx.spawn({ - let downstream_client = downstream_client.clone(); - let name = envelope.payload.name.clone(); - async move |_, _| { - while let Some(status) = status_rx.recv().await.ok() { - downstream_client.send( - proto::ExternalAgentLoadingStatusUpdated { - project_id, - name: name.clone(), - status: status.to_string(), - }, - )?; - } - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - cx.spawn({ - let name = envelope.payload.name.clone(); - async move |_, _| { - if let Some(version) = - new_version_available_rx.recv().await.ok().flatten() - { - downstream_client.send( - proto::NewExternalAgentVersionAvailable { - project_id, - name: name.clone(), - version, - }, - )?; + let new_version_available_tx = + downstream_client + .clone() + .map(|(project_id, downstream_client)| { + let (new_version_available_tx, mut new_version_available_rx) = + watch::channel(None); + cx.spawn({ + let name = envelope.payload.name.clone(); + async move |_, _| { + if let Some(version) = + new_version_available_rx.recv().await.ok().flatten() + { + downstream_client.send( + proto::NewExternalAgentVersionAvailable { + project_id, + name: name.clone(), + version, + }, + )?; + } + anyhow::Ok(()) } - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - (status_tx, new_version_available_tx) - }) - .unzip(); + }) + .detach_and_log_err(cx); + new_version_available_tx + }); let mut extra_env = HashMap::default(); if no_browser { extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned()); } anyhow::Ok(agent.get_command( extra_env, - status_tx, new_version_available_tx, &mut cx.to_async(), )) @@ -782,13 +775,11 @@ impl AgentServerStore { }; let mut previous_entries = std::mem::take(&mut this.external_agents); - let mut status_txs = HashMap::default(); let mut new_version_available_txs = HashMap::default(); let mut metadata = HashMap::default(); for (name, mut entry) in previous_entries.drain() { if let Some(agent) = entry.server.downcast_mut::() { - status_txs.insert(name.clone(), agent.status_tx.take()); new_version_available_txs .insert(name.clone(), agent.new_version_available_tx.take()); } @@ -801,12 +792,12 @@ impl AgentServerStore { .names .into_iter() .map(|name| { - let agent_name = ExternalAgentServerName(name.into()); + let agent_id = AgentId(name.into()); let (icon, display_name, source) = metadata - .remove(&agent_name) + .remove(&agent_id) .or_else(|| { AgentRegistryStore::try_global(cx) - .and_then(|store| store.read(cx).agent(&agent_name.0)) + .and_then(|store| store.read(cx).agent(&agent_id)) .map(|s| { ( s.icon_path().cloned(), @@ -819,14 +810,13 @@ impl AgentServerStore { let agent = RemoteExternalAgentServer { project_id: *project_id, upstream_client: upstream_client.clone(), - name: agent_name.clone(), - status_tx: status_txs.remove(&agent_name).flatten(), + name: agent_id.clone(), new_version_available_tx: new_version_available_txs - .remove(&agent_name) + .remove(&agent_id) .flatten(), }; ( - agent_name, + agent_id, ExternalAgentEntry::new( Box::new(agent) as Box, source, @@ -884,22 +874,6 @@ impl AgentServerStore { }) } - async fn handle_loading_status_updated( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result<()> { - this.update(&mut cx, |this, _| { - if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) - && let Some(agent) = agent.server.downcast_mut::() - && let Some(status_tx) = &mut agent.status_tx - { - status_tx.send(envelope.payload.status.into()).ok(); - } - }); - Ok(()) - } - async fn handle_new_version_available( this: Entity, envelope: TypedEnvelope, @@ -918,10 +892,7 @@ impl AgentServerStore { Ok(()) } - pub fn get_extension_id_for_agent( - &mut self, - name: &ExternalAgentServerName, - ) -> Option> { + pub fn get_extension_id_for_agent(&mut self, name: &AgentId) -> Option> { self.external_agents.get_mut(name).and_then(|entry| { entry .server @@ -935,8 +906,7 @@ impl AgentServerStore { struct RemoteExternalAgentServer { project_id: u64, upstream_client: Entity, - name: ExternalAgentServerName, - status_tx: Option>, + name: AgentId, new_version_available_tx: Option>>, } @@ -944,14 +914,12 @@ impl ExternalAgentServer for RemoteExternalAgentServer { fn get_command( &mut self, extra_env: HashMap, - status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { let project_id = self.project_id; let name = self.name.to_string(); let upstream_client = self.upstream_client.downgrade(); - self.status_tx = status_tx; self.new_version_available_tx = new_version_available_tx; cx.spawn(async move |cx| { let mut response = upstream_client @@ -1005,7 +973,6 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1205,7 +1172,6 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1386,7 +1352,6 @@ impl ExternalAgentServer for LocalRegistryNpxAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1453,7 +1418,6 @@ impl ExternalAgentServer for LocalCustomAgent { fn get_command( &mut self, extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, ) -> Task> { @@ -1482,9 +1446,9 @@ impl ExternalAgentServer for LocalCustomAgent { } } -pub const GEMINI_NAME: &str = "gemini"; -pub const CLAUDE_AGENT_NAME: &str = "claude-acp"; -pub const CODEX_NAME: &str = "codex-acp"; +pub const GEMINI_ID: &str = "gemini"; +pub const CLAUDE_AGENT_ID: &str = "claude-acp"; +pub const CODEX_ID: &str = "codex-acp"; #[derive(Default, Clone, JsonSchema, Debug, PartialEq, RegisterSetting)] pub struct AllAgentServersSettings(pub HashMap); diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index b9d1105ad02415699fa6a9bd1be8ec1f9c71271a..d2f05a119a1883a1ec744b40d4cdb467074d3c83 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -527,7 +527,10 @@ impl LocalBufferStore { let new_file = if let Some(entry) = snapshot_entry { File { disk_state: match entry.mtime { - Some(mtime) => DiskState::Present { mtime }, + Some(mtime) => DiskState::Present { + mtime, + size: entry.size, + }, None => old_file.disk_state, }, is_local: true, diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index a6c3f52b17a4a6cf241aa49329f3f14f0b5cefbc..87e11cfd97a2f63bba3cefca671e4413deb6765f 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -2187,21 +2187,27 @@ impl Session { self.capabilities.supports_restart_request.unwrap_or(false) && !self.is_terminated(); self.restart_task = Some(cx.spawn(async move |this, cx| { - let _ = this.update(cx, |session, cx| { + this.update(cx, |session, cx| { if supports_dap_restart { - session - .request( - RestartCommand { - raw: args.unwrap_or(Value::Null), - }, - Self::fallback_to_manual_restart, - cx, - ) - .detach(); + session.request( + RestartCommand { + raw: args.unwrap_or(Value::Null), + }, + Self::fallback_to_manual_restart, + cx, + ) } else { cx.emit(SessionStateEvent::Restart); + Task::ready(None) } - }); + }) + .unwrap_or_else(|_| Task::ready(None)) + .await; + + this.update(cx, |session, _cx| { + session.restart_task = None; + }) + .ok(); })); } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index eed16761974876247df2e5936f9db9fbdd8fafcc..e9330014c3f066705ac3ea1e54f5e498c5d22348 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -293,6 +293,7 @@ pub struct RepositorySnapshot { pub remote_origin_url: Option, pub remote_upstream_url: Option, pub stash_entries: GitStash, + pub linked_worktrees: Arc<[GitWorktree]>, } type JobId = u64; @@ -429,6 +430,7 @@ pub enum RepositoryEvent { StatusesChanged, BranchChanged, StashEntriesChanged, + GitWorktreeListChanged, PendingOpsChanged { pending_ops: SumTree }, GraphEvent((LogSource, LogOrder), GitGraphEvent), } @@ -578,6 +580,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_git_clone); client.add_entity_request_handler(Self::handle_get_worktrees); client.add_entity_request_handler(Self::handle_create_worktree); + client.add_entity_request_handler(Self::handle_remove_worktree); + client.add_entity_request_handler(Self::handle_rename_worktree); } pub fn is_local(&self) -> bool { @@ -2384,6 +2388,44 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_remove_worktree( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let path = PathBuf::from(envelope.payload.path); + let force = envelope.payload.force; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.remove_worktree(path, force) + }) + .await??; + + Ok(proto::Ack {}) + } + + async fn handle_rename_worktree( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let old_path = PathBuf::from(envelope.payload.old_path); + let new_path = PathBuf::from(envelope.payload.new_path); + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.rename_worktree(old_path, new_path) + }) + .await??; + + Ok(proto::Ack {}) + } + async fn handle_get_branches( this: Entity, envelope: TypedEnvelope, @@ -3535,6 +3577,7 @@ impl RepositorySnapshot { remote_origin_url: None, remote_upstream_url: None, stash_entries: Default::default(), + linked_worktrees: Arc::from([]), path_style, } } @@ -3573,6 +3616,11 @@ impl RepositorySnapshot { original_repo_abs_path: Some( self.original_repo_abs_path.to_string_lossy().into_owned(), ), + linked_worktrees: self + .linked_worktrees + .iter() + .map(worktree_to_proto) + .collect(), } } @@ -3649,9 +3697,18 @@ impl RepositorySnapshot { original_repo_abs_path: Some( self.original_repo_abs_path.to_string_lossy().into_owned(), ), + linked_worktrees: self + .linked_worktrees + .iter() + .map(worktree_to_proto) + .collect(), } } + pub fn linked_worktrees(&self) -> &[GitWorktree] { + &self.linked_worktrees + } + pub fn status(&self) -> impl Iterator + '_ { self.statuses_by_path.iter().cloned() } @@ -5731,6 +5788,7 @@ impl Repository { } pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> { + let id = self.id; self.send_job( Some(format!("git worktree remove: {}", path.display()).into()), move |repo, _cx| async move { @@ -5738,10 +5796,47 @@ impl Repository { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.remove_worktree(path, force).await } - RepositoryState::Remote(_) => { - anyhow::bail!( - "Removing worktrees on remote repositories is not yet supported" - ) + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRemoveWorktree { + project_id: project_id.0, + repository_id: id.to_proto(), + path: path.to_string_lossy().to_string(), + force, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn rename_worktree( + &mut self, + old_path: PathBuf, + new_path: PathBuf, + ) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some(format!("git worktree move: {}", old_path.display()).into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.rename_worktree(old_path, new_path).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRenameWorktree { + project_id: project_id.0, + repository_id: id.to_proto(), + old_path: old_path.to_string_lossy().to_string(), + new_path: new_path.to_string_lossy().to_string(), + }) + .await?; + + Ok(()) } } }, @@ -6067,6 +6162,15 @@ impl Repository { cx.emit(RepositoryEvent::StashEntriesChanged) } self.snapshot.stash_entries = new_stash_entries; + let new_linked_worktrees: Arc<[GitWorktree]> = update + .linked_worktrees + .iter() + .map(proto_to_worktree) + .collect(); + if *self.snapshot.linked_worktrees != *new_linked_worktrees { + cx.emit(RepositoryEvent::GitWorktreeListChanged); + } + self.snapshot.linked_worktrees = new_linked_worktrees; self.snapshot.remote_upstream_url = update.remote_upstream_url; self.snapshot.remote_origin_url = update.remote_origin_url; @@ -6823,14 +6927,20 @@ async fn compute_snapshot( })) .boxed() }; - let (statuses, diff_stats) = futures::future::try_join( + let (statuses, diff_stats, all_worktrees) = futures::future::try_join3( backend.status(&[RepoPath::from_rel_path( &RelPath::new(".".as_ref(), PathStyle::local()).unwrap(), )]), diff_stat_future, + backend.worktrees(), ) .await?; + let linked_worktrees: Arc<[GitWorktree]> = all_worktrees + .into_iter() + .filter(|wt| wt.path != *work_directory_abs_path) + .collect(); + let diff_stat_map: HashMap<&RepoPath, DiffStat> = diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect(); let stash_entries = backend.stash_entries().await?; @@ -6860,6 +6970,10 @@ async fn compute_snapshot( events.push(RepositoryEvent::BranchChanged); } + if *linked_worktrees != *prev_snapshot.linked_worktrees { + events.push(RepositoryEvent::GitWorktreeListChanged); + } + let remote_origin_url = backend.remote_url("origin").await; let remote_upstream_url = backend.remote_url("upstream").await; @@ -6876,6 +6990,7 @@ async fn compute_snapshot( remote_origin_url, remote_upstream_url, stash_entries, + linked_worktrees, }; Ok((snapshot, events)) diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 654fb0344db4b7dc581234a5b446e8ac4d2b10ab..0ba9787d2e4144cb529756b15fc05ff72dab83c8 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -808,7 +808,10 @@ impl LocalImageStore { let new_file = if let Some(entry) = snapshot_entry { worktree::File { disk_state: match entry.mtime { - Some(mtime) => DiskState::Present { mtime }, + Some(mtime) => DiskState::Present { + mtime, + size: entry.size, + }, None => old_file.disk_state, }, is_local: true, diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 67edd6c13ca5a850a99f28dee849718d9e7ec9ae..ebc5ea038e0726384bc7d677f6fc6aa8ce87661e 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -4857,9 +4857,14 @@ impl LspCommand for GetFoldingRanges { self, message: proto::GetFoldingRangesResponse, _: Entity, - _: Entity, - _: AsyncApp, + buffer: Entity, + mut cx: AsyncApp, ) -> Result { + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(deserialize_version(&message.version)) + }) + .await?; message .ranges .into_iter() diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 97aa03cec730c61acfb129579c77f6a5b560ee32..25a614052789c85b8c418086e803b9b5cb9e6fae 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 { @@ -4429,7 +4426,7 @@ impl LspStore { cx: &mut Context, ) { match event { - language::BufferEvent::Edited => { + language::BufferEvent::Edited { .. } => { self.on_buffer_edited(buffer, cx); } @@ -6646,6 +6643,7 @@ impl LspStore { completions: Rc>>, completion_index: usize, push_to_history: bool, + all_commit_ranges: Vec>, cx: &mut Context, ) -> Task>> { if let Some((client, project_id)) = self.upstream_client() { @@ -6662,6 +6660,11 @@ impl LspStore { new_text: completion.new_text, source: completion.source, })), + all_commit_ranges: all_commit_ranges + .iter() + .cloned() + .map(language::proto::serialize_anchor_range) + .collect(), } }; @@ -6755,12 +6758,15 @@ impl LspStore { let has_overlap = if is_file_start_auto_import { false } else { - let start_within = primary.start.cmp(&range.start, buffer).is_le() - && primary.end.cmp(&range.start, buffer).is_ge(); - let end_within = range.start.cmp(&primary.end, buffer).is_le() - && range.end.cmp(&primary.end, buffer).is_ge(); - let result = start_within || end_within; - result + all_commit_ranges.iter().any(|commit_range| { + let start_within = + commit_range.start.cmp(&range.start, buffer).is_le() + && commit_range.end.cmp(&range.start, buffer).is_ge(); + let end_within = + range.start.cmp(&commit_range.end, buffer).is_le() + && range.end.cmp(&commit_range.end, buffer).is_ge(); + start_within || end_within + }) }; //Skip additional edits which overlap with the primary completion edit @@ -10421,13 +10427,19 @@ impl LspStore { envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - let (buffer, completion) = this.update(&mut cx, |this, cx| { + let (buffer, completion, all_commit_ranges) = this.update(&mut cx, |this, cx| { let buffer_id = BufferId::new(envelope.payload.buffer_id)?; let buffer = this.buffer_store.read(cx).get_existing(buffer_id)?; let completion = Self::deserialize_completion( envelope.payload.completion.context("invalid completion")?, )?; - anyhow::Ok((buffer, completion)) + let all_commit_ranges = envelope + .payload + .all_commit_ranges + .into_iter() + .map(language::proto::deserialize_anchor_range) + .collect::, _>>()?; + anyhow::Ok((buffer, completion, all_commit_ranges)) })?; let apply_additional_edits = this.update(&mut cx, |this, cx| { @@ -10447,6 +10459,7 @@ impl LspStore { }]))), 0, false, + all_commit_ranges, cx, ) }); diff --git a/crates/project/src/lsp_store/semantic_tokens.rs b/crates/project/src/lsp_store/semantic_tokens.rs index cfcd74ad7de7baaf60833cd9db1085d60307c20e..7865e8f20ca0e4dbc9d06c2ffd808fe4090634ed 100644 --- a/crates/project/src/lsp_store/semantic_tokens.rs +++ b/crates/project/src/lsp_store/semantic_tokens.rs @@ -585,8 +585,7 @@ async fn raw_to_buffer_semantic_tokens( } Some(BufferSemanticToken { - range: buffer_snapshot.anchor_before(start) - ..buffer_snapshot.anchor_after(end), + range: buffer_snapshot.anchor_range_around(start..end), token_type: token.token_type, token_modifiers: token.token_modifiers, }) @@ -611,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/src/project.rs b/crates/project/src/project.rs index 756f095511a9688678df013458710e69d720c52e..d26f60350b5656b1730993ff76e07c31139c41da 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -43,9 +43,7 @@ use crate::{ worktree_store::WorktreeIdCounter, }; pub use agent_registry_store::{AgentRegistryStore, RegistryAgent}; -pub use agent_server_store::{ - AgentServerStore, AgentServersUpdated, ExternalAgentServerName, ExternalAgentSource, -}; +pub use agent_server_store::{AgentId, AgentServerStore, AgentServersUpdated, ExternalAgentSource}; pub use git_store::{ ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate, git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal}, @@ -3636,11 +3634,11 @@ impl Project { event: &BufferEvent, cx: &mut Context, ) -> Option<()> { - if matches!(event, BufferEvent::Edited | BufferEvent::Reloaded) { + if matches!(event, BufferEvent::Edited { .. } | BufferEvent::Reloaded) { self.request_buffer_diff_recalculation(&buffer, cx); } - if matches!(event, BufferEvent::Edited) { + if matches!(event, BufferEvent::Edited { .. }) { cx.emit(Event::BufferEdited); } diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs index f3c398a619a81ee81146de16f8e58b1093569e8a..38da460023ebb6c4d24dd02f21928db7e3cd54e3 100644 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ b/crates/project/tests/integration/ext_agent_tests.rs @@ -10,7 +10,6 @@ impl ExternalAgentServer for NoopExternalAgent { fn get_command( &mut self, _extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, ) -> Task> { @@ -28,7 +27,7 @@ impl ExternalAgentServer for NoopExternalAgent { #[test] fn external_agent_server_name_display() { - let name = ExternalAgentServerName(SharedString::from("Ext: Tool")); + let name = AgentId(SharedString::from("Ext: Tool")); let mut s = String::new(); write!(&mut s, "{name}").unwrap(); assert_eq!(s, "Ext: Tool"); @@ -40,7 +39,7 @@ fn sync_extension_agents_removes_previous_extension_entries() { // Seed with a couple of agents that will be replaced by extensions store.external_agents.insert( - ExternalAgentServerName(SharedString::from("foo-agent")), + AgentId(SharedString::from("foo-agent")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, @@ -49,7 +48,7 @@ fn sync_extension_agents_removes_previous_extension_entries() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("bar-agent")), + AgentId(SharedString::from("bar-agent")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, @@ -58,7 +57,7 @@ fn sync_extension_agents_removes_previous_extension_entries() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("custom")), + AgentId(SharedString::from("custom")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs index eff41a99cab878336206f232450f3c1b490d1fc8..1824fbec0d172e2bac626e726d305883818d51ad 100644 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ b/crates/project/tests/integration/extension_agent_tests.rs @@ -9,14 +9,14 @@ use std::{any::Any, path::PathBuf, sync::Arc}; #[test] fn extension_agent_constructs_proper_display_names() { // Verify the display name format for extension-provided agents - let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent")); + let name1 = AgentId(SharedString::from("Extension: Agent")); assert!(name1.0.contains(": ")); - let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent")); + let name2 = AgentId(SharedString::from("MyExt: MyAgent")); assert_eq!(name2.0, "MyExt: MyAgent"); // Non-extension agents shouldn't have the separator - let custom = ExternalAgentServerName(SharedString::from("custom")); + let custom = AgentId(SharedString::from("custom")); assert!(!custom.0.contains(": ")); } @@ -26,7 +26,6 @@ impl ExternalAgentServer for NoopExternalAgent { fn get_command( &mut self, _extra_env: HashMap, - _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, ) -> Task> { @@ -48,7 +47,7 @@ fn sync_removes_only_extension_provided_agents() { // Seed with extension agents (contain ": ") and custom agents (don't contain ": ") store.external_agents.insert( - ExternalAgentServerName(SharedString::from("Ext1: Agent1")), + AgentId(SharedString::from("Ext1: Agent1")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Extension, @@ -57,7 +56,7 @@ fn sync_removes_only_extension_provided_agents() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("Ext2: Agent2")), + AgentId(SharedString::from("Ext2: Agent2")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Extension, @@ -66,7 +65,7 @@ fn sync_removes_only_extension_provided_agents() { ), ); store.external_agents.insert( - ExternalAgentServerName(SharedString::from("custom-agent")), + AgentId(SharedString::from("custom-agent")), ExternalAgentEntry::new( Box::new(NoopExternalAgent) as Box, ExternalAgentSource::Custom, @@ -85,7 +84,7 @@ fn sync_removes_only_extension_provided_agents() { assert!( store .external_agents - .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent"))) + .contains_key(&AgentId(SharedString::from("custom-agent"))) ); } @@ -118,7 +117,7 @@ fn archive_launcher_constructs_with_all_fields() { }; // Verify display name construction - let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent")); + let expected_name = AgentId(SharedString::from("GitHub Agent")); assert_eq!(expected_name.0, "GitHub Agent"); } @@ -171,7 +170,7 @@ async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAp fn sync_extension_agents_registers_archive_launcher() { use extension::AgentServerManifestEntry; - let expected_name = ExternalAgentServerName(SharedString::from("Release Agent")); + let expected_name = AgentId(SharedString::from("Release Agent")); assert_eq!(expected_name.0, "Release Agent"); // Verify the manifest entry structure for archive-based installation diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index d86b969e61ed173ee314cde6f584f2dbab6859f9..0080236758214b284b74abc2f1831b9f9978241e 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -5552,7 +5552,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); @@ -5581,9 +5581,9 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged, - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, ], ); events.lock().clear(); @@ -5598,7 +5598,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); @@ -5638,7 +5638,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( mem::take(&mut *events.lock()), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); @@ -5653,7 +5653,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { assert_eq!( *events.lock(), &[ - language::BufferEvent::Edited, + language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); @@ -5687,6 +5687,75 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { cx.update(|cx| assert!(buffer3.read(cx).is_dirty())); } +#[gpui::test] +async fn test_dirty_buffer_reloads_after_undo(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "file.txt": "version 1", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let buffer = project + .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file.txt"), cx)) + .await + .unwrap(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), "version 1"); + assert!(!buffer.is_dirty()); + }); + + // User makes an edit, making the buffer dirty. + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "user edit: ")], None, cx); + }); + + buffer.read_with(cx, |buffer, _| { + assert!(buffer.is_dirty()); + assert_eq!(buffer.text(), "user edit: version 1"); + }); + + // External tool writes new content while buffer is dirty. + // file_updated() updates the File but suppresses ReloadNeeded. + fs.save( + path!("/dir/file.txt").as_ref(), + &"version 2 from external tool".into(), + Default::default(), + ) + .await + .unwrap(); + cx.executor().run_until_parked(); + + buffer.read_with(cx, |buffer, _| { + assert!(buffer.has_conflict()); + assert_eq!(buffer.text(), "user edit: version 1"); + }); + + // User undoes their edit. Buffer becomes clean, but disk has different + // content. did_edit() detects the dirty->clean transition and checks if + // disk changed while dirty. Since mtime differs from saved_mtime, it + // emits ReloadNeeded. + buffer.update(cx, |buffer, cx| { + buffer.undo(cx); + }); + cx.executor().run_until_parked(); + + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "version 2 from external tool", + "buffer should reload from disk after undo makes it clean" + ); + assert!(!buffer.is_dirty()); + }); +} + #[gpui::test] async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 5149c6f7834474439bd6119511bb294b560fe4de..88d85c75f9e6452a72eb4181a94a8bf6395ba754 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -54,7 +54,6 @@ criterion.workspace = true editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } -remote_connection = { workspace = true, features = ["test-support"] } serde_json.workspace = true tempfile.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index d647676834e9847ac697f1b51fc61bc1b2425adf..96e680c0d1648bd4cf337cbc55e321e3948c217a 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -2371,6 +2371,11 @@ impl ProjectPanel { } let answer = if !skip_prompt { let operation = if trash { "Trash" } else { "Delete" }; + let message_start = if trash { + "Do you want to trash" + } else { + "Are you sure you want to permanently delete" + }; let prompt = match file_paths.first() { Some((_, path)) if file_paths.len() == 1 => { let unsaved_warning = if dirty_buffers > 0 { @@ -2379,7 +2384,7 @@ impl ProjectPanel { "" }; - format!("{operation} {path}?{unsaved_warning}") + format!("{message_start} {path}?{unsaved_warning}") } _ => { const CUTOFF_POINT: usize = 10; @@ -2411,14 +2416,20 @@ impl ProjectPanel { }; format!( - "Do you want to {} the following {} files?\n{}{unsaved_warning}", - operation.to_lowercase(), + "{message_start} the following {} files?\n{}{unsaved_warning}", file_paths.len(), names.join("\n") ) } }; - Some(window.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"], cx)) + let detail = (!trash).then_some("This cannot be undone."); + Some(window.prompt( + PromptLevel::Info, + &prompt, + detail, + &[operation, "Cancel"], + cx, + )) } else { None }; @@ -3403,8 +3414,7 @@ impl ProjectPanel { _: &mut Window, cx: &mut Context, ) { - if let Some((worktree, entry)) = self.selected_sub_entry(cx) { - let path = worktree.read(cx).absolutize(&entry.path); + if let Some(path) = self.reveal_in_file_manager_path(cx) { self.project .update(cx, |project, cx| project.reveal_path(&path, cx)); } @@ -3761,6 +3771,20 @@ impl ProjectPanel { } Some((worktree, entry)) } + + fn reveal_in_file_manager_path(&self, cx: &App) -> Option { + if let Some((worktree, entry)) = self.selected_sub_entry(cx) { + return Some(worktree.read(cx).absolutize(&entry.path)); + } + + let root_entry_id = self.state.last_worktree_root_id?; + let project = self.project.read(cx); + let worktree = project.worktree_for_entry(root_entry_id, cx)?; + let worktree = worktree.read(cx); + let root_entry = worktree.entry_for_id(root_entry_id)?; + Some(worktree.absolutize(&root_entry.path)) + } + fn selected_entry_handle<'a>( &self, cx: &'a App, @@ -4415,16 +4439,24 @@ impl ProjectPanel { return; } + let workspace = self.workspace.clone(); if folded_selection_info.is_empty() { for (_, task) in move_tasks { - task.detach_and_log_err(cx); + let workspace = workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { + task.await.notify_workspace_async_err(workspace, &mut cx); + }) + .detach(); } } else { - cx.spawn_in(window, async move |project_panel, cx| { + cx.spawn_in(window, async move |project_panel, mut cx| { // Await all move tasks and collect successful results let mut move_results: Vec<(ProjectEntryId, Entry)> = Vec::new(); for (entry_id, task) in move_tasks { - if let Some(CreatedEntry::Included(new_entry)) = task.await.log_err() { + if let Some(CreatedEntry::Included(new_entry)) = task + .await + .notify_workspace_async_err(workspace.clone(), &mut cx) + { move_results.push((entry_id, new_entry)); } } @@ -6309,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(); @@ -6681,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( @@ -6845,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/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index af84a7f522a60abf2608bf1f3435b367d24f6bdc..720ac04fdd2a656a32668add23e7af021a71ef00 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -4412,6 +4412,90 @@ async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppCo ); } +#[gpui::test] +async fn test_dragging_same_named_files_preserves_one_source_on_conflict( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dir_a": { + "shared.txt": "from a" + }, + "dir_b": { + "shared.txt": "from b" + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + panel.update_in(cx, |panel, window, cx| { + let (root_entry_id, worktree_id, entry_a_id, entry_b_id) = { + let worktree = panel.project.read(cx).visible_worktrees(cx).next().unwrap(); + let worktree = worktree.read(cx); + let root_entry_id = worktree.root_entry().unwrap().id; + let worktree_id = worktree.id(); + let entry_a_id = worktree + .entry_for_path(rel_path("dir_a/shared.txt")) + .unwrap() + .id; + let entry_b_id = worktree + .entry_for_path(rel_path("dir_b/shared.txt")) + .unwrap() + .id; + (root_entry_id, worktree_id, entry_a_id, entry_b_id) + }; + + let drag = DraggedSelection { + active_selection: SelectedEntry { + worktree_id, + entry_id: entry_a_id, + }, + marked_selections: Arc::new([ + SelectedEntry { + worktree_id, + entry_id: entry_a_id, + }, + SelectedEntry { + worktree_id, + entry_id: entry_b_id, + }, + ]), + }; + + panel.drag_onto(&drag, root_entry_id, false, window, cx); + }); + cx.executor().run_until_parked(); + + let files = fs.files(); + assert!(files.contains(&PathBuf::from(path!("/root/shared.txt")))); + + let remaining_sources = [ + PathBuf::from(path!("/root/dir_a/shared.txt")), + PathBuf::from(path!("/root/dir_b/shared.txt")), + ] + .into_iter() + .filter(|path| files.contains(path)) + .count(); + + assert_eq!( + remaining_sources, 1, + "one conflicting source file should remain in place" + ); +} + #[gpui::test] async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -8586,6 +8670,55 @@ async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) { } } +#[gpui::test] +async fn test_reveal_in_file_manager_path_falls_back_to_worktree_root( + cx: &mut gpui::TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "content", + "dir": {}, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); + cx.run_until_parked(); + + select_path(&panel, "root/file.txt", cx); + let selected_reveal_path = panel + .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx)) + .expect("selected entry should produce a reveal path"); + assert!( + selected_reveal_path.ends_with(Path::new("file.txt")), + "Expected selected file path, got {:?}", + selected_reveal_path + ); + + panel.update(cx, |panel, _| { + panel.selection = None; + panel.marked_entries.clear(); + }); + let fallback_reveal_path = panel + .update(cx, |panel, cx| panel.reveal_in_file_manager_path(cx)) + .expect("project root should be used when selection is empty"); + assert!( + fallback_reveal_path.ends_with(Path::new("root")), + "Expected worktree root path, got {:?}", + fallback_reveal_path + ); +} + #[gpui::test] async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index 5b5b8b985cbc102cc451050403cff2e3699f612f..dfa4166f2077aea60aa87084af4918c92882f2df 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -7,7 +7,7 @@ publish.workspace = true license = "GPL-3.0-or-later" [features] -test-support = ["collections/test-support"] +test-support = [] [lints] workspace = true @@ -25,5 +25,3 @@ serde.workspace = true prost-build.workspace = true [dev-dependencies] -collections = { workspace = true, features = ["test-support"] } -typed-path = "0.11" diff --git a/crates/proto/proto/ai.proto b/crates/proto/proto/ai.proto index 428d971c536f6e830e0c056372d311dc7ed7028f..8db36153b5ef75218f0c007e113f1c2c06ded7eb 100644 --- a/crates/proto/proto/ai.proto +++ b/crates/proto/proto/ai.proto @@ -222,7 +222,7 @@ message ExternalExtensionAgentsUpdated { message ExternalAgentLoadingStatusUpdated { uint64 project_id = 1; string name = 2; - string status = 3; + reserved 3; } message NewExternalAgentVersionAvailable { diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 736abcdaa49f62d72582750a8a28ea785baee282..bb6b73ce3b89d51e9bf594c9e01254f5f0d579a4 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -126,6 +126,7 @@ message UpdateRepository { optional string remote_upstream_url = 14; optional string remote_origin_url = 15; optional string original_repo_abs_path = 16; + repeated Worktree linked_worktrees = 17; } message RemoveRepository { @@ -583,6 +584,20 @@ message GitCreateWorktree { optional string commit = 5; } +message GitRemoveWorktree { + uint64 project_id = 1; + uint64 repository_id = 2; + string path = 3; + bool force = 4; +} + +message GitRenameWorktree { + uint64 project_id = 1; + uint64 repository_id = 2; + string old_path = 3; + string new_path = 4; +} + message RunGitHook { enum GitHook { PRE_COMMIT = 0; diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 226373a111b6e29e4731edd638a5317dcd244273..813f9e9ec652a7b97281bea29f368b0dcf37d537 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -230,6 +230,7 @@ message ApplyCompletionAdditionalEdits { uint64 project_id = 1; uint64 buffer_id = 2; Completion completion = 3; + repeated AnchorRange all_commit_ranges = 4; } message ApplyCompletionAdditionalEditsResponse { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index c129b6eff26404b66b38439c29f0b83289b37172..1fd7dfb89b01c16c6099a0e79a9d320a788fd7e4 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -474,7 +474,9 @@ message Envelope { SpawnKernel spawn_kernel = 426; SpawnKernelResponse spawn_kernel_response = 427; - KillKernel kill_kernel = 428; // current max + KillKernel kill_kernel = 428; + GitRemoveWorktree git_remove_worktree = 431; + GitRenameWorktree git_rename_worktree = 432; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index dd0a77beb29345021563b21bafd261d02b87e1ab..88607abf6decdd167cf3594e56ad1eb6b79d3ac6 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -354,6 +354,8 @@ messages!( (GitGetWorktrees, Background), (GitWorktreesResponse, Background), (GitCreateWorktree, Background), + (GitRemoveWorktree, Background), + (GitRenameWorktree, Background), (ShareAgentThread, Foreground), (GetSharedAgentThread, Foreground), (GetSharedAgentThreadResponse, Foreground), @@ -557,6 +559,8 @@ request_messages!( (RemoteStarted, Ack), (GitGetWorktrees, GitWorktreesResponse), (GitCreateWorktree, Ack), + (GitRemoveWorktree, Ack), + (GitRenameWorktree, Ack), (TrustWorktrees, Ack), (RestrictWorktrees, Ack), (FindSearchCandidatesChunk, Ack), @@ -747,6 +751,8 @@ entity_messages!( NewExternalAgentVersionAvailable, GitGetWorktrees, GitCreateWorktree, + GitRemoveWorktree, + GitRenameWorktree, TrustWorktrees, RestrictWorktrees, FindSearchCandidatesChunk, diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 11daee79adc8099a8915b427394256eeed8b5e20..a2aa9f78a2a5edaf13a4f23f52f3695de636850f 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -59,7 +59,6 @@ indoc.workspace = true windows-registry = "0.6.0" [dev-dependencies] -dap.workspace = true editor = { workspace = true, features = ["test-support"] } extension.workspace = true fs.workspace = true 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/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 548e08eccb49c19551984e6acdd086d78927d614..c9720af2aba7f4a27adf8e40745bb05012c4dafd 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -935,7 +935,14 @@ impl PickerDelegate for RecentProjectsDelegate { } return; } else { - workspace.open_workspace_for_paths(false, paths, window, cx) + workspace + .open_workspace_for_paths(false, paths, window, cx) + .detach_and_prompt_err( + "Failed to open project", + window, + cx, + |_, _, _| None, + ); } } SerializedWorkspaceLocation::Remote(mut connection) => { @@ -964,14 +971,14 @@ impl PickerDelegate for RecentProjectsDelegate { ) .await }) + .detach_and_prompt_err( + "Failed to open project", + window, + cx, + |_, _, _| None, + ); } } - .detach_and_prompt_err( - "Failed to open project", - window, - cx, - |_, _, _| None, - ); }); cx.emit(DismissEvent); } @@ -1241,8 +1248,8 @@ impl PickerDelegate for RecentProjectsDelegate { let focus_handle = self.focus_handle.clone(); let popover_style = matches!(self.style, ProjectPickerStyle::Popover); let open_folder_section = matches!( - self.filtered_entries.get(self.selected_index)?, - ProjectPickerEntry::OpenFolder { .. } + self.filtered_entries.get(self.selected_index), + Some(ProjectPickerEntry::OpenFolder { .. }) ); if popover_style { diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index b5af1a110a5b0ebae6cb8e6e035791b564e15527..5275cdaa1526a670e817ff3b229d7e92b94bb309 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -10,7 +10,6 @@ use extension_host::ExtensionStore; use futures::{FutureExt as _, channel::oneshot, select}; use gpui::{AppContext, AsyncApp, PromptLevel, WindowHandle}; -use language::Point; use project::trusted_worktrees; use remote::{ DockerConnectionOptions, Interactive, RemoteConnection, RemoteConnectionOptions, @@ -458,7 +457,12 @@ pub fn navigate_to_positions( active_editor.update(cx, |editor, cx| { let row = row.saturating_sub(1); let col = path.column.unwrap_or(0).saturating_sub(1); - editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); + let Some(buffer) = editor.buffer().read(cx).as_singleton() else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let point = buffer_snapshot.point_from_external_input(row, col); + editor.go_to_singleton_buffer_point(point, window, cx); }); }) .ok(); diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index b094ff6c5bc5499e7ed1f3e6c9e0b9331b6bb7c2..4569492d4c73b6e8087cf8363db805a645e5314e 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -17,7 +17,6 @@ use gpui::{ EventEmitter, FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, WeakEntity, Window, canvas, }; -use language::Point; use log::{debug, info}; use open_path_prompt::OpenPathDelegate; use paths::{global_ssh_config_file, user_ssh_config_file}; @@ -390,7 +389,7 @@ impl ProjectPicker { ) -> Entity { let (tx, rx) = oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = open_path_prompt::OpenPathDelegate::new(tx, lister, false, cx); + let delegate = open_path_prompt::OpenPathDelegate::new(tx, lister, false, cx).show_hidden(); let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) @@ -519,11 +518,15 @@ impl ProjectPicker { active_editor.update(cx, |editor, cx| { let row = row.saturating_sub(1); let col = path.column.unwrap_or(0).saturating_sub(1); - editor.go_to_singleton_buffer_point( - Point::new(row, col), - window, - cx, - ); + let Some(buffer) = + editor.buffer().read(cx).as_singleton() + else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let point = + buffer_snapshot.point_from_external_input(row, col); + editor.go_to_singleton_buffer_point(point, window, cx); }); }) .ok(); @@ -2117,8 +2120,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/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index ee729a80eaa9eff56eee7f3bcb8fe6eaf31f0c41..36944261cded68b564df8093d5b7a7621a644c11 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -89,9 +89,7 @@ action_log.workspace = true agent = { workspace = true, features = ["test-support"] } client = { workspace = true, features = ["test-support"] } clock = { workspace = true, features = ["test-support"] } -dap = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } @@ -103,7 +101,6 @@ remote = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } -prompt_store.workspace = true unindent.workspace = true serde_json.workspace = true zlog.workspace = true diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 7f9953c8a4e746d9586b663330badb38149cfb64..0f1d1e3769c405abce5ebf55818f19e64afadc82 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2028,7 +2028,6 @@ async fn test_remote_external_agent_server( .get_command( HashMap::from_iter([("OTHER_VAR".into(), "other-val".into())]), None, - None, &mut cx.to_async(), ) }) diff --git a/crates/repl/Cargo.toml b/crates/repl/Cargo.toml index c2d6f745d9272651bd90bcdfdc689263958b8b09..4329b29ada504cf536337c94b14790acea73ea11 100644 --- a/crates/repl/Cargo.toml +++ b/crates/repl/Cargo.toml @@ -62,7 +62,6 @@ zed_actions.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } -env_logger.workspace = true gpui = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } indoc.workspace = true 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/reqwest_client/Cargo.toml b/crates/reqwest_client/Cargo.toml index 41fcd1f5d2f8ca1c78b0a2261a7c48566999e0de..105a3e7df81be5e125477968cf8e8751dfbb9e78 100644 --- a/crates/reqwest_client/Cargo.toml +++ b/crates/reqwest_client/Cargo.toml @@ -31,4 +31,3 @@ gpui_util.workspace = true http_client_tls.workspace = true [dev-dependencies] -gpui.workspace = true diff --git a/crates/rich_text/Cargo.toml b/crates/rich_text/Cargo.toml deleted file mode 100644 index 17bd8d2a4b8977b2bf0079b84dc8f27a9999974b..0000000000000000000000000000000000000000 --- a/crates/rich_text/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "rich_text" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/rich_text.rs" -doctest = false - -[features] -test-support = [ - "gpui/test-support", - "util/test-support", -] - -[dependencies] -futures.workspace = true -gpui.workspace = true -language.workspace = true -linkify.workspace = true -pulldown-cmark.workspace = true -theme.workspace = true -ui.workspace = true -util.workspace = true diff --git a/crates/rich_text/LICENSE-GPL b/crates/rich_text/LICENSE-GPL deleted file mode 120000 index 89e542f750cd3860a0598eff0dc34b56d7336dc4..0000000000000000000000000000000000000000 --- a/crates/rich_text/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/rich_text/src/rich_text.rs b/crates/rich_text/src/rich_text.rs deleted file mode 100644 index 2af9988f032c5dc9651e1da6e8c3b52c6c668866..0000000000000000000000000000000000000000 --- a/crates/rich_text/src/rich_text.rs +++ /dev/null @@ -1,418 +0,0 @@ -use futures::FutureExt; -use gpui::{ - AnyElement, AnyView, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText, - IntoElement, SharedString, StrikethroughStyle, StyledText, UnderlineStyle, Window, -}; -use language::{HighlightId, Language, LanguageRegistry}; -use std::{ops::Range, sync::Arc}; -use theme::ActiveTheme; -use ui::LinkPreview; -use util::RangeExt; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Highlight { - Code, - Id(HighlightId), - InlineCode(bool), - Highlight(HighlightStyle), - Mention, - SelfMention, -} - -impl From for Highlight { - fn from(style: HighlightStyle) -> Self { - Self::Highlight(style) - } -} - -impl From for Highlight { - fn from(style: HighlightId) -> Self { - Self::Id(style) - } -} - -#[derive(Clone, Default)] -pub struct RichText { - pub text: SharedString, - pub highlights: Vec<(Range, Highlight)>, - pub link_ranges: Vec>, - pub link_urls: Arc<[String]>, - - pub custom_ranges: Vec>, - custom_ranges_tooltip_fn: - Option, &mut Window, &mut App) -> Option>>, -} - -/// Allows one to specify extra links to the rendered markdown, which can be used -/// for e.g. mentions. -#[derive(Debug)] -pub struct Mention { - pub range: Range, - pub is_self_mention: bool, -} - -impl RichText { - pub fn new( - block: String, - mentions: &[Mention], - language_registry: &Arc, - ) -> Self { - let mut text = String::new(); - let mut highlights = Vec::new(); - let mut link_ranges = Vec::new(); - let mut link_urls = Vec::new(); - render_markdown_mut( - &block, - mentions, - language_registry, - None, - &mut text, - &mut highlights, - &mut link_ranges, - &mut link_urls, - ); - text.truncate(text.trim_end().len()); - - RichText { - text: SharedString::from(text), - link_urls: link_urls.into(), - link_ranges, - highlights, - custom_ranges: Vec::new(), - custom_ranges_tooltip_fn: None, - } - } - - pub fn set_tooltip_builder_for_custom_ranges( - &mut self, - f: impl Fn(usize, Range, &mut Window, &mut App) -> Option + 'static, - ) { - self.custom_ranges_tooltip_fn = Some(Arc::new(f)); - } - - pub fn element(&self, id: ElementId, window: &mut Window, cx: &mut App) -> AnyElement { - let theme = cx.theme(); - let code_background = theme.colors().surface_background; - - InteractiveText::new( - id, - StyledText::new(self.text.clone()).with_default_highlights( - &window.text_style(), - self.highlights.iter().map(|(range, highlight)| { - ( - range.clone(), - match highlight { - Highlight::Code => HighlightStyle { - background_color: Some(code_background), - ..Default::default() - }, - Highlight::Id(id) => HighlightStyle { - background_color: Some(code_background), - ..id.style(theme.syntax()).unwrap_or_default() - }, - Highlight::InlineCode(link) => { - if *link { - HighlightStyle { - background_color: Some(code_background), - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - } - } else { - HighlightStyle { - background_color: Some(code_background), - ..Default::default() - } - } - } - Highlight::Highlight(highlight) => *highlight, - Highlight::Mention => HighlightStyle { - font_weight: Some(FontWeight::BOLD), - ..Default::default() - }, - Highlight::SelfMention => HighlightStyle { - font_weight: Some(FontWeight::BOLD), - ..Default::default() - }, - }, - ) - }), - ), - ) - .on_click(self.link_ranges.clone(), { - let link_urls = self.link_urls.clone(); - move |ix, _, cx| { - let url = &link_urls[ix]; - if url.starts_with("http") { - cx.open_url(url); - } - } - }) - .tooltip({ - let link_ranges = self.link_ranges.clone(); - let link_urls = self.link_urls.clone(); - let custom_tooltip_ranges = self.custom_ranges.clone(); - let custom_tooltip_fn = self.custom_ranges_tooltip_fn.clone(); - move |idx, window, cx| { - for (ix, range) in link_ranges.iter().enumerate() { - if range.contains(&idx) { - return Some(LinkPreview::new(&link_urls[ix], cx)); - } - } - for range in &custom_tooltip_ranges { - if range.contains(&idx) - && let Some(f) = &custom_tooltip_fn - { - return f(idx, range.clone(), window, cx); - } - } - None - } - }) - .into_any_element() - } -} - -pub fn render_markdown_mut( - block: &str, - mut mentions: &[Mention], - language_registry: &Arc, - language: Option<&Arc>, - text: &mut String, - highlights: &mut Vec<(Range, Highlight)>, - link_ranges: &mut Vec>, - link_urls: &mut Vec, -) { - use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; - - let mut bold_depth = 0; - let mut italic_depth = 0; - let mut strikethrough_depth = 0; - let mut link_url = None; - let mut current_language = None; - let mut list_stack = Vec::new(); - - let mut options = Options::all(); - options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST); - - for (event, source_range) in Parser::new_ext(block, options).into_offset_iter() { - let prev_len = text.len(); - match event { - Event::Text(t) => { - if let Some(language) = ¤t_language { - render_code(text, highlights, t.as_ref(), language); - } else { - while let Some(mention) = mentions.first() { - if !source_range.contains_inclusive(&mention.range) { - break; - } - mentions = &mentions[1..]; - let range = (prev_len + mention.range.start - source_range.start) - ..(prev_len + mention.range.end - source_range.start); - highlights.push(( - range.clone(), - if mention.is_self_mention { - Highlight::SelfMention - } else { - Highlight::Mention - }, - )); - } - - text.push_str(t.as_ref()); - let mut style = HighlightStyle::default(); - if bold_depth > 0 { - style.font_weight = Some(FontWeight::BOLD); - } - if italic_depth > 0 { - style.font_style = Some(FontStyle::Italic); - } - if strikethrough_depth > 0 { - style.strikethrough = Some(StrikethroughStyle { - thickness: 1.0.into(), - ..Default::default() - }); - } - let last_run_len = if let Some(link_url) = link_url.clone() { - link_ranges.push(prev_len..text.len()); - link_urls.push(link_url); - style.underline = Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }); - prev_len - } else { - // Manually scan for links - let mut finder = linkify::LinkFinder::new(); - finder.kinds(&[linkify::LinkKind::Url]); - let mut last_link_len = prev_len; - for link in finder.links(&t) { - let start = link.start(); - let end = link.end(); - let range = (prev_len + start)..(prev_len + end); - link_ranges.push(range.clone()); - link_urls.push(link.as_str().to_string()); - - // If there is a style before we match a link, we have to add this to the highlighted ranges - if style != HighlightStyle::default() && last_link_len < link.start() { - highlights.push(( - last_link_len..link.start(), - Highlight::Highlight(style), - )); - } - - highlights.push(( - range, - Highlight::Highlight(HighlightStyle { - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..style - }), - )); - - last_link_len = end; - } - last_link_len - }; - - if style != HighlightStyle::default() && last_run_len < text.len() { - let mut new_highlight = true; - if let Some((last_range, last_style)) = highlights.last_mut() - && last_range.end == last_run_len - && last_style == &Highlight::Highlight(style) - { - last_range.end = text.len(); - new_highlight = false; - } - if new_highlight { - highlights - .push((last_run_len..text.len(), Highlight::Highlight(style))); - } - } - } - } - Event::Code(t) => { - text.push_str(t.as_ref()); - let is_link = link_url.is_some(); - - if let Some(link_url) = link_url.clone() { - link_ranges.push(prev_len..text.len()); - link_urls.push(link_url); - } - - highlights.push((prev_len..text.len(), Highlight::InlineCode(is_link))) - } - Event::Start(tag) => match tag { - Tag::Paragraph => new_paragraph(text, &mut list_stack), - Tag::Heading { .. } => { - new_paragraph(text, &mut list_stack); - bold_depth += 1; - } - Tag::CodeBlock(kind) => { - new_paragraph(text, &mut list_stack); - current_language = if let CodeBlockKind::Fenced(language) = kind { - language_registry - .language_for_name(language.as_ref()) - .now_or_never() - .and_then(Result::ok) - } else { - language.cloned() - } - } - Tag::Emphasis => italic_depth += 1, - Tag::Strong => bold_depth += 1, - Tag::Strikethrough => strikethrough_depth += 1, - Tag::Link { dest_url, .. } => link_url = Some(dest_url.to_string()), - Tag::List(number) => { - list_stack.push((number, false)); - } - Tag::Item => { - let len = list_stack.len(); - if let Some((list_number, has_content)) = list_stack.last_mut() { - *has_content = false; - if !text.is_empty() && !text.ends_with('\n') { - text.push('\n'); - } - for _ in 0..len - 1 { - text.push_str(" "); - } - if let Some(number) = list_number { - text.push_str(&format!("{}. ", number)); - *number += 1; - *has_content = false; - } else { - text.push_str("- "); - } - } - } - _ => {} - }, - Event::End(tag) => match tag { - TagEnd::Heading(_) => bold_depth -= 1, - TagEnd::CodeBlock => current_language = None, - TagEnd::Emphasis => italic_depth -= 1, - TagEnd::Strong => bold_depth -= 1, - TagEnd::Strikethrough => strikethrough_depth -= 1, - TagEnd::Link => link_url = None, - TagEnd::List(_) => drop(list_stack.pop()), - _ => {} - }, - Event::HardBreak => text.push('\n'), - Event::SoftBreak => text.push('\n'), - _ => {} - } - } -} - -pub fn render_code( - text: &mut String, - highlights: &mut Vec<(Range, Highlight)>, - content: &str, - language: &Arc, -) { - let prev_len = text.len(); - text.push_str(content); - let mut offset = 0; - for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) { - if range.start > offset { - highlights.push((prev_len + offset..prev_len + range.start, Highlight::Code)); - } - highlights.push(( - prev_len + range.start..prev_len + range.end, - Highlight::Id(highlight_id), - )); - offset = range.end; - } - if offset < content.len() { - highlights.push((prev_len + offset..prev_len + content.len(), Highlight::Code)); - } -} - -pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option, bool)>) { - let mut is_subsequent_paragraph_of_list = false; - if let Some((_, has_content)) = list_stack.last_mut() { - if *has_content { - is_subsequent_paragraph_of_list = true; - } else { - *has_content = true; - return; - } - } - - if !text.is_empty() { - if !text.ends_with('\n') { - text.push('\n'); - } - text.push('\n'); - } - for _ in 0..list_stack.len().saturating_sub(1) { - text.push_str(" "); - } - if is_subsequent_paragraph_of_list { - text.push_str(" "); - } -} diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 73bf5fdd8fcaaf1437013d300102a9e593823c7b..387417d8bfb8a4cb058ad367df7e0742c4fef7de 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -1159,10 +1159,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/Cargo.toml b/crates/search/Cargo.toml index 9613bd720919d77f2e7c9421ed51a0b18edf7355..dea69a9a02f3761cec2d953285b178d41dd76d56 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -7,7 +7,7 @@ license = "GPL-3.0-or-later" [features] test-support = [ - "client/test-support", + "editor/test-support", "gpui/test-support", "workspace/test-support", @@ -47,7 +47,6 @@ ztracing.workspace = true tracing.workspace = true [dev-dependencies] -client = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } 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..abfe0ec727c7388a612c38f5bb0b0c4d0dbf5682 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 }), @@ -872,6 +877,7 @@ impl VsCodeSettings { scrollbar: None, scroll_multiplier: None, toolbar: None, + show_count_badge: None, }) } diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 87e117b8b0bbdd9a789bae18c3f9dce98a6f1bc0..1b71f9b33c58b6980431d25f2af51007ae861a1c 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -9,6 +9,30 @@ use crate::ExtendingVec; use crate::DockPosition; +/// Where new threads should start by default. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum NewThreadLocation { + /// Start threads in the current project. + #[default] + LocalProject, + /// Start threads in a new worktree. + NewWorktree, +} + #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] pub struct AgentSettingsContent { @@ -59,6 +83,10 @@ pub struct AgentSettingsContent { /// /// Default: "thread" pub default_view: Option, + /// Where new threads should start by default. + /// + /// Default: "local_project" + pub new_thread_location: Option, /// The available agent profiles. pub profiles: Option, AgentProfileContent>>, /// Where to show a popup notification when the agent is waiting for user input. @@ -146,6 +174,10 @@ impl AgentSettingsContent { self.default_profile = Some(profile_id); } + pub fn set_new_thread_location(&mut self, value: NewThreadLocation) { + self.new_thread_location = Some(value); + } + pub fn add_favorite_model(&mut self, model: LanguageModelSelection) { if !self.favorite_models.contains(&model) { self.favorite_models.push(model); diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 5a4e87c384d802f3de4c96c07f65cf163c3a6d1a..8ab0ad6874a9c87a2104ba580c7fb1a90276027e 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -593,6 +593,17 @@ pub struct GitPanelSettingsContent { /// /// Default: icon pub status_style: Option, + + /// Whether to show file icons in the git panel. + /// + /// Default: false + pub file_icons: Option, + + /// Whether to show folder icons or chevrons for directories in the git panel. + /// + /// Default: true + pub folder_icons: Option, + /// How and when the scrollbar should be displayed. /// /// Default: inherits editor scrollbar settings @@ -622,8 +633,13 @@ pub struct GitPanelSettingsContent { /// Whether to show the addition/deletion change count next to each file in the Git panel. /// - /// Default: false + /// Default: true pub diff_stats: Option, + + /// Whether to show a badge on the git panel icon with the count of uncommitted changes. + /// + /// Default: false + pub show_count_badge: Option, } #[derive( @@ -671,6 +687,10 @@ pub struct NotificationPanelSettingsContent { /// Default: 300 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub default_width: Option, + /// Whether to show a badge on the notification panel icon with the count of unread notifications. + /// + /// Default: false + pub show_count_badge: Option, } #[with_fallible_options] @@ -721,6 +741,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/terminal.rs b/crates/settings_content/src/terminal.rs index a13613badfaa0a375dbcbdf6424e7bda59a84dc4..83f3b32fdd14a6ee693f775b74022af4841af0a5 100644 --- a/crates/settings_content/src/terminal.rs +++ b/crates/settings_content/src/terminal.rs @@ -171,6 +171,10 @@ pub struct TerminalSettingsContent { /// Default: 45 #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")] pub minimum_contrast: Option, + /// Whether to show a badge on the terminal panel icon with the count of open terminals. + /// + /// Default: false + pub show_count_badge: Option, } /// Shell configuration to open the terminal with. 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_profile_selector/Cargo.toml b/crates/settings_profile_selector/Cargo.toml index 23ccac2e43dec6c1ab335eeb2ffb4d9159d85859..9fcce14b0434386068a9c94f47c9ed675210abbb 100644 --- a/crates/settings_profile_selector/Cargo.toml +++ b/crates/settings_profile_selector/Cargo.toml @@ -22,10 +22,8 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -client = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } menu.workspace = true project = { workspace = true, features = ["test-support"] } serde_json.workspace = true diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 399534b968dfba941d17e2f6ce76261ca4e71859..7632c2857a41ba43fe7d2b2d517752f53b8f694d 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -28,6 +28,7 @@ cpal.workspace = true edit_prediction.workspace = true edit_prediction_ui.workspace = true editor.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true @@ -59,20 +60,13 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -assets.workspace = true -client.workspace = true fs = { workspace = true, features = ["test-support"] } futures.workspace = true gpui = { workspace = true, features = ["test-support"] } -language.workspace = true -node_runtime.workspace = true paths.workspace = true pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } -recent_projects = { workspace = true, features = ["test-support"] } serde_json.workspace = true -session.workspace = true settings = { workspace = true, features = ["test-support"] } title_bar = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } -zlog.workspace = true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index dbac4d7ba350fcff07016a2ccfa483f3d84472c7..f5398b60fe528153c3a6d146fcf1eb9b105f713f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1,3 +1,4 @@ +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use gpui::{Action as _, App}; use itertools::Itertools as _; use settings::{ @@ -74,7 +75,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec { terminal_page(), version_control_page(), collaboration_page(), - ai_page(), + ai_page(cx), network_page(), ] } @@ -4238,7 +4239,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 +4517,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.", @@ -4793,7 +4820,7 @@ fn panels_page() -> SettingsPage { ] } - fn terminal_panel_section() -> [SettingsPageItem; 2] { + fn terminal_panel_section() -> [SettingsPageItem; 3] { [ SettingsPageItem::SectionHeader("Terminal Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -4809,6 +4836,28 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Count Badge", + description: "Show a badge on the terminal panel icon with the count of open terminals.", + field: Box::new(SettingField { + json_path: Some("terminal.show_count_badge"), + pick: |settings_content| { + settings_content + .terminal + .as_ref()? + .show_count_badge + .as_ref() + }, + write: |settings_content, value| { + settings_content + .terminal + .get_or_insert_default() + .show_count_badge = value; + }, + }), + metadata: None, + files: USER, + }), ] } @@ -5021,7 +5070,7 @@ fn panels_page() -> SettingsPage { ] } - fn git_panel_section() -> [SettingsPageItem; 11] { + fn git_panel_section() -> [SettingsPageItem; 14] { [ SettingsPageItem::SectionHeader("Git Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5163,6 +5212,42 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "File Icons", + description: "Show file icons next to the Git status icon.", + field: Box::new(SettingField { + json_path: Some("git_panel.file_icons"), + pick: |settings_content| { + settings_content.git_panel.as_ref()?.file_icons.as_ref() + }, + write: |settings_content, value| { + settings_content + .git_panel + .get_or_insert_default() + .file_icons = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Folder Icons", + description: "Whether to show folder icons or chevrons for directories in the git panel.", + field: Box::new(SettingField { + json_path: Some("git_panel.folder_icons"), + pick: |settings_content| { + settings_content.git_panel.as_ref()?.folder_icons.as_ref() + }, + write: |settings_content, value| { + settings_content + .git_panel + .get_or_insert_default() + .folder_icons = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Diff Stats", description: "Whether to show the addition/deletion change count next to each file in the Git panel.", @@ -5181,6 +5266,28 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Count Badge", + description: "Whether to show a badge on the git panel icon with the count of uncommitted changes.", + field: Box::new(SettingField { + json_path: Some("git_panel.show_count_badge"), + pick: |settings_content| { + settings_content + .git_panel + .as_ref()? + .show_count_badge + .as_ref() + }, + write: |settings_content, value| { + settings_content + .git_panel + .get_or_insert_default() + .show_count_badge = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Scroll Bar", description: "How and when the scrollbar should be displayed.", @@ -5231,7 +5338,7 @@ fn panels_page() -> SettingsPage { ] } - fn notification_panel_section() -> [SettingsPageItem; 4] { + fn notification_panel_section() -> [SettingsPageItem; 5] { [ SettingsPageItem::SectionHeader("Notification Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5296,6 +5403,28 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show Count Badge", + description: "Show a badge on the notification panel icon with the count of unread notifications.", + field: Box::new(SettingField { + json_path: Some("notification_panel.show_count_badge"), + pick: |settings_content| { + settings_content + .notification_panel + .as_ref()? + .show_count_badge + .as_ref() + }, + write: |settings_content, value| { + settings_content + .notification_panel + .get_or_insert_default() + .show_count_badge = value; + }, + }), + metadata: None, + files: USER, + }), ] } @@ -6952,7 +7081,7 @@ fn collaboration_page() -> SettingsPage { } } -fn ai_page() -> SettingsPage { +fn ai_page(cx: &App) -> SettingsPage { fn general_section() -> [SettingsPageItem; 2] { [ SettingsPageItem::SectionHeader("General"), @@ -6972,8 +7101,8 @@ fn ai_page() -> SettingsPage { ] } - fn agent_configuration_section() -> [SettingsPageItem; 12] { - [ + fn agent_configuration_section(cx: &App) -> Box<[SettingsPageItem]> { + let mut items = vec![ SettingsPageItem::SectionHeader("Agent Configuration"), SettingsPageItem::SubPageLink(SubPageLink { title: "Tool Permissions".into(), @@ -6984,6 +7113,34 @@ fn ai_page() -> SettingsPage { files: USER, render: render_tool_permissions_setup_page, }), + ]; + + if cx.has_flag::() { + items.push(SettingsPageItem::SettingItem(SettingItem { + title: "New Thread Location", + description: "Whether to start a new thread in the current local project or in a new Git worktree.", + field: Box::new(SettingField { + json_path: Some("agent.new_thread_location"), + pick: |settings_content| { + settings_content + .agent + .as_ref()? + .new_thread_location + .as_ref() + }, + write: |settings_content, value| { + settings_content + .agent + .get_or_insert_default() + .new_thread_location = value; + }, + }), + metadata: None, + files: USER, + })); + } + + items.extend([ SettingsPageItem::SettingItem(SettingItem { title: "Single File Review", description: "When enabled, agent edits will also be displayed in single-file buffers for review.", @@ -7188,7 +7345,9 @@ fn ai_page() -> SettingsPage { metadata: None, files: USER, }), - ] + ]); + + items.into_boxed_slice() } fn context_servers_section() -> [SettingsPageItem; 2] { @@ -7273,7 +7432,7 @@ fn ai_page() -> SettingsPage { title: "AI", items: concat_sections![ general_section(), - agent_configuration_section(), + agent_configuration_section(cx), context_servers_section(), edit_prediction_language_settings_section(), edit_prediction_display_sub_section() diff --git a/crates/settings_ui/src/pages/audio_test_window.rs b/crates/settings_ui/src/pages/audio_test_window.rs index 63bd1d14ffb3ad9c7d1b2d176d9de58aa762ec25..d50d017d7abde836fb2945baf2f1434472281005 100644 --- a/crates/settings_ui/src/pages/audio_test_window.rs +++ b/crates/settings_ui/src/pages/audio_test_window.rs @@ -88,7 +88,7 @@ fn start_test_playback( } }; - let Ok(output) = audio::open_output_stream(output_device_id) else { + let Ok(output) = audio::open_test_output(output_device_id) else { log::error!("Could not open output device for audio test"); return; }; 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..6388dc5a283656e89d805455732a0044cf43e353 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -530,7 +530,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) - .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) @@ -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/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml deleted file mode 100644 index d835e9a602d7610eb412d8e3fc4135cb55d5a634..0000000000000000000000000000000000000000 --- a/crates/sidebar/Cargo.toml +++ /dev/null @@ -1,49 +0,0 @@ -[package] -name = "sidebar" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/sidebar.rs" - -[features] -default = [] - -[dependencies] -acp_thread.workspace = true -agent.workspace = true -agent-client-protocol.workspace = true -agent_ui.workspace = true -chrono.workspace = true -editor.workspace = true -feature_flags.workspace = true -fs.workspace = true -gpui.workspace = true -menu.workspace = true -project.workspace = true -settings.workspace = true -theme.workspace = true -ui.workspace = true -util.workspace = true -workspace.workspace = true -zed_actions.workspace = true - -[dev-dependencies] -acp_thread = { workspace = true, features = ["test-support"] } -agent = { workspace = true, features = ["test-support"] } -agent_ui = { workspace = true, features = ["test-support"] } -assistant_text_thread = { workspace = true, features = ["test-support"] } -editor.workspace = true -language_model = { workspace = true, features = ["test-support"] } -serde_json.workspace = true -feature_flags.workspace = true -fs = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -project = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } \ No newline at end of file diff --git a/crates/sidebar/LICENSE-GPL b/crates/sidebar/LICENSE-GPL deleted file mode 120000 index 89e542f750cd3860a0598eff0dc34b56d7336dc4..0000000000000000000000000000000000000000 --- a/crates/sidebar/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs deleted file mode 100644 index 40ba738ba98ff4d77932eabeca9bdf0a7d0b8861..0000000000000000000000000000000000000000 --- a/crates/sidebar/src/sidebar.rs +++ /dev/null @@ -1,3466 +0,0 @@ -use acp_thread::ThreadStatus; -use agent::ThreadStore; -use agent_client_protocol as acp; -use agent_ui::{AgentPanel, AgentPanelEvent, NewThread}; -use chrono::Utc; -use editor::{Editor, EditorElement, EditorStyle}; -use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; -use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState, - Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px, - relative, rems, -}; -use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; -use project::Event as ProjectEvent; -use settings::Settings; -use std::collections::{HashMap, HashSet}; -use std::mem; -use theme::{ActiveTheme, ThemeSettings}; -use ui::utils::TRAFFIC_LIGHT_PADDING; -use ui::{ - AgentThreadStatus, HighlightedLabel, IconButtonShape, KeyBinding, ListItem, Tab, ThreadItem, - Tooltip, WithScrollbar, prelude::*, -}; -use util::path_list::PathList; -use workspace::{ - FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, - SidebarEvent, ToggleWorkspaceSidebar, Workspace, -}; -use zed_actions::editor::{MoveDown, MoveUp}; - -actions!( - agents_sidebar, - [ - /// Collapses the selected entry in the workspace sidebar. - CollapseSelectedEntry, - /// Expands the selected entry in the workspace sidebar. - ExpandSelectedEntry, - ] -); - -const DEFAULT_WIDTH: Pixels = px(320.0); -const MIN_WIDTH: Pixels = px(200.0); -const MAX_WIDTH: Pixels = px(800.0); -const DEFAULT_THREADS_SHOWN: usize = 5; - -#[derive(Clone, Debug)] -struct ActiveThreadInfo { - session_id: acp::SessionId, - title: SharedString, - status: AgentThreadStatus, - icon: IconName, - icon_from_external_svg: Option, - is_background: bool, -} - -impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo { - fn from(info: &ActiveThreadInfo) -> Self { - Self { - session_id: info.session_id.clone(), - cwd: None, - title: Some(info.title.clone()), - updated_at: Some(Utc::now()), - meta: None, - } - } -} - -#[derive(Clone, Debug)] -#[allow(dead_code)] -enum ListEntry { - ProjectHeader { - path_list: PathList, - label: SharedString, - workspace: Entity, - highlight_positions: Vec, - has_threads: bool, - }, - Thread { - session_info: acp_thread::AgentSessionInfo, - icon: IconName, - icon_from_external_svg: Option, - status: AgentThreadStatus, - diff_stats: Option<(usize, usize)>, - workspace: Entity, - is_live: bool, - is_background: bool, - highlight_positions: Vec, - }, - ViewMore { - path_list: PathList, - remaining_count: usize, - }, - NewThread { - path_list: PathList, - workspace: Entity, - }, -} - -#[derive(Default)] -struct SidebarContents { - entries: Vec, - notified_threads: HashSet, -} - -impl SidebarContents { - fn is_thread_notified(&self, session_id: &acp::SessionId) -> bool { - self.notified_threads.contains(session_id) - } -} - -fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { - let mut positions = Vec::new(); - let mut query_chars = query.chars().peekable(); - - for (byte_idx, candidate_char) in candidate.char_indices() { - if let Some(&query_char) = query_chars.peek() { - if candidate_char.eq_ignore_ascii_case(&query_char) { - positions.push(byte_idx); - query_chars.next(); - } - } else { - break; - } - } - - if query_chars.peek().is_none() { - Some(positions) - } else { - None - } -} - -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()); - if let Some(name) = abs_path.file_name() { - names.push(name.to_string_lossy().to_string()); - } - } - - let label: SharedString = 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 { - multi_workspace: WeakEntity, - width: Pixels, - focus_handle: FocusHandle, - filter_editor: Entity, - list_state: ListState, - contents: SidebarContents, - /// The index of the list item that currently has the keyboard focus - /// - /// Note: This is NOT the same as the active item. - selection: Option, - focused_thread: Option, - active_entry_index: Option, - collapsed_groups: HashSet, - expanded_groups: HashSet, -} - -impl EventEmitter for Sidebar {} - -impl Sidebar { - pub fn new( - multi_workspace: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - cx.on_focus_in(&focus_handle, window, Self::focus_in) - .detach(); - - let filter_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Search…", window, cx); - editor - }); - - cx.subscribe_in( - &multi_workspace, - window, - |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { - MultiWorkspaceEvent::ActiveWorkspaceChanged => { - this.focused_thread = None; - this.update_entries(cx); - } - MultiWorkspaceEvent::WorkspaceAdded(workspace) => { - this.subscribe_to_workspace(workspace, window, cx); - this.update_entries(cx); - } - MultiWorkspaceEvent::WorkspaceRemoved(_) => { - this.update_entries(cx); - } - }, - ) - .detach(); - - cx.subscribe(&filter_editor, |this: &mut Self, _, event, cx| { - if let editor::EditorEvent::BufferEdited = event { - let query = this.filter_editor.read(cx).text(cx); - if !query.is_empty() { - this.selection.take(); - } - this.update_entries(cx); - if !query.is_empty() { - this.selection = this - .contents - .entries - .iter() - .position(|entry| matches!(entry, ListEntry::Thread { .. })) - .or_else(|| { - if this.contents.entries.is_empty() { - None - } else { - Some(0) - } - }); - } - } - }) - .detach(); - - let thread_store = ThreadStore::global(cx); - cx.observe_in(&thread_store, window, |this, _, _window, cx| { - this.update_entries(cx); - }) - .detach(); - - cx.observe_flag::(window, |_is_enabled, this, _window, cx| { - this.update_entries(cx); - }) - .detach(); - - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); - cx.defer_in(window, move |this, window, cx| { - for workspace in &workspaces { - this.subscribe_to_workspace(workspace, window, cx); - } - this.update_entries(cx); - }); - - Self { - multi_workspace: multi_workspace.downgrade(), - width: DEFAULT_WIDTH, - focus_handle, - filter_editor, - list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), - contents: SidebarContents::default(), - selection: None, - focused_thread: None, - active_entry_index: None, - collapsed_groups: HashSet::new(), - expanded_groups: HashSet::new(), - } - } - - fn subscribe_to_workspace( - &self, - workspace: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let project = workspace.read(cx).project().clone(); - cx.subscribe_in( - &project, - window, - |this, _project, event, _window, cx| match event { - ProjectEvent::WorktreeAdded(_) - | ProjectEvent::WorktreeRemoved(_) - | ProjectEvent::WorktreeOrderChanged => { - this.update_entries(cx); - } - _ => {} - }, - ) - .detach(); - - cx.subscribe_in( - workspace, - window, - |this, _workspace, event: &workspace::Event, window, cx| { - if let workspace::Event::PanelAdded(view) = event { - if let Ok(agent_panel) = view.clone().downcast::() { - this.subscribe_to_agent_panel(&agent_panel, window, cx); - } - } - }, - ) - .detach(); - - if let Some(agent_panel) = workspace.read(cx).panel::(cx) { - self.subscribe_to_agent_panel(&agent_panel, window, cx); - } - } - - fn subscribe_to_agent_panel( - &self, - agent_panel: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - cx.subscribe_in( - agent_panel, - window, - |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { - AgentPanelEvent::ActiveViewChanged => { - match agent_panel.read(cx).active_connection_view() { - Some(thread) => { - if let Some(session_id) = thread.read(cx).parent_id(cx) { - this.focused_thread = Some(session_id); - } - } - None => { - this.focused_thread = None; - } - } - this.update_entries(cx); - } - AgentPanelEvent::ThreadFocused => { - let new_focused = agent_panel - .read(cx) - .active_connection_view() - .and_then(|thread| thread.read(cx).parent_id(cx)); - if new_focused.is_some() && new_focused != this.focused_thread { - this.focused_thread = new_focused; - this.update_entries(cx); - } - } - AgentPanelEvent::BackgroundThreadChanged => { - this.update_entries(cx); - } - }, - ) - .detach(); - } - - fn all_thread_infos_for_workspace( - workspace: &Entity, - cx: &App, - ) -> Vec { - let Some(agent_panel) = workspace.read(cx).panel::(cx) else { - return Vec::new(); - }; - let agent_panel_ref = agent_panel.read(cx); - - agent_panel_ref - .parent_threads(cx) - .into_iter() - .map(|thread_view| { - let thread_view_ref = thread_view.read(cx); - let thread = thread_view_ref.thread.read(cx); - - let icon = thread_view_ref.agent_icon; - let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); - let title = thread.title(); - let session_id = thread.session_id().clone(); - let is_background = agent_panel_ref.is_background_thread(&session_id); - - let status = if thread.is_waiting_for_confirmation() { - AgentThreadStatus::WaitingForConfirmation - } else if thread.had_error() { - AgentThreadStatus::Error - } else { - match thread.status() { - ThreadStatus::Generating => AgentThreadStatus::Running, - ThreadStatus::Idle => AgentThreadStatus::Completed, - } - }; - - ActiveThreadInfo { - session_id, - title, - status, - icon, - icon_from_external_svg, - is_background, - } - }) - .collect() - } - - fn rebuild_contents(&mut self, cx: &App) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - let mw = multi_workspace.read(cx); - let workspaces = mw.workspaces().to_vec(); - let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); - - let thread_store = ThreadStore::try_global(cx); - let query = self.filter_editor.read(cx).text(cx); - - let previous = mem::take(&mut self.contents); - - let old_statuses: HashMap = previous - .entries - .iter() - .filter_map(|entry| match entry { - ListEntry::Thread { - session_info, - status, - is_live: true, - .. - } => Some((session_info.session_id.clone(), *status)), - _ => None, - }) - .collect(); - - let mut entries = Vec::new(); - let mut notified_threads = previous.notified_threads; - - for workspace in workspaces.iter() { - let (path_list, label) = workspace_path_list_and_label(workspace, cx); - - let is_collapsed = self.collapsed_groups.contains(&path_list); - let should_load_threads = !is_collapsed || !query.is_empty(); - - let mut threads: Vec = Vec::new(); - - if should_load_threads { - if let Some(ref thread_store) = thread_store { - for meta in thread_store.read(cx).threads_for_paths(&path_list) { - threads.push(ListEntry::Thread { - session_info: meta.into(), - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::default(), - diff_stats: None, - workspace: workspace.clone(), - is_live: false, - is_background: false, - highlight_positions: Vec::new(), - }); - } - } - - let live_infos = Self::all_thread_infos_for_workspace(workspace, cx); - - for info in &live_infos { - let Some(existing) = threads.iter_mut().find(|t| { - matches!(t, ListEntry::Thread { session_info, .. } if session_info.session_id == info.session_id) - }) else { - continue; - }; - - if let ListEntry::Thread { - session_info, - status, - icon, - icon_from_external_svg, - workspace: _, - is_live, - is_background, - .. - } = existing - { - session_info.title = Some(info.title.clone()); - *status = info.status; - *icon = info.icon; - *icon_from_external_svg = info.icon_from_external_svg.clone(); - *is_live = true; - *is_background = info.is_background; - } - } - - // Update notification state for live threads. - for thread in &threads { - if let ListEntry::Thread { - workspace: thread_workspace, - session_info, - status, - is_background, - .. - } = thread - { - let session_id = &session_info.session_id; - if *is_background && *status == AgentThreadStatus::Completed { - notified_threads.insert(session_id.clone()); - } else if *status == AgentThreadStatus::Completed - && active_workspace - .as_ref() - .is_none_or(|active| active != thread_workspace) - && old_statuses.get(session_id) == Some(&AgentThreadStatus::Running) - { - notified_threads.insert(session_id.clone()); - } - - if active_workspace - .as_ref() - .is_some_and(|active| active == thread_workspace) - && !*is_background - { - notified_threads.remove(session_id); - } - } - } - - threads.sort_by(|a, b| { - let a_time = match a { - ListEntry::Thread { session_info, .. } => session_info.updated_at, - _ => unreachable!(), - }; - let b_time = match b { - ListEntry::Thread { session_info, .. } => session_info.updated_at, - _ => unreachable!(), - }; - b_time.cmp(&a_time) - }); - } - - if !query.is_empty() { - let has_threads = !threads.is_empty(); - let mut matched_threads = Vec::new(); - for mut thread in threads { - if let ListEntry::Thread { - session_info, - highlight_positions, - .. - } = &mut thread - { - let title = session_info - .title - .as_ref() - .map(|s| s.as_ref()) - .unwrap_or(""); - if let Some(positions) = fuzzy_match_positions(&query, title) { - *highlight_positions = positions; - matched_threads.push(thread); - } - } - } - - let workspace_highlight_positions = - fuzzy_match_positions(&query, &label).unwrap_or_default(); - - if matched_threads.is_empty() && workspace_highlight_positions.is_empty() { - continue; - } - - entries.push(ListEntry::ProjectHeader { - path_list: path_list.clone(), - label, - workspace: workspace.clone(), - highlight_positions: workspace_highlight_positions, - has_threads, - }); - entries.extend(matched_threads); - } else { - let has_threads = !threads.is_empty(); - entries.push(ListEntry::ProjectHeader { - path_list: path_list.clone(), - label, - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_threads, - }); - - if is_collapsed { - continue; - } - - let total = threads.len(); - let show_view_more = - total > DEFAULT_THREADS_SHOWN && !self.expanded_groups.contains(&path_list); - - let count = if show_view_more { - DEFAULT_THREADS_SHOWN - } else { - total - }; - - entries.extend(threads.into_iter().take(count)); - - if show_view_more { - entries.push(ListEntry::ViewMore { - path_list: path_list.clone(), - remaining_count: total - DEFAULT_THREADS_SHOWN, - }); - } - - if total == 0 { - entries.push(ListEntry::NewThread { - path_list: path_list.clone(), - workspace: workspace.clone(), - }); - } - } - } - - // Prune stale entries from notified_threads. - let current_session_ids: HashSet<&acp::SessionId> = entries - .iter() - .filter_map(|e| match e { - ListEntry::Thread { session_info, .. } => Some(&session_info.session_id), - _ => None, - }) - .collect(); - notified_threads.retain(|id| current_session_ids.contains(id)); - - self.contents = SidebarContents { - entries, - notified_threads, - }; - } - - fn update_entries(&mut self, cx: &mut Context) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - if !multi_workspace.read(cx).multi_workspace_enabled(cx) { - return; - } - - let had_notifications = self.has_notifications(cx); - - self.rebuild_contents(cx); - self.recompute_active_entry_index(cx); - - self.list_state.reset(self.contents.entries.len()); - - if had_notifications != self.has_notifications(cx) { - multi_workspace.update(cx, |_, cx| { - cx.notify(); - }); - } - - cx.notify(); - } - - fn recompute_active_entry_index(&mut self, cx: &App) { - self.active_entry_index = if let Some(session_id) = &self.focused_thread { - self.contents.entries.iter().position(|entry| { - matches!(entry, ListEntry::Thread { session_info, .. } if &session_info.session_id == session_id) - }) - } else { - let active_workspace = self - .multi_workspace - .upgrade() - .map(|mw| mw.read(cx).workspace().clone()); - active_workspace.and_then(|active| { - self.contents.entries.iter().position(|entry| { - matches!(entry, ListEntry::ProjectHeader { workspace, .. } if workspace == &active) - }) - }) - }; - } - - fn render_list_entry( - &mut self, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let Some(entry) = self.contents.entries.get(ix) else { - return div().into_any_element(); - }; - let is_focused = self.focus_handle.is_focused(window) - || self.filter_editor.focus_handle(cx).is_focused(window); - // is_selected means the keyboard selector is here. - let is_selected = is_focused && self.selection == Some(ix); - - let is_group_header_after_first = - ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. }); - - let rendered = match entry { - ListEntry::ProjectHeader { - path_list, - label, - workspace, - highlight_positions, - has_threads, - } => self.render_project_header( - ix, - path_list, - label, - workspace, - highlight_positions, - *has_threads, - is_selected, - cx, - ), - ListEntry::Thread { - session_info, - icon, - icon_from_external_svg, - status, - workspace, - highlight_positions, - .. - } => self.render_thread( - ix, - session_info, - *icon, - icon_from_external_svg.clone(), - *status, - workspace, - highlight_positions, - is_selected, - cx, - ), - ListEntry::ViewMore { - path_list, - remaining_count, - } => self.render_view_more(ix, path_list, *remaining_count, is_selected, cx), - ListEntry::NewThread { - path_list, - workspace, - } => self.render_new_thread(ix, path_list, workspace, is_selected, cx), - }; - - // add the blue border here, not in the sub methods - - if is_group_header_after_first { - v_flex() - .w_full() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(rendered) - .into_any_element() - } else { - rendered - } - } - - fn render_project_header( - &self, - ix: usize, - path_list: &PathList, - label: &SharedString, - workspace: &Entity, - highlight_positions: &[usize], - has_threads: bool, - is_selected: bool, - cx: &mut Context, - ) -> AnyElement { - let id = SharedString::from(format!("project-header-{}", ix)); - let ib_id = SharedString::from(format!("project-header-new-thread-{}", ix)); - - let is_collapsed = self.collapsed_groups.contains(path_list); - let disclosure_icon = if is_collapsed { - IconName::ChevronRight - } else { - IconName::ChevronDown - }; - let workspace_for_new_thread = workspace.clone(); - let workspace_for_remove = workspace.clone(); - // let workspace_for_activate = workspace.clone(); - let path_list_for_toggle = path_list.clone(); - let multi_workspace = self.multi_workspace.upgrade(); - let workspace_count = multi_workspace - .as_ref() - .map_or(0, |mw| mw.read(cx).workspaces().len()); - let is_active_workspace = self.focused_thread.is_none() - && multi_workspace - .as_ref() - .is_some_and(|mw| mw.read(cx).workspace() == workspace); - - let label = if highlight_positions.is_empty() { - Label::new(label.clone()) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - } else { - HighlightedLabel::new(label.clone(), highlight_positions.to_vec()) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - }; - - ListItem::new(id) - .toggle_state(is_active_workspace) - .focused(is_selected) - .child( - h_flex() - .p_1() - .gap_1p5() - .child( - Icon::new(disclosure_icon) - .size(IconSize::Small) - .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))), - ) - .child(label), - ) - .end_hover_slot( - h_flex() - .gap_0p5() - .when(workspace_count > 1, |this| { - this.child( - IconButton::new( - SharedString::from(format!("project-header-remove-{}", ix)), - IconName::Close, - ) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Remove Project")) - .on_click(cx.listener( - move |this, _, window, cx| { - this.remove_workspace(&workspace_for_remove, window, cx); - }, - )), - ) - }) - .when(has_threads, |this| { - this.child( - IconButton::new(ib_id, IconName::NewThread) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("New Thread")) - .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - this.create_new_thread(&workspace_for_new_thread, window, cx); - })), - ) - }), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - this.toggle_collapse(&path_list_for_toggle, window, cx); - })) - // TODO: Decide if we really want the header to be activating different workspaces - // .on_click(cx.listener(move |this, _, window, cx| { - // this.selection = None; - // this.activate_workspace(&workspace_for_activate, window, cx); - // })) - .into_any_element() - } - - fn activate_workspace( - &mut self, - workspace: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - - self.focused_thread = None; - - multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), cx); - }); - - multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.focus_active_workspace(window, cx); - }); - } - - fn remove_workspace( - &mut self, - workspace: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - - multi_workspace.update(cx, |multi_workspace, cx| { - let Some(index) = multi_workspace - .workspaces() - .iter() - .position(|w| w == workspace) - else { - return; - }; - multi_workspace.remove_workspace(index, window, cx); - }); - } - - fn toggle_collapse( - &mut self, - path_list: &PathList, - _window: &mut Window, - cx: &mut Context, - ) { - if self.collapsed_groups.contains(path_list) { - self.collapsed_groups.remove(path_list); - } else { - self.collapsed_groups.insert(path_list.clone()); - } - self.update_entries(cx); - } - - fn focus_in(&mut self, _window: &mut Window, _cx: &mut Context) {} - - fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - if self.reset_filter_editor_text(window, cx) { - self.update_entries(cx); - } else { - self.focus_handle.focus(window, cx); - } - } - - fn reset_filter_editor_text(&mut self, window: &mut Window, cx: &mut Context) -> bool { - self.filter_editor.update(cx, |editor, cx| { - if editor.buffer().read(cx).len(cx).0 > 0 { - editor.set_text("", window, cx); - true - } else { - false - } - }) - } - - fn filter_query(&self, cx: &App) -> String { - self.filter_editor.read(cx).text(cx) - } - - fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { - self.select_next(&SelectNext, window, cx); - } - - fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { - self.select_previous(&SelectPrevious, window, cx); - } - - fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { - let next = match self.selection { - Some(ix) if ix + 1 < self.contents.entries.len() => ix + 1, - None if !self.contents.entries.is_empty() => 0, - _ => return, - }; - self.selection = Some(next); - self.list_state.scroll_to_reveal_item(next); - cx.notify(); - } - - fn select_previous( - &mut self, - _: &SelectPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - let prev = match self.selection { - Some(ix) if ix > 0 => ix - 1, - None if !self.contents.entries.is_empty() => self.contents.entries.len() - 1, - _ => return, - }; - self.selection = Some(prev); - self.list_state.scroll_to_reveal_item(prev); - cx.notify(); - } - - fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - if !self.contents.entries.is_empty() { - self.selection = Some(0); - self.list_state.scroll_to_reveal_item(0); - cx.notify(); - } - } - - fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { - if let Some(last) = self.contents.entries.len().checked_sub(1) { - self.selection = Some(last); - self.list_state.scroll_to_reveal_item(last); - cx.notify(); - } - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let Some(ix) = self.selection else { return }; - let Some(entry) = self.contents.entries.get(ix) else { - return; - }; - - match entry { - ListEntry::ProjectHeader { workspace, .. } => { - let workspace = workspace.clone(); - self.activate_workspace(&workspace, window, cx); - } - ListEntry::Thread { - session_info, - workspace, - .. - } => { - let session_info = session_info.clone(); - let workspace = workspace.clone(); - self.activate_thread(session_info, &workspace, window, cx); - } - ListEntry::ViewMore { path_list, .. } => { - let path_list = path_list.clone(); - self.expanded_groups.insert(path_list); - self.update_entries(cx); - } - ListEntry::NewThread { workspace, .. } => { - let workspace = workspace.clone(); - self.create_new_thread(&workspace, window, cx); - } - } - } - - fn activate_thread( - &mut self, - session_info: acp_thread::AgentSessionInfo, - workspace: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - - multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), cx); - }); - - workspace.update(cx, |workspace, cx| { - workspace.open_panel::(window, cx); - }); - - if let Some(agent_panel) = workspace.read(cx).panel::(cx) { - agent_panel.update(cx, |panel, cx| { - panel.load_agent_thread( - session_info.session_id, - session_info.cwd, - session_info.title, - window, - cx, - ); - }); - } - } - - fn expand_selected_entry( - &mut self, - _: &ExpandSelectedEntry, - _window: &mut Window, - cx: &mut Context, - ) { - let Some(ix) = self.selection else { return }; - - match self.contents.entries.get(ix) { - Some(ListEntry::ProjectHeader { path_list, .. }) => { - if self.collapsed_groups.contains(path_list) { - let path_list = path_list.clone(); - self.collapsed_groups.remove(&path_list); - self.update_entries(cx); - } else if ix + 1 < self.contents.entries.len() { - self.selection = Some(ix + 1); - self.list_state.scroll_to_reveal_item(ix + 1); - cx.notify(); - } - } - _ => {} - } - } - - fn collapse_selected_entry( - &mut self, - _: &CollapseSelectedEntry, - _window: &mut Window, - cx: &mut Context, - ) { - let Some(ix) = self.selection else { return }; - - match self.contents.entries.get(ix) { - Some(ListEntry::ProjectHeader { path_list, .. }) => { - if !self.collapsed_groups.contains(path_list) { - let path_list = path_list.clone(); - self.collapsed_groups.insert(path_list); - self.update_entries(cx); - } - } - Some( - ListEntry::Thread { .. } | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. }, - ) => { - for i in (0..ix).rev() { - if let Some(ListEntry::ProjectHeader { path_list, .. }) = - self.contents.entries.get(i) - { - let path_list = path_list.clone(); - self.selection = Some(i); - self.collapsed_groups.insert(path_list); - self.update_entries(cx); - break; - } - } - } - None => {} - } - } - - fn render_thread( - &self, - ix: usize, - session_info: &acp_thread::AgentSessionInfo, - icon: IconName, - icon_from_external_svg: Option, - status: AgentThreadStatus, - workspace: &Entity, - highlight_positions: &[usize], - is_selected: bool, - cx: &mut Context, - ) -> AnyElement { - let has_notification = self.contents.is_thread_notified(&session_info.session_id); - - let title: SharedString = session_info - .title - .clone() - .unwrap_or_else(|| "Untitled".into()); - let session_info = session_info.clone(); - let workspace = workspace.clone(); - - let id = SharedString::from(format!("thread-entry-{}", ix)); - ThreadItem::new(id, title) - .icon(icon) - .when_some(icon_from_external_svg, |this, svg| { - this.custom_icon_from_external_svg(svg) - }) - .highlight_positions(highlight_positions.to_vec()) - .status(status) - .notified(has_notification) - .selected(self.focused_thread.as_ref() == Some(&session_info.session_id)) - .focused(is_selected) - .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - this.activate_thread(session_info.clone(), &workspace, window, cx); - })) - .into_any_element() - } - - fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { - let settings = ThemeSettings::get_global(cx); - let text_style = TextStyle { - color: cx.theme().colors().text, - font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features.clone(), - font_fallbacks: settings.ui_font.fallbacks.clone(), - font_size: rems(0.875).into(), - font_weight: settings.ui_font.weight, - font_style: FontStyle::Normal, - line_height: relative(1.3), - ..Default::default() - }; - - EditorElement::new( - &self.filter_editor, - EditorStyle { - local_player: cx.theme().players().local(), - text: text_style, - ..Default::default() - }, - ) - } - - fn render_view_more( - &self, - ix: usize, - path_list: &PathList, - remaining_count: usize, - is_selected: bool, - cx: &mut Context, - ) -> AnyElement { - let path_list = path_list.clone(); - let id = SharedString::from(format!("view-more-{}", ix)); - - let count = format!("({})", remaining_count); - - ListItem::new(id) - .focused(is_selected) - .child( - h_flex() - .px_1() - .py_1p5() - .gap_1p5() - .child( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child(Label::new("View More").color(Color::Muted)) - .child(Label::new(count).color(Color::Muted).size(LabelSize::Small)), - ) - .on_click(cx.listener(move |this, _, _window, cx| { - this.selection = None; - this.expanded_groups.insert(path_list.clone()); - this.update_entries(cx); - })) - .into_any_element() - } - - fn create_new_thread( - &mut self, - workspace: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - - multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.activate(workspace.clone(), cx); - }); - - workspace.update(cx, |workspace, cx| { - if let Some(agent_panel) = workspace.panel::(cx) { - agent_panel.update(cx, |panel, cx| { - panel.new_thread(&NewThread, window, cx); - }); - } - workspace.focus_panel::(window, cx); - }); - } - - fn render_new_thread( - &self, - ix: usize, - _path_list: &PathList, - workspace: &Entity, - is_selected: bool, - cx: &mut Context, - ) -> AnyElement { - let workspace = workspace.clone(); - - div() - .w_full() - .p_2() - .child( - Button::new( - SharedString::from(format!("new-thread-btn-{}", ix)), - "New Thread", - ) - .full_width() - .style(ButtonStyle::Outlined) - .icon(IconName::Plus) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .toggle_state(is_selected) - .on_click(cx.listener(move |this, _, window, cx| { - this.selection = None; - this.create_new_thread(&workspace, window, cx); - })), - ) - .into_any_element() - } -} - -impl WorkspaceSidebar for Sidebar { - fn width(&self, _cx: &App) -> Pixels { - self.width - } - - fn set_width(&mut self, width: Option, cx: &mut Context) { - self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); - cx.notify(); - } - - fn has_notifications(&self, _cx: &App) -> bool { - !self.contents.notified_threads.is_empty() - } -} - -impl Focusable for Sidebar { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.filter_editor.focus_handle(cx) - } -} - -impl Render for Sidebar { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let titlebar_height = ui::utils::platform_title_bar_height(window); - let ui_font = theme::setup_ui_font(window, cx); - let is_focused = self.focus_handle.is_focused(window) - || self.filter_editor.focus_handle(cx).is_focused(window); - let has_query = !self.filter_query(cx).is_empty(); - - let focus_tooltip_label = if is_focused { - "Focus Workspace" - } else { - "Focus Sidebar" - }; - - v_flex() - .id("workspace-sidebar") - .key_context("WorkspaceSidebar") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::editor_move_down)) - .on_action(cx.listener(Self::editor_move_up)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::expand_selected_entry)) - .on_action(cx.listener(Self::collapse_selected_entry)) - .on_action(cx.listener(Self::cancel)) - .font(ui_font) - .h_full() - .w(self.width) - .bg(cx.theme().colors().surface_background) - .border_r_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .flex_none() - .h(titlebar_height) - .w_full() - .mt_px() - .pb_px() - .pr_1() - .when_else( - cfg!(target_os = "macos") && !window.is_fullscreen(), - |this| this.pl(px(TRAFFIC_LIGHT_PADDING)), - |this| this.pl_2(), - ) - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border) - .child({ - let focus_handle_toggle = self.focus_handle.clone(); - let focus_handle_focus = self.focus_handle.clone(); - IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) - .icon_size(IconSize::Small) - .tooltip(Tooltip::element(move |_, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Close Sidebar")) - .child(KeyBinding::for_action_in( - &ToggleWorkspaceSidebar, - &focus_handle_toggle, - cx, - )), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .justify_between() - .child(Label::new(focus_tooltip_label)) - .child(KeyBinding::for_action_in( - &FocusWorkspaceSidebar, - &focus_handle_focus, - cx, - )), - ) - .into_any_element() - })) - .on_click(cx.listener(|_this, _, _window, cx| { - cx.emit(SidebarEvent::Close); - })) - }) - .child( - IconButton::new("open-project", IconName::OpenFolder) - .icon_size(IconSize::Small) - .tooltip(|_window, cx| { - Tooltip::for_action( - "Open Project", - &workspace::Open { - create_new_window: false, - }, - cx, - ) - }) - .on_click(|_event, window, cx| { - window.dispatch_action( - Box::new(workspace::Open { - create_new_window: false, - }), - cx, - ); - }), - ), - ) - .child( - h_flex() - .flex_none() - .p_2() - .h(Tab::container_height(cx)) - .gap_1p5() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Icon::new(IconName::MagnifyingGlass) - .size(IconSize::Small) - .color(Color::Muted), - ) - .child(self.render_filter_input(cx)) - .when(has_query, |this| { - this.pr_1().child( - IconButton::new("clear_filter", IconName::Close) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::text("Clear Search")) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_filter_editor_text(window, cx); - this.update_entries(cx); - })), - ) - }), - ) - .child( - v_flex() - .flex_1() - .overflow_hidden() - .child( - list( - self.list_state.clone(), - cx.processor(Self::render_list_entry), - ) - .flex_1() - .size_full(), - ) - .vertical_scrollbar_for(&self.list_state, window, cx), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use acp_thread::StubAgentConnection; - use agent::ThreadStore; - use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; - use assistant_text_thread::TextThreadStore; - use chrono::DateTime; - use feature_flags::FeatureFlagAppExt as _; - use fs::FakeFs; - use gpui::TestAppContext; - use settings::SettingsStore; - use std::sync::Arc; - use util::path_list::PathList; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - }); - } - - fn make_test_thread(title: &str, updated_at: DateTime) -> agent::DbThread { - agent::DbThread { - title: title.to_string().into(), - messages: Vec::new(), - updated_at, - detailed_summary: None, - initial_project_snapshot: None, - cumulative_token_usage: Default::default(), - request_token_usage: Default::default(), - model: None, - profile: None, - imported: false, - subagent_context: None, - speed: None, - thinking_enabled: false, - thinking_effort: None, - draft_prompt: None, - ui_scroll_position: None, - } - } - - async fn init_test_project( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - - fn setup_sidebar( - multi_workspace: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - let multi_workspace = multi_workspace.clone(); - let sidebar = - cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.register_sidebar(sidebar.clone(), window, cx); - }); - cx.run_until_parked(); - sidebar - } - - async fn save_n_test_threads( - count: u32, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - for i in 0..count { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - make_test_thread( - &format!("Thread {}", i + 1), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - } - cx.run_until_parked(); - } - - async fn save_thread_to_store( - session_id: &acp::SessionId, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - session_id.clone(), - make_test_thread( - "Test", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - cx.run_until_parked(); - } - - fn open_and_focus_sidebar( - sidebar: &Entity, - multi_workspace: &Entity, - cx: &mut gpui::VisualTestContext, - ) { - multi_workspace.update_in(cx, |mw, window, cx| { - mw.toggle_sidebar(window, cx); - }); - cx.run_until_parked(); - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); - }); - cx.run_until_parked(); - } - - fn visible_entries_as_strings( - sidebar: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Vec { - sidebar.read_with(cx, |sidebar, _cx| { - sidebar - .contents - .entries - .iter() - .enumerate() - .map(|(ix, entry)| { - let selected = if sidebar.selection == Some(ix) { - " <== selected" - } else { - "" - }; - match entry { - ListEntry::ProjectHeader { - label, - path_list, - highlight_positions: _, - .. - } => { - let icon = if sidebar.collapsed_groups.contains(path_list) { - ">" - } else { - "v" - }; - format!("{} [{}]{}", icon, label, selected) - } - ListEntry::Thread { - session_info, - status, - is_live, - .. - } => { - let title = session_info - .title - .as_ref() - .map(|s| s.as_ref()) - .unwrap_or("Untitled"); - let active = if *is_live { " *" } else { "" }; - let status_str = match status { - AgentThreadStatus::Running => " (running)", - AgentThreadStatus::Error => " (error)", - AgentThreadStatus::WaitingForConfirmation => " (waiting)", - _ => "", - }; - let notified = if sidebar - .contents - .is_thread_notified(&session_info.session_id) - { - " (!)" - } else { - "" - }; - format!( - " {}{}{}{}{}", - title, active, status_str, notified, selected - ) - } - ListEntry::ViewMore { - remaining_count, .. - } => { - format!(" + View More ({}){}", remaining_count, selected) - } - ListEntry::NewThread { .. } => { - format!(" [+ New Thread]{}", selected) - } - } - }) - .collect() - }) - } - - #[gpui::test] - async fn test_single_workspace_no_threads(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]"] - ); - } - - #[gpui::test] - async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-1")), - make_test_thread( - "Fix crash in project panel", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-2")), - make_test_thread( - "Add inline diff view", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - cx.run_until_parked(); - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in project panel", - " Add inline diff view", - ] - ); - } - - #[gpui::test] - async fn test_workspace_lifecycle(cx: &mut TestAppContext) { - let project = init_test_project("/project-a", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Single workspace with a thread - let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-a1")), - make_test_thread( - "Thread A1", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - cx.run_until_parked(); - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] - ); - - // Add a second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_workspace(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Thread A1", - "v [Empty Workspace]", - " [+ New Thread]" - ] - ); - - // Remove the second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(1, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] - ); - } - - #[gpui::test] - async fn test_view_more_pagination(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(12, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Thread 12", - " Thread 11", - " Thread 10", - " Thread 9", - " Thread 8", - " + View More (7)", - ] - ); - } - - #[gpui::test] - async fn test_collapse_and_expand_group(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Collapse - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]"] - ); - - // Expand - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - } - - #[gpui::test] - async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); - let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); - - sidebar.update_in(cx, |s, _window, _cx| { - s.collapsed_groups.insert(collapsed_path.clone()); - s.contents - .notified_threads - .insert(acp::SessionId::new(Arc::from("t-5"))); - s.contents.entries = vec![ - // Expanded project header - ListEntry::ProjectHeader { - path_list: expanded_path.clone(), - label: "expanded-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_threads: true, - }, - // Thread with default (Completed) status, not active - ListEntry::Thread { - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-1")), - cwd: None, - title: Some("Completed thread".into()), - updated_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - diff_stats: None, - workspace: workspace.clone(), - is_live: false, - is_background: false, - highlight_positions: Vec::new(), - }, - // Active thread with Running status - ListEntry::Thread { - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-2")), - cwd: None, - title: Some("Running thread".into()), - updated_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Running, - diff_stats: None, - workspace: workspace.clone(), - is_live: true, - is_background: false, - highlight_positions: Vec::new(), - }, - // Active thread with Error status - ListEntry::Thread { - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-3")), - cwd: None, - title: Some("Error thread".into()), - updated_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Error, - diff_stats: None, - workspace: workspace.clone(), - is_live: true, - is_background: false, - highlight_positions: Vec::new(), - }, - // Thread with WaitingForConfirmation status, not active - ListEntry::Thread { - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-4")), - cwd: None, - title: Some("Waiting thread".into()), - updated_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::WaitingForConfirmation, - diff_stats: None, - workspace: workspace.clone(), - is_live: false, - is_background: false, - highlight_positions: Vec::new(), - }, - // Background thread that completed (should show notification) - ListEntry::Thread { - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-5")), - cwd: None, - title: Some("Notified thread".into()), - updated_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - diff_stats: None, - workspace: workspace.clone(), - is_live: true, - is_background: true, - highlight_positions: Vec::new(), - }, - // View More entry - ListEntry::ViewMore { - path_list: expanded_path.clone(), - remaining_count: 42, - }, - // Collapsed project header - ListEntry::ProjectHeader { - path_list: collapsed_path.clone(), - label: "collapsed-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_threads: true, - }, - ]; - // Select the Running thread (index 2) - s.selection = Some(2); - }); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [expanded-project]", - " Completed thread", - " Running thread * (running) <== selected", - " Error thread * (error)", - " Waiting thread (waiting)", - " Notified thread * (!)", - " + View More (42)", - "> [collapsed-project]", - ] - ); - - // Move selection to the collapsed header - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = Some(7); - }); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx).last().cloned(), - Some("> [collapsed-project] <== selected".to_string()), - ); - - // Clear selection - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = None; - }); - - // No entry should have the selected marker - let entries = visible_entries_as_strings(&sidebar, cx); - for entry in &entries { - assert!( - !entry.contains("<== selected"), - "unexpected selection marker in: {}", - entry - ); - } - } - - #[gpui::test] - async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Entries: [header, thread3, thread2, thread1] - // Focusing the sidebar does not set a selection; select_next/select_previous - // handle None gracefully by starting from the first or last entry. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Move down through remaining entries - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // At the end, selection stays on the last entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // Move back up - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // At the top, selection stays on the first entry - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - - // SelectLast jumps to the end - cx.dispatch_action(SelectLast); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // SelectFirst jumps to the beginning - cx.dispatch_action(SelectFirst); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Initially no selection - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Open the sidebar so it's rendered, then focus it to trigger focus_in. - // focus_in no longer sets a default selection. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Manually set a selection, blur, then refocus — selection should be preserved - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.update(|window, _cx| { - window.blur(); - }); - cx.run_until_parked(); - - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); - }); - cx.run_until_parked(); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_project_header_activates_workspace(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_workspace(window, cx); - }); - cx.run_until_parked(); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Thread 1", - "v [Empty Workspace]", - " [+ New Thread]", - ] - ); - - // Switch to workspace 1 so we can verify confirm switches back. - 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 - ); - - // Focus the sidebar and manually select the header (index 0) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - // Press confirm on project header (workspace 0) to activate it. - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - - // Focus should have moved out of the sidebar to the workspace center. - let workspace_0 = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); - workspace_0.update_in(cx, |workspace, window, cx| { - let pane_focus = workspace.active_pane().read(cx).focus_handle(cx); - assert!( - pane_focus.contains_focused(window, cx), - "Confirming a project header should focus the workspace center pane" - ); - }); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(8, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Should show header + 5 threads + "View More (3)" - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); - assert!(entries.iter().any(|e| e.contains("View More (3)"))); - - // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); - - // Confirm on "View More" to expand - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // All 8 threads should now be visible, no "View More" - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 9); // header + 8 threads - assert!(!entries.iter().any(|e| e.contains("View More"))); - } - - #[gpui::test] - async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - - // Focus sidebar and manually select the header (index 0). Press left to collapse. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.dispatch_action(CollapseSelectedEntry); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // Press right to expand - cx.dispatch_action(ExpandSelectedEntry); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] - ); - - // Press right again on already-expanded header moves selection down - cx.dispatch_action(ExpandSelectedEntry); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - } - - #[gpui::test] - async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), then navigate down to the thread (child) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1 <== selected",] - ); - - // Pressing left on a child collapses the parent group and selects it - cx.dispatch_action(CollapseSelectedEntry); - cx.run_until_parked(); - - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - } - - #[gpui::test] - async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { - let project = init_test_project("/empty-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Even an empty project has the header and a new thread button - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [empty-project]", " [+ New Thread]"] - ); - - // Focus sidebar — focus_in does not set a selection - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 (header) - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // SelectNext moves to the new thread button - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // At the end, selection stays on the last entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // SelectPrevious goes back to the header - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), navigate down to the thread (index 1) - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // Collapse the group, which removes the thread from the list - cx.dispatch_action(CollapseSelectedEntry); - cx.run_until_parked(); - - // Selection should be clamped to the last valid index (0 = header) - let selection = sidebar.read_with(cx, |s, _| s.selection); - let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); - assert!( - selection.unwrap_or(0) < entry_count, - "selection {} should be within bounds (entries: {})", - selection.unwrap_or(0), - entry_count, - ); - } - - async fn init_test_project_with_agent_panel( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - - fn add_agent_panel( - workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - workspace.update_in(cx, |workspace, window, cx| { - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - } - - fn setup_sidebar_with_agent_panel( - multi_workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> (Entity, Entity) { - let sidebar = setup_sidebar(multi_workspace, cx); - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - let panel = add_agent_panel(&workspace, project, cx); - (sidebar, panel) - } - - #[gpui::test] - async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Open thread A and keep it generating. - let connection_a = StubAgentConnection::new(); - open_thread_with_connection(&panel, connection_a.clone(), cx); - send_message(&panel, cx); - - let session_id_a = active_session_id(&panel, cx); - save_thread_to_store(&session_id_a, &path_list, cx).await; - - cx.update(|_, cx| { - connection_a.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Open thread B (idle, default response) — thread A goes to background. - let connection_b = StubAgentConnection::new(); - connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection_b, cx); - send_message(&panel, cx); - - let session_id_b = active_session_id(&panel, cx); - save_thread_to_store(&session_id_b, &path_list, cx).await; - - cx.run_until_parked(); - - let mut entries = visible_entries_as_strings(&sidebar, cx); - entries[1..].sort(); - assert_eq!( - entries, - vec!["v [my-project]", " Hello *", " Hello * (running)",] - ); - } - - #[gpui::test] - async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Open thread on workspace A and keep it generating. - let connection_a = StubAgentConnection::new(); - open_thread_with_connection(&panel_a, connection_a.clone(), cx); - send_message(&panel_a, cx); - - let session_id_a = active_session_id(&panel_a, cx); - save_thread_to_store(&session_id_a, &path_list_a, cx).await; - - cx.update(|_, cx| { - connection_a.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Add a second workspace and activate it (making workspace A the background). - let fs = cx.update(|_, cx| ::global(cx)); - let project_b = project::Project::test(fs, [], cx).await; - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - cx.run_until_parked(); - - // Thread A is still running; no notification yet. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Hello * (running)", - "v [Empty Workspace]", - " [+ New Thread]", - ] - ); - - // Complete thread A's turn (transition Running → Completed). - connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); - cx.run_until_parked(); - - // The completed background thread shows a notification indicator. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Hello * (!)", - "v [Empty Workspace]", - " [+ New Thread]", - ] - ); - } - - fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { - sidebar.update_in(cx, |sidebar, window, cx| { - window.focus(&sidebar.filter_editor.focus_handle(cx), cx); - sidebar.filter_editor.update(cx, |editor, cx| { - editor.set_text(query, window, cx); - }); - }); - cx.run_until_parked(); - } - - #[gpui::test] - async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - for (id, title, hour) in [ - ("t-1", "Fix crash in project panel", 3), - ("t-2", "Add inline diff view", 2), - ("t-3", "Refactor settings module", 1), - ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in project panel", - " Add inline diff view", - " Refactor settings module", - ] - ); - - // User types "diff" in the search box — only the matching thread remains, - // with its workspace header preserved for context. - type_in_search(&sidebar, "diff", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Add inline diff view <== selected",] - ); - - // User changes query to something with no matches — list is empty. - type_in_search(&sidebar, "nonexistent", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new() - ); - } - - #[gpui::test] - async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { - // Scenario: A user remembers a thread title but not the exact casing. - // Search should match case-insensitively so they can still find it. - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-1")), - make_test_thread( - "Fix Crash In Project Panel", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - cx.run_until_parked(); - - // Lowercase query matches mixed-case title. - type_in_search(&sidebar, "fix crash", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - - // Uppercase query also matches the same title. - type_in_search(&sidebar, "FIX CRASH", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - } - - #[gpui::test] - async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { - // Scenario: A user searches, finds what they need, then presses Escape - // to dismiss the filter and see the full list again. - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - } - cx.run_until_parked(); - - // Confirm the full list is showing. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread", " Beta thread",] - ); - - // User types a search query to filter down. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread <== selected",] - ); - - // User presses Escape — filter clears, full list is restored. - cx.dispatch_action(Cancel); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Alpha thread <== selected", - " Beta thread", - ] - ); - } - - #[gpui::test] - async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { - let project_a = init_test_project("/project-a", 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); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_a.clone(), - cx, - ) - }); - save_task.await.unwrap(); - } - - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_workspace(window, cx); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_b.clone(), - cx, - ) - }); - save_task.await.unwrap(); - } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar", - " Add tests for editor", - "v [Empty Workspace]", - " Refactor sidebar layout", - " Fix typo in README", - ] - ); - - // "sidebar" matches a thread in each workspace — both headers stay visible. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar <== selected", - "v [Empty Workspace]", - " Refactor sidebar layout", - ] - ); - - // "typo" only matches in the second workspace — the first header disappears. - type_in_search(&sidebar, "typo", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [Empty Workspace]", " Fix typo in README <== selected",] - ); - - // "project-a" matches the first workspace name — the header appears alone - // without any child threads (none of them match "project-a"). - type_in_search(&sidebar, "project-a", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a] <== selected"] - ); - } - - #[gpui::test] - async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { - let project_a = init_test_project("/alpha-project", 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); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_a.clone(), - cx, - ) - }); - save_task.await.unwrap(); - } - - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_workspace(window, cx); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list_b.clone(), - cx, - ) - }); - save_task.await.unwrap(); - } - cx.run_until_parked(); - - // "alpha" matches the workspace name "alpha-project" but no thread titles. - // The workspace header should appear with no child threads. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project] <== selected"] - ); - - // "sidebar" matches thread titles in both workspaces but not workspace names. - // Both headers appear with their matching threads. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - "v [Empty Workspace]", - " Refactor sidebar layout", - ] - ); - - // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r - // doesn't match) — but does not match either workspace name or any thread. - // Actually let's test something simpler: a query that matches both a workspace - // name AND some threads in that workspace. Matching threads should still appear. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - "v [Empty Workspace]", - " Fix typo in README", - ] - ); - - // A query that matches a workspace name AND a thread in that same workspace. - // Both the header (highlighted) and the matching thread should appear. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project] <== selected"] - ); - - // Now search for something that matches only a workspace name when there - // are also threads with matching titles — the non-matching workspace's - // threads should still appear if their titles match. - type_in_search(&sidebar, "alp", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project] <== selected"] - ); - } - - #[gpui::test] - async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - // Create 8 threads. The oldest one has a unique name and will be - // behind View More (only 5 shown by default). - for i in 0..8u32 { - let title = if i == 0 { - "Hidden gem thread".to_string() - } else { - format!("Thread {}", i + 1) - }; - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - make_test_thread( - &title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - } - cx.run_until_parked(); - - // Confirm the thread is not visible and View More is shown. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - entries.iter().any(|e| e.contains("View More")), - "should have View More button" - ); - assert!( - !entries.iter().any(|e| e.contains("Hidden gem")), - "Hidden gem should be behind View More" - ); - - // User searches for the hidden thread — it appears, and View More is gone. - type_in_search(&sidebar, "hidden gem", cx); - let filtered = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - filtered, - vec!["v [my-project]", " Hidden gem thread <== selected",] - ); - assert!( - !filtered.iter().any(|e| e.contains("View More")), - "View More should not appear when filtering" - ); - } - - #[gpui::test] - async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("thread-1")), - make_test_thread( - "Important thread", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - cx.run_until_parked(); - - // User focuses the sidebar and collapses the group using keyboard: - // manually select the header, then press CollapseSelectedEntry to collapse. - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - cx.dispatch_action(CollapseSelectedEntry); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // User types a search — the thread appears even though its group is collapsed. - type_in_search(&sidebar, "important", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]", " Important thread <== selected",] - ); - } - - #[gpui::test] - async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - for (id, title, hour) in [ - ("t-1", "Fix crash in panel", 3), - ("t-2", "Fix lint warnings", 2), - ("t-3", "Add new feature", 1), - ] { - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from(id)), - make_test_thread( - title, - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - } - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, &multi_workspace, cx); - - // User types "fix" — two threads match. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - - // Selection starts on the first matching thread. User presses - // SelectNext to move to the second match. - cx.dispatch_action(SelectNext); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel", - " Fix lint warnings <== selected", - ] - ); - - // User can also jump back with SelectPrevious. - cx.dispatch_action(SelectPrevious); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - } - - #[gpui::test] - async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_workspace(window, cx); - }); - cx.run_until_parked(); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("hist-1")), - make_test_thread( - "Historical Thread", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - cx.run_until_parked(); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Historical Thread", - "v [Empty Workspace]", - " [+ New Thread]", - ] - ); - - // Switch to workspace 1 so we can verify the confirm switches back. - 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 - ); - - // Confirm on the historical (non-live) thread at index 1. - // Before a previous fix, the workspace field was Option and - // historical threads had None, so activate_thread early-returned - // without switching the workspace. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - } - - #[gpui::test] - async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) { - let project = init_test_project("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - let thread_store = cx.update(|_window, cx| ThreadStore::global(cx)); - - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("t-1")), - make_test_thread( - "Thread A", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - let save_task = thread_store.update(cx, |store, cx| { - store.save_thread( - acp::SessionId::new(Arc::from("t-2")), - make_test_thread( - "Thread B", - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - ), - path_list.clone(), - cx, - ) - }); - save_task.await.unwrap(); - cx.run_until_parked(); - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread A", " Thread B",] - ); - - // Keyboard confirm preserves selection. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); - }); - assert_eq!( - sidebar.read_with(cx, |sidebar, _| sidebar.selection), - Some(1) - ); - - // Click handlers clear selection to None so no highlight lingers - // after a click regardless of focus state. The hover style provides - // visual feedback during mouse interaction instead. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = None; - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - sidebar.toggle_collapse(&path_list, window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - - // When the user tabs back into the sidebar, focus_in no longer - // restores selection — it stays None. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.focus_in(window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - } - - #[gpui::test] - async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/my-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Hi there!".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id = active_session_id(&panel, cx); - save_thread_to_store(&session_id, &path_list, cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] - ); - - // Simulate the agent generating a title. The notification chain is: - // AcpThread::set_title emits TitleUpdated → - // ConnectionView::handle_thread_event calls cx.notify() → - // AgentPanel observer fires and emits AgentPanelEvent → - // Sidebar subscription calls update_entries / rebuild_contents. - // - // Before the fix, handle_thread_event did NOT call cx.notify() for - // TitleUpdated, so the AgentPanel observer never fired and the - // sidebar kept showing the old title. - let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); - thread.update(cx, |thread, cx| { - thread - .set_title("Friendly Greeting with AI".into(), cx) - .detach(); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Friendly Greeting with AI *"] - ); - } - - #[gpui::test] - async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Save a thread so it appears in the list. - let connection_a = StubAgentConnection::new(); - connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel_a, connection_a, cx); - send_message(&panel_a, cx); - let session_id_a = active_session_id(&panel_a, cx); - save_thread_to_store(&session_id_a, &path_list_a, cx).await; - - // Add a second workspace with its own agent panel. - let fs = cx.update(|_, cx| ::global(cx)); - fs.as_fake() - .insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; - let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b.clone(), window, cx) - }); - let panel_b = add_agent_panel(&workspace_b, &project_b, cx); - cx.run_until_parked(); - - let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); - - // ── 1. Initial state: no focused thread ────────────────────────────── - // Workspace B is active (just added), so its header is the active entry. - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread, None, - "Initially no thread should be focused" - ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the active workspace header" - ); - }); - - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_thread( - acp_thread::AgentSessionInfo { - session_id: session_id_a.clone(), - cwd: None, - title: Some("Test".into()), - updated_at: None, - meta: None, - }, - &workspace_a, - window, - cx, - ); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "After clicking a thread, it should be the focused thread" - ); - let active_entry = sidebar.active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_a), - "Active entry should be the clicked thread" - ); - }); - - workspace_a.read_with(cx, |workspace, cx| { - assert!( - workspace.panel::(cx).is_some(), - "Agent panel should exist" - ); - let dock = workspace.right_dock().read(cx); - assert!( - dock.is_open(), - "Clicking a thread should open the agent panel dock" - ); - }); - - let connection_b = StubAgentConnection::new(); - connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Thread B".into()), - )]); - open_thread_with_connection(&panel_b, connection_b, cx); - send_message(&panel_b, cx); - let session_id_b = active_session_id(&panel_b, cx); - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - save_thread_to_store(&session_id_b, &path_list_b, cx).await; - cx.run_until_parked(); - - // Workspace A is currently active. Click a thread in workspace B, - // which also triggers a workspace switch. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_thread( - acp_thread::AgentSessionInfo { - session_id: session_id_b.clone(), - cwd: None, - title: Some("Thread B".into()), - updated_at: None, - meta: None, - }, - &workspace_b, - window, - cx, - ); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b), - "Clicking a thread in another workspace should focus that thread" - ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b), - "Active entry should be the cross-workspace thread" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_next_workspace(window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread, None, - "External workspace switch should clear focused_thread" - ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the workspace header after external switch" - ); - }); - - let connection_b2 = StubAgentConnection::new(); - connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("New thread".into()), - )]); - open_thread_with_connection(&panel_b, connection_b2, cx); - send_message(&panel_b, cx); - let session_id_b2 = active_session_id(&panel_b, cx); - save_thread_to_store(&session_id_b2, &path_list_b, cx).await; - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Opening a thread externally should set focused_thread" - ); - }); - - workspace_b.update_in(cx, |workspace, window, cx| { - workspace.focus_handle(cx).focus(window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Defocusing the sidebar should not clear focused_thread" - ); - }); - - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_workspace(&workspace_b, window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread, None, - "Clicking a workspace header should clear focused_thread" - ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::ProjectHeader { .. })), - "Active entry should be the workspace header" - ); - }); - - // ── 8. Focusing the agent panel thread restores focused_thread ──── - // Workspace B still has session_id_b2 loaded in the agent panel. - // Clicking into the thread (simulated by focusing its view) should - // set focused_thread via the ThreadFocused event. - panel_b.update_in(cx, |panel, window, cx| { - if let Some(thread_view) = panel.active_connection_view() { - thread_view.read(cx).focus_handle(cx).focus(window, cx); - } - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Focusing the agent panel thread should set focused_thread" - ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); - assert!( - matches!(active_entry, Some(ListEntry::Thread { session_info, .. }) if session_info.session_id == session_id_b2), - "Active entry should be the focused thread" - ); - }); - } -} diff --git a/crates/sqlez/src/connection.rs b/crates/sqlez/src/connection.rs index 53f0d4e2614f340cc0563d5cd9374bdc3626d9bb..fb3194aaf428f9848b858b104e94de60765d6f9a 100644 --- a/crates/sqlez/src/connection.rs +++ b/crates/sqlez/src/connection.rs @@ -18,7 +18,7 @@ pub struct Connection { unsafe impl Send for Connection {} impl Connection { - pub(crate) fn open(uri: &str, persistent: bool) -> Result { + fn open_with_flags(uri: &str, persistent: bool, flags: i32) -> Result { let mut connection = Self { sqlite3: ptr::null_mut(), persistent, @@ -26,7 +26,6 @@ impl Connection { _sqlite: PhantomData, }; - let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE; unsafe { sqlite3_open_v2( CString::new(uri)?.as_ptr(), @@ -44,6 +43,14 @@ impl Connection { Ok(connection) } + pub(crate) fn open(uri: &str, persistent: bool) -> Result { + Self::open_with_flags( + uri, + persistent, + SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE, + ) + } + /// Attempts to open the database at uri. If it fails, a shared memory db will be opened /// instead. pub fn open_file(uri: &str) -> Self { @@ -51,13 +58,17 @@ impl Connection { } pub fn open_memory(uri: Option<&str>) -> Self { - let in_memory_path = if let Some(uri) = uri { - format!("file:{}?mode=memory&cache=shared", uri) + if let Some(uri) = uri { + let in_memory_path = format!("file:{}?mode=memory&cache=shared", uri); + return Self::open_with_flags( + &in_memory_path, + false, + SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE | SQLITE_OPEN_URI, + ) + .expect("Could not create fallback in memory db"); } else { - ":memory:".to_string() - }; - - Self::open(&in_memory_path, false).expect("Could not create fallback in memory db") + Self::open(":memory:", false).expect("Could not create fallback in memory db") + } } pub fn persistent(&self) -> bool { @@ -265,9 +276,50 @@ impl Drop for Connection { mod test { use anyhow::Result; use indoc::indoc; + use std::{ + fs, + sync::atomic::{AtomicUsize, Ordering}, + }; use crate::connection::Connection; + static NEXT_NAMED_MEMORY_DB_ID: AtomicUsize = AtomicUsize::new(0); + + fn unique_named_memory_db(prefix: &str) -> String { + format!( + "{prefix}_{}_{}", + std::process::id(), + NEXT_NAMED_MEMORY_DB_ID.fetch_add(1, Ordering::Relaxed) + ) + } + + fn literal_named_memory_paths(name: &str) -> [String; 3] { + let main = format!("file:{name}?mode=memory&cache=shared"); + [main.clone(), format!("{main}-wal"), format!("{main}-shm")] + } + + struct NamedMemoryPathGuard { + paths: [String; 3], + } + + impl NamedMemoryPathGuard { + fn new(name: &str) -> Self { + let paths = literal_named_memory_paths(name); + for path in &paths { + let _ = fs::remove_file(path); + } + Self { paths } + } + } + + impl Drop for NamedMemoryPathGuard { + fn drop(&mut self) { + for path in &self.paths { + let _ = fs::remove_file(path); + } + } + } + #[test] fn string_round_trips() -> Result<()> { let connection = Connection::open_memory(Some("string_round_trips")); @@ -382,6 +434,41 @@ mod test { assert_eq!(read_blobs, vec![blob]); } + #[test] + fn named_memory_connections_do_not_create_literal_backing_files() { + let name = unique_named_memory_db("named_memory_connections_do_not_create_backing_files"); + let guard = NamedMemoryPathGuard::new(&name); + + let connection1 = Connection::open_memory(Some(&name)); + connection1 + .exec(indoc! {" + CREATE TABLE shared ( + value INTEGER + )"}) + .unwrap()() + .unwrap(); + connection1 + .exec("INSERT INTO shared (value) VALUES (7)") + .unwrap()() + .unwrap(); + + let connection2 = Connection::open_memory(Some(&name)); + assert_eq!( + connection2 + .select_row::("SELECT value FROM shared") + .unwrap()() + .unwrap(), + Some(7) + ); + + for path in &guard.paths { + assert!( + fs::metadata(path).is_err(), + "named in-memory database unexpectedly created backing file {path}" + ); + } + } + #[test] fn multi_step_statement_works() { let connection = Connection::open_memory(Some("multi_step_statement_works")); diff --git a/crates/sqlez/src/thread_safe_connection.rs b/crates/sqlez/src/thread_safe_connection.rs index 966f14a9c2f244780da7190aebac88e95c7ac068..7b3630cdf65f900469e3d7544f3bd75b33250625 100644 --- a/crates/sqlez/src/thread_safe_connection.rs +++ b/crates/sqlez/src/thread_safe_connection.rs @@ -7,12 +7,15 @@ use std::{ ops::Deref, sync::{Arc, LazyLock}, thread, + time::Duration, }; use thread_local::ThreadLocal; use crate::{connection::Connection, domain::Migrator, util::UnboundedSyncSender}; const MIGRATION_RETRIES: usize = 10; +const CONNECTION_INITIALIZE_RETRIES: usize = 50; +const CONNECTION_INITIALIZE_RETRY_DELAY: Duration = Duration::from_millis(1); type QueuedWrite = Box; type WriteQueue = Box; @@ -197,21 +200,54 @@ impl ThreadSafeConnection { Self::open_shared_memory(uri) }; + if let Some(initialize_query) = connection_initialize_query { + let mut last_error = None; + let initialized = (0..CONNECTION_INITIALIZE_RETRIES).any(|attempt| { + match connection + .exec(initialize_query) + .and_then(|mut statement| statement()) + { + Ok(()) => true, + Err(err) + if is_schema_lock_error(&err) + && attempt + 1 < CONNECTION_INITIALIZE_RETRIES => + { + last_error = Some(err); + thread::sleep(CONNECTION_INITIALIZE_RETRY_DELAY); + false + } + Err(err) => { + panic!( + "Initialize query failed to execute: {}\n\nCaused by:\n{err:#}", + initialize_query + ) + } + } + }); + + if !initialized { + let err = last_error + .expect("connection initialization retries should record the last error"); + panic!( + "Initialize query failed to execute after retries: {}\n\nCaused by:\n{err:#}", + initialize_query + ); + } + } + // Disallow writes on the connection. The only writes allowed for thread safe connections // are from the background thread that can serialize them. *connection.write.get_mut() = false; - if let Some(initialize_query) = connection_initialize_query { - connection.exec(initialize_query).unwrap_or_else(|_| { - panic!("Initialize query failed to execute: {}", initialize_query) - })() - .unwrap() - } - connection } } +fn is_schema_lock_error(err: &anyhow::Error) -> bool { + let message = format!("{err:#}"); + message.contains("database schema is locked") || message.contains("database is locked") +} + impl ThreadSafeConnection { /// Special constructor for ThreadSafeConnection which disallows db initialization and migrations. /// This allows construction to be infallible and not write to the db. @@ -282,7 +318,7 @@ mod test { use indoc::indoc; use std::ops::Deref; - use std::thread; + use std::{thread, time::Duration}; use crate::{domain::Domain, thread_safe_connection::ThreadSafeConnection}; @@ -318,38 +354,21 @@ mod test { } #[test] - #[should_panic] - fn wild_zed_lost_failure() { - enum TestWorkspace {} - impl Domain for TestWorkspace { - const NAME: &str = "workspace"; - - const MIGRATIONS: &[&str] = &[" - CREATE TABLE workspaces( - workspace_id INTEGER PRIMARY KEY, - dock_visible INTEGER, -- Boolean - dock_anchor TEXT, -- Enum: 'Bottom' / 'Right' / 'Expanded' - dock_pane INTEGER, -- NULL indicates that we don't have a dock pane yet - timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - FOREIGN KEY(dock_pane) REFERENCES panes(pane_id), - FOREIGN KEY(active_pane) REFERENCES panes(pane_id) - ) STRICT; - - CREATE TABLE panes( - pane_id INTEGER PRIMARY KEY, - workspace_id INTEGER NOT NULL, - active INTEGER NOT NULL, -- Boolean - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ON UPDATE CASCADE - ) STRICT; - "]; - } - - let builder = - ThreadSafeConnection::builder::("wild_zed_lost_failure", false) - .with_connection_initialize_query("PRAGMA FOREIGN_KEYS=true"); - - smol::block_on(builder.build()).unwrap(); + fn connection_initialize_query_retries_transient_schema_lock() { + let name = "connection_initialize_query_retries_transient_schema_lock"; + let locking_connection = crate::connection::Connection::open_memory(Some(name)); + locking_connection.exec("BEGIN IMMEDIATE").unwrap()().unwrap(); + locking_connection + .exec("CREATE TABLE test(col TEXT)") + .unwrap()() + .unwrap(); + + let releaser = thread::spawn(move || { + thread::sleep(Duration::from_millis(10)); + locking_connection.exec("ROLLBACK").unwrap()().unwrap(); + }); + + ThreadSafeConnection::create_connection(false, name, Some("PRAGMA FOREIGN_KEYS=true")); + releaser.join().unwrap(); } } diff --git a/crates/svg_preview/src/svg_preview_view.rs b/crates/svg_preview/src/svg_preview_view.rs index cc7e2052295f735f06e94f080a60ef25ec4da49d..1a001c6e18854428636626cc499e49433710a84d 100644 --- a/crates/svg_preview/src/svg_preview_view.rs +++ b/crates/svg_preview/src/svg_preview_view.rs @@ -182,7 +182,7 @@ impl SvgPreviewView { buffer, window, move |this, _buffer, event: &BufferEvent, window, cx| match event { - BufferEvent::Edited | BufferEvent::Saved => { + BufferEvent::Edited { .. } | BufferEvent::Saved => { this.render_image(window, cx); } _ => {} diff --git a/crates/tab_switcher/Cargo.toml b/crates/tab_switcher/Cargo.toml index 36e4ba77342796ae5967e81cd34e01b8d41aecf6..e2855aa1696c3af0c3efeb2b927f968783978332 100644 --- a/crates/tab_switcher/Cargo.toml +++ b/crates/tab_switcher/Cargo.toml @@ -29,10 +29,8 @@ util.workspace = true workspace.workspace = true [dev-dependencies] -anyhow.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index 29e6a9de7fab9b5421fe38fee0fd24fd43b12ccc..fdacef3b193beb8a656916edb61fbff1a200385b 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/crates/tasks_ui/src/tasks_ui.rs @@ -316,7 +316,9 @@ pub fn task_contexts( let lsp_task_sources = active_editor .as_ref() - .map(|active_editor| active_editor.update(cx, |editor, cx| editor.lsp_task_sources(cx))) + .map(|active_editor| { + active_editor.update(cx, |editor, cx| editor.lsp_task_sources(false, false, cx)) + }) .unwrap_or_default(); let latest_selection = active_editor.as_ref().map(|active_editor| { diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index ee29546b81c32038e85805850bc07111fca81af7..fcb637f14b3785cf2d11b68b8cbf60934f055df4 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -49,6 +49,5 @@ windows.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } rand.workspace = true -serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } util_macros.workspace = true diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 45f22319869381ae497e64c2f8e65abed6fe9d69..f24bd5ead6cfd8cb0d4ded66a770a6040d957b72 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -50,6 +50,7 @@ pub struct TerminalSettings { pub minimum_contrast: f32, pub path_hyperlink_regexes: Vec, pub path_hyperlink_timeout_ms: u64, + pub show_count_badge: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -129,6 +130,7 @@ impl settings::Settings for TerminalSettings { }) .collect(), path_hyperlink_timeout_ms: project_content.path_hyperlink_timeout_ms.unwrap(), + show_count_badge: user_content.show_count_badge.unwrap(), } } } diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 08ffbf36263d11d4b73f02c212e571c7c11d29b8..6fc1d4ae710a342b2d275b6dd5713d37a14b1da6 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -48,11 +48,9 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -client = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } -rand.workspace = true terminal = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 93b9e651191e791da8bbda35600c3db001b46d90..b3c1f0bf1754d9b0d814bea3dff48b5a7f205613 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1606,6 +1606,9 @@ impl Panel for TerminalPanel { } fn icon_label(&self, _window: &Window, cx: &App) -> Option { + if !TerminalSettings::get_global(cx).show_count_badge { + return None; + } let count = self .center .panes() 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/text/Cargo.toml b/crates/text/Cargo.toml index 47c1dd768d19492e43231a3e8cd8270fb648f39c..4dc186b374719bdf0112243160d09c14e0bc5970 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -35,6 +35,4 @@ ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } rand.workspace = true util = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } zlog.workspace = true -proptest.workspace = true diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 194ac2a40d5ac96a39177eedd35b991ded30de38..d5d3facb9b97d09e4724369bd17df639e2b6ac42 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -30,6 +30,24 @@ fn test_edit() { assert_eq!(buffer.text(), "ghiamnoef"); } +#[test] +fn test_point_for_row_and_column_from_external_source() { + let buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + "aéøbcdef\nsecond", + ); + let snapshot = buffer.snapshot(); + + assert_eq!(snapshot.point_from_external_input(0, 0), Point::new(0, 0)); + assert_eq!(snapshot.point_from_external_input(0, 4), Point::new(0, 6)); + assert_eq!( + snapshot.point_from_external_input(0, 100), + Point::new(0, 10) + ); + assert_eq!(snapshot.point_from_external_input(1, 3), Point::new(1, 3)); +} + #[gpui::test(iterations = 100)] fn test_random_edits(mut rng: StdRng) { let operations = env::var("OPERATIONS") diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index a991a72df40c502a90aa0b82191b37c54b3f8de2..c054a4caacd34904090397612474be55c48ffbfd 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2254,6 +2254,37 @@ impl BufferSnapshot { (row_end_offset - row_start_offset) as u32 } + /// A function to convert character offsets from e.g. user's `go.mod:22:33` input into byte-offset Point columns. + pub fn point_from_external_input(&self, row: u32, characters: u32) -> Point { + const MAX_BYTES_IN_UTF_8: u32 = 4; + + let row = row.min(self.max_point().row); + let start = Point::new(row, 0); + let end = self.clip_point( + Point::new( + row, + characters + .saturating_mul(MAX_BYTES_IN_UTF_8) + .saturating_add(1), + ), + Bias::Right, + ); + let range = start..end; + let mut point = range.start; + let mut remaining_columns = characters; + + for chunk in self.text_for_range(range) { + for character in chunk.chars() { + if remaining_columns == 0 { + return point; + } + remaining_columns -= 1; + point.column += character.len_utf8() as u32; + } + } + point + } + pub fn line_indents_in_row_range( &self, row_range: Range, diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index a092e2698722a980f0b2a4b5ea64b9bfa0f33d01..c09d3daf6074f24248de12e56ebc2122e2c123e7 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme/src/settings.rs @@ -378,14 +378,14 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { if let Some(selection) = theme.theme.as_mut() { match selection { - settings::ThemeSelection::Static(theme) => { + settings::ThemeSelection::Static(_) => { // If the theme was previously set to a single static theme, - // we don't know whether it was a light or dark theme, so we - // just use it for both. + // reset to the default dynamic light/dark pair and let users + // customize light/dark themes explicitly afterward. *selection = settings::ThemeSelection::Dynamic { - mode, - light: theme.clone(), - dark: theme.clone(), + mode: ThemeAppearanceMode::System, + light: ThemeName(settings::DEFAULT_LIGHT_THEME.into()), + dark: ThemeName(settings::DEFAULT_DARK_THEME.into()), }; } settings::ThemeSelection::Dynamic { 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/Cargo.toml b/crates/title_bar/Cargo.toml index a9988d498e463edb463175ec19867fa6624479e5..f6483d1d70d4017edf8ab8b188d67ecf85e19aef 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -18,9 +18,9 @@ stories = ["dep:story"] test-support = [ "call/test-support", "client/test-support", - "collections/test-support", + "gpui/test-support", - "http_client/test-support", + "project/test-support", "remote/test-support", "util/test-support", @@ -38,7 +38,6 @@ chrono.workspace = true client.workspace = true cloud_api_types.workspace = true db.workspace = true -feature_flags.workspace = true git_ui.workspace = true gpui = { workspace = true, features = ["screen-capture"] } notifications.workspace = true @@ -65,17 +64,13 @@ windows.workspace = true [dev-dependencies] call = { workspace = true, features = ["test-support"] } client = { workspace = true, features = ["test-support"] } -collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } notifications = { workspace = true, features = ["test-support"] } -pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } release_channel.workspace = true remote = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } semver.workspace = true settings = { workspace = true, features = ["test-support"] } -tree-sitter-md.workspace = true util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/title_bar/src/plan_chip.rs b/crates/title_bar/src/plan_chip.rs index edec0da2dea317bd122ece14d6afb90a31990c96..237e507ed8e4d1a5f63a7df116bf08fd69086bc2 100644 --- a/crates/title_bar/src/plan_chip.rs +++ b/crates/title_bar/src/plan_chip.rs @@ -33,6 +33,7 @@ impl RenderOnce for PlanChip { Plan::ZedFree => ("Free", Color::Default, free_chip_bg), Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), + Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), }; diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 3566d6210769c09a8a6de1706cb258ff2b119ce9..7fc86706a3eb0971b1f8539d76b8daf3b709537e 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -24,16 +24,13 @@ use auto_update::AutoUpdateStatus; use call::ActiveCall; use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; -use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ Action, AnyElement, App, Context, Corner, Element, Empty, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, }; use onboarding_banner::OnboardingBanner; -use project::{ - DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees, -}; +use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees}; use remote::RemoteConnectionOptions; use settings::Settings; use settings::WorktreeId; @@ -47,8 +44,7 @@ use ui::{ use update_version::UpdateVersion; use util::ResultExt; use workspace::{ - MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace, - notifications::NotifyResultExt, + MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt, }; use zed_actions::OpenRemote; @@ -151,6 +147,7 @@ pub struct TitleBar { user_store: Entity, client: Arc, workspace: WeakEntity, + multi_workspace: Option>, application_menu: Option>, _subscriptions: Vec, banner: Entity, @@ -173,7 +170,6 @@ impl Render for TitleBar { let mut render_project_items = title_bar_settings.show_branch_name || title_bar_settings.show_project_items; title_bar - .children(self.render_workspace_sidebar_toggle(window, cx)) .when_some( self.application_menu.clone().filter(|_| !show_menus), |title_bar, menu| { @@ -188,7 +184,7 @@ impl Render for TitleBar { .when(title_bar_settings.show_project_items, |title_bar| { title_bar .children(self.render_project_host(cx)) - .child(self.render_project_name(cx)) + .child(self.render_project_name(window, cx)) }) .when(title_bar_settings.show_branch_name, |title_bar| { title_bar.children(self.render_project_branch(cx)) @@ -356,7 +352,6 @@ impl TitleBar { // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar. { - let platform_titlebar = platform_titlebar.clone(); let window_handle = window.window_handle(); cx.spawn(async move |this: WeakEntity, cx| { let Some(multi_workspace_handle) = window_handle.downcast::() @@ -369,26 +364,9 @@ impl TitleBar { return; }; - let is_open = multi_workspace.read(cx).is_sidebar_open(); - let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - titlebar.set_sidebar_has_notifications(has_notifications, cx); - }); - - let platform_titlebar = platform_titlebar.clone(); - let subscription = cx.observe(&multi_workspace, move |mw, cx| { - let is_open = mw.read(cx).is_sidebar_open(); - let has_notifications = mw.read(cx).sidebar_has_notifications(cx); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - titlebar.set_sidebar_has_notifications(has_notifications, cx); - }); - }); - if let Some(this) = this.upgrade() { this.update(cx, |this, _| { - this._subscriptions.push(subscription); + this.multi_workspace = Some(multi_workspace.downgrade()); }); } }); @@ -400,6 +378,7 @@ impl TitleBar { platform_titlebar, application_menu, workspace: workspace.weak_handle(), + multi_workspace: None, project, user_store, client, @@ -604,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", @@ -683,42 +663,7 @@ impl TitleBar { ) } - fn render_workspace_sidebar_toggle( - &self, - _window: &mut Window, - cx: &mut Context, - ) -> Option { - if !cx.has_flag::() || DisableAiSettings::get_global(cx).disable_ai { - return None; - } - - let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); - - if is_sidebar_open { - return None; - } - - let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications(); - - Some( - IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) - .icon_size(IconSize::Small) - .when(has_notifications, |button| { - button - .indicator(Indicator::dot().color(Color::Accent)) - .indicator_border_color(Some(cx.theme().colors().title_bar_background)) - }) - .tooltip(move |_, cx| { - Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) - }) - .on_click(|_, window, cx| { - window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); - }) - .into_any_element(), - ) - } - - pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { + pub fn render_project_name(&self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let workspace = self.workspace.clone(); let name = self.effective_active_worktree(cx).map(|worktree| { @@ -753,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)), @@ -835,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.rs b/crates/ui/src/components.rs index cce736e237e2c2500b56f13ae579dee4426b5bfb..68b1ff9beb7a8918ee3f5e1857e3cc68e15a3fc1 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -6,12 +6,14 @@ mod callout; mod chip; mod collab; mod context_menu; +mod count_badge; mod data_table; mod diff_stat; mod disclosure; mod divider; mod dropdown_menu; mod facepile; +mod gradient_fade; mod group; mod icon; mod image; @@ -48,12 +50,14 @@ pub use callout::*; pub use chip::*; pub use collab::*; pub use context_menu::*; +pub use count_badge::*; pub use data_table::*; pub use diff_stat::*; pub use disclosure::*; pub use divider::*; pub use dropdown_menu::*; pub use facepile::*; +pub use gradient_fade::*; pub use group::*; pub use icon::*; pub use image::*; 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 171a6968290b3239e21faf9cd669559b88f9a964..6ab137227a4699e38a90b530a5554e6fe66f1ee5 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -1,9 +1,10 @@ use crate::{ - DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel, - prelude::*, + CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration, + IconDecorationKind, prelude::*, }; -use gpui::{AnyView, ClickEvent, Hsla, SharedString, linear_color_stop, linear_gradient}; +use gpui::{Animation, AnimationExt, AnyView, ClickEvent, Hsla, SharedString, pulsating_between}; +use std::time::Duration; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum AgentThreadStatus { @@ -23,9 +24,11 @@ pub struct ThreadItem { timestamp: SharedString, notified: bool, status: AgentThreadStatus, + generating_title: bool, selected: bool, focused: bool, hovered: bool, + docked_right: bool, added: Option, removed: Option, worktree: Option, @@ -47,9 +50,11 @@ impl ThreadItem { timestamp: "".into(), notified: false, status: AgentThreadStatus::default(), + generating_title: false, selected: false, focused: false, hovered: false, + docked_right: false, added: None, removed: None, worktree: None, @@ -87,6 +92,11 @@ impl ThreadItem { self } + pub fn generating_title(mut self, generating: bool) -> Self { + self.generating_title = generating; + self + } + pub fn selected(mut self, selected: bool) -> Self { self.selected = selected; self @@ -107,6 +117,11 @@ impl ThreadItem { self } + pub fn docked_right(mut self, docked_right: bool) -> Self { + self.docked_right = docked_right; + self + } + pub fn worktree(mut self, worktree: impl Into) -> Self { self.worktree = Some(worktree.into()); self @@ -154,12 +169,12 @@ impl ThreadItem { impl RenderOnce for ThreadItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let color = cx.theme().colors(); - // let dot_separator = || { - // Label::new("•") - // .size(LabelSize::Small) - // .color(Color::Muted) - // .alpha(0.5) - // }; + let dot_separator = || { + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5) + }; let icon_container = || h_flex().size_4().flex_none().justify_center(); let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg { @@ -194,21 +209,38 @@ impl RenderOnce for ThreadItem { None }; - let icon = if let Some(decoration) = decoration { - icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration))) - } else { - icon_container().child(agent_icon) - }; - let is_running = matches!( self.status, AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation ); - let running_or_action = is_running || (self.hovered && self.action_slot.is_some()); + + let icon = if is_running { + icon_container().child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(2), + ) + } else if let Some(decoration) = decoration { + icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration))) + } else { + icon_container().child(agent_icon) + }; let title = self.title; let highlight_positions = self.highlight_positions; - let title_label = if highlight_positions.is_empty() { + let title_label = if self.generating_title { + Label::new("New Thread…") + .color(Color::Muted) + .with_animation( + "generating-title", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any_element() + } else if highlight_positions.is_empty() { Label::new(title).into_any_element() } else { HighlightedLabel::new(title, highlight_positions).into_any_element() @@ -220,24 +252,20 @@ impl RenderOnce for ThreadItem { color.panel_background }; - let gradient_overlay = div() - .absolute() - .top_0() - .right(px(-10.0)) - .w_12() - .h_full() - .bg(linear_gradient( - 90., - linear_color_stop(base_bg, 0.6), - linear_color_stop(base_bg.opacity(0.0), 0.), - )) - .group_hover("thread-item", |s| { - s.bg(linear_gradient( - 90., - linear_color_stop(color.element_hover, 0.6), - linear_color_stop(color.element_hover.opacity(0.0), 0.), - )) - }); + let gradient_overlay = + GradientFade::new(base_bg, color.element_hover, color.element_active) + .width(px(64.0)) + .right(px(-10.0)) + .gradient_stop(0.75) + .group_name("thread-item"); + + let has_diff_stats = self.added.is_some() || self.removed.is_some(); + let added_count = self.added.unwrap_or(0); + let removed_count = self.removed.unwrap_or(0); + let diff_stat_id = self.id.clone(); + let has_worktree = self.worktree.is_some(); + let has_timestamp = !self.timestamp.is_empty(); + let timestamp = self.timestamp; v_flex() .id(self.id.clone()) @@ -246,18 +274,16 @@ impl RenderOnce for ThreadItem { .overflow_hidden() .cursor_pointer() .w_full() - .map(|this| { - if self.worktree.is_some() { - this.p_2() - } else { - this.px_2().py_1() - } - }) + .p_1() .when(self.selected, |s| s.bg(color.element_active)) .border_1() .border_color(gpui::transparent_black()) - .when(self.focused, |s| s.border_color(color.panel_focused_border)) + .when(self.focused, |s| { + s.when(self.docked_right, |s| s.border_r_2()) + .border_color(color.border_focused) + }) .hover(|s| s.bg(color.element_hover)) + .active(|s| s.bg(color.element_active)) .on_hover(self.on_hover) .child( h_flex() @@ -276,20 +302,20 @@ impl RenderOnce for ThreadItem { .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)), ) .child(gradient_overlay) - .when(running_or_action, |this| { - this.child( - h_flex() - .gap_1() - .when(is_running, |this| { - this.child( - icon_container() - .child(SpinnerLabel::new().color(Color::Accent)), - ) - }) - .when(self.hovered, |this| { - this.when_some(self.action_slot, |this, slot| this.child(slot)) - }), - ) + .when(self.hovered, |this| { + this.when_some(self.action_slot, |this, slot| { + let overlay = GradientFade::new( + base_bg, + color.element_hover, + color.element_active, + ) + .width(px(64.0)) + .right(px(6.)) + .gradient_stop(0.75) + .group_name("thread-item"); + + this.child(h_flex().relative().child(overlay).child(slot)) + }) }), ) .when_some(self.worktree, |this, worktree| { @@ -312,32 +338,48 @@ impl RenderOnce for ThreadItem { .gap_1p5() .child(icon_container()) // Icon Spacing .child(worktree_label) - // TODO: Uncomment the elements below when we're ready to expose this data - // .child(dot_separator()) - // .child( - // Label::new(self.timestamp) - // .size(LabelSize::Small) - // .color(Color::Muted), - // ) - // .child( - // Label::new("•") - // .size(LabelSize::Small) - // .color(Color::Muted) - // .alpha(0.5), - // ) - // .when(has_no_changes, |this| { - // this.child( - // Label::new("No Changes") - // .size(LabelSize::Small) - // .color(Color::Muted), - // ) - // }) - .when(self.added.is_some() || self.removed.is_some(), |this| { - this.child(DiffStat::new( - self.id, - self.added.unwrap_or(0), - self.removed.unwrap_or(0), - )) + .when(has_diff_stats || has_timestamp, |this| { + this.child(dot_separator()) + }) + .when(has_diff_stats, |this| { + 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()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + }) + .when(!has_worktree && (has_diff_stats || has_timestamp), |this| { + this.child( + h_flex() + .min_w_0() + .gap_1p5() + .child(icon_container()) // Icon Spacing + .when(has_diff_stats, |this| { + 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()) + }) + .when(has_timestamp, |this| { + this.child( + Label::new(timestamp.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) }), ) }) @@ -361,21 +403,31 @@ impl Component for ThreadItem { let thread_item_examples = vec![ single_example( - "Default", + "Default (minutes)", container() .child( ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings") .icon(IconName::AiOpenAi) - .timestamp("1:33 AM"), + .timestamp("15m"), ) .into_any_element(), ), single_example( - "Notified", + "Timestamp Only (hours)", + container() + .child( + ThreadItem::new("ti-1b", "Thread with just a timestamp") + .icon(IconName::AiClaude) + .timestamp("3h"), + ) + .into_any_element(), + ), + single_example( + "Notified (weeks)", container() .child( ThreadItem::new("ti-2", "Refine thread view scrolling behavior") - .timestamp("12:12 AM") + .timestamp("1w") .notified(true), ) .into_any_element(), @@ -385,7 +437,7 @@ impl Component for ThreadItem { container() .child( ThreadItem::new("ti-2b", "Execute shell command in terminal") - .timestamp("12:15 AM") + .timestamp("2h") .status(AgentThreadStatus::WaitingForConfirmation), ) .into_any_element(), @@ -395,7 +447,7 @@ impl Component for ThreadItem { container() .child( ThreadItem::new("ti-2c", "Failed to connect to language server") - .timestamp("12:20 AM") + .timestamp("5h") .status(AgentThreadStatus::Error), ) .into_any_element(), @@ -406,7 +458,7 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) - .timestamp("7:30 PM") + .timestamp("23h") .status(AgentThreadStatus::Running), ) .into_any_element(), @@ -417,30 +469,43 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) - .timestamp("7:37 PM") + .timestamp("2w") .worktree("link-agent-panel"), ) .into_any_element(), ), single_example( - "With Changes", + "With Changes (months)", container() .child( ThreadItem::new("ti-5", "Managing user and project settings interactions") .icon(IconName::AiClaude) - .timestamp("7:37 PM") + .timestamp("1mo") .added(10) .removed(3), ) .into_any_element(), ), + single_example( + "Worktree + Changes + Timestamp", + container() + .child( + ThreadItem::new("ti-5b", "Full metadata example") + .icon(IconName::AiClaude) + .worktree("my-project") + .added(42) + .removed(17) + .timestamp("3w"), + ) + .into_any_element(), + ), single_example( "Selected Item", container() .child( ThreadItem::new("ti-6", "Refine textarea interaction behavior") .icon(IconName::AiGemini) - .timestamp("3:00 PM") + .timestamp("45m") .selected(true), ) .into_any_element(), @@ -451,23 +516,74 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-7", "Implement keyboard navigation") .icon(IconName::AiClaude) - .timestamp("4:00 PM") + .timestamp("12h") .focused(true), ) .into_any_element(), ), + single_example( + "Focused + Docked Right", + container() + .child( + ThreadItem::new("ti-7b", "Focused with right dock border") + .icon(IconName::AiClaude) + .timestamp("1w") + .focused(true) + .docked_right(true), + ) + .into_any_element(), + ), single_example( "Selected + Focused", container() .child( ThreadItem::new("ti-8", "Active and keyboard-focused thread") .icon(IconName::AiGemini) - .timestamp("5:00 PM") + .timestamp("2mo") .selected(true) .focused(true), ) .into_any_element(), ), + single_example( + "Hovered with Action Slot", + container() + .child( + ThreadItem::new("ti-9", "Hover to see action button") + .icon(IconName::AiClaude) + .timestamp("6h") + .hovered(true) + .action_slot( + IconButton::new("delete", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ), + ) + .into_any_element(), + ), + single_example( + "Search Highlight", + container() + .child( + ThreadItem::new("ti-10", "Implement keyboard navigation") + .icon(IconName::AiClaude) + .timestamp("4w") + .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), + ) + .into_any_element(), + ), + single_example( + "Worktree Search Highlight", + container() + .child( + ThreadItem::new("ti-11", "Search in worktree name") + .icon(IconName::AiClaude) + .timestamp("3mo") + .worktree("my-project-name") + .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]), + ) + .into_any_element(), + ), ]; Some( 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 feba75a579883cb5e8373479c3fade2fbd4f0006..fbd5903cf56ad439cbc3b798d2f4f20d4962315e 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, Role}; 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 @@ -440,16 +382,12 @@ impl RenderOnce for Button { .aria_label(&label) .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() @@ -469,16 +407,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 + }) }), ) } @@ -589,24 +523,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/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index 438f20dea229a5d687141fbf44e59e03e759ca0a..304eb82f29728aedf9c3359f6f37208c56af9a80 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, @@ -117,6 +119,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 } @@ -197,9 +200,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 => { @@ -208,20 +227,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/chip.rs b/crates/ui/src/components/chip.rs index ce709fe3962f742f5208808315f3bdac09c1f513..06dc7e6afa6fa8723985913dfece4205e360511e 100644 --- a/crates/ui/src/components/chip.rs +++ b/crates/ui/src/components/chip.rs @@ -81,8 +81,7 @@ impl RenderOnce for Chip { h_flex() .when_some(self.height, |this, h| this.h(h)) - .min_w_0() - .flex_initial() + .flex_none() .px_1() .border_1() .rounded_sm() diff --git a/crates/ui/src/components/count_badge.rs b/crates/ui/src/components/count_badge.rs new file mode 100644 index 0000000000000000000000000000000000000000..c546d69e6d15b12e75ff94424b03b82f371ac94a --- /dev/null +++ b/crates/ui/src/components/count_badge.rs @@ -0,0 +1,93 @@ +use gpui::FontWeight; + +use crate::prelude::*; + +/// A small, pill-shaped badge that displays a numeric count. +/// +/// The count is capped at 99 and displayed as "99+" beyond that. +#[derive(IntoElement, RegisterComponent)] +pub struct CountBadge { + count: usize, +} + +impl CountBadge { + pub fn new(count: usize) -> Self { + Self { count } + } +} + +impl RenderOnce for CountBadge { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let label = if self.count > 99 { + "99+".to_string() + } else { + self.count.to_string() + }; + + let bg = cx + .theme() + .colors() + .editor_background + .blend(cx.theme().status().error.opacity(0.4)); + + h_flex() + .absolute() + .top_0() + .right_0() + .p_px() + .h_3p5() + .min_w_3p5() + .rounded_full() + .justify_center() + .text_center() + .border_1() + .border_color(cx.theme().colors().border) + .bg(bg) + .shadow_sm() + .child( + Label::new(label) + .size(LabelSize::Custom(rems_from_px(9.))) + .weight(FontWeight::MEDIUM), + ) + } +} + +impl Component for CountBadge { + fn scope() -> ComponentScope { + ComponentScope::Status + } + + fn description() -> Option<&'static str> { + Some("A small, pill-shaped badge that displays a numeric count.") + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || { + div() + .relative() + .size_8() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().background) + }; + + Some( + v_flex() + .gap_6() + .child(example_group_with_title( + "Count Badge", + vec![ + single_example( + "Basic Count", + container().child(CountBadge::new(3)).into_any_element(), + ), + single_example( + "Capped Count", + container().child(CountBadge::new(150)).into_any_element(), + ), + ], + )) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 32808e0620adbeb6027a18b7c572b53562ffca16..3dc3e62c3e0a3dda5d130a1fcec6c2b4e5a01505 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -18,216 +18,9 @@ use crate::{ }; use itertools::intersperse_with; -pub mod table_row { - //! A newtype for a table row that enforces a fixed column count at runtime. - //! - //! This type ensures that all rows in a table have the same width, preventing accidental creation or mutation of rows with inconsistent lengths. - //! It is especially useful for CSV or tabular data where rectangular invariants must be maintained, but the number of columns is only known at runtime. - //! By using `TableRow`, we gain stronger guarantees and safer APIs compared to a bare `Vec`, without requiring const generics. - - use std::{ - any::type_name, - ops::{ - Index, IndexMut, Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive, - }, - }; - - #[derive(Clone, Debug, PartialEq, Eq)] - pub struct TableRow(Vec); - - impl TableRow { - pub fn from_element(element: T, length: usize) -> Self - where - T: Clone, - { - Self::from_vec(vec![element; length], length) - } - - /// Constructs a `TableRow` from a `Vec`, panicking if the length does not match `expected_length`. - /// - /// Use this when you want to ensure at construction time that the row has the correct number of columns. - /// This enforces the rectangular invariant for table data, preventing accidental creation of malformed rows. - /// - /// # Panics - /// Panics if `data.len() != expected_length`. - pub fn from_vec(data: Vec, expected_length: usize) -> Self { - Self::try_from_vec(data, expected_length).unwrap_or_else(|e| { - let name = type_name::>(); - panic!("Expected {name} to be created successfully: {e}"); - }) - } - - /// Attempts to construct a `TableRow` from a `Vec`, returning an error if the length does not match `expected_len`. - /// - /// This is a fallible alternative to `from_vec`, allowing you to handle inconsistent row lengths gracefully. - /// Returns `Ok(TableRow)` if the length matches, or an `Err` with a descriptive message otherwise. - pub fn try_from_vec(data: Vec, expected_len: usize) -> Result { - if data.len() != expected_len { - Err(format!( - "Row length {} does not match expected {}", - data.len(), - expected_len - )) - } else { - Ok(Self(data)) - } - } - - /// Returns reference to element by column index. - /// - /// # Panics - /// Panics if `col` is out of bounds (i.e., `col >= self.cols()`). - pub fn expect_get(&self, col: impl Into) -> &T { - let col = col.into(); - self.0.get(col).unwrap_or_else(|| { - panic!( - "Expected table row of `{}` to have {col:?}", - type_name::() - ) - }) - } - - pub fn get(&self, col: impl Into) -> Option<&T> { - self.0.get(col.into()) - } - - pub fn as_slice(&self) -> &[T] { - &self.0 - } - - pub fn into_vec(self) -> Vec { - self.0 - } - - /// Like [`map`], but borrows the row and clones each element before mapping. - /// - /// This is useful when you want to map over a borrowed row without consuming it, - /// but your mapping function requires ownership of each element. - /// - /// # Difference - /// - `map_cloned` takes `&self`, clones each element, and applies `f(T) -> U`. - /// - [`map`] takes `self` by value and applies `f(T) -> U` directly, consuming the row. - /// - [`map_ref`] takes `&self` and applies `f(&T) -> U` to references of each element. - pub fn map_cloned(&self, f: F) -> TableRow - where - F: FnMut(T) -> U, - T: Clone, - { - self.clone().map(f) - } - - /// Consumes the row and transforms all elements within it in a length-safe way. - /// - /// # Difference - /// - `map` takes ownership of the row (`self`) and applies `f(T) -> U` to each element. - /// - Use this when you want to transform and consume the row in one step. - /// - See also [`map_cloned`] (for mapping over a borrowed row with cloning) and [`map_ref`] (for mapping over references). - pub fn map(self, f: F) -> TableRow - where - F: FnMut(T) -> U, - { - TableRow(self.0.into_iter().map(f).collect()) - } - - /// Borrows the row and transforms all elements by reference in a length-safe way. - /// - /// # Difference - /// - `map_ref` takes `&self` and applies `f(&T) -> U` to each element by reference. - /// - Use this when you want to map over a borrowed row without cloning or consuming it. - /// - See also [`map`] (for consuming the row) and [`map_cloned`] (for mapping with cloning). - pub fn map_ref(&self, f: F) -> TableRow - where - F: FnMut(&T) -> U, - { - TableRow(self.0.iter().map(f).collect()) - } - - /// Number of columns (alias to `len()` with more semantic meaning) - pub fn cols(&self) -> usize { - self.0.len() - } - } - - ///// Convenience traits ///// - pub trait IntoTableRow { - fn into_table_row(self, expected_length: usize) -> TableRow; - } - impl IntoTableRow for Vec { - fn into_table_row(self, expected_length: usize) -> TableRow { - TableRow::from_vec(self, expected_length) - } - } - - // Index implementations for convenient access - impl Index for TableRow { - type Output = T; - - fn index(&self, index: usize) -> &Self::Output { - &self.0[index] - } - } - - impl IndexMut for TableRow { - fn index_mut(&mut self, index: usize) -> &mut Self::Output { - &mut self.0[index] - } - } - - // Range indexing implementations for slice operations - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: Range) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: RangeFrom) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: RangeTo) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: RangeToInclusive) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl Index for TableRow { - type Output = [T]; - - fn index(&self, index: RangeFull) -> &Self::Output { - as Index>::index(&self.0, index) - } - } - - impl Index> for TableRow { - type Output = [T]; - - fn index(&self, index: RangeInclusive) -> &Self::Output { - as Index>>::index(&self.0, index) - } - } - - impl IndexMut> for TableRow { - fn index_mut(&mut self, index: RangeInclusive) -> &mut Self::Output { - as IndexMut>>::index_mut(&mut self.0, index) - } - } -} +pub mod table_row; +#[cfg(test)] +mod tests; const RESIZE_COLUMN_WIDTH: f32 = 8.0; @@ -1467,330 +1260,3 @@ impl Component for Table { ) } } - -#[cfg(test)] -mod test { - use super::*; - - fn is_almost_eq(a: &[f32], b: &[f32]) -> bool { - a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) - } - - fn cols_to_str(cols: &[f32], total_size: f32) -> String { - cols.iter() - .map(|f| "*".repeat(f32::round(f * total_size) as usize)) - .collect::>() - .join("|") - } - - fn parse_resize_behavior( - input: &str, - total_size: f32, - expected_cols: usize, - ) -> Vec { - let mut resize_behavior = Vec::with_capacity(expected_cols); - for col in input.split('|') { - if col.starts_with('X') || col.is_empty() { - resize_behavior.push(TableResizeBehavior::None); - } else if col.starts_with('*') { - resize_behavior.push(TableResizeBehavior::MinSize(col.len() as f32 / total_size)); - } else { - panic!("invalid test input: unrecognized resize behavior: {}", col); - } - } - - if resize_behavior.len() != expected_cols { - panic!( - "invalid test input: expected {} columns, got {}", - expected_cols, - resize_behavior.len() - ); - } - resize_behavior - } - - mod reset_column_size { - use super::*; - - fn parse(input: &str) -> (Vec, f32, Option) { - let mut widths = Vec::new(); - let mut column_index = None; - for (index, col) in input.split('|').enumerate() { - widths.push(col.len() as f32); - if col.starts_with('X') { - column_index = Some(index); - } - } - - for w in &widths { - assert!(w.is_finite(), "incorrect number of columns"); - } - let total = widths.iter().sum::(); - for width in &mut widths { - *width /= total; - } - (widths, total, column_index) - } - - #[track_caller] - fn check_reset_size( - initial_sizes: &str, - widths: &str, - expected: &str, - resize_behavior: &str, - ) { - let (initial_sizes, total_1, None) = parse(initial_sizes) else { - panic!("invalid test input: initial sizes should not be marked"); - }; - let (widths, total_2, Some(column_index)) = parse(widths) else { - panic!("invalid test input: widths should be marked"); - }; - assert_eq!( - total_1, total_2, - "invalid test input: total width not the same {total_1}, {total_2}" - ); - let (expected, total_3, None) = parse(expected) else { - panic!("invalid test input: expected should not be marked: {expected:?}"); - }; - assert_eq!( - total_2, total_3, - "invalid test input: total width not the same" - ); - let cols = initial_sizes.len(); - let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols); - let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols); - let result = TableColumnWidths::reset_to_initial_size( - column_index, - TableRow::from_vec(widths, cols), - TableRow::from_vec(initial_sizes, cols), - &resize_behavior, - ); - let result_slice = result.as_slice(); - let is_eq = is_almost_eq(result_slice, &expected); - if !is_eq { - let result_str = cols_to_str(result_slice, total_1); - let expected_str = cols_to_str(&expected, total_1); - panic!( - "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_slice:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}" - ); - } - } - - macro_rules! check_reset_size { - (columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => { - check_reset_size($initial, $current, $expected, $resizing); - }; - ($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => { - #[test] - fn $name() { - check_reset_size($initial, $current, $expected, $resizing); - } - }; - } - - check_reset_size!( - basic_right, - columns: 5, - starting: "**|**|**|**|**", - snapshot: "**|**|X|***|**", - expected: "**|**|**|**|**", - minimums: "X|*|*|*|*", - ); - - check_reset_size!( - basic_left, - columns: 5, - starting: "**|**|**|**|**", - snapshot: "**|**|***|X|**", - expected: "**|**|**|**|**", - minimums: "X|*|*|*|**", - ); - - check_reset_size!( - squashed_left_reset_col2, - columns: 6, - starting: "*|***|**|**|****|*", - snapshot: "*|*|X|*|*|********", - expected: "*|*|**|*|*|*******", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - grow_cascading_right, - columns: 6, - starting: "*|***|****|**|***|*", - snapshot: "*|***|X|**|**|*****", - expected: "*|***|****|*|*|****", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - squashed_right_reset_col4, - columns: 6, - starting: "*|***|**|**|****|*", - snapshot: "*|********|*|*|X|*", - expected: "*|*****|*|*|****|*", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - reset_col6_right, - columns: 6, - starting: "*|***|**|***|***|**", - snapshot: "*|***|**|***|**|XXX", - expected: "*|***|**|***|***|**", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - reset_col6_left, - columns: 6, - starting: "*|***|**|***|***|**", - snapshot: "*|***|**|***|****|X", - expected: "*|***|**|***|***|**", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - last_column_grow_cascading, - columns: 6, - starting: "*|***|**|**|**|***", - snapshot: "*|*******|*|**|*|X", - expected: "*|******|*|*|*|***", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - goes_left_when_left_has_extreme_diff, - columns: 6, - starting: "*|***|****|**|**|***", - snapshot: "*|********|X|*|**|**", - expected: "*|*****|****|*|**|**", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - basic_shrink_right, - columns: 6, - starting: "**|**|**|**|**|**", - snapshot: "**|**|XXX|*|**|**", - expected: "**|**|**|**|**|**", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - shrink_should_go_left, - columns: 6, - starting: "*|***|**|*|*|*", - snapshot: "*|*|XXX|**|*|*", - expected: "*|**|**|**|*|*", - minimums: "X|*|*|*|*|*", - ); - - check_reset_size!( - shrink_should_go_right, - columns: 6, - starting: "*|***|**|**|**|*", - snapshot: "*|****|XXX|*|*|*", - expected: "*|****|**|**|*|*", - minimums: "X|*|*|*|*|*", - ); - } - - mod drag_handle { - use super::*; - - fn parse(input: &str) -> (Vec, f32, Option) { - let mut widths = Vec::new(); - let column_index = input.replace("*", "").find("I"); - for col in input.replace("I", "|").split('|') { - widths.push(col.len() as f32); - } - - for w in &widths { - assert!(w.is_finite(), "incorrect number of columns"); - } - let total = widths.iter().sum::(); - for width in &mut widths { - *width /= total; - } - (widths, total, column_index) - } - - #[track_caller] - fn check(distance: i32, widths: &str, expected: &str, resize_behavior: &str) { - let (widths, total_1, Some(column_index)) = parse(widths) else { - panic!("invalid test input: widths should be marked"); - }; - let (expected, total_2, None) = parse(expected) else { - panic!("invalid test input: expected should not be marked: {expected:?}"); - }; - assert_eq!( - total_1, total_2, - "invalid test input: total width not the same" - ); - let cols = widths.len(); - let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols); - let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols); - - let distance = distance as f32 / total_1; - - let mut widths_table_row = TableRow::from_vec(widths, cols); - TableColumnWidths::drag_column_handle( - distance, - column_index, - &mut widths_table_row, - &resize_behavior, - ); - - let result_widths = widths_table_row.as_slice(); - let is_eq = is_almost_eq(result_widths, &expected); - if !is_eq { - let result_str = cols_to_str(result_widths, total_1); - let expected_str = cols_to_str(&expected, total_1); - panic!( - "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_widths:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}" - ); - } - } - - macro_rules! check { - (columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => { - check($dist, $current, $expected, $resizing); - }; - ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => { - #[test] - fn $name() { - check($dist, $current, $expected, $resizing); - } - }; - } - - check!( - basic_right_drag, - columns: 3, - distance: 1, - snapshot: "**|**I**", - expected: "**|***|*", - minimums: "X|*|*", - ); - - check!( - drag_left_against_mins, - columns: 5, - distance: -1, - snapshot: "*|*|*|*I*******", - expected: "*|*|*|*|*******", - minimums: "X|*|*|*|*", - ); - - check!( - drag_left, - columns: 5, - distance: -2, - snapshot: "*|*|*|*****I***", - expected: "*|*|*|***|*****", - minimums: "X|*|*|*|*", - ); - } -} diff --git a/crates/ui/src/components/data_table/table_row.rs b/crates/ui/src/components/data_table/table_row.rs new file mode 100644 index 0000000000000000000000000000000000000000..9ef75e4cbbb72755294ae5c34724a55fbc40f8b8 --- /dev/null +++ b/crates/ui/src/components/data_table/table_row.rs @@ -0,0 +1,208 @@ +//! A newtype for a table row that enforces a fixed column count at runtime. +//! +//! This type ensures that all rows in a table have the same width, preventing accidental creation or mutation of rows with inconsistent lengths. +//! It is especially useful for CSV or tabular data where rectangular invariants must be maintained, but the number of columns is only known at runtime. +//! By using `TableRow`, we gain stronger guarantees and safer APIs compared to a bare `Vec`, without requiring const generics. + +use std::{ + any::type_name, + ops::{ + Index, IndexMut, Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive, + }, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TableRow(Vec); + +impl TableRow { + pub fn from_element(element: T, length: usize) -> Self + where + T: Clone, + { + Self::from_vec(vec![element; length], length) + } + + /// Constructs a `TableRow` from a `Vec`, panicking if the length does not match `expected_length`. + /// + /// Use this when you want to ensure at construction time that the row has the correct number of columns. + /// This enforces the rectangular invariant for table data, preventing accidental creation of malformed rows. + /// + /// # Panics + /// Panics if `data.len() != expected_length`. + pub fn from_vec(data: Vec, expected_length: usize) -> Self { + Self::try_from_vec(data, expected_length).unwrap_or_else(|e| { + let name = type_name::>(); + panic!("Expected {name} to be created successfully: {e}"); + }) + } + + /// Attempts to construct a `TableRow` from a `Vec`, returning an error if the length does not match `expected_len`. + /// + /// This is a fallible alternative to `from_vec`, allowing you to handle inconsistent row lengths gracefully. + /// Returns `Ok(TableRow)` if the length matches, or an `Err` with a descriptive message otherwise. + pub fn try_from_vec(data: Vec, expected_len: usize) -> Result { + if data.len() != expected_len { + Err(format!( + "Row length {} does not match expected {}", + data.len(), + expected_len + )) + } else { + Ok(Self(data)) + } + } + + /// Returns reference to element by column index. + /// + /// # Panics + /// Panics if `col` is out of bounds (i.e., `col >= self.cols()`). + pub fn expect_get(&self, col: impl Into) -> &T { + let col = col.into(); + self.0.get(col).unwrap_or_else(|| { + panic!( + "Expected table row of `{}` to have {col:?}", + type_name::() + ) + }) + } + + pub fn get(&self, col: impl Into) -> Option<&T> { + self.0.get(col.into()) + } + + pub fn as_slice(&self) -> &[T] { + &self.0 + } + + pub fn into_vec(self) -> Vec { + self.0 + } + + /// Like [`map`], but borrows the row and clones each element before mapping. + /// + /// This is useful when you want to map over a borrowed row without consuming it, + /// but your mapping function requires ownership of each element. + /// + /// # Difference + /// - `map_cloned` takes `&self`, clones each element, and applies `f(T) -> U`. + /// - [`map`] takes `self` by value and applies `f(T) -> U` directly, consuming the row. + /// - [`map_ref`] takes `&self` and applies `f(&T) -> U` to references of each element. + pub fn map_cloned(&self, f: F) -> TableRow + where + F: FnMut(T) -> U, + T: Clone, + { + self.clone().map(f) + } + + /// Consumes the row and transforms all elements within it in a length-safe way. + /// + /// # Difference + /// - `map` takes ownership of the row (`self`) and applies `f(T) -> U` to each element. + /// - Use this when you want to transform and consume the row in one step. + /// - See also [`map_cloned`] (for mapping over a borrowed row with cloning) and [`map_ref`] (for mapping over references). + pub fn map(self, f: F) -> TableRow + where + F: FnMut(T) -> U, + { + TableRow(self.0.into_iter().map(f).collect()) + } + + /// Borrows the row and transforms all elements by reference in a length-safe way. + /// + /// # Difference + /// - `map_ref` takes `&self` and applies `f(&T) -> U` to each element by reference. + /// - Use this when you want to map over a borrowed row without cloning or consuming it. + /// - See also [`map`] (for consuming the row) and [`map_cloned`] (for mapping with cloning). + pub fn map_ref(&self, f: F) -> TableRow + where + F: FnMut(&T) -> U, + { + TableRow(self.0.iter().map(f).collect()) + } + + /// Number of columns (alias to `len()` with more semantic meaning) + pub fn cols(&self) -> usize { + self.0.len() + } +} + +///// Convenience traits ///// +pub trait IntoTableRow { + fn into_table_row(self, expected_length: usize) -> TableRow; +} +impl IntoTableRow for Vec { + fn into_table_row(self, expected_length: usize) -> TableRow { + TableRow::from_vec(self, expected_length) + } +} + +// Index implementations for convenient access +impl Index for TableRow { + type Output = T; + + fn index(&self, index: usize) -> &Self::Output { + &self.0[index] + } +} + +impl IndexMut for TableRow { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + &mut self.0[index] + } +} + +// Range indexing implementations for slice operations +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: Range) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: RangeFrom) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: RangeTo) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: RangeToInclusive) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl Index for TableRow { + type Output = [T]; + + fn index(&self, index: RangeFull) -> &Self::Output { + as Index>::index(&self.0, index) + } +} + +impl Index> for TableRow { + type Output = [T]; + + fn index(&self, index: RangeInclusive) -> &Self::Output { + as Index>>::index(&self.0, index) + } +} + +impl IndexMut> for TableRow { + fn index_mut(&mut self, index: RangeInclusive) -> &mut Self::Output { + as IndexMut>>::index_mut(&mut self.0, index) + } +} diff --git a/crates/ui/src/components/data_table/tests.rs b/crates/ui/src/components/data_table/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f0982a8aa5abe5f5a9351ebaaaf4072ca17839e6 --- /dev/null +++ b/crates/ui/src/components/data_table/tests.rs @@ -0,0 +1,318 @@ +use super::*; + +fn is_almost_eq(a: &[f32], b: &[f32]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) +} + +fn cols_to_str(cols: &[f32], total_size: f32) -> String { + cols.iter() + .map(|f| "*".repeat(f32::round(f * total_size) as usize)) + .collect::>() + .join("|") +} + +fn parse_resize_behavior( + input: &str, + total_size: f32, + expected_cols: usize, +) -> Vec { + let mut resize_behavior = Vec::with_capacity(expected_cols); + for col in input.split('|') { + if col.starts_with('X') || col.is_empty() { + resize_behavior.push(TableResizeBehavior::None); + } else if col.starts_with('*') { + resize_behavior.push(TableResizeBehavior::MinSize(col.len() as f32 / total_size)); + } else { + panic!("invalid test input: unrecognized resize behavior: {}", col); + } + } + + if resize_behavior.len() != expected_cols { + panic!( + "invalid test input: expected {} columns, got {}", + expected_cols, + resize_behavior.len() + ); + } + resize_behavior +} + +mod reset_column_size { + use super::*; + + fn parse(input: &str) -> (Vec, f32, Option) { + let mut widths = Vec::new(); + let mut column_index = None; + for (index, col) in input.split('|').enumerate() { + widths.push(col.len() as f32); + if col.starts_with('X') { + column_index = Some(index); + } + } + + for w in &widths { + assert!(w.is_finite(), "incorrect number of columns"); + } + let total = widths.iter().sum::(); + for width in &mut widths { + *width /= total; + } + (widths, total, column_index) + } + + #[track_caller] + fn check_reset_size(initial_sizes: &str, widths: &str, expected: &str, resize_behavior: &str) { + let (initial_sizes, total_1, None) = parse(initial_sizes) else { + panic!("invalid test input: initial sizes should not be marked"); + }; + let (widths, total_2, Some(column_index)) = parse(widths) else { + panic!("invalid test input: widths should be marked"); + }; + assert_eq!( + total_1, total_2, + "invalid test input: total width not the same {total_1}, {total_2}" + ); + let (expected, total_3, None) = parse(expected) else { + panic!("invalid test input: expected should not be marked: {expected:?}"); + }; + assert_eq!( + total_2, total_3, + "invalid test input: total width not the same" + ); + let cols = initial_sizes.len(); + let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols); + let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols); + let result = TableColumnWidths::reset_to_initial_size( + column_index, + TableRow::from_vec(widths, cols), + TableRow::from_vec(initial_sizes, cols), + &resize_behavior, + ); + let result_slice = result.as_slice(); + let is_eq = is_almost_eq(result_slice, &expected); + if !is_eq { + let result_str = cols_to_str(result_slice, total_1); + let expected_str = cols_to_str(&expected, total_1); + panic!( + "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_slice:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}" + ); + } + } + + macro_rules! check_reset_size { + (columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => { + check_reset_size($initial, $current, $expected, $resizing); + }; + ($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => { + #[test] + fn $name() { + check_reset_size($initial, $current, $expected, $resizing); + } + }; + } + + check_reset_size!( + basic_right, + columns: 5, + starting: "**|**|**|**|**", + snapshot: "**|**|X|***|**", + expected: "**|**|**|**|**", + minimums: "X|*|*|*|*", + ); + + check_reset_size!( + basic_left, + columns: 5, + starting: "**|**|**|**|**", + snapshot: "**|**|***|X|**", + expected: "**|**|**|**|**", + minimums: "X|*|*|*|**", + ); + + check_reset_size!( + squashed_left_reset_col2, + columns: 6, + starting: "*|***|**|**|****|*", + snapshot: "*|*|X|*|*|********", + expected: "*|*|**|*|*|*******", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + grow_cascading_right, + columns: 6, + starting: "*|***|****|**|***|*", + snapshot: "*|***|X|**|**|*****", + expected: "*|***|****|*|*|****", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + squashed_right_reset_col4, + columns: 6, + starting: "*|***|**|**|****|*", + snapshot: "*|********|*|*|X|*", + expected: "*|*****|*|*|****|*", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + reset_col6_right, + columns: 6, + starting: "*|***|**|***|***|**", + snapshot: "*|***|**|***|**|XXX", + expected: "*|***|**|***|***|**", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + reset_col6_left, + columns: 6, + starting: "*|***|**|***|***|**", + snapshot: "*|***|**|***|****|X", + expected: "*|***|**|***|***|**", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + last_column_grow_cascading, + columns: 6, + starting: "*|***|**|**|**|***", + snapshot: "*|*******|*|**|*|X", + expected: "*|******|*|*|*|***", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + goes_left_when_left_has_extreme_diff, + columns: 6, + starting: "*|***|****|**|**|***", + snapshot: "*|********|X|*|**|**", + expected: "*|*****|****|*|**|**", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + basic_shrink_right, + columns: 6, + starting: "**|**|**|**|**|**", + snapshot: "**|**|XXX|*|**|**", + expected: "**|**|**|**|**|**", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + shrink_should_go_left, + columns: 6, + starting: "*|***|**|*|*|*", + snapshot: "*|*|XXX|**|*|*", + expected: "*|**|**|**|*|*", + minimums: "X|*|*|*|*|*", + ); + + check_reset_size!( + shrink_should_go_right, + columns: 6, + starting: "*|***|**|**|**|*", + snapshot: "*|****|XXX|*|*|*", + expected: "*|****|**|**|*|*", + minimums: "X|*|*|*|*|*", + ); +} + +mod drag_handle { + use super::*; + + fn parse(input: &str) -> (Vec, f32, Option) { + let mut widths = Vec::new(); + let column_index = input.replace("*", "").find("I"); + for col in input.replace("I", "|").split('|') { + widths.push(col.len() as f32); + } + + for w in &widths { + assert!(w.is_finite(), "incorrect number of columns"); + } + let total = widths.iter().sum::(); + for width in &mut widths { + *width /= total; + } + (widths, total, column_index) + } + + #[track_caller] + fn check(distance: i32, widths: &str, expected: &str, resize_behavior: &str) { + let (widths, total_1, Some(column_index)) = parse(widths) else { + panic!("invalid test input: widths should be marked"); + }; + let (expected, total_2, None) = parse(expected) else { + panic!("invalid test input: expected should not be marked: {expected:?}"); + }; + assert_eq!( + total_1, total_2, + "invalid test input: total width not the same" + ); + let cols = widths.len(); + let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols); + let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols); + + let distance = distance as f32 / total_1; + + let mut widths_table_row = TableRow::from_vec(widths, cols); + TableColumnWidths::drag_column_handle( + distance, + column_index, + &mut widths_table_row, + &resize_behavior, + ); + + let result_widths = widths_table_row.as_slice(); + let is_eq = is_almost_eq(result_widths, &expected); + if !is_eq { + let result_str = cols_to_str(result_widths, total_1); + let expected_str = cols_to_str(&expected, total_1); + panic!( + "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_widths:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}" + ); + } + } + + macro_rules! check { + (columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => { + check($dist, $current, $expected, $resizing); + }; + ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => { + #[test] + fn $name() { + check($dist, $current, $expected, $resizing); + } + }; + } + + check!( + basic_right_drag, + columns: 3, + distance: 1, + snapshot: "**|**I**", + expected: "**|***|*", + minimums: "X|*|*", + ); + + check!( + drag_left_against_mins, + columns: 5, + distance: -1, + snapshot: "*|*|*|*I*******", + expected: "*|*|*|*|*******", + minimums: "X|*|*|*|*", + ); + + check!( + drag_left, + columns: 5, + distance: -2, + snapshot: "*|*|*|*****I***", + expected: "*|*|*|***|*****", + minimums: "X|*|*|*|*", + ); +} diff --git a/crates/ui/src/components/diff_stat.rs b/crates/ui/src/components/diff_stat.rs index ec6d515f1b4f847631fc65fae4ed3ccd3185d271..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,41 +25,32 @@ 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() .child( - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::Plus) - .size(IconSize::XSmall) - .color(Color::Success), - ) - .child( - Label::new(self.added.to_string()) - .color(Color::Success) - .size(self.label_size), - ), + Label::new(format!("+\u{2009}{}", self.added)) + .color(Color::Success) + .size(self.label_size), ) .child( - h_flex() - .gap_0p5() - .child( - Icon::new(IconName::Dash) - .size(IconSize::XSmall) - .color(Color::Error), - ) - .child( - Label::new(self.removed.to_string()) - .color(Color::Error) - .size(self.label_size), - ), + Label::new(format!("\u{2012}\u{2009}{}", self.removed)) + .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/ui/src/components/gradient_fade.rs b/crates/ui/src/components/gradient_fade.rs new file mode 100644 index 0000000000000000000000000000000000000000..2173fdf06ea8c07c947f092066c2a12d716d4b44 --- /dev/null +++ b/crates/ui/src/components/gradient_fade.rs @@ -0,0 +1,88 @@ +use gpui::{Hsla, Pixels, SharedString, linear_color_stop, linear_gradient, px}; + +use crate::prelude::*; + +/// A gradient overlay that fades from a solid color to transparent. +#[derive(IntoElement)] +pub struct GradientFade { + base_bg: Hsla, + hover_bg: Hsla, + active_bg: Hsla, + width: Pixels, + right: Pixels, + gradient_stop: f32, + group_name: Option, +} + +impl GradientFade { + pub fn new(base_bg: Hsla, hover_bg: Hsla, active_bg: Hsla) -> Self { + Self { + base_bg, + hover_bg, + active_bg, + width: px(48.0), + right: px(0.0), + gradient_stop: 0.6, + group_name: None, + } + } + + pub fn width(mut self, width: Pixels) -> Self { + self.width = width; + self + } + + pub fn right(mut self, right: Pixels) -> Self { + self.right = right; + self + } + + pub fn gradient_stop(mut self, stop: f32) -> Self { + self.gradient_stop = stop; + self + } + + pub fn group_name(mut self, name: impl Into) -> Self { + self.group_name = Some(name.into()); + self + } +} + +impl RenderOnce for GradientFade { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let stop = self.gradient_stop; + let hover_bg = self.hover_bg; + let active_bg = self.active_bg; + + div() + .id("gradient_fade") + .absolute() + .top_0() + .right(self.right) + .w(self.width) + .h_full() + .bg(linear_gradient( + 90., + linear_color_stop(self.base_bg, stop), + linear_color_stop(self.base_bg.opacity(0.0), 0.), + )) + .when_some(self.group_name.clone(), |element, group_name| { + element.group_hover(group_name, move |s| { + s.bg(linear_gradient( + 90., + linear_color_stop(hover_bg, stop), + linear_color_stop(hover_bg.opacity(0.0), 0.), + )) + }) + }) + .when_some(self.group_name, |element, group_name| { + element.group_active(group_name, move |s| { + s.bg(linear_gradient( + 90., + linear_color_stop(active_bg, stop), + linear_color_stop(active_bg.opacity(0.0), 0.), + )) + }) + }) + } +} diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index d0f50c00336eb971621e2da7bbaf53cf09569caa..405948ea06c7e86fcb3dec217186596bdaaf0aeb 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -73,6 +73,34 @@ impl Label { gpui::margin_style_methods!({ visibility: pub }); + + pub fn flex_1(mut self) -> Self { + self.style().flex_grow = Some(1.); + self.style().flex_shrink = Some(1.); + self.style().flex_basis = Some(gpui::relative(0.).into()); + self + } + + pub fn flex_none(mut self) -> Self { + self.style().flex_grow = Some(0.); + self.style().flex_shrink = Some(0.); + self + } + + pub fn flex_grow(mut self) -> Self { + self.style().flex_grow = Some(1.); + self + } + + pub fn flex_shrink(mut self) -> Self { + self.style().flex_shrink = Some(1.); + self + } + + pub fn flex_shrink_0(mut self) -> Self { + self.style().flex_shrink = Some(0.); + self + } } impl LabelCommon for Label { diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index ba15a35d000d929578779319266b94e2931c3a0d..4fc7541c01a78916933c6edbf8fb5274b2f00f79 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -4,7 +4,7 @@ use component::{Component, ComponentScope, example_group_with_title, single_exam use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, Role, px}; use smallvec::SmallVec; -use crate::{Disclosure, prelude::*}; +use crate::{Disclosure, GradientFade, prelude::*}; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum ListItemSpacing { @@ -31,6 +31,9 @@ pub struct ListItem { /// A slot for content that appears on hover after the children /// It will obscure the `end_slot` when visible. end_hover_slot: Option, + /// When true, renders a gradient fade overlay before the `end_hover_slot` + /// to smoothly truncate overflowing content. + end_hover_gradient_overlay: bool, toggle: Option, inset: bool, on_click: Option>, @@ -47,6 +50,7 @@ pub struct ListItem { focused: Option, override_role: Option, a11y_label: Option, + docked_right: bool, } impl ListItem { @@ -62,6 +66,7 @@ impl ListItem { start_slot: None, end_slot: None, end_hover_slot: None, + end_hover_gradient_overlay: false, toggle: None, inset: false, on_click: None, @@ -78,6 +83,7 @@ impl ListItem { focused: None, override_role: None, a11y_label: None, + docked_right: false, } } @@ -170,6 +176,11 @@ impl ListItem { self } + pub fn end_hover_gradient_overlay(mut self, show: bool) -> Self { + self.end_hover_gradient_overlay = show; + self + } + pub fn outlined(mut self) -> Self { self.outlined = true; self @@ -190,6 +201,11 @@ impl ListItem { self } + pub fn docked_right(mut self, docked_right: bool) -> Self { + self.docked_right = docked_right; + self + } + pub fn role(mut self, role: Role) -> Self { self.override_role = Some(role); self @@ -223,6 +239,21 @@ impl ParentElement for ListItem { impl RenderOnce for ListItem { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let color = cx.theme().colors(); + + let base_bg = if self.selected { + color.element_active + } else { + color.panel_background + }; + + let end_hover_gradient_overlay = + GradientFade::new(base_bg, color.element_hover, color.element_active) + .width(px(96.0)) + .when_some(self.group_name.clone(), |fade, group| { + fade.group_name(group) + }); + h_flex() .id(self.id) .role(self.override_role.unwrap_or(Role::ListItem)) @@ -236,25 +267,23 @@ impl RenderOnce for ListItem { .px(DynamicSpacing::Base04.rems(cx)) }) .when(!self.inset && !self.disabled, |this| { - this - // TODO: Add focus state - // .when(self.state == InteractionState::Focused, |this| { - .when_some(self.focused, |this, focused| { - if focused { - this.border_1() - .border_color(cx.theme().colors().border_focused) - } else { - this.border_1() - } - }) - .when(self.selectable, |this| { - this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) - .active(|style| style.bg(cx.theme().colors().ghost_element_active)) - .when(self.outlined, |this| this.rounded_sm()) - .when(self.selected, |this| { - this.bg(cx.theme().colors().ghost_element_selected) - }) - }) + this.when_some(self.focused, |this, focused| { + if focused { + this.border_1() + .when(self.docked_right, |this| this.border_r_2()) + .border_color(cx.theme().colors().border_focused) + } else { + this.border_1() + } + }) + .when(self.selectable, |this| { + this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) + .active(|style| style.bg(cx.theme().colors().ghost_element_active)) + .when(self.outlined, |this| this.rounded_sm()) + .when(self.selected, |this| { + this.bg(cx.theme().colors().ghost_element_selected) + }) + }) }) .when(self.rounded, |this| this.rounded_sm()) .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover)) @@ -366,6 +395,9 @@ impl RenderOnce for ListItem { .right(DynamicSpacing::Base06.rems(cx)) .top_0() .visible_on_hover("list_item") + .when(self.end_hover_gradient_overlay, |this| { + this.child(end_hover_gradient_overlay) + }) .child(end_hover_slot), ) }), diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 9ccbe0ec9c9b1fb69a8d003d1983ddce9d352612..26d7e12d7f9c6f77135950edeb3b59cff46ac4b1 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -1041,7 +1041,18 @@ impl ScrollbarLayout { impl PartialEq for ScrollbarLayout { fn eq(&self, other: &Self) -> bool { - self.axis == other.axis && self.thumb_bounds == other.thumb_bounds + if self.axis != other.axis { + return false; + } + + let axis = self.axis; + let thumb_offset = + self.thumb_bounds.origin.along(axis) - self.track_bounds.origin.along(axis); + let other_thumb_offset = + other.thumb_bounds.origin.along(axis) - other.track_bounds.origin.along(axis); + + thumb_offset == other_thumb_offset + && self.thumb_bounds.size.along(axis) == other.thumb_bounds.size.along(axis) } } diff --git a/crates/ui_input/src/input_field.rs b/crates/ui_input/src/input_field.rs index 59a05497627838364b4037c44b236ab70c2b3c6b..16932b58e87cb9df83c14919b79bd048f33275fe 100644 --- a/crates/ui_input/src/input_field.rs +++ b/crates/ui_input/src/input_field.rs @@ -3,6 +3,7 @@ use component::{example_group, single_example}; use gpui::{App, FocusHandle, Focusable, Hsla, Length}; use std::sync::Arc; +use ui::Tooltip; use ui::prelude::*; use crate::ErasedEditor; @@ -38,6 +39,8 @@ pub struct InputField { tab_index: Option, /// Whether this field is a tab stop (can be focused via Tab key). tab_stop: bool, + /// Whether the field content is masked (for sensitive fields like passwords or API keys). + masked: Option, } impl Focusable for InputField { @@ -63,6 +66,7 @@ impl InputField { min_width: px(192.).into(), tab_index: None, tab_stop: true, + masked: None, } } @@ -96,6 +100,12 @@ impl InputField { self } + /// Sets this field as a masked/sensitive input (e.g., for passwords or API keys). + pub fn masked(mut self, masked: bool) -> Self { + self.masked = Some(masked); + self + } + pub fn is_empty(&self, cx: &App) -> bool { self.editor().text(cx).trim().is_empty() } @@ -115,12 +125,20 @@ impl InputField { pub fn set_text(&self, text: &str, window: &mut Window, cx: &mut App) { self.editor().set_text(text, window, cx) } + + pub fn set_masked(&self, masked: bool, window: &mut Window, cx: &mut App) { + self.editor().set_masked(masked, window, cx) + } } impl Render for InputField { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let editor = self.editor.clone(); + if let Some(masked) = self.masked { + self.editor.set_masked(masked, window, cx); + } + let theme_color = cx.theme().colors(); let style = InputFieldStyle { @@ -172,7 +190,31 @@ impl Render for InputField { this.gap_1() .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) }) - .child(self.editor.render(window, cx)), + .child(self.editor.render(window, cx)) + .when_some(self.masked, |this, is_masked| { + this.child( + IconButton::new( + "toggle-masked", + if is_masked { + IconName::Eye + } else { + IconName::EyeOff + }, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(if is_masked { "Show" } else { "Hide" })) + .on_click(cx.listener( + |this, _, window, cx| { + if let Some(ref mut masked) = this.masked { + *masked = !*masked; + this.editor.set_masked(*masked, window, cx); + cx.notify(); + } + }, + )), + ) + }), ) } } diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 6a9b30d463af2d9407e8f4c9e3a81133a87c1bce..4f317e79e0cfc92087250182531ae33a591b1f48 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -21,7 +21,7 @@ test-support = ["git2", "rand", "util_macros"] anyhow.workspace = true async_zip.workspace = true collections.workspace = true -dunce = "1.0" +dunce.workspace = true futures-lite.workspace = true futures.workspace = true globset.workspace = true @@ -64,7 +64,6 @@ tendril = "0.4.3" [dev-dependencies] git2.workspace = true -indoc.workspace = true rand.workspace = true util_macros.workspace = true pretty_assertions.workspace = true diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index 7d605c7924a7d9c25a89634ca7339a457fb99ae4..bd012e43dd0c073d78822a5e831af1d78503e8ab 100644 --- a/crates/util/src/path_list.rs +++ b/crates/util/src/path_list.rs @@ -5,7 +5,7 @@ use std::{ use crate::paths::SanitizedPath; use itertools::Itertools; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; /// A list of absolute paths, in a specific order. /// @@ -23,7 +23,7 @@ pub struct PathList { order: Arc<[usize]>, } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct SerializedPathList { pub paths: String, pub order: String, @@ -119,19 +119,6 @@ impl PathList { } } -impl Serialize for PathList { - fn serialize(&self, serializer: S) -> Result { - self.paths.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for PathList { - fn deserialize>(deserializer: D) -> Result { - let paths: Vec = Vec::deserialize(deserializer)?; - Ok(PathList::new(&paths)) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 39b4064a1bd9d3c4c240abf9665b17151066e9ef..3ff07c67a8d2def75e4e7f756c4a466ea2b68ed0 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -601,6 +601,7 @@ const ROW_COL_CAPTURE_REGEX: &str = r"(?xs) | \((\d+)\)() # filename(row) ) + \:*$ | (.+?)(?: \:+(\d+)\:(\d+)\:*$ # filename:row:column @@ -2097,6 +2098,15 @@ mod tests { column: Some(9), } ); + + assert_eq!( + PathWithPosition::parse_str("main (1).log"), + PathWithPosition { + path: PathBuf::from("main (1).log"), + row: None, + column: None + } + ); } #[perf] @@ -2175,6 +2185,15 @@ mod tests { column: None } ); + + assert_eq!( + PathWithPosition::parse_str("C:\\Users\\someone\\main (1).log"), + PathWithPosition { + path: PathBuf::from("C:\\Users\\someone\\main (1).log"), + row: None, + column: None + } + ); } #[perf] diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 38bf9fed621aa3aa378cbcaa3479f7ecd7b60e11..7b4cff5ff9bdf37666076c403593c45131a63067 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -54,11 +54,9 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] -assets.workspace = true command_palette = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } git_ui = { workspace = true, features = ["test-support"] } -title_bar = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true language = { workspace = true, features = ["test-support"] } diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 126683f0b419ae9a44d17d90d760f06b106fad8a..60d87572eb3151f8e36c06f91501921ea9affb3b 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -12,6 +12,7 @@ use editor::{ }; use gpui::actions; use gpui::{Context, Window}; +use itertools::Itertools as _; use language::{CharClassifier, CharKind, Point}; use search::{BufferSearchBar, SearchOptions}; use settings::Settings; @@ -36,6 +37,8 @@ actions!( HelixInsert, /// Appends at the end of the selection. HelixAppend, + /// Inserts at the end of the current Helix cursor line. + HelixInsertEndOfLine, /// Goes to the location of the last modification. HelixGotoLastModification, /// Select entire line or multiple lines, extending downwards. @@ -64,6 +67,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::helix_select_lines); Vim::action(editor, cx, Vim::helix_insert); Vim::action(editor, cx, Vim::helix_append); + Vim::action(editor, cx, Vim::helix_insert_end_of_line); Vim::action(editor, cx, Vim::helix_yank); Vim::action(editor, cx, Vim::helix_goto_last_modification); Vim::action(editor, cx, Vim::helix_paste); @@ -600,6 +604,34 @@ impl Vim { }); } + /// Helix-specific implementation of `shift-a` that accounts for Helix's + /// selection model, where selecting a line with `x` creates a selection + /// from column 0 of the current row to column 0 of the next row, so the + /// default [`vim::normal::InsertEndOfLine`] would move the cursor to the + /// end of the wrong line. + fn helix_insert_end_of_line( + &mut self, + _: &HelixInsertEndOfLine, + window: &mut Window, + cx: &mut Context, + ) { + self.start_recording(cx); + self.switch_mode(Mode::Insert, false, window, cx); + self.update_editor(cx, |_, editor, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.move_with(&mut |map, selection| { + let cursor = if !selection.is_empty() && !selection.reversed { + movement::left(map, selection.head()) + } else { + selection.head() + }; + selection + .collapse_to(motion::next_line_end(map, cursor, 1), SelectionGoal::None); + }); + }); + }); + } + pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context) { self.update_editor(cx, |_, editor, cx| { editor.transact(window, cx, |editor, window, cx| { @@ -845,11 +877,22 @@ impl Vim { self.update_editor(cx, |_vim, editor, cx| { let snapshot = editor.snapshot(window, cx); editor.change_selections(SelectionEffects::default(), window, cx, |s| { + let buffer = snapshot.buffer_snapshot(); + s.select_anchor_ranges( prior_selections .iter() .cloned() - .chain(s.all_anchors(&snapshot).iter().map(|s| s.range())), + .chain(s.all_anchors(&snapshot).iter().map(|s| s.range())) + .sorted_by(|a, b| { + a.start + .cmp(&b.start, buffer) + .then_with(|| a.end.cmp(&b.end, buffer)) + }) + .dedup_by(|a, b| { + a.start.cmp(&b.start, buffer).is_eq() + && a.end.cmp(&b.end, buffer).is_eq() + }), ); }) }); @@ -1447,6 +1490,47 @@ mod test { ˇ»line five"}, Mode::HelixNormal, ); + + // Test selecting with an empty line below the current line + cx.set_state( + indoc! {" + line one + line twoˇ + + line four + line five"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + line one + «line two + ˇ» + line four + line five"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + line one + «line two + + ˇ»line four + line five"}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + line one + «line two + + line four + ˇ»line five"}, + Mode::HelixNormal, + ); } #[gpui::test] @@ -1598,6 +1682,25 @@ mod test { cx.assert_state("hello two «oneˇ» two «oneˇ» two «oneˇ»", Mode::HelixSelect); } + #[gpui::test] + async fn test_helix_select_next_match_wrapping(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Three occurrences of "one". After selecting all three with `n n`, + // pressing `n` again wraps the search to the first occurrence. + // The prior selections (at higher offsets) are chained before the + // wrapped selection (at a lower offset), producing unsorted anchors + // that cause `rope::Cursor::summary` to panic with + // "cannot summarize backward". + cx.set_state("ˇhello two one two one two one", Mode::HelixSelect); + cx.simulate_keystrokes("/ o n e"); + cx.simulate_keystrokes("enter"); + cx.simulate_keystrokes("n n n"); + // Should not panic; all three occurrences should remain selected. + cx.assert_state("hello two «oneˇ» two «oneˇ» two «oneˇ»", Mode::HelixSelect); + } + #[gpui::test] async fn test_helix_substitute(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; @@ -1848,4 +1951,51 @@ mod test { Mode::HelixSelect, ); } + + #[gpui::test] + async fn test_helix_insert_end_of_line(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Ensure that, when lines are selected using `x`, pressing `shift-a` + // actually puts the cursor at the end of the selected lines and not at + // the end of the line below. + cx.set_state( + indoc! {" + line oˇne + line two"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("x"); + cx.assert_state( + indoc! {" + «line one + ˇ»line two"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("shift-a"); + cx.assert_state( + indoc! {" + line oneˇ + line two"}, + Mode::Insert, + ); + + cx.set_state( + indoc! {" + line «one + lineˇ» two"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("shift-a"); + cx.assert_state( + indoc! {" + line one + line twoˇ"}, + Mode::Insert, + ); + } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 1501d29c7b9b712f3f8edc25025545d0fa0baa08..6763c5cddb8bf2cda6aa4fa0988ff6be67119d3c 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -949,17 +949,16 @@ impl Vim { let current_line = point.row; let percentage = current_line as f32 / lines as f32; let modified = if buffer.is_dirty() { " [modified]" } else { "" }; - vim.status_label = Some( + vim.set_status_label( format!( "{}{} {} lines --{:.0}%--", filename, modified, lines, percentage * 100.0, - ) - .into(), + ), + cx, ); - cx.notify(); }); } diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index ec964ec9ae3af08b108aa027a0aa62883dbcbcc5..fab9b353e3e9bb5b5d00d9d415783b4a5a31ae95 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -50,6 +50,10 @@ impl Vim { }) .filter(|reg| !reg.text.is_empty()) else { + vim.set_status_label( + format!("Nothing in register {}", selected_register.unwrap_or('"')), + cx, + ); return; }; let clipboard_selections = clipboard_selections @@ -249,7 +253,7 @@ impl Vim { ) { self.stop_recording(cx); let selected_register = self.selected_register.take(); - self.update_editor(cx, |_, editor, cx| { + self.update_editor(cx, |vim, editor, cx| { editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { @@ -262,6 +266,10 @@ impl Vim { globals.read_register(selected_register, Some(editor), cx) }) .filter(|reg| !reg.text.is_empty()) else { + vim.set_status_label( + format!("Nothing in register {}", selected_register.unwrap_or('"')), + cx, + ); return; }; editor.insert(&text, window, cx); @@ -286,7 +294,7 @@ impl Vim { ) { self.stop_recording(cx); let selected_register = self.selected_register.take(); - self.update_editor(cx, |_, editor, cx| { + self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(window, cx); editor.transact(window, cx, |editor, window, cx| { editor.set_clip_at_line_ends(false, cx); @@ -306,6 +314,10 @@ impl Vim { globals.read_register(selected_register, Some(editor), cx) }) .filter(|reg| !reg.text.is_empty()) else { + vim.set_status_label( + format!("Nothing in register {}", selected_register.unwrap_or('"')), + cx, + ); return; }; editor.insert(&text, window, cx); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 8a4bfc241d1b0c62b17464bfb1dd5076015ac638..387bca0912be303fbe86bf947446fe85a50d6022 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -291,6 +291,24 @@ impl Vim { }) else { return; }; + + // Dot repeat always uses the recorded register, ignoring any "X + // override, as the register is an inherent part of the recorded action. + // For numbered registers, Neovim increments on each dot repeat so after + // using `"1p`, using `.` will equate to `"2p", the next `.` to `"3p`, + // etc.. + let recorded_register = cx.global::().recorded_register_for_dot; + let next_register = recorded_register + .filter(|c| matches!(c, '1'..='9')) + .map(|c| ((c as u8 + 1).min(b'9')) as char); + + self.selected_register = next_register.or(recorded_register); + if let Some(next_register) = next_register { + Vim::update_globals(cx, |globals, _| { + globals.recorded_register_for_dot = Some(next_register) + }) + }; + if mode != Some(self.mode) { if let Some(mode) = mode { self.switch_mode(mode, false, window, cx) @@ -441,6 +459,207 @@ mod test { cx.shared_state().await.assert_eq("THE QUICK ˇbrown fox"); } + #[gpui::test] + async fn test_dot_repeat_registers_paste(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // basic paste repeat uses the unnamed register + cx.set_shared_state("ˇhello\n").await; + cx.simulate_shared_keystrokes("y y p").await; + cx.shared_state().await.assert_eq("hello\nˇhello\n"); + cx.simulate_shared_keystrokes(".").await; + cx.shared_state().await.assert_eq("hello\nhello\nˇhello\n"); + + // "_ (blackhole) is recorded and replayed, so the pasted text is still + // the original yanked line. + cx.set_shared_state(indoc! {" + ˇone + two + three + four + "}) + .await; + cx.simulate_shared_keystrokes("y y j \" _ d d . p").await; + cx.shared_state().await.assert_eq(indoc! {" + one + four + ˇone + "}); + + // the recorded register is replayed, not whatever is in the unnamed register + cx.set_shared_state(indoc! {" + ˇone + two + "}) + .await; + cx.simulate_shared_keystrokes("y y j \" a y y \" a p .") + .await; + cx.shared_state().await.assert_eq(indoc! {" + one + two + two + ˇtwo + "}); + + // `"X.` ignores the override and always uses the recorded register. + // Both `dd` calls go into register `a`, so register `b` is empty and + // `"bp` pastes nothing. + cx.set_shared_state(indoc! {" + ˇone + two + three + "}) + .await; + cx.simulate_shared_keystrokes("\" a d d \" b .").await; + cx.shared_state().await.assert_eq(indoc! {" + ˇthree + "}); + cx.simulate_shared_keystrokes("\" a p \" b p").await; + cx.shared_state().await.assert_eq(indoc! {" + three + ˇtwo + "}); + + // numbered registers cycle on each dot repeat: "1p . . uses registers 2, 3, … + // Since the cycling behavior caps at register 9, the first line to be + // deleted `1`, is no longer in any of the registers. + cx.set_shared_state(indoc! {" + ˇone + two + three + four + five + six + seven + eight + nine + ten + "}) + .await; + cx.simulate_shared_keystrokes("d d . . . . . . . . .").await; + cx.shared_state().await.assert_eq(indoc! {"ˇ"}); + cx.simulate_shared_keystrokes("\" 1 p . . . . . . . . .") + .await; + cx.shared_state().await.assert_eq(indoc! {" + + ten + nine + eight + seven + six + five + four + three + two + ˇtwo"}); + + // unnamed register repeat: dd records None, so . pastes the same + // deleted text + cx.set_shared_state(indoc! {" + ˇone + two + three + "}) + .await; + cx.simulate_shared_keystrokes("d d p .").await; + cx.shared_state().await.assert_eq(indoc! {" + two + one + ˇone + three + "}); + + // After `"1p` cycles to `2`, using `"ap` resets recorded_register to `a`, + // so the next `.` uses `a` and not 3. + cx.set_shared_state(indoc! {" + one + two + ˇthree + "}) + .await; + cx.simulate_shared_keystrokes("\" 2 y y k k \" a y y j \" 1 y y k \" 1 p . \" a p .") + .await; + cx.shared_state().await.assert_eq(indoc! {" + one + two + three + one + ˇone + two + three + "}); + } + + // This needs to be a separate test from `test_dot_repeat_registers_paste` + // as Neovim doesn't have support for using registers in replace operations + // by default. + #[gpui::test] + async fn test_dot_repeat_registers_replace(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + line ˇone + line two + line three + "}, + Mode::Normal, + ); + + // 1. Yank `one` into register `a` + // 2. Move down and yank `two` into the default register + // 3. Replace `two` with the contents of register `a` + cx.simulate_keystrokes("\" a y w j y w \" a g R w"); + cx.assert_state( + indoc! {" + line one + line onˇe + line three + "}, + Mode::Normal, + ); + + // 1. Move down to `three` + // 2. Repeat the replace operation + cx.simulate_keystrokes("j ."); + cx.assert_state( + indoc! {" + line one + line one + line onˇe + "}, + Mode::Normal, + ); + + // Similar test, but this time using numbered registers, as those should + // automatically increase on successive uses of `.` . + cx.set_state( + indoc! {" + line ˇone + line two + line three + line four + "}, + Mode::Normal, + ); + + // 1. Yank `one` into register `1` + // 2. Yank `two` into register `2` + // 3. Move down and yank `three` into the default register + // 4. Replace `three` with the contents of register `1` + // 5. Move down and repeat + cx.simulate_keystrokes("\" 1 y w j \" 2 y w j y w \" 1 g R w j ."); + cx.assert_state( + indoc! {" + line one + line two + line one + line twˇo + "}, + Mode::Normal, + ); + } + #[gpui::test] async fn test_repeat_ime(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 0244a14c83b422a1fed803c761c7e873b42bd267..9ba744de6855e101a1871ddcf0a84cc3fc931830 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -73,6 +73,10 @@ impl Mode { Self::Normal | Self::Insert | Self::Replace | Self::HelixNormal => false, } } + + pub fn is_helix(&self) -> bool { + matches!(self, Self::HelixNormal | Self::HelixSelect) + } } #[derive(Clone, Debug, PartialEq)] @@ -228,7 +232,15 @@ pub struct VimGlobals { pub recorded_actions: Vec, pub recorded_selection: RecordedSelection, + /// The register being written to by the active `q{register}` macro + /// recording. pub recording_register: Option, + /// The register that was selected at the start of the current + /// dot-recording, for example, `"ap`. + pub recording_register_for_dot: Option, + /// The register from the last completed dot-recording. Used when replaying + /// with `.`. + pub recorded_register_for_dot: Option, pub last_recorded_register: Option, pub last_replayed_register: Option, pub replayer: Option, @@ -515,7 +527,7 @@ impl MarksState { cx: &mut Context, ) { let on_change = cx.subscribe(buffer_handle, move |this, buffer, event, cx| match event { - BufferEvent::Edited => { + BufferEvent::Edited { .. } => { if let Some(path) = this.path_for_buffer(&buffer, cx) { this.serialize_buffer_marks(path, &buffer, cx); } @@ -915,6 +927,7 @@ impl VimGlobals { self.dot_recording = false; self.recorded_actions = std::mem::take(&mut self.recording_actions); self.recorded_count = self.recording_count.take(); + self.recorded_register_for_dot = self.recording_register_for_dot.take(); self.stop_recording_after_next_action = false; } } @@ -942,6 +955,7 @@ impl VimGlobals { self.dot_recording = false; self.recorded_actions = std::mem::take(&mut self.recording_actions); self.recorded_count = self.recording_count.take(); + self.recorded_register_for_dot = self.recording_register_for_dot.take(); self.stop_recording_after_next_action = false; } } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 8c551bcd2768043ae416157c80d4d2f9faa19092..c1058f5738915359b107865bf99d9f2c73f2085d 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -996,7 +996,14 @@ impl Vim { cx: &mut Context, f: impl Fn(&mut Vim, &A, &mut Window, &mut Context) + 'static, ) { - let subscription = editor.register_action(cx.listener(f)); + let subscription = editor.register_action(cx.listener(move |vim, action, window, cx| { + if !Vim::globals(cx).dot_replaying { + if vim.status_label.take().is_some() { + cx.notify(); + } + } + f(vim, action, window, cx); + })); cx.on_release(|_, _| drop(subscription)).detach(); } @@ -1155,7 +1162,6 @@ impl Vim { let last_mode = self.mode; let prior_mode = self.last_mode; let prior_tx = self.current_tx; - self.status_label.take(); self.last_mode = last_mode; self.mode = mode; self.operator_stack.clear(); @@ -1586,6 +1592,7 @@ impl Vim { globals.dot_recording = true; globals.recording_actions = Default::default(); globals.recording_count = None; + globals.recording_register_for_dot = self.selected_register; let selections = self.editor().map(|editor| { editor.update(cx, |editor, cx| { @@ -2070,7 +2077,7 @@ impl Vim { input_enabled: self.editor_input_enabled(), expects_character_input: self.expects_character_input(), autoindent: self.should_autoindent(), - cursor_offset_on_selection: self.mode.is_visual(), + cursor_offset_on_selection: self.mode.is_visual() || self.mode.is_helix(), line_mode: matches!(self.mode, Mode::VisualLine), hide_edit_predictions: !matches!(self.mode, Mode::Insert | Mode::Replace), } @@ -2092,6 +2099,11 @@ impl Vim { editor.selections.set_line_mode(state.line_mode); editor.set_edit_predictions_hidden_for_vim_mode(state.hide_edit_predictions, window, cx); } + + fn set_status_label(&mut self, label: impl Into, cx: &mut Context) { + self.status_label = Some(label.into()); + cx.notify(); + } } struct VimEditorSettingsState { diff --git a/crates/vim/test_data/test_dot_repeat_registers.json b/crates/vim/test_data/test_dot_repeat_registers.json new file mode 100644 index 0000000000000000000000000000000000000000..76ca1af20fe14cacb23482cd6988dea16cfb9194 --- /dev/null +++ b/crates/vim/test_data/test_dot_repeat_registers.json @@ -0,0 +1,125 @@ +{"Put":{"state":"ˇhello\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"p"} +{"Get":{"state":"hello\nˇhello\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello\nhello\nˇhello\n","mode":"Normal"}} +{"Put":{"state":"ˇtocopytext\n1\n2\n3\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"_"} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"p"} +{"Get":{"state":"tocopytext\n3\nˇtocopytext\n","mode":"Normal"}} +{"Put":{"state":"ˇtocopytext\n1\n2\n3\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"tocopytext\n1\n2\n3\nˇ1\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"d"} +{"Key":"d"} +{"Key":"\""} +{"Key":"b"} +{"Key":"."} +{"Get":{"state":"ˇthree\n","mode":"Normal"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"\""} +{"Key":"b"} +{"Key":"p"} +{"Get":{"state":"three\nˇtwo\n","mode":"Normal"}} +{"Put":{"state":"ˇline one\nline two\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Key":"\""} +{"Key":"b"} +{"Key":"."} +{"Get":{"state":"line one\nline two\nline one\nline one\nˇline one\n","mode":"Normal"}} +{"Put":{"state":"ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"ˇ","mode":"Normal"}} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"\n9\n8\n7\n6\n5\n4\n3\n2\n1\nˇ1","mode":"Normal"}} +{"Put":{"state":"ˇa\nb\nc\n"}} +{"Key":"\""} +{"Key":"9"} +{"Key":"y"} +{"Key":"y"} +{"Key":"\""} +{"Key":"9"} +{"Key":"p"} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"a\na\na\nˇa\nb\nc\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"two\none\nˇone\nthree\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"y"} +{"Key":"y"} +{"Key":"k"} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"one\ntwo\n9\none\nˇone\ntwo\nthree\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_dot_repeat_registers_paste.json b/crates/vim/test_data/test_dot_repeat_registers_paste.json new file mode 100644 index 0000000000000000000000000000000000000000..f5a08d432d0b1fda8ec1bfe71d7401ec8769d8d2 --- /dev/null +++ b/crates/vim/test_data/test_dot_repeat_registers_paste.json @@ -0,0 +1,105 @@ +{"Put":{"state":"ˇhello\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"p"} +{"Get":{"state":"hello\nˇhello\n","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello\nhello\nˇhello\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\nfour\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"_"} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"p"} +{"Get":{"state":"one\nfour\nˇone\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\n"}} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"one\ntwo\ntwo\nˇtwo\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"d"} +{"Key":"d"} +{"Key":"\""} +{"Key":"b"} +{"Key":"."} +{"Get":{"state":"ˇthree\n","mode":"Normal"}} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"\""} +{"Key":"b"} +{"Key":"p"} +{"Get":{"state":"three\nˇtwo\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"ˇ","mode":"Normal"}} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Key":"."} +{"Get":{"state":"\nten\nnine\neight\nseven\nsix\nfive\nfour\nthree\ntwo\nˇtwo","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\n"}} +{"Key":"d"} +{"Key":"d"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"two\none\nˇone\nthree\n","mode":"Normal"}} +{"Put":{"state":"one\ntwo\nˇthree\n"}} +{"Key":"\""} +{"Key":"2"} +{"Key":"y"} +{"Key":"y"} +{"Key":"k"} +{"Key":"k"} +{"Key":"\""} +{"Key":"a"} +{"Key":"y"} +{"Key":"y"} +{"Key":"j"} +{"Key":"\""} +{"Key":"1"} +{"Key":"y"} +{"Key":"y"} +{"Key":"k"} +{"Key":"\""} +{"Key":"1"} +{"Key":"p"} +{"Key":"."} +{"Key":"\""} +{"Key":"a"} +{"Key":"p"} +{"Key":"."} +{"Get":{"state":"one\ntwo\nthree\none\nˇone\ntwo\nthree\n","mode":"Normal"}} diff --git a/crates/watch/Cargo.toml b/crates/watch/Cargo.toml index 9d77eaeddec66a08dd2e9d5056249671c9b02670..aea8b0bbbda7d53d17400553407eceb7cb8253b2 100644 --- a/crates/watch/Cargo.toml +++ b/crates/watch/Cargo.toml @@ -19,5 +19,4 @@ parking_lot.workspace = true ctor.workspace = true futures.workspace = true gpui = { workspace = true, features = ["test-support"] } -rand.workspace = true zlog.workspace = true diff --git a/crates/web_search_providers/src/cloud.rs b/crates/web_search_providers/src/cloud.rs index c8bc89953f2b2d3ec62bac07e80f2737522824f7..17addd24d445a666138a1b37fef872beedd07aed 100644 --- a/crates/web_search_providers/src/cloud.rs +++ b/crates/web_search_providers/src/cloud.rs @@ -5,9 +5,9 @@ use client::{Client, UserStore}; use cloud_api_types::OrganizationId; use cloud_llm_client::{WebSearchBody, WebSearchResponse}; use futures::AsyncReadExt as _; -use gpui::{App, AppContext, Context, Entity, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, Task}; use http_client::{HttpClient, Method}; -use language_model::{LlmApiToken, NeedsLlmTokenRefresh, RefreshLlmTokenListener}; +use language_model::{LlmApiToken, NeedsLlmTokenRefresh}; use web_search::{WebSearchProvider, WebSearchProviderId}; pub struct CloudWebSearchProvider { @@ -26,34 +26,16 @@ pub struct State { client: Arc, user_store: Entity, llm_api_token: LlmApiToken, - _llm_token_subscription: Subscription, } impl State { pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { - let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let llm_api_token = LlmApiToken::global(cx); Self { client, user_store, - llm_api_token: LlmApiToken::default(), - _llm_token_subscription: cx.subscribe( - &refresh_llm_token_listener, - |this, _, _event, cx| { - let client = this.client.clone(); - let llm_api_token = this.llm_api_token.clone(); - let organization_id = this - .user_store - .read(cx) - .current_organization() - .map(|o| o.id.clone()); - cx.spawn(async move |_this, _cx| { - llm_api_token.refresh(&client, organization_id).await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - }, - ), + llm_api_token, } } } @@ -73,7 +55,7 @@ impl WebSearchProvider for CloudWebSearchProvider { .user_store .read(cx) .current_organization() - .map(|o| o.id.clone()); + .map(|organization| organization.id.clone()); let body = WebSearchBody { query }; cx.background_spawn(async move { perform_web_search(client, llm_api_token, organization_id, body).await diff --git a/crates/which_key/src/which_key.rs b/crates/which_key/src/which_key.rs index 70889c100f33020a3ceaa8af1ba8812d5e7d4adb..d71bd646e70a4ede6047bd88416ea9314bddf12d 100644 --- a/crates/which_key/src/which_key.rs +++ b/crates/which_key/src/which_key.rs @@ -61,12 +61,8 @@ pub fn init(cx: &mut App) { pub static FILTERED_KEYSTROKES: LazyLock>> = LazyLock::new(|| { [ // Modifiers on normal vim commands - "g h", "g j", "g k", - "g l", - "g $", - "g ^", // Duplicate keys with "ctrl" held, e.g. "ctrl-w ctrl-a" is duplicate of "ctrl-w a" "ctrl-w ctrl-a", "ctrl-w ctrl-c", diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 84fd10c8c03e4f7411fc8c813b70255f5e00031d..e884b834af1294a368ad67d72057561b42876ce2 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -72,7 +72,6 @@ windows.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } -dap = { workspace = true, features = ["test-support"] } db = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 439c6df5ee45938368895a67834d57df695fde89..44a24f687a49552f707d968e82d19387b74b0ac1 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -12,8 +12,10 @@ use gpui::{ }; use settings::SettingsStore; use std::sync::Arc; -use ui::{ContextMenu, Divider, DividerColor, IconButton, Tooltip, h_flex}; -use ui::{prelude::*, right_click_menu}; +use ui::{ + ContextMenu, CountBadge, Divider, DividerColor, IconButton, Tooltip, prelude::*, + right_click_menu, +}; use util::ResultExt as _; pub(crate) const RESIZE_HANDLE_SIZE: Pixels = px(6.); @@ -940,6 +942,7 @@ impl Render for PanelButtons { }; let focus_handle = dock.focus_handle(cx); + let icon_label = entry.panel.icon_label(window, cx); Some( right_click_menu(name) @@ -973,7 +976,7 @@ impl Render for PanelButtons { .trigger(move |is_active, _window, _cx| { // Include active state in element ID to invalidate the cached // tooltip when panel state changes (e.g., via keyboard shortcut) - IconButton::new((name, is_active_button as u64), icon) + let button = IconButton::new((name, is_active_button as u64), icon) .icon_size(IconSize::Small) .toggle_state(is_active_button) .on_click({ @@ -987,7 +990,15 @@ impl Render for PanelButtons { this.tooltip(move |_window, cx| { Tooltip::for_action(tooltip.clone(), &*action, cx) }) - }) + }); + + div().relative().child(button).when_some( + icon_label + .clone() + .filter(|_| !is_active_button) + .and_then(|label| label.parse::().ok()), + |this, count| this.child(CountBadge::new(count)), + ) }), ) }) 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/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 3f5981178fe118f41196538e1a22960bd55644d0..cb60978d85220baa8519a7a1816434b4c06eb0c3 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1,9 +1,8 @@ use anyhow::Result; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ - AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId, - actions, deferred, px, + App, Context, Entity, EntityId, EventEmitter, Focusable, ManagedView, Pixels, Render, + Subscription, Task, Tiling, Window, WindowId, actions, px, }; use project::{DisableAiSettings, Project}; use settings::Settings; @@ -12,11 +11,12 @@ use std::path::PathBuf; use ui::prelude::*; use util::ResultExt; -const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +pub const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); use crate::{ CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast, Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId, + persistence::model::MultiWorkspaceId, }; actions!( @@ -41,27 +41,6 @@ pub enum MultiWorkspaceEvent { WorkspaceRemoved(EntityId), } -pub enum SidebarEvent { - Open, - Close, -} - -pub trait Sidebar: EventEmitter + Focusable + Render + Sized { - fn width(&self, cx: &App) -> Pixels; - fn set_width(&mut self, width: Option, cx: &mut Context); - fn has_notifications(&self, cx: &App) -> bool; -} - -pub trait SidebarHandle: 'static + Send + Sync { - fn width(&self, cx: &App) -> Pixels; - fn set_width(&self, width: Option, cx: &mut App); - fn focus_handle(&self, cx: &App) -> FocusHandle; - fn focus(&self, window: &mut Window, cx: &mut App); - fn has_notifications(&self, cx: &App) -> bool; - fn to_any(&self) -> AnyView; - fn entity_id(&self) -> EntityId; -} - #[derive(Clone)] pub struct DraggedSidebar; @@ -71,44 +50,11 @@ impl Render for DraggedSidebar { } } -impl SidebarHandle for Entity { - fn width(&self, cx: &App) -> Pixels { - self.read(cx).width(cx) - } - - fn set_width(&self, width: Option, cx: &mut App) { - self.update(cx, |this, cx| this.set_width(width, cx)) - } - - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.read(cx).focus_handle(cx) - } - - fn focus(&self, window: &mut Window, cx: &mut App) { - let handle = self.read(cx).focus_handle(cx); - window.focus(&handle, cx); - } - - fn has_notifications(&self, cx: &App) -> bool { - self.read(cx).has_notifications(cx) - } - - fn to_any(&self) -> AnyView { - self.clone().into() - } - - fn entity_id(&self) -> EntityId { - Entity::entity_id(self) - } -} - pub struct MultiWorkspace { window_id: WindowId, workspaces: Vec>, + database_id: Option, active_workspace_index: usize, - sidebar: Option>, - sidebar_open: bool, - _sidebar_subscription: Option, pending_removal_tasks: Vec>, _serialize_task: Option>, _create_task: Option>, @@ -117,6 +63,10 @@ pub struct MultiWorkspace { impl EventEmitter for MultiWorkspace {} +pub fn multi_workspace_enabled(cx: &App) -> bool { + cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai +} + impl MultiWorkspace { pub fn new(workspace: Entity, window: &mut Window, cx: &mut Context) -> Self { let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| { @@ -131,130 +81,17 @@ impl MultiWorkspace { } }); let quit_subscription = cx.on_app_quit(Self::app_will_quit); - let settings_subscription = - cx.observe_global_in::(window, |this, window, cx| { - if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open { - this.close_sidebar(window, cx); - } - }); Self::subscribe_to_workspace(&workspace, cx); Self { window_id: window.window_handle().window_id(), + database_id: None, workspaces: vec![workspace], active_workspace_index: 0, - sidebar: None, - sidebar_open: false, - _sidebar_subscription: None, pending_removal_tasks: Vec::new(), _serialize_task: None, _create_task: None, - _subscriptions: vec![ - release_subscription, - quit_subscription, - settings_subscription, - ], - } - } - - pub fn register_sidebar( - &mut self, - sidebar: Entity, - window: &mut Window, - cx: &mut Context, - ) { - let subscription = - cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event { - SidebarEvent::Open => this.toggle_sidebar(window, cx), - SidebarEvent::Close => { - this.close_sidebar(window, cx); - } - }); - self.sidebar = Some(Box::new(sidebar)); - self._sidebar_subscription = Some(subscription); - } - - pub fn sidebar(&self) -> Option<&dyn SidebarHandle> { - self.sidebar.as_deref() - } - - pub fn sidebar_open(&self) -> bool { - self.sidebar_open && self.sidebar.is_some() - } - - pub fn sidebar_has_notifications(&self, cx: &App) -> bool { - self.sidebar - .as_ref() - .map_or(false, |s| s.has_notifications(cx)) - } - - pub fn multi_workspace_enabled(&self, cx: &App) -> bool { - cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai - } - - pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { - return; - } - - if self.sidebar_open { - self.close_sidebar(window, cx); - } else { - self.open_sidebar(cx); - if let Some(sidebar) = &self.sidebar { - sidebar.focus(window, cx); - } - } - } - - pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { - return; - } - - if self.sidebar_open { - let sidebar_is_focused = self - .sidebar - .as_ref() - .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx)); - - if sidebar_is_focused { - let pane = self.workspace().read(cx).active_pane().clone(); - let pane_focus = pane.read(cx).focus_handle(cx); - window.focus(&pane_focus, cx); - } else if let Some(sidebar) = &self.sidebar { - sidebar.focus(window, cx); - } - } else { - self.open_sidebar(cx); - if let Some(sidebar) = &self.sidebar { - sidebar.focus(window, cx); - } - } - } - - pub fn open_sidebar(&mut self, cx: &mut Context) { - self.sidebar_open = true; - for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, cx); - }); - } - self.serialize(cx); - cx.notify(); - } - - fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context) { - self.sidebar_open = false; - for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(false, cx); - }); + _subscriptions: vec![release_subscription, quit_subscription], } - let pane = self.workspace().read(cx).active_pane().clone(); - let pane_focus = pane.read(cx).focus_handle(cx); - window.focus(&pane_focus, cx); - self.serialize(cx); - cx.notify(); } pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context) { @@ -292,10 +129,6 @@ impl MultiWorkspace { .detach(); } - pub fn is_sidebar_open(&self) -> bool { - self.sidebar_open - } - pub fn workspace(&self) -> &Entity { &self.workspaces[self.active_workspace_index] } @@ -309,7 +142,7 @@ impl MultiWorkspace { } pub fn activate(&mut self, workspace: Entity, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { + if !multi_workspace_enabled(cx) { self.workspaces[0] = workspace; self.active_workspace_index = 0; cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged); @@ -345,11 +178,6 @@ impl MultiWorkspace { if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { index } else { - if self.sidebar_open { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, cx); - }); - } Self::subscribe_to_workspace(&workspace, cx); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); @@ -358,6 +186,14 @@ impl MultiWorkspace { } } + pub fn database_id(&self) -> Option { + self.database_id + } + + pub fn set_database_id(&mut self, id: Option) { + self.database_id = id; + } + pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context) { debug_assert!( index < self.workspaces.len(), @@ -395,7 +231,6 @@ impl MultiWorkspace { let window_id = self.window_id; let state = crate::persistence::model::MultiWorkspaceState { active_workspace_id: self.workspace().read(cx).database_id(), - sidebar_open: self.sidebar_open, }; self._serialize_task = Some(cx.background_spawn(async move { crate::persistence::write_multi_workspace_state(window_id, state).await; @@ -514,7 +349,7 @@ impl MultiWorkspace { self.workspace().read(cx).items_of_type::(cx) } - pub fn database_id(&self, cx: &App) -> Option { + pub fn active_workspace_database_id(&self, cx: &App) -> Option { self.workspace().read(cx).database_id() } @@ -557,7 +392,7 @@ impl MultiWorkspace { } pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context) { - if !self.multi_workspace_enabled(cx) { + if !multi_workspace_enabled(cx) { return; } let app_state = self.workspace().read(cx).app_state().clone(); @@ -663,10 +498,10 @@ impl MultiWorkspace { paths: Vec, window: &mut Window, cx: &mut Context, - ) -> Task> { + ) -> Task>> { let workspace = self.workspace().clone(); - if self.multi_workspace_enabled(cx) { + if multi_workspace_enabled(cx) { workspace.update(cx, |workspace, cx| { workspace.open_workspace_for_paths(true, paths, window, cx) }) @@ -684,7 +519,7 @@ impl MultiWorkspace { })? .await } else { - Ok(()) + Ok(workspace) } }) } @@ -693,57 +528,6 @@ impl MultiWorkspace { impl Render for MultiWorkspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let multi_workspace_enabled = self.multi_workspace_enabled(cx); - - let sidebar: Option = if multi_workspace_enabled && self.sidebar_open { - self.sidebar.as_ref().map(|sidebar_handle| { - let weak = cx.weak_entity(); - - let sidebar_width = sidebar_handle.width(cx); - let resize_handle = deferred( - div() - .id("sidebar-resize-handle") - .absolute() - .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) - .top(px(0.)) - .h_full() - .w(SIDEBAR_RESIZE_HANDLE_SIZE) - .cursor_col_resize() - .on_drag(DraggedSidebar, |dragged, _, _, cx| { - cx.stop_propagation(); - cx.new(|_| dragged.clone()) - }) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up(MouseButton::Left, move |event, _, cx| { - if event.click_count == 2 { - weak.update(cx, |this, cx| { - if let Some(sidebar) = this.sidebar.as_mut() { - sidebar.set_width(None, cx); - } - }) - .ok(); - cx.stop_propagation(); - } - }) - .occlude(), - ); - - div() - .id("sidebar-container") - .relative() - .h_full() - .w(sidebar_width) - .flex_shrink_0() - .child(sidebar_handle.to_any()) - .child(resize_handle) - .into_any_element() - }) - } else { - None - }; - let ui_font = theme::setup_ui_font(window, cx); let text_color = cx.theme().colors().text; @@ -773,32 +557,6 @@ impl Render for MultiWorkspace { this.activate_previous_workspace(window, cx); }, )) - .when(self.multi_workspace_enabled(cx), |this| { - this.on_action(cx.listener( - |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| { - this.toggle_sidebar(window, cx); - }, - )) - .on_action(cx.listener( - |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| { - this.focus_sidebar(window, cx); - }, - )) - }) - .when( - self.sidebar_open() && self.multi_workspace_enabled(cx), - |this| { - this.on_drag_move(cx.listener( - |this: &mut Self, e: &DragMoveEvent, _window, cx| { - if let Some(sidebar) = &this.sidebar { - let new_width = e.event.position.x; - sidebar.set_width(Some(new_width), cx); - } - }, - )) - .children(sidebar) - }, - ) .child( div() .flex() @@ -811,98 +569,9 @@ impl Render for MultiWorkspace { window, cx, Tiling { - left: multi_workspace_enabled && self.sidebar_open, + left: false, ..Tiling::default() }, ) } } - -#[cfg(test)] -mod tests { - use super::*; - use fs::FakeFs; - use gpui::TestAppContext; - use settings::SettingsStore; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - DisableAiSettings::register(cx); - cx.update_flags(false, vec!["agent-v2".into()]); - }); - } - - #[gpui::test] - async fn test_sidebar_disabled_when_disable_ai_is_enabled(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, window, cx)); - - multi_workspace.read_with(cx, |mw, cx| { - assert!(mw.multi_workspace_enabled(cx)); - }); - - multi_workspace.update_in(cx, |mw, _window, cx| { - mw.open_sidebar(cx); - assert!(mw.is_sidebar_open()); - }); - - cx.update(|_window, cx| { - DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); - }); - cx.run_until_parked(); - - multi_workspace.read_with(cx, |mw, cx| { - assert!( - !mw.is_sidebar_open(), - "Sidebar should be closed when disable_ai is true" - ); - assert!( - !mw.multi_workspace_enabled(cx), - "Multi-workspace should be disabled when disable_ai is true" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.toggle_sidebar(window, cx); - }); - multi_workspace.read_with(cx, |mw, _cx| { - assert!( - !mw.is_sidebar_open(), - "Sidebar should remain closed when toggled with disable_ai true" - ); - }); - - cx.update(|_window, cx| { - DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); - }); - cx.run_until_parked(); - - multi_workspace.read_with(cx, |mw, cx| { - assert!( - mw.multi_workspace_enabled(cx), - "Multi-workspace should be enabled after re-enabling AI" - ); - assert!( - !mw.is_sidebar_open(), - "Sidebar should still be closed after re-enabling AI (not auto-opened)" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.toggle_sidebar(window, cx); - }); - multi_workspace.read_with(cx, |mw, _cx| { - assert!( - mw.is_sidebar_open(), - "Sidebar should open when toggled after re-enabling AI" - ); - }); - } -} diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 84f479b77e4f0274e0775353d3a7cd5579768f1c..85b1fe4e707acbc7107df14d23caa3bda24519e5 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -234,6 +234,14 @@ impl Workspace { self.suppressed_notifications.insert(id.clone()); } + pub fn is_notification_suppressed(&self, notification_id: NotificationId) -> bool { + self.suppressed_notifications.contains(¬ification_id) + } + + pub fn unsuppress(&mut self, notification_id: NotificationId) { + self.suppressed_notifications.remove(¬ification_id); + } + pub fn show_initial_notifications(&mut self, cx: &mut Context) { // Allow absence of the global so that tests don't need to initialize it. let app_notifications = GLOBAL_APP_NOTIFICATIONS @@ -657,15 +665,17 @@ impl RenderOnce for NotificationFrame { IconButton::new(close_id, close_icon) .tooltip(move |_window, cx| { if suppress { - Tooltip::for_action( - "Suppress.\nClose with click.", - &SuppressNotification, + Tooltip::with_meta( + "Suppress", + Some(&SuppressNotification), + "Click to Close", cx, ) } else if show_suppress_button { - Tooltip::for_action( - "Close.\nSuppress with shift-click.", - &menu::Cancel, + Tooltip::with_meta( + "Close", + Some(&menu::Cancel), + "Shift-click to Suppress", cx, ) } else { @@ -915,11 +925,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 @@ -935,11 +945,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 @@ -953,9 +963,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 492b7a8f385730feaa06dfe3b5e8b4cc0a20bb59..89ce7dade6e17d5b422dceb46cd9b0a6107eaa46 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -341,6 +341,7 @@ pub fn read_serialized_multi_workspaces( .map(read_multi_workspace_state) .unwrap_or_default(); model::SerializedMultiWorkspace { + id: window_id.map(|id| model::MultiWorkspaceId(id.as_u64())), workspaces: group, state, } @@ -1783,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; @@ -1843,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)); @@ -1903,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, @@ -3877,7 +3886,6 @@ mod tests { window_10, MultiWorkspaceState { active_workspace_id: Some(WorkspaceId(2)), - sidebar_open: true, }, ) .await; @@ -3886,7 +3894,6 @@ mod tests { window_20, MultiWorkspaceState { active_workspace_id: Some(WorkspaceId(3)), - sidebar_open: false, }, ) .await; @@ -3924,23 +3931,20 @@ mod tests { // Should produce 3 groups: window 10, window 20, and the orphan. assert_eq!(results.len(), 3); - // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open. + // Window 10 group: 2 workspaces, active_workspace_id = 2. let group_10 = &results[0]; assert_eq!(group_10.workspaces.len(), 2); assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2))); - assert_eq!(group_10.state.sidebar_open, true); - // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed. + // Window 20 group: 1 workspace, active_workspace_id = 3. let group_20 = &results[1]; assert_eq!(group_20.workspaces.len(), 1); assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3))); - assert_eq!(group_20.state.sidebar_open, false); // Orphan group: no window_id, so state is default. let group_none = &results[2]; assert_eq!(group_none.workspaces.len(), 1); assert_eq!(group_none.state.active_workspace_id, None); - assert_eq!(group_none.state.sidebar_open, false); } #[gpui::test] diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 0971ebd0ddc9265ccf9ea10da7745ba59914db30..c5251f20be9313a50f2256c54823d8839bdfe7fd 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -63,18 +63,19 @@ pub struct SessionWorkspace { #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct MultiWorkspaceState { pub active_workspace_id: Option, - pub sidebar_open: bool, } -/// The serialized state of a single MultiWorkspace window from a previous session: -/// all workspaces that shared the window, which one was active, and whether the -/// sidebar was open. +/// The serialized state of a single MultiWorkspace window from a previous session. #[derive(Debug, Clone)] pub struct SerializedMultiWorkspace { + pub id: Option, pub workspaces: Vec, pub state: MultiWorkspaceState, } +#[derive(Debug, Clone, Copy)] +pub struct MultiWorkspaceId(pub u64); + #[derive(Debug, PartialEq, Clone)] pub(crate) struct SerializedWorkspace { pub(crate) id: WorkspaceId, diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 5e0b8a7f6eabbd652f1f429342a837aa0b43e6d2..6164ff3f7f1ba3ee2b578beb6aa0c3ccced50884 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -34,7 +34,6 @@ pub struct StatusBar { right_items: Vec>, active_pane: Entity, _observe_active_pane: Subscription, - workspace_sidebar_open: bool, } impl Render for StatusBar { @@ -52,10 +51,9 @@ impl Render for StatusBar { .when(!(tiling.bottom || tiling.right), |el| { el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) }) - .when( - !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open, - |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING), - ) + .when(!(tiling.bottom || tiling.left), |el| { + el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) + }) // This border is to avoid a transparent gap in the rounded corners .mb(px(-1.)) .border_b(px(1.0)) @@ -70,12 +68,14 @@ impl StatusBar { fn render_left_tools(&self) -> impl IntoElement { h_flex() .gap_1() + .min_w_0() .overflow_x_hidden() .children(self.left_items.iter().map(|item| item.to_any())) } fn render_right_tools(&self) -> impl IntoElement { h_flex() + .flex_shrink_0() .gap_1() .overflow_x_hidden() .children(self.right_items.iter().rev().map(|item| item.to_any())) @@ -91,17 +91,11 @@ impl StatusBar { _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| { this.update_active_pane_item(window, cx) }), - workspace_sidebar_open: false, }; this.update_active_pane_item(window, cx); this } - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); - } - pub fn add_left_item(&mut self, item: Entity, window: &mut Window, cx: &mut Context) where T: 'static + StatusItemView, diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 1a16b731b44db9e1678bba9c316e388139d39058..92f1cb4840731bedda5b0b6751f44bfdcdb8ea52 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -10,8 +10,10 @@ use gpui::{ ParentElement, Render, Styled, Task, Window, actions, }; use menu::{SelectNext, SelectPrevious}; +use project::DisableAiSettings; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings; use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*}; use util::ResultExt; use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette}; @@ -121,21 +123,43 @@ impl RenderOnce for SectionButton { } } +enum SectionVisibility { + Always, + Conditional(fn(&App) -> bool), +} + +impl SectionVisibility { + fn is_visible(&self, cx: &App) -> bool { + match self { + SectionVisibility::Always => true, + SectionVisibility::Conditional(f) => f(cx), + } + } +} + struct SectionEntry { icon: IconName, title: &'static str, action: &'static dyn Action, + visibility_guard: SectionVisibility, } impl SectionEntry { - fn render(&self, button_index: usize, focus: &FocusHandle, _cx: &App) -> impl IntoElement { - SectionButton::new( - self.title, - self.icon, - self.action, - button_index, - focus.clone(), - ) + fn render( + &self, + button_index: usize, + focus: &FocusHandle, + cx: &App, + ) -> Option { + self.visibility_guard.is_visible(cx).then(|| { + SectionButton::new( + self.title, + self.icon, + self.action, + button_index, + focus.clone(), + ) + }) } } @@ -147,21 +171,25 @@ const CONTENT: (Section<4>, Section<3>) = ( icon: IconName::Plus, title: "New File", action: &NewFile, + visibility_guard: SectionVisibility::Always, }, SectionEntry { icon: IconName::FolderOpen, title: "Open Project", action: &Open::DEFAULT, + visibility_guard: SectionVisibility::Always, }, SectionEntry { icon: IconName::CloudDownload, title: "Clone Repository", action: &GitClone, + visibility_guard: SectionVisibility::Always, }, SectionEntry { icon: IconName::ListCollapse, title: "Open Command Palette", action: &command_palette::Toggle, + visibility_guard: SectionVisibility::Always, }, ], }, @@ -172,11 +200,15 @@ const CONTENT: (Section<4>, Section<3>) = ( icon: IconName::Settings, title: "Open Settings", action: &OpenSettings, + visibility_guard: SectionVisibility::Always, }, SectionEntry { icon: IconName::ZedAssistant, title: "View AI Settings", action: &agent::OpenSettings, + visibility_guard: SectionVisibility::Conditional(|cx| { + !DisableAiSettings::get_global(cx).disable_ai + }), }, SectionEntry { icon: IconName::Blocks, @@ -185,6 +217,7 @@ const CONTENT: (Section<4>, Section<3>) = ( category_filter: None, id: None, }, + visibility_guard: SectionVisibility::Always, }, ], }, @@ -204,7 +237,7 @@ impl Section { self.entries .iter() .enumerate() - .map(|(index, entry)| entry.render(index_offset + index, focus, cx)), + .filter_map(|(index, entry)| entry.render(index_offset + index, focus, cx)), ) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 90f05d07a3a87a53ca25a1dc15da7663a95984a8..38271ac77cf05d9545f22084696837121b13f93d 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -28,8 +28,8 @@ pub use crate::notifications::NotificationFrame; pub use dock::Panel; pub use multi_workspace::{ DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, - NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, - SidebarHandle, ToggleWorkspaceSidebar, + NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, + SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, multi_workspace_enabled, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -80,8 +80,8 @@ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace}; pub use persistence::{ DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items, model::{ - DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, - SessionWorkspace, + DockStructure, ItemId, MultiWorkspaceId, SerializedMultiWorkspace, + SerializedWorkspaceLocation, SessionWorkspace, }, read_serialized_multi_workspaces, }; @@ -146,7 +146,7 @@ pub use workspace_settings::{ AutosaveSetting, BottomDockLayout, RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings, WorkspaceSettings, }; -use zed_actions::{Spawn, feedback::FileBugReport}; +use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode}; use crate::{item::ItemBufferKind, notifications::NotificationId}; use crate::{ @@ -659,7 +659,7 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c } else { let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, true, cx); cx.spawn(async move |cx| { - let (window, _) = task.await?; + let OpenResult { window, .. } = task.await?; window.update(cx, |multi_workspace, window, cx| { window.activate_window(); let workspace = multi_workspace.workspace().clone(); @@ -1752,12 +1752,7 @@ impl Workspace { init: Option) + Send>>, activate: bool, cx: &mut App, - ) -> Task< - anyhow::Result<( - WindowHandle, - Vec>>>, - )>, - > { + ) -> Task> { let project_handle = Project::local( app_state.client.clone(), app_state.node_runtime.clone(), @@ -1997,7 +1992,11 @@ impl Workspace { }); }) .log_err(); - Ok((window, opened_items)) + Ok(OpenResult { + window, + workspace, + opened_items, + }) }) } @@ -2154,12 +2153,6 @@ impl Workspace { &self.status_bar } - pub fn set_workspace_sidebar_open(&self, open: bool, cx: &mut App) { - self.status_bar.update(cx, |status_bar, cx| { - status_bar.set_workspace_sidebar_open(open, cx); - }); - } - pub fn status_bar_visible(&self, cx: &App) -> bool { StatusBarSettings::get_global(cx).show } @@ -2691,7 +2684,10 @@ impl Workspace { cx, ); cx.spawn_in(window, async move |_vh, cx| { - let (multi_workspace_window, _) = task.await?; + let OpenResult { + window: multi_workspace_window, + .. + } = task.await?; multi_workspace_window.update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| callback(workspace, window, cx)) @@ -2729,7 +2725,10 @@ impl Workspace { cx, ); cx.spawn_in(window, async move |_vh, cx| { - let (multi_workspace_window, _) = task.await?; + let OpenResult { + window: multi_workspace_window, + .. + } = task.await?; multi_workspace_window.update(cx, |multi_workspace, window, cx| { let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| callback(workspace, window, cx)) @@ -3108,7 +3107,7 @@ impl Workspace { paths: Vec, window: &mut Window, cx: &mut Context, - ) -> Task> { + ) -> Task>> { let window_handle = window.window_handle().downcast::(); let is_remote = self.project.read(cx).is_via_collab(); let has_worktree = self.project.read(cx).worktrees(cx).next().is_some(); @@ -3124,19 +3123,20 @@ impl Workspace { let app_state = self.app_state.clone(); cx.spawn(async move |_, cx| { - cx.update(|cx| { - open_paths( - &paths, - app_state, - OpenOptions { - replace_window: window_to_replace, - ..Default::default() - }, - cx, - ) - }) - .await?; - Ok(()) + let OpenResult { workspace, .. } = cx + .update(|cx| { + open_paths( + &paths, + app_state, + OpenOptions { + replace_window: window_to_replace, + ..Default::default() + }, + cx, + ) + }) + .await?; + Ok(workspace) }) } @@ -6505,6 +6505,7 @@ impl Workspace { .on_action(cx.listener(Self::move_item_to_pane_at_index)) .on_action(cx.listener(Self::move_focused_panel_to_next_position)) .on_action(cx.listener(Self::toggle_edit_predictions_all_files)) + .on_action(cx.listener(Self::toggle_theme_mode)) .on_action(cx.listener(|workspace, _: &Unfollow, window, cx| { let pane = workspace.active_pane().clone(); workspace.unfollow_in_pane(&pane, window, cx); @@ -7159,6 +7160,23 @@ impl Workspace { }); } + fn toggle_theme_mode(&mut self, _: &ToggleMode, _window: &mut Window, cx: &mut Context) { + let current_mode = ThemeSettings::get_global(cx).theme.mode(); + let next_mode = match current_mode { + Some(theme::ThemeAppearanceMode::Light) => theme::ThemeAppearanceMode::Dark, + Some(theme::ThemeAppearanceMode::Dark) => theme::ThemeAppearanceMode::Light, + Some(theme::ThemeAppearanceMode::System) | None => match cx.theme().appearance() { + theme::Appearance::Light => theme::ThemeAppearanceMode::Dark, + theme::Appearance::Dark => theme::ThemeAppearanceMode::Light, + }, + }; + + let fs = self.project().read(cx).fs().clone(); + settings::update_settings_file(fs, cx, move |settings, _cx| { + theme::set_mode(settings, next_mode); + }); + } + pub fn show_worktree_trust_security_modal( &mut self, toggle: bool, @@ -7250,6 +7268,12 @@ impl GlobalAnyActiveCall { cx.global() } } + +pub fn merge_conflict_notification_id() -> NotificationId { + struct MergeConflictNotification; + NotificationId::unique::() +} + /// Workspace-local view of a remote participant's location. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ParticipantLocation { @@ -7844,7 +7868,6 @@ impl Render for Workspace { window, cx, )), - BottomDockLayout::RightAligned => div() .flex() .flex_row() @@ -7903,7 +7926,6 @@ impl Render for Workspace { .children(self.render_dock(DockPosition::Bottom, &self.bottom_dock, window, cx)) ), ), - BottomDockLayout::Contained => div() .flex() .flex_row() @@ -8184,7 +8206,11 @@ pub async fn restore_multiworkspace( app_state: Arc, cx: &mut AsyncApp, ) -> anyhow::Result { - let SerializedMultiWorkspace { workspaces, state } = multi_workspace; + let SerializedMultiWorkspace { + workspaces, + state, + id: window_id, + } = multi_workspace; let mut group_iter = workspaces.into_iter(); let first = group_iter .next() @@ -8194,7 +8220,7 @@ pub async fn restore_multiworkspace( cx.update(|cx| open_workspace_by_id(first.workspace_id, app_state.clone(), None, cx)) .await? } else { - let (window, _items) = cx + let OpenResult { window, .. } = cx .update(|cx| { Workspace::new_local( first.paths.paths().to_vec(), @@ -8248,6 +8274,7 @@ pub async fn restore_multiworkspace( if let Some(target_id) = state.active_workspace_id { window_handle .update(cx, |multi_workspace, window, cx| { + multi_workspace.set_database_id(window_id); let target_index = multi_workspace .workspaces() .iter() @@ -8269,14 +8296,6 @@ pub async fn restore_multiworkspace( .ok(); } - if state.sidebar_open { - window_handle - .update(cx, |multi_workspace, _, cx| { - multi_workspace.open_sidebar(cx); - }) - .ok(); - } - window_handle .update(cx, |_, window, _cx| { window.activate_window(); @@ -8315,6 +8334,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, [ @@ -8494,7 +8522,10 @@ pub fn join_channel( let mut active_window = requesting_window.or_else(|| activate_any_workspace_window(cx)); if active_window.is_none() { // no open workspaces, make one to show the error in (blergh) - let (window_handle, _) = cx + let OpenResult { + window: window_handle, + .. + } = cx .update(|cx| { Workspace::new_local( vec![], @@ -8750,6 +8781,14 @@ pub struct OpenOptions { pub env: Option>, } +/// The result of opening a workspace via [`open_paths`], [`Workspace::new_local`], +/// or [`Workspace::open_workspace_for_paths`]. +pub struct OpenResult { + pub window: WindowHandle, + pub workspace: Entity, + pub opened_items: Vec>>>, +} + /// Opens a workspace by its database ID, used for restoring empty workspaces with unsaved content. pub fn open_workspace_by_id( workspace_id: WorkspaceId, @@ -8869,12 +8908,7 @@ pub fn open_paths( app_state: Arc, open_options: OpenOptions, cx: &mut App, -) -> Task< - anyhow::Result<( - WindowHandle, - Vec>>>, - )>, -> { +) -> Task> { let abs_paths = abs_paths.to_vec(); #[cfg(target_os = "windows")] let wsl_path = abs_paths @@ -8953,7 +8987,7 @@ pub fn open_paths( }); }); - Ok((existing, open_task)) + Ok(OpenResult { window: existing, workspace: target_workspace, opened_items: open_task }) } else { let result = cx .update(move |cx| { @@ -8969,8 +9003,8 @@ pub fn open_paths( }) .await; - if let Ok((ref window_handle, _)) = result { - window_handle + if let Ok(ref result) = result { + result.window .update(cx, |_, window, _cx| { window.activate_window(); }) @@ -8982,9 +9016,9 @@ pub fn open_paths( #[cfg(target_os = "windows")] if let Some(util::paths::WslPath{distro, path}) = wsl_path - && let Ok((multi_workspace_window, _)) = &result + && let Ok(ref result) = result { - multi_workspace_window + result.window .update(cx, move |multi_workspace, _window, cx| { struct OpenInWsl; let workspace = multi_workspace.workspace().clone(); @@ -9031,7 +9065,7 @@ pub fn open_new( cx, ); cx.spawn(async move |cx| { - let (window, _opened_paths) = task.await?; + let OpenResult { window, .. } = task.await?; window .update(cx, |_, window, _cx| { window.activate_window(); @@ -9973,7 +10007,7 @@ pub fn with_active_or_new_workspace( #[cfg(test)] mod tests { - use std::{cell::RefCell, rc::Rc}; + use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration}; use super::*; use crate::{ @@ -9991,6 +10025,7 @@ mod tests { use project::{Project, ProjectEntryId}; use serde_json::json; use settings::SettingsStore; + use util::path; use util::rel_path::rel_path; #[gpui::test] @@ -13549,6 +13584,74 @@ mod tests { }); } + #[gpui::test] + async fn test_toggle_theme_mode_persists_and_updates_active_theme(cx: &mut TestAppContext) { + use settings::{ThemeName, ThemeSelection}; + use theme::SystemAppearance; + use zed_actions::theme::ToggleMode; + + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let settings_fs: Arc = fs.clone(); + + fs.insert_tree(path!("/root"), json!({ "file.rs": "fn main() {}\n" })) + .await; + + // Build a test project and workspace view so the test can invoke + // the workspace action handler the same way the UI would. + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + // Seed the settings file with a plain static light theme so the + // first toggle always starts from a known persisted state. + workspace.update_in(cx, |_workspace, _window, cx| { + *SystemAppearance::global_mut(cx) = SystemAppearance(theme::Appearance::Light); + settings::update_settings_file(settings_fs.clone(), cx, |settings, _cx| { + settings.theme.theme = Some(ThemeSelection::Static(ThemeName("One Light".into()))); + }); + }); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + // Confirm the initial persisted settings contain the static theme + // we just wrote before any toggling happens. + let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap(); + assert!(settings_text.contains(r#""theme": "One Light""#)); + + // Toggle once. This should migrate the persisted theme settings + // into light/dark slots and enable system mode. + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_theme_mode(&ToggleMode, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + // 1. Static -> Dynamic + // this assertion checks theme changed from static to dynamic. + let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap(); + let parsed: serde_json::Value = settings::parse_json_with_comments(&settings_text).unwrap(); + assert_eq!( + parsed["theme"], + serde_json::json!({ + "mode": "system", + "light": "One Light", + "dark": "One Dark" + }) + ); + + // 2. Toggle again, suppose it will change the mode to light + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_theme_mode(&ToggleMode, window, cx); + }); + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + let settings_text = SettingsStore::load_settings(&settings_fs).await.unwrap(); + assert!(settings_text.contains(r#""mode": "light""#)); + } + fn dirty_project_item(id: u64, path: &str, cx: &mut App) -> Entity { let item = TestProjectItem::new(id, path, cx); item.update(cx, |item, _| { diff --git a/crates/worktree/Cargo.toml b/crates/worktree/Cargo.toml index 788333b5e801f2a0bb22558945d2f142b50ef0a5..6d8faad3dc495a02e054f3fa652f5815f301cf3f 100644 --- a/crates/worktree/Cargo.toml +++ b/crates/worktree/Cargo.toml @@ -21,7 +21,7 @@ workspace = true [features] test-support = [ "gpui/test-support", - "http_client/test-support", + "language/test-support", "pretty_assertions", "settings/test-support", @@ -63,9 +63,7 @@ ztracing.workspace = true [dev-dependencies] clock = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } -git2.workspace = true gpui = { workspace = true, features = ["test-support"] } -http_client.workspace = true paths = { workspace = true, features = ["test-support"] } rand.workspace = true rpc = { workspace = true, features = ["test-support"] } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 9e62beb3c375fb8d580be02382091cafe04d31e2..44ba4e752cff778b7918b9a29935d0f0e1ebb614 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1322,6 +1322,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, is_local: true, is_private, @@ -1378,6 +1379,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, is_local: true, is_private, @@ -1575,6 +1577,7 @@ impl LocalWorktree { path, disk_state: DiskState::Present { mtime: metadata.mtime, + size: metadata.len, }, entry_id: None, is_local: true, @@ -3289,7 +3292,10 @@ impl File { worktree, path: entry.path.clone(), disk_state: if let Some(mtime) = entry.mtime { - DiskState::Present { mtime } + DiskState::Present { + mtime, + size: entry.size, + } } else { DiskState::New }, @@ -3318,7 +3324,7 @@ impl File { } else if proto.is_deleted { DiskState::Deleted } else if let Some(mtime) = proto.mtime.map(&Into::into) { - DiskState::Present { mtime } + DiskState::Present { mtime, size: 0 } } else { DiskState::New }; diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index e3c6782989ef9dfabebab04a2ab1123b32b0c169..af925a703485a00a0e26fa0bfae6d30eec979505 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.228.0" +version = "0.229.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -182,7 +182,6 @@ settings.workspace = true settings_profile_selector.workspace = true settings_ui.workspace = true shellexpand.workspace = true -sidebar.workspace = true smol.workspace = true snippet_provider.workspace = true snippets_ui.workspace = true @@ -244,7 +243,6 @@ pkg-config = "0.3.22" [dev-dependencies] call = { workspace = true, features = ["test-support"] } -dap = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } image_viewer = { workspace = true, features = ["test-support"] } @@ -254,8 +252,6 @@ pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } semver.workspace = true terminal_view = { workspace = true, features = ["test-support"] } -tree-sitter-md.workspace = true -tree-sitter-rust.workspace = true title_bar = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } image.workspace = true diff --git a/crates/zed/build.rs b/crates/zed/build.rs index e169760acf16d6caa44aeb2004cd823a355f36ee..9b9ed59bf4de65220f36c1fd53421fdf44c1e529 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -43,12 +43,28 @@ fn main() { "cargo:rustc-env=TARGET={}", std::env::var("TARGET").unwrap() ); - if let Ok(output) = Command::new("git").args(["rev-parse", "HEAD"]).output() - && output.status.success() - { - let git_sha = String::from_utf8_lossy(&output.stdout); - let git_sha = git_sha.trim(); + let git_sha = match std::env::var("ZED_COMMIT_SHA").ok() { + Some(git_sha) => { + // In deterministic build environments such as Nix, we inject the commit sha into the build script. + Some(git_sha) + } + None => { + if let Some(output) = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + && output.status.success() + { + let git_sha = String::from_utf8_lossy(&output.stdout); + Some(git_sha.trim().to_string()) + } else { + None + } + } + }; + + if let Some(git_sha) = git_sha { println!("cargo:rustc-env=ZED_COMMIT_SHA={git_sha}"); if let Some(build_identifier) = option_env!("GITHUB_RUN_NUMBER") { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 3bf3fd190f61ffead59d08d4da556468e2bb1fcf..f98d51061630fefba33f7703eac68670cde67502 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -276,7 +276,7 @@ fn main() { zlog::init(); - if true { + if stdout_is_a_pty() { zlog::init_output_stdout(); } else { let result = zlog::init_output_file(paths::log_file(), Some(paths::old_log_file())); diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index ead16b911e3ccf9ebd1b9f54113cb01dca849e9d..197a7c6003737c486bc6adfb7190a1f23dbcf94b 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -103,10 +103,11 @@ use { feature_flags::FeatureFlagAppExt as _, git_ui::project_diff::ProjectDiff, gpui::{ - App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, VisualTestAppContext, + Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, VisualTestAppContext, WindowBounds, WindowHandle, WindowOptions, point, px, size, }, image::RgbaImage, + project::AgentId, project_panel::ProjectPanel, settings::{NotifyWhenAgentWaiting, Settings as _}, settings_ui::SettingsWindow, @@ -1958,7 +1959,7 @@ impl AgentServer for StubAgentServer { ui::IconName::ZedAssistant } - fn name(&self) -> SharedString { + fn agent_id(&self) -> AgentId { "Visual Test Agent".into() } @@ -2649,22 +2650,6 @@ fn run_multi_workspace_sidebar_visual_tests( cx.run_until_parked(); - // Create the sidebar and register it on the MultiWorkspace - let sidebar = multi_workspace_window - .update(cx, |_multi_workspace, window, cx| { - let multi_workspace_handle = cx.entity(); - cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) - }) - .context("Failed to create sidebar")?; - - multi_workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.register_sidebar(sidebar.clone(), window, cx); - }) - .context("Failed to register sidebar")?; - - cx.run_until_parked(); - // Save test threads to the ThreadStore for each workspace let save_tasks = multi_workspace_window .update(cx, |multi_workspace, _window, cx| { @@ -2742,8 +2727,8 @@ fn run_multi_workspace_sidebar_visual_tests( // Open the sidebar multi_workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.toggle_sidebar(window, cx); + .update(cx, |_multi_workspace, window, cx| { + window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx); }) .context("Failed to toggle sidebar")?; @@ -3181,24 +3166,10 @@ edition = "2021" cx.run_until_parked(); - // Create and register the workspace sidebar - let sidebar = workspace_window - .update(cx, |_multi_workspace, window, cx| { - let multi_workspace_handle = cx.entity(); - cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) - }) - .context("Failed to create sidebar")?; - - workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.register_sidebar(sidebar.clone(), window, cx); - }) - .context("Failed to register sidebar")?; - // Open the sidebar workspace_window - .update(cx, |multi_workspace, window, cx| { - multi_workspace.toggle_sidebar(window, cx); + .update(cx, |_multi_workspace, window, cx| { + window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx); }) .context("Failed to toggle sidebar")?; @@ -3488,7 +3459,7 @@ edition = "2021" // Insert a message into the active thread's message editor and submit. let thread_view = cx - .read(|cx| panel.read(cx).as_active_thread_view(cx)) + .read(|cx| panel.read(cx).active_thread_view(cx)) .ok_or_else(|| anyhow::anyhow!("No active thread view"))?; cx.update_window(workspace_window.into(), |_, window, cx| { @@ -3557,7 +3528,7 @@ edition = "2021" new_workspace.read(cx).panel::(cx) })?; if let Some(new_panel) = new_panel { - let new_thread_view = cx.read(|cx| new_panel.read(cx).as_active_thread_view(cx)); + let new_thread_view = cx.read(|cx| new_panel.read(cx).active_thread_view(cx)); if let Some(new_thread_view) = new_thread_view { cx.update_window(workspace_window.into(), |_, window, cx| { let message_editor = new_thread_view.read(cx).message_editor.clone(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 079a78225c248e341121f1980a368b37f85eea84..ebc09a7a6b38a02e8b30e20482e4e8656e146933 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -68,7 +68,6 @@ use settings::{ initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content, update_settings_file, }; -use sidebar::Sidebar; use std::time::Duration; use std::{ borrow::Cow, @@ -163,21 +162,24 @@ pub fn init(cx: &mut App) { cx.on_action(quit); cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx)); - let flag = cx.wait_for_flag::(); - cx.spawn(async |cx| { - if cx.update(|cx| ReleaseChannel::global(cx) == ReleaseChannel::Dev) || flag.await { - cx.update(|cx| { - cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) - .on_action(|_: &TestCrash, _| { - unsafe extern "C" { - fn puts(s: *const i8); - } - unsafe { - puts(0xabad1d3a as *const i8); - } - }); - }); - }; + + cx.observe_flag::({ + let mut added = false; + move |enabled, cx| { + if added || !enabled { + return; + } + added = true; + cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) + .on_action(|_: &TestCrash, _| { + unsafe extern "C" { + fn puts(s: *const i8); + } + unsafe { + puts(0xabad1d3a as *const i8); + } + }); + } }) .detach(); cx.on_action(|_: &OpenLog, cx| { @@ -386,20 +388,6 @@ pub fn initialize_workspace( }) .unwrap_or(true) }); - - let window_handle = window.window_handle(); - let multi_workspace_handle = cx.entity(); - cx.defer(move |cx| { - window_handle - .update(cx, |_, window, cx| { - let sidebar = - cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx)); - multi_workspace_handle.update(cx, |multi_workspace, cx| { - multi_workspace.register_sidebar(sidebar, window, cx); - }); - }) - .ok(); - }); }) .detach(); @@ -1078,37 +1066,54 @@ fn register_actions( }) .register_action({ let app_state = Arc::downgrade(&app_state); - move |_, _: &CloseProject, window, cx| { + move |_workspace, _: &CloseProject, window, cx| { let Some(window_handle) = window.window_handle().downcast::() else { return; }; if let Some(app_state) = app_state.upgrade() { - open_new( - workspace::OpenOptions { - replace_window: Some(window_handle), - ..Default::default() - }, - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - // Create buffer synchronously to avoid flicker - let project = workspace.project().clone(); - let buffer = project.update(cx, |project, cx| { - project.create_local_buffer("", None, true, cx) - }); - let editor = cx.new(|cx| { - Editor::for_buffer(buffer, Some(project), window, cx) - }); - workspace.add_item_to_active_pane( - Box::new(editor), - None, - true, - window, - cx, - ); - }, - ) + cx.spawn_in(window, async move |this, cx| { + let should_continue = this + .update_in(cx, |workspace, window, cx| { + workspace.prepare_to_close( + CloseIntent::ReplaceWindow, + window, + cx, + ) + })? + .await?; + if should_continue { + let task = cx.update(|_window, cx| { + open_new( + workspace::OpenOptions { + replace_window: Some(window_handle), + ..Default::default() + }, + app_state, + cx, + |workspace, window, cx| { + cx.activate(true); + let project = workspace.project().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("", None, true, cx) + }); + let editor = cx.new(|cx| { + Editor::for_buffer(buffer, Some(project), window, cx) + }); + workspace.add_item_to_active_pane( + Box::new(editor), + None, + true, + window, + cx, + ); + }, + ) + })?; + task.await + } else { + Ok(()) + } + }) .detach_and_log_err(cx); } } @@ -3454,7 +3459,11 @@ mod tests { PathBuf::from(path!("/root/.git/HEAD")), PathBuf::from(path!("/root/excluded_dir/ignored_subdir")), ]; - let (opened_workspace, new_items) = cx + let workspace::OpenResult { + window: opened_workspace, + opened_items: new_items, + .. + } = cx .update(|cx| { workspace::open_paths( &paths_to_open, @@ -4890,6 +4899,7 @@ mod tests { "task", "terminal", "terminal_panel", + "theme", "theme_selector", "toast", "toolchain", @@ -5877,7 +5887,9 @@ mod tests { // // Window A: workspace for dir1, workspace for dir2 // Window B: workspace for dir3 - let (window_a, _) = cx + let workspace::OpenResult { + window: window_a, .. + } = cx .update(|cx| { Workspace::new_local( vec![dir1.into()], @@ -5901,7 +5913,9 @@ mod tests { .expect("failed to open second workspace into window A"); cx.run_until_parked(); - let (window_b, _) = cx + let workspace::OpenResult { + window: window_b, .. + } = cx .update(|cx| { Workspace::new_local( vec![dir3.into()], diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index e8f8554482680c4a51fc182c58369de19184bcb0..ca376f300d97de83d0b4a9af7620ee98ba5b4215 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -29,7 +29,7 @@ use util::ResultExt; use util::paths::PathWithPosition; use workspace::PathList; use workspace::item::ItemHandle; -use workspace::{AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation}; +use workspace::{AppState, MultiWorkspace, OpenOptions, OpenResult, SerializedWorkspaceLocation}; #[derive(Default, Debug)] pub struct OpenRequest { @@ -345,7 +345,11 @@ pub async fn open_paths_with_positions( .map(|path_with_position| path_with_position.path.clone()) .collect::>(); - let (multi_workspace, mut items) = cx + let OpenResult { + window: multi_workspace, + opened_items: mut items, + .. + } = cx .update(|cx| workspace::open_paths(&paths, app_state, open_options, cx)) .await?; diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index ae785bb4a0c792dd7f55d8850e8c05ce6327c108..8edc80b4ec7816cd9e2ae2d7b995dd74b8128a9a 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -325,6 +325,12 @@ pub mod feedback { ); } +pub mod theme { + use gpui::actions; + + actions!(theme, [ToggleMode]); +} + pub mod theme_selector { use gpui::Action; use schemars::JsonSchema; @@ -469,6 +475,33 @@ pub mod agent { /// The base ref that the diff was computed against (e.g. "main"). pub base_ref: SharedString, } + + /// A single merge conflict region extracted from a file. + #[derive(Clone, Debug, PartialEq, Deserialize, JsonSchema)] + pub struct ConflictContent { + pub file_path: String, + pub conflict_text: String, + pub ours_branch_name: String, + pub theirs_branch_name: String, + } + + /// Opens a new agent thread to resolve specific merge conflicts. + #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] + #[action(namespace = agent)] + #[serde(deny_unknown_fields)] + pub struct ResolveConflictsWithAgent { + /// Individual conflicts with their full text. + pub conflicts: Vec, + } + + /// Opens a new agent thread to resolve merge conflicts in the given file paths. + #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] + #[action(namespace = agent)] + #[serde(deny_unknown_fields)] + pub struct ResolveConflictedFilesWithAgent { + /// File paths with unresolved conflicts (for project-wide resolution). + pub conflicted_file_paths: Vec, + } } pub mod assistant { diff --git a/crates/zeta_prompt/src/excerpt_ranges.rs b/crates/zeta_prompt/src/excerpt_ranges.rs new file mode 100644 index 0000000000000000000000000000000000000000..40621fe98a13bfa9195293ad29ba549240532a2e --- /dev/null +++ b/crates/zeta_prompt/src/excerpt_ranges.rs @@ -0,0 +1,443 @@ +use std::ops::Range; + +use serde::{Deserialize, Serialize}; + +use crate::estimate_tokens; + +/// Pre-computed byte offset ranges within `cursor_excerpt` for different +/// editable and context token budgets. Allows the server to select the +/// appropriate ranges for whichever model it uses. +#[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)] +pub struct ExcerptRanges { + /// Editable region computed with a 150-token budget. + pub editable_150: Range, + /// Editable region computed with a 180-token budget. + pub editable_180: Range, + /// Editable region computed with a 350-token budget. + pub editable_350: Range, + /// Editable region computed with a 350-token budget. + pub editable_512: Option>, + /// Context boundary when using editable_150 with 350 tokens of additional context. + pub editable_150_context_350: Range, + /// Context boundary when using editable_180 with 350 tokens of additional context. + pub editable_180_context_350: Range, + /// Context boundary when using editable_350 with 150 tokens of additional context. + pub editable_350_context_150: Range, + pub editable_350_context_512: Option>, + pub editable_350_context_1024: Option>, + pub context_4096: Option>, + pub context_8192: Option>, +} + +/// Builds an `ExcerptRanges` by computing editable and context ranges for each +/// budget combination, using the syntax-aware logic in +/// `compute_editable_and_context_ranges`. +pub fn compute_legacy_excerpt_ranges( + cursor_excerpt: &str, + cursor_offset: usize, + syntax_ranges: &[Range], +) -> ExcerptRanges { + let compute = |editable_tokens, context_tokens| { + compute_editable_and_context_ranges( + cursor_excerpt, + cursor_offset, + syntax_ranges, + editable_tokens, + context_tokens, + ) + }; + + let (editable_150, editable_150_context_350) = compute(150, 350); + let (editable_180, editable_180_context_350) = compute(180, 350); + let (editable_350, editable_350_context_150) = compute(350, 150); + let (editable_512, _) = compute(512, 0); + let (_, editable_350_context_512) = compute(350, 512); + let (_, editable_350_context_1024) = compute(350, 1024); + let (_, context_4096) = compute(350, 4096); + let (_, context_8192) = compute(350, 8192); + + ExcerptRanges { + editable_150, + editable_180, + editable_350, + editable_512: Some(editable_512), + editable_150_context_350, + editable_180_context_350, + editable_350_context_150, + editable_350_context_512: Some(editable_350_context_512), + editable_350_context_1024: Some(editable_350_context_1024), + context_4096: Some(context_4096), + context_8192: Some(context_8192), + } +} + +/// Given the cursor excerpt text, cursor offset, and the syntax node ranges +/// containing the cursor (innermost to outermost), compute the editable range +/// and context range as byte offset ranges within `cursor_excerpt`. +/// +/// This is the server-side equivalent of `compute_excerpt_ranges` in +/// `edit_prediction::cursor_excerpt`, but operates on plain text with +/// pre-computed syntax boundaries instead of a `BufferSnapshot`. +pub fn compute_editable_and_context_ranges( + cursor_excerpt: &str, + cursor_offset: usize, + syntax_ranges: &[Range], + editable_token_limit: usize, + context_token_limit: usize, +) -> (Range, Range) { + let line_starts = compute_line_starts(cursor_excerpt); + let cursor_row = offset_to_row(&line_starts, cursor_offset); + let max_row = line_starts.len().saturating_sub(1) as u32; + + let editable_range = compute_editable_range_from_text( + cursor_excerpt, + &line_starts, + cursor_row, + max_row, + syntax_ranges, + editable_token_limit, + ); + + let context_range = expand_context_from_text( + cursor_excerpt, + &line_starts, + max_row, + &editable_range, + syntax_ranges, + context_token_limit, + ); + + (editable_range, context_range) +} + +fn compute_line_starts(text: &str) -> Vec { + let mut starts = vec![0]; + for (index, byte) in text.bytes().enumerate() { + if byte == b'\n' { + starts.push(index + 1); + } + } + starts +} + +fn offset_to_row(line_starts: &[usize], offset: usize) -> u32 { + match line_starts.binary_search(&offset) { + Ok(row) => row as u32, + Err(row) => (row.saturating_sub(1)) as u32, + } +} + +fn row_start_offset(line_starts: &[usize], row: u32) -> usize { + line_starts.get(row as usize).copied().unwrap_or(0) +} + +fn row_end_offset(text: &str, line_starts: &[usize], row: u32) -> usize { + if let Some(&next_start) = line_starts.get(row as usize + 1) { + // End before the newline of this row. + next_start.saturating_sub(1).min(text.len()) + } else { + text.len() + } +} + +fn row_range_to_byte_range( + text: &str, + line_starts: &[usize], + start_row: u32, + end_row: u32, +) -> Range { + let start = row_start_offset(line_starts, start_row); + let end = row_end_offset(text, line_starts, end_row); + start..end +} + +fn estimate_tokens_for_row_range( + text: &str, + line_starts: &[usize], + start_row: u32, + end_row: u32, +) -> usize { + let mut tokens = 0; + for row in start_row..end_row { + let row_len = row_end_offset(text, line_starts, row) + .saturating_sub(row_start_offset(line_starts, row)); + tokens += estimate_tokens(row_len).max(1); + } + tokens +} + +fn line_token_count_from_text(text: &str, line_starts: &[usize], row: u32) -> usize { + let row_len = + row_end_offset(text, line_starts, row).saturating_sub(row_start_offset(line_starts, row)); + estimate_tokens(row_len).max(1) +} + +/// Returns syntax boundaries (as row ranges) that contain the given row range +/// and extend beyond it, ordered from smallest to largest. +fn containing_syntax_boundaries_from_ranges( + line_starts: &[usize], + syntax_ranges: &[Range], + start_row: u32, + end_row: u32, +) -> Vec<(u32, u32)> { + let mut boundaries = Vec::new(); + let mut last: Option<(u32, u32)> = None; + + // syntax_ranges is innermost to outermost, so iterate in order. + for range in syntax_ranges { + let node_start_row = offset_to_row(line_starts, range.start); + let node_end_row = offset_to_row(line_starts, range.end); + + // Skip nodes that don't extend beyond the current range. + if node_start_row >= start_row && node_end_row <= end_row { + continue; + } + + let rows = (node_start_row, node_end_row); + if last == Some(rows) { + continue; + } + + last = Some(rows); + boundaries.push(rows); + } + + boundaries +} + +fn compute_editable_range_from_text( + text: &str, + line_starts: &[usize], + cursor_row: u32, + max_row: u32, + syntax_ranges: &[Range], + token_limit: usize, +) -> Range { + // Phase 1: Expand symmetrically from cursor using 75% of budget. + let initial_budget = (token_limit * 3) / 4; + let (mut start_row, mut end_row, mut remaining_tokens) = + expand_symmetric(text, line_starts, cursor_row, max_row, initial_budget); + + remaining_tokens += token_limit.saturating_sub(initial_budget); + + let original_start = start_row; + let original_end = end_row; + + // Phase 2: Expand to syntax boundaries that fit within budget. + let boundaries = + containing_syntax_boundaries_from_ranges(line_starts, syntax_ranges, start_row, end_row); + for (boundary_start, boundary_end) in &boundaries { + let tokens_for_start = if *boundary_start < start_row { + estimate_tokens_for_row_range(text, line_starts, *boundary_start, start_row) + } else { + 0 + }; + let tokens_for_end = if *boundary_end > end_row { + estimate_tokens_for_row_range(text, line_starts, end_row + 1, *boundary_end + 1) + } else { + 0 + }; + + let total_needed = tokens_for_start + tokens_for_end; + if total_needed <= remaining_tokens { + if *boundary_start < start_row { + start_row = *boundary_start; + } + if *boundary_end > end_row { + end_row = *boundary_end; + } + remaining_tokens = remaining_tokens.saturating_sub(total_needed); + } else { + break; + } + } + + // Phase 3: Continue line-wise in the direction we expanded least. + let expanded_up = original_start.saturating_sub(start_row); + let expanded_down = end_row.saturating_sub(original_end); + let prefer_up = expanded_up <= expanded_down; + + (start_row, end_row, _) = expand_linewise( + text, + line_starts, + start_row, + end_row, + max_row, + remaining_tokens, + prefer_up, + ); + + row_range_to_byte_range(text, line_starts, start_row, end_row) +} + +fn expand_context_from_text( + text: &str, + line_starts: &[usize], + max_row: u32, + editable_range: &Range, + syntax_ranges: &[Range], + context_token_limit: usize, +) -> Range { + let mut start_row = offset_to_row(line_starts, editable_range.start); + let mut end_row = offset_to_row(line_starts, editable_range.end); + let mut remaining_tokens = context_token_limit; + let mut did_syntax_expand = false; + + let boundaries = + containing_syntax_boundaries_from_ranges(line_starts, syntax_ranges, start_row, end_row); + for (boundary_start, boundary_end) in &boundaries { + let tokens_for_start = if *boundary_start < start_row { + estimate_tokens_for_row_range(text, line_starts, *boundary_start, start_row) + } else { + 0 + }; + let tokens_for_end = if *boundary_end > end_row { + estimate_tokens_for_row_range(text, line_starts, end_row + 1, *boundary_end + 1) + } else { + 0 + }; + + let total_needed = tokens_for_start + tokens_for_end; + if total_needed <= remaining_tokens { + if *boundary_start < start_row { + start_row = *boundary_start; + } + if *boundary_end > end_row { + end_row = *boundary_end; + } + remaining_tokens = remaining_tokens.saturating_sub(total_needed); + did_syntax_expand = true; + } else { + break; + } + } + + // Only expand line-wise if no syntax expansion occurred. + if !did_syntax_expand { + (start_row, end_row, _) = expand_linewise( + text, + line_starts, + start_row, + end_row, + max_row, + remaining_tokens, + true, + ); + } + + row_range_to_byte_range(text, line_starts, start_row, end_row) +} + +fn expand_symmetric( + text: &str, + line_starts: &[usize], + cursor_row: u32, + max_row: u32, + mut token_budget: usize, +) -> (u32, u32, usize) { + let mut start_row = cursor_row; + let mut end_row = cursor_row; + + let cursor_line_tokens = line_token_count_from_text(text, line_starts, cursor_row); + token_budget = token_budget.saturating_sub(cursor_line_tokens); + + loop { + let can_expand_up = start_row > 0; + let can_expand_down = end_row < max_row; + + if token_budget == 0 || (!can_expand_up && !can_expand_down) { + break; + } + + if can_expand_down { + let next_row = end_row + 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= token_budget { + end_row = next_row; + token_budget = token_budget.saturating_sub(line_tokens); + } else { + break; + } + } + + if can_expand_up && token_budget > 0 { + let next_row = start_row - 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= token_budget { + start_row = next_row; + token_budget = token_budget.saturating_sub(line_tokens); + } else { + break; + } + } + } + + (start_row, end_row, token_budget) +} + +fn expand_linewise( + text: &str, + line_starts: &[usize], + mut start_row: u32, + mut end_row: u32, + max_row: u32, + mut remaining_tokens: usize, + prefer_up: bool, +) -> (u32, u32, usize) { + loop { + let can_expand_up = start_row > 0; + let can_expand_down = end_row < max_row; + + if remaining_tokens == 0 || (!can_expand_up && !can_expand_down) { + break; + } + + let mut expanded = false; + + if prefer_up { + if can_expand_up { + let next_row = start_row - 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= remaining_tokens { + start_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + if can_expand_down && remaining_tokens > 0 { + let next_row = end_row + 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= remaining_tokens { + end_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + } else { + if can_expand_down { + let next_row = end_row + 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= remaining_tokens { + end_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + if can_expand_up && remaining_tokens > 0 { + let next_row = start_row - 1; + let line_tokens = line_token_count_from_text(text, line_starts, next_row); + if line_tokens <= remaining_tokens { + start_row = next_row; + remaining_tokens = remaining_tokens.saturating_sub(line_tokens); + expanded = true; + } + } + } + + if !expanded { + break; + } + } + + (start_row, end_row, remaining_tokens) +} diff --git a/crates/zeta_prompt/src/multi_region.rs b/crates/zeta_prompt/src/multi_region.rs new file mode 100644 index 0000000000000000000000000000000000000000..1bac794b1d71fdf5ca8e086b748b8aa426bad1bd --- /dev/null +++ b/crates/zeta_prompt/src/multi_region.rs @@ -0,0 +1,557 @@ +use anyhow::{Context as _, Result, anyhow}; + +pub const MARKER_TAG_PREFIX: &str = "<|marker_"; +pub const MARKER_TAG_SUFFIX: &str = "|>"; +const MIN_BLOCK_LINES: usize = 3; +const MAX_BLOCK_LINES: usize = 8; + +pub fn marker_tag(number: usize) -> String { + format!("{MARKER_TAG_PREFIX}{number}{MARKER_TAG_SUFFIX}") +} + +/// Compute byte offsets within `editable_text` where marker boundaries should +/// be placed. +/// +/// Returns a sorted `Vec` that always starts with `0` and ends with +/// `editable_text.len()`. Interior offsets are placed at line boundaries +/// (right after a `\n`), preferring blank-line boundaries when available and +/// respecting `MIN_BLOCK_LINES` / `MAX_BLOCK_LINES` constraints. +pub fn compute_marker_offsets(editable_text: &str) -> Vec { + if editable_text.is_empty() { + return vec![0, 0]; + } + + let mut offsets = vec![0usize]; + let mut lines_since_last_marker = 0usize; + let mut byte_offset = 0usize; + + for line in editable_text.split('\n') { + let line_end = byte_offset + line.len() + 1; + let is_past_end = line_end > editable_text.len(); + let actual_line_end = line_end.min(editable_text.len()); + lines_since_last_marker += 1; + + let is_blank = line.trim().is_empty(); + + if !is_past_end && lines_since_last_marker >= MIN_BLOCK_LINES { + if is_blank { + // Blank-line boundary found. We'll place the marker when we + // find the next non-blank line (handled below). + } else if lines_since_last_marker >= MAX_BLOCK_LINES { + offsets.push(actual_line_end); + lines_since_last_marker = 0; + } + } + + // Non-blank line immediately following blank line(s): split here so + // the new block starts with this line. + if !is_blank && byte_offset > 0 && lines_since_last_marker >= MIN_BLOCK_LINES { + let before = &editable_text[..byte_offset]; + let has_preceding_blank_line = before + .strip_suffix('\n') + .map(|stripped| { + let last_line = match stripped.rfind('\n') { + Some(pos) => &stripped[pos + 1..], + None => stripped, + }; + last_line.trim().is_empty() + }) + .unwrap_or(false); + + if has_preceding_blank_line { + offsets.push(byte_offset); + lines_since_last_marker = 1; + } + } + + byte_offset = actual_line_end; + + // Re-check after blank-line logic since lines_since_last_marker may + // have been reset. + if !is_past_end && lines_since_last_marker >= MAX_BLOCK_LINES { + if *offsets.last().unwrap_or(&0) != actual_line_end { + offsets.push(actual_line_end); + lines_since_last_marker = 0; + } + } + } + + let end = editable_text.len(); + if *offsets.last().unwrap_or(&0) != end { + offsets.push(end); + } + + offsets +} + +/// Write the editable region content with marker tags, inserting the cursor +/// marker at the given offset within the editable text. +pub fn write_editable_with_markers( + output: &mut String, + editable_text: &str, + cursor_offset_in_editable: usize, + cursor_marker: &str, +) { + let marker_offsets = compute_marker_offsets(editable_text); + let mut cursor_placed = false; + for (i, &offset) in marker_offsets.iter().enumerate() { + let marker_num = i + 1; + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&marker_tag(marker_num)); + + if let Some(&next_offset) = marker_offsets.get(i + 1) { + output.push('\n'); + let block = &editable_text[offset..next_offset]; + if !cursor_placed + && cursor_offset_in_editable >= offset + && cursor_offset_in_editable <= next_offset + { + cursor_placed = true; + let cursor_in_block = cursor_offset_in_editable - offset; + output.push_str(&block[..cursor_in_block]); + output.push_str(cursor_marker); + output.push_str(&block[cursor_in_block..]); + } else { + output.push_str(block); + } + } + } +} + +/// Strip any `<|marker_N|>` tags from `text`. +/// +/// When a marker tag sits on its own line (followed by `\n`), the trailing +/// newline is also removed so the surrounding lines stay joined naturally. +fn strip_marker_tags(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut pos = 0; + let bytes = text.as_bytes(); + while let Some(rel) = text[pos..].find(MARKER_TAG_PREFIX) { + result.push_str(&text[pos..pos + rel]); + let num_start = pos + rel + MARKER_TAG_PREFIX.len(); + if let Some(suffix_rel) = text[num_start..].find(MARKER_TAG_SUFFIX) { + let mut tag_end = num_start + suffix_rel + MARKER_TAG_SUFFIX.len(); + if bytes.get(tag_end) == Some(&b'\n') { + tag_end += 1; + } + pos = tag_end; + } else { + result.push_str(MARKER_TAG_PREFIX); + pos = num_start; + } + } + result.push_str(&text[pos..]); + result +} + +/// Parse model output that uses the marker format. +/// +/// Returns `(start_marker_num, end_marker_num, content_between_markers)`. +/// The leading format-level newline after the start marker is stripped. +/// Trailing newlines are preserved so blank-line endings in the editable +/// region are not lost. +/// +/// Any extra intermediate marker tags that the model may have inserted +/// between the first and last markers are stripped from the returned content. +pub fn extract_marker_span(text: &str) -> Result<(usize, usize, String)> { + let first_tag_start = text + .find(MARKER_TAG_PREFIX) + .context("no start marker found in output")?; + let first_num_start = first_tag_start + MARKER_TAG_PREFIX.len(); + let first_num_end = text[first_num_start..] + .find(MARKER_TAG_SUFFIX) + .map(|i| i + first_num_start) + .context("malformed start marker tag")?; + let start_num: usize = text[first_num_start..first_num_end] + .parse() + .context("start marker number is not a valid integer")?; + let first_tag_end = first_num_end + MARKER_TAG_SUFFIX.len(); + + let last_tag_start = text + .rfind(MARKER_TAG_PREFIX) + .context("no end marker found in output")?; + let last_num_start = last_tag_start + MARKER_TAG_PREFIX.len(); + let last_num_end = text[last_num_start..] + .find(MARKER_TAG_SUFFIX) + .map(|i| i + last_num_start) + .context("malformed end marker tag")?; + let end_num: usize = text[last_num_start..last_num_end] + .parse() + .context("end marker number is not a valid integer")?; + + if start_num == end_num { + return Err(anyhow!( + "start and end markers are the same (marker {})", + start_num + )); + } + + let mut content_start = first_tag_end; + if text.as_bytes().get(content_start) == Some(&b'\n') { + content_start += 1; + } + let content_end = last_tag_start; + + let content = &text[content_start..content_end.max(content_start)]; + let content = strip_marker_tags(content); + Ok((start_num, end_num, content)) +} + +/// Given old editable text and model output with marker span, reconstruct the +/// full new editable region. +pub fn apply_marker_span(old_editable: &str, output: &str) -> Result { + let (start_num, end_num, raw_new_span) = extract_marker_span(output)?; + let marker_offsets = compute_marker_offsets(old_editable); + + let start_idx = start_num + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let end_idx = end_num + .checked_sub(1) + .context("marker numbers are 1-indexed")?; + let start_byte = *marker_offsets + .get(start_idx) + .context("start marker number out of range")?; + let end_byte = *marker_offsets + .get(end_idx) + .context("end marker number out of range")?; + + if start_byte > end_byte { + return Err(anyhow!("start marker must come before end marker")); + } + + let old_span = &old_editable[start_byte..end_byte]; + let mut new_span = raw_new_span; + if old_span.ends_with('\n') && !new_span.ends_with('\n') && !new_span.is_empty() { + new_span.push('\n'); + } + if !old_span.ends_with('\n') && new_span.ends_with('\n') { + new_span.pop(); + } + + let mut result = String::new(); + result.push_str(&old_editable[..start_byte]); + result.push_str(&new_span); + result.push_str(&old_editable[end_byte..]); + + Ok(result) +} + +/// Compare old and new editable text, find the minimal marker span that covers +/// all changes, and encode the result with marker tags. +pub fn encode_from_old_and_new( + old_editable: &str, + new_editable: &str, + cursor_offset_in_new: Option, + cursor_marker: &str, + end_marker: &str, + no_edits_marker: &str, +) -> Result { + if old_editable == new_editable { + return Ok(format!("{no_edits_marker}{end_marker}")); + } + + let marker_offsets = compute_marker_offsets(old_editable); + + let common_prefix = old_editable + .bytes() + .zip(new_editable.bytes()) + .take_while(|(a, b)| a == b) + .count(); + + let old_remaining = old_editable.len() - common_prefix; + let new_remaining = new_editable.len() - common_prefix; + let max_suffix = old_remaining.min(new_remaining); + let common_suffix = old_editable.as_bytes()[old_editable.len() - max_suffix..] + .iter() + .rev() + .zip( + new_editable.as_bytes()[new_editable.len() - max_suffix..] + .iter() + .rev(), + ) + .take_while(|(a, b)| a == b) + .count(); + + let change_end_in_old = old_editable.len() - common_suffix; + + let start_marker_idx = marker_offsets + .iter() + .rposition(|&offset| offset <= common_prefix) + .unwrap_or(0); + let end_marker_idx = marker_offsets + .iter() + .position(|&offset| offset >= change_end_in_old) + .unwrap_or(marker_offsets.len() - 1); + + let old_start = marker_offsets[start_marker_idx]; + let old_end = marker_offsets[end_marker_idx]; + + let new_start = old_start; + let new_end = new_editable + .len() + .saturating_sub(old_editable.len().saturating_sub(old_end)); + + let new_span = &new_editable[new_start..new_end]; + + let start_marker_num = start_marker_idx + 1; + let end_marker_num = end_marker_idx + 1; + + let mut result = String::new(); + result.push_str(&marker_tag(start_marker_num)); + result.push('\n'); + + if let Some(cursor_offset) = cursor_offset_in_new { + if cursor_offset >= new_start && cursor_offset <= new_end { + let cursor_in_span = cursor_offset - new_start; + let bounded = cursor_in_span.min(new_span.len()); + result.push_str(&new_span[..bounded]); + result.push_str(cursor_marker); + result.push_str(&new_span[bounded..]); + } else { + result.push_str(new_span); + } + } else { + result.push_str(new_span); + } + + if !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(&marker_tag(end_marker_num)); + result.push('\n'); + result.push_str(end_marker); + + Ok(result) +} + +/// Extract the full editable region from text that uses marker tags. +/// +/// Returns the concatenation of all block contents between the first and last +/// markers, with intermediate marker tags stripped. +pub fn extract_editable_region_from_markers(text: &str) -> Option { + let first_marker_start = text.find(MARKER_TAG_PREFIX)?; + + let mut markers: Vec<(usize, usize)> = Vec::new(); + let mut search_start = first_marker_start; + while let Some(rel_pos) = text[search_start..].find(MARKER_TAG_PREFIX) { + let tag_start = search_start + rel_pos; + let num_start = tag_start + MARKER_TAG_PREFIX.len(); + let num_end = text[num_start..].find(MARKER_TAG_SUFFIX)?; + let tag_end = num_start + num_end + MARKER_TAG_SUFFIX.len(); + markers.push((tag_start, tag_end)); + search_start = tag_end; + } + + if markers.len() < 2 { + return None; + } + + let (_, first_tag_end) = markers[0]; + let (last_tag_start, _) = markers[markers.len() - 1]; + + let mut content_start = first_tag_end; + if text.as_bytes().get(content_start) == Some(&b'\n') { + content_start += 1; + } + let mut content_end = last_tag_start; + if content_end > content_start && text.as_bytes().get(content_end - 1) == Some(&b'\n') { + content_end -= 1; + } + + let raw = &text[content_start..content_end]; + let result = strip_marker_tags(raw); + let result = result.strip_suffix('\n').unwrap_or(&result).to_string(); + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_marker_offsets_small_block() { + let text = "aaa\nbbb\nccc\n"; + let offsets = compute_marker_offsets(text); + assert_eq!(offsets, vec![0, text.len()]); + } + + #[test] + fn test_compute_marker_offsets_blank_line_split() { + let text = "aaa\nbbb\nccc\n\nddd\neee\nfff\n"; + let offsets = compute_marker_offsets(text); + assert_eq!(offsets[0], 0); + assert!(offsets.contains(&13), "offsets: {:?}", offsets); + assert_eq!(*offsets.last().unwrap(), text.len()); + } + + #[test] + fn test_compute_marker_offsets_max_lines_split() { + let text = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n"; + let offsets = compute_marker_offsets(text); + assert!(offsets.len() >= 3, "offsets: {:?}", offsets); + } + + #[test] + fn test_compute_marker_offsets_empty() { + let offsets = compute_marker_offsets(""); + assert_eq!(offsets, vec![0, 0]); + } + + #[test] + fn test_extract_marker_span() { + let text = "<|marker_2|>\n new content\n<|marker_3|>\n"; + let (start, end, content) = extract_marker_span(text).unwrap(); + assert_eq!(start, 2); + assert_eq!(end, 3); + assert_eq!(content, " new content\n"); + } + + #[test] + fn test_extract_marker_span_multi_line() { + let text = "<|marker_1|>\nline1\nline2\nline3\n<|marker_4|>"; + let (start, end, content) = extract_marker_span(text).unwrap(); + assert_eq!(start, 1); + assert_eq!(end, 4); + assert_eq!(content, "line1\nline2\nline3\n"); + } + + #[test] + fn test_apply_marker_span_basic() { + let old = "aaa\nbbb\nccc\n"; + let output = "<|marker_1|>\naaa\nBBB\nccc\n<|marker_2|>"; + let result = apply_marker_span(old, output).unwrap(); + assert_eq!(result, "aaa\nBBB\nccc\n"); + } + + #[test] + fn test_apply_marker_span_preserves_trailing_blank_line() { + let old = "/\nresult\n\n"; + let output = "<|marker_1|>\n//\nresult\n\n<|marker_2|>"; + let result = apply_marker_span(old, output).unwrap(); + assert_eq!(result, "//\nresult\n\n"); + } + + #[test] + fn test_encode_no_edits() { + let old = "aaa\nbbb\nccc\n"; + let result = encode_from_old_and_new( + old, + old, + None, + "<|user_cursor|>", + ">>>>>>> UPDATED\n", + "NO_EDITS\n", + ) + .unwrap(); + assert_eq!(result, "NO_EDITS\n>>>>>>> UPDATED\n"); + } + + #[test] + fn test_encode_with_change() { + let old = "aaa\nbbb\nccc\n"; + let new = "aaa\nBBB\nccc\n"; + let result = encode_from_old_and_new( + old, + new, + None, + "<|user_cursor|>", + ">>>>>>> UPDATED\n", + "NO_EDITS\n", + ) + .unwrap(); + assert!(result.contains("<|marker_1|>")); + assert!(result.contains("<|marker_2|>")); + assert!(result.contains("aaa\nBBB\nccc\n")); + assert!(result.ends_with(">>>>>>> UPDATED\n")); + } + + #[test] + fn test_roundtrip_encode_apply() { + let old = "line1\nline2\nline3\n\nline5\nline6\nline7\nline8\nline9\nline10\n"; + let new = "line1\nline2\nline3\n\nline5\nLINE6\nline7\nline8\nline9\nline10\n"; + let encoded = encode_from_old_and_new( + old, + new, + None, + "<|user_cursor|>", + ">>>>>>> UPDATED\n", + "NO_EDITS\n", + ) + .unwrap(); + let output = encoded + .strip_suffix(">>>>>>> UPDATED\n") + .expect("should have end marker"); + let reconstructed = apply_marker_span(old, output).unwrap(); + assert_eq!(reconstructed, new); + } + + #[test] + fn test_extract_editable_region_from_markers_multi() { + let text = "prefix\n<|marker_1|>\naaa\nbbb\n<|marker_2|>\nccc\nddd\n<|marker_3|>\nsuffix"; + let parsed = extract_editable_region_from_markers(text).unwrap(); + assert_eq!(parsed, "aaa\nbbb\nccc\nddd"); + } + + #[test] + fn test_extract_editable_region_two_markers() { + let text = "<|marker_1|>\none\ntwo three\n<|marker_2|>"; + let parsed = extract_editable_region_from_markers(text).unwrap(); + assert_eq!(parsed, "one\ntwo three"); + } + + #[test] + fn test_encode_with_cursor() { + let old = "aaa\nbbb\nccc\n"; + let new = "aaa\nBBB\nccc\n"; + let result = encode_from_old_and_new( + old, + new, + Some(5), + "<|user_cursor|>", + ">>>>>>> UPDATED\n", + "NO_EDITS\n", + ) + .unwrap(); + assert!(result.contains("<|user_cursor|>"), "result: {result}"); + assert!(result.contains("B<|user_cursor|>BB"), "result: {result}"); + } + + #[test] + fn test_extract_marker_span_strips_intermediate_markers() { + let text = "<|marker_2|>\nline1\n<|marker_3|>\nline2\n<|marker_4|>"; + let (start, end, content) = extract_marker_span(text).unwrap(); + assert_eq!(start, 2); + assert_eq!(end, 4); + assert_eq!(content, "line1\nline2\n"); + } + + #[test] + fn test_extract_marker_span_strips_multiple_intermediate_markers() { + let text = "<|marker_1|>\naaa\n<|marker_2|>\nbbb\n<|marker_3|>\nccc\n<|marker_4|>"; + let (start, end, content) = extract_marker_span(text).unwrap(); + assert_eq!(start, 1); + assert_eq!(end, 4); + assert_eq!(content, "aaa\nbbb\nccc\n"); + } + + #[test] + fn test_apply_marker_span_with_extra_intermediate_marker() { + let old = "aaa\nbbb\nccc\n"; + let output = "<|marker_1|>\naaa\n<|marker_1|>\nBBB\nccc\n<|marker_2|>"; + let result = apply_marker_span(old, output).unwrap(); + assert_eq!(result, "aaa\nBBB\nccc\n"); + } + + #[test] + fn test_strip_marker_tags_inline() { + assert_eq!(strip_marker_tags("no markers here"), "no markers here"); + assert_eq!(strip_marker_tags("before<|marker_5|>after"), "beforeafter"); + assert_eq!( + strip_marker_tags("line1\n<|marker_3|>\nline2"), + "line1\nline2" + ); + } +} diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index b7b67ed851419dcf0f125f46e5a17e7f9ac9aa92..0dce7764e7b9c451b4360fb2177d9d3e0eb7315b 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -1,3 +1,6 @@ +pub mod excerpt_ranges; +pub mod multi_region; + use anyhow::{Result, anyhow}; use serde::{Deserialize, Serialize}; use std::fmt::Write; @@ -6,6 +9,10 @@ use std::path::Path; use std::sync::Arc; use strum::{EnumIter, IntoEnumIterator as _, IntoStaticStr}; +pub use crate::excerpt_ranges::{ + ExcerptRanges, compute_editable_and_context_ranges, compute_legacy_excerpt_ranges, +}; + pub const CURSOR_MARKER: &str = "<|user_cursor|>"; pub const MAX_PROMPT_TOKENS: usize = 4096; @@ -18,31 +25,6 @@ fn estimate_tokens(bytes: usize) -> usize { bytes / 3 } -/// Pre-computed byte offset ranges within `cursor_excerpt` for different -/// editable and context token budgets. Allows the server to select the -/// appropriate ranges for whichever model it uses. -#[derive(Clone, Debug, Default, PartialEq, Hash, Serialize, Deserialize)] -pub struct ExcerptRanges { - /// Editable region computed with a 150-token budget. - pub editable_150: Range, - /// Editable region computed with a 180-token budget. - pub editable_180: Range, - /// Editable region computed with a 350-token budget. - pub editable_350: Range, - /// Editable region computed with a 350-token budget. - pub editable_512: Option>, - /// Context boundary when using editable_150 with 350 tokens of additional context. - pub editable_150_context_350: Range, - /// Context boundary when using editable_180 with 350 tokens of additional context. - pub editable_180_context_350: Range, - /// Context boundary when using editable_350 with 150 tokens of additional context. - pub editable_350_context_150: Range, - pub editable_350_context_512: Option>, - pub editable_350_context_1024: Option>, - pub context_4096: Option>, - pub context_8192: Option>, -} - #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)] pub struct ZetaPromptInput { pub cursor_path: Arc, @@ -51,9 +33,18 @@ pub struct ZetaPromptInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub excerpt_start_row: Option, pub events: Vec>, - pub related_files: Vec, + #[serde(default)] + pub related_files: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub active_buffer_diagnostics: Vec, /// These ranges let the server select model-appropriate subsets. pub excerpt_ranges: ExcerptRanges, + /// Byte offset ranges within `cursor_excerpt` for all syntax nodes that + /// contain `cursor_offset_in_excerpt`, ordered from innermost to outermost. + /// When present, the server uses these to compute editable/context ranges + /// instead of `excerpt_ranges`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub syntax_ranges: Option>>, /// The name of the edit prediction model experiment to use. #[serde(default, skip_serializing_if = "Option::is_none")] pub experiment: Option, @@ -91,6 +82,7 @@ pub enum ZetaFormat { v0226Hashline, V0304VariableEdit, V0304SeedNoEdits, + V0306SeedMultiRegions, } impl std::fmt::Display for ZetaFormat { @@ -180,6 +172,15 @@ pub fn write_event(prompt: &mut String, event: &Event) { } } +#[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)] +pub struct ActiveBufferDiagnostic { + pub severity: Option, + pub message: String, + pub snippet: String, + pub snippet_buffer_row_range: Range, + pub diagnostic_range_in_snippet: Range, +} + #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)] pub struct RelatedFile { pub path: Arc, @@ -203,7 +204,7 @@ pub fn prompt_input_contains_special_tokens(input: &ZetaPromptInput, format: Zet .any(|token| input.cursor_excerpt.contains(token)) } -pub fn format_zeta_prompt(input: &ZetaPromptInput, format: ZetaFormat) -> String { +pub fn format_zeta_prompt(input: &ZetaPromptInput, format: ZetaFormat) -> Option { format_prompt_with_budget_for_format(input, format, MAX_PROMPT_TOKENS) } @@ -219,6 +220,52 @@ pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] ZetaFormat::v0226Hashline => hashline::special_tokens(), ZetaFormat::V0304VariableEdit => v0304_variable_edit::special_tokens(), ZetaFormat::V0304SeedNoEdits => seed_coder::special_tokens(), + ZetaFormat::V0306SeedMultiRegions => { + static TOKENS: &[&str] = &[ + seed_coder::FIM_SUFFIX, + seed_coder::FIM_PREFIX, + seed_coder::FIM_MIDDLE, + seed_coder::FILE_MARKER, + seed_coder::START_MARKER, + seed_coder::SEPARATOR, + seed_coder::END_MARKER, + CURSOR_MARKER, + multi_region::MARKER_TAG_PREFIX, + ]; + TOKENS + } + } +} + +/// Returns the (editable_token_limit, context_token_limit) for a given format. +pub fn token_limits_for_format(format: ZetaFormat) -> (usize, usize) { + match format { + ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered => (150, 350), + ZetaFormat::V0114180EditableRegion => (180, 350), + ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill + | ZetaFormat::V0211SeedCoder + | ZetaFormat::v0226Hashline + | ZetaFormat::V0306SeedMultiRegions + | ZetaFormat::V0304SeedNoEdits => (350, 150), + ZetaFormat::V0304VariableEdit => (1024, 0), + } +} + +pub fn stop_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] { + match format { + ZetaFormat::v0226Hashline => &[hashline::NO_EDITS_COMMAND_MARKER], + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion + | ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill + | ZetaFormat::V0211SeedCoder + | ZetaFormat::V0304VariableEdit + | ZetaFormat::V0306SeedMultiRegions + | ZetaFormat::V0304SeedNoEdits => &[], } } @@ -240,14 +287,16 @@ pub fn excerpt_ranges_for_format( | ZetaFormat::V0211Prefill | ZetaFormat::V0211SeedCoder | ZetaFormat::v0226Hashline - | ZetaFormat::V0304SeedNoEdits => ( + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0306SeedMultiRegions => ( ranges.editable_350.clone(), ranges.editable_350_context_150.clone(), ), ZetaFormat::V0304VariableEdit => { let context = ranges - .context_8192 + .editable_350_context_1024 .clone() + .or(ranges.editable_350_context_512.clone()) .unwrap_or_else(|| ranges.editable_350_context_150.clone()); (context.clone(), context) } @@ -314,7 +363,44 @@ pub fn write_cursor_excerpt_section_for_format( ZetaFormat::V0304VariableEdit => { v0304_variable_edit::write_cursor_excerpt_section(prompt, path, context, cursor_offset) } + ZetaFormat::V0306SeedMultiRegions => { + prompt.push_str(&build_v0306_cursor_prefix( + path, + context, + editable_range, + cursor_offset, + )); + } + } +} + +fn build_v0306_cursor_prefix( + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, +) -> String { + let mut section = String::new(); + let path_str = path.to_string_lossy(); + write!(section, "{}{}\n", seed_coder::FILE_MARKER, path_str).ok(); + + section.push_str(&context[..editable_range.start]); + section.push_str(seed_coder::START_MARKER); + + let editable_text = &context[editable_range.clone()]; + let cursor_in_editable = cursor_offset - editable_range.start; + multi_region::write_editable_with_markers( + &mut section, + editable_text, + cursor_in_editable, + CURSOR_MARKER, + ); + + if !section.ends_with('\n') { + section.push('\n'); } + section.push_str(seed_coder::SEPARATOR); + section } fn offset_range_to_row_range(text: &str, range: Range) -> Range { @@ -330,31 +416,44 @@ pub fn format_prompt_with_budget_for_format( input: &ZetaPromptInput, format: ZetaFormat, max_tokens: usize, -) -> String { +) -> Option { let (context, editable_range, context_range, cursor_offset) = resolve_cursor_region(input, format); let path = &*input.cursor_path; + let empty_files = Vec::new(); + let input_related_files = input.related_files.as_deref().unwrap_or(&empty_files); let related_files = if let Some(cursor_excerpt_start_row) = input.excerpt_start_row { let relative_row_range = offset_range_to_row_range(&input.cursor_excerpt, context_range); let row_range = relative_row_range.start + cursor_excerpt_start_row ..relative_row_range.end + cursor_excerpt_start_row; &filter_redundant_excerpts( - input.related_files.clone(), + input_related_files.to_vec(), input.cursor_path.as_ref(), row_range, ) } else { - &input.related_files + input_related_files }; - match format { - ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => { - seed_coder::format_prompt_with_budget( + let prompt = match format { + ZetaFormat::V0211SeedCoder + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0306SeedMultiRegions => { + let mut cursor_section = String::new(); + write_cursor_excerpt_section_for_format( + format, + &mut cursor_section, path, context, &editable_range, cursor_offset, + ); + + seed_coder::assemble_fim_prompt( + context, + &editable_range, + &cursor_section, &input.events, related_files, max_tokens, @@ -379,6 +478,7 @@ pub fn format_prompt_with_budget_for_format( "<|file_sep|>", "edit history", budget_after_cursor, + max_edit_event_count_for_format(&format), ); let edit_history_tokens = estimate_tokens(edit_history_section.len()); let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); @@ -396,7 +496,12 @@ pub fn format_prompt_with_budget_for_format( prompt.push_str(&cursor_section); prompt } + }; + let prompt_tokens = estimate_tokens(prompt.len()); + if prompt_tokens > max_tokens { + return None; } + return Some(prompt); } pub fn filter_redundant_excerpts( @@ -416,6 +521,22 @@ pub fn filter_redundant_excerpts( related_files } +pub fn max_edit_event_count_for_format(format: &ZetaFormat) -> usize { + match format { + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion + | ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill + | ZetaFormat::V0211SeedCoder + | ZetaFormat::v0226Hashline + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0304VariableEdit + | ZetaFormat::V0306SeedMultiRegions => 6, + } +} + pub fn get_prefill_for_format( format: ZetaFormat, context: &str, @@ -431,7 +552,7 @@ pub fn get_prefill_for_format( | ZetaFormat::V0211SeedCoder | ZetaFormat::v0226Hashline | ZetaFormat::V0304VariableEdit => String::new(), - ZetaFormat::V0304SeedNoEdits => String::new(), + ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions => String::new(), } } @@ -440,7 +561,9 @@ pub fn output_end_marker_for_format(format: ZetaFormat) -> Option<&'static str> ZetaFormat::V0120GitMergeMarkers => Some(v0120_git_merge_markers::END_MARKER), ZetaFormat::V0131GitMergeMarkersPrefix => Some(v0131_git_merge_markers_prefix::END_MARKER), ZetaFormat::V0211Prefill => Some(v0131_git_merge_markers_prefix::END_MARKER), - ZetaFormat::V0211SeedCoder | ZetaFormat::V0304SeedNoEdits => Some(seed_coder::END_MARKER), + ZetaFormat::V0211SeedCoder + | ZetaFormat::V0304SeedNoEdits + | ZetaFormat::V0306SeedMultiRegions => Some(seed_coder::END_MARKER), ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered | ZetaFormat::V0114180EditableRegion @@ -465,7 +588,9 @@ pub fn encode_patch_as_output_for_format( cursor_offset, ) .map(Some), - ZetaFormat::V0304SeedNoEdits => Ok(seed_coder::no_edits(patch)), + ZetaFormat::V0304SeedNoEdits | ZetaFormat::V0306SeedMultiRegions => { + Ok(seed_coder::no_edits(patch)) + } _ => Ok(None), } } @@ -511,6 +636,14 @@ pub fn parse_zeta2_model_output( output.to_string() }, ), + ZetaFormat::V0306SeedMultiRegions => ( + editable_range_in_context, + if output.starts_with(seed_coder::NO_EDITS) { + old_editable_region.to_string() + } else { + multi_region::apply_marker_span(old_editable_region, output)? + }, + ), _ => (editable_range_in_context, output.to_string()), }; @@ -534,7 +667,18 @@ pub fn resolve_cursor_region( input: &ZetaPromptInput, format: ZetaFormat, ) -> (&str, Range, Range, usize) { - let (editable_range, context_range) = excerpt_range_for_format(format, &input.excerpt_ranges); + let (editable_range, context_range) = if let Some(syntax_ranges) = &input.syntax_ranges { + let (editable_tokens, context_tokens) = token_limits_for_format(format); + compute_editable_and_context_ranges( + &input.cursor_excerpt, + input.cursor_offset_in_excerpt, + syntax_ranges, + editable_tokens, + context_tokens, + ) + } else { + excerpt_range_for_format(format, &input.excerpt_ranges) + }; let context_start = context_range.start; let context_text = &input.cursor_excerpt[context_range.clone()]; let adjusted_editable = @@ -559,6 +703,7 @@ fn format_edit_history_within_budget( file_marker: &str, edit_history_name: &str, max_tokens: usize, + max_edit_event_count: usize, ) -> String { let header = format!("{}{}\n", file_marker, edit_history_name); let header_tokens = estimate_tokens(header.len()); @@ -569,7 +714,7 @@ fn format_edit_history_within_budget( let mut event_strings: Vec = Vec::new(); let mut total_tokens = header_tokens; - for event in events.iter().rev() { + for event in events.iter().rev().take(max_edit_event_count) { let mut event_str = String::new(); write_event(&mut event_str, event); let event_tokens = estimate_tokens(event_str.len()); @@ -1010,12 +1155,14 @@ pub mod hashline { const SET_COMMAND_MARKER: &str = "<|set|>"; const INSERT_COMMAND_MARKER: &str = "<|insert|>"; + pub const NO_EDITS_COMMAND_MARKER: &str = "<|no_edits|>"; pub fn special_tokens() -> &'static [&'static str] { return &[ SET_COMMAND_MARKER, "<|set_range|>", INSERT_COMMAND_MARKER, + NO_EDITS_COMMAND_MARKER, CURSOR_MARKER, "<|file_sep|>", "<|fim_prefix|>", @@ -1109,6 +1256,7 @@ pub mod hashline { } prompt.push_str(END_MARKER); + prompt.push('\n'); } /// A single edit command parsed from the model output. @@ -1234,7 +1382,9 @@ pub mod hashline { } pub fn output_has_edit_commands(model_output: &str) -> bool { - model_output.contains(SET_COMMAND_MARKER) || model_output.contains(INSERT_COMMAND_MARKER) + model_output.contains(SET_COMMAND_MARKER) + || model_output.contains(INSERT_COMMAND_MARKER) + || model_output.contains(NO_EDITS_COMMAND_MARKER) } /// Apply `<|set|>` and `<|insert|>` edit commands from the model output to the @@ -1245,6 +1395,13 @@ pub mod hashline { /// /// Returns the full replacement text for the editable region. pub fn apply_edit_commands(editable_region: &str, model_output: &str) -> String { + if model_output + .trim_start() + .starts_with(NO_EDITS_COMMAND_MARKER) + { + return editable_region.to_string(); + } + let original_lines: Vec<&str> = editable_region.lines().collect(); let old_hashes: Vec = original_lines .iter() @@ -1549,6 +1706,10 @@ pub mod hashline { result.pop(); } + if result.is_empty() { + return Ok(NO_EDITS_COMMAND_MARKER.to_string()); + } + Ok(result) } @@ -1579,7 +1740,8 @@ pub mod hashline { <|fim_middle|>current 0:5c|hello<|user_cursor|> world <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "multiline_cursor_on_second_line", @@ -1594,7 +1756,8 @@ pub mod hashline { 1:26|b<|user_cursor|>bb 2:29|ccc <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "no_trailing_newline_in_context", @@ -1608,7 +1771,8 @@ pub mod hashline { 0:d9|lin<|user_cursor|>e1 1:da|line2 <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "leading_newline_in_editable_region", @@ -1622,7 +1786,8 @@ pub mod hashline { 0:00| 1:26|a<|user_cursor|>bc <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "with_suffix", @@ -1636,7 +1801,8 @@ pub mod hashline { 0:26|ab<|user_cursor|>c <|fim_suffix|> def - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "unicode_two_byte_chars", @@ -1649,7 +1815,8 @@ pub mod hashline { <|fim_middle|>current 0:1b|hé<|user_cursor|>llo <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "unicode_three_byte_chars", @@ -1662,7 +1829,8 @@ pub mod hashline { <|fim_middle|>current 0:80|日本<|user_cursor|>語 <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "unicode_four_byte_chars", @@ -1675,7 +1843,8 @@ pub mod hashline { <|fim_middle|>current 0:6b|a🌍<|user_cursor|>b <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "cursor_at_start_of_region_not_placed", @@ -1688,7 +1857,8 @@ pub mod hashline { <|fim_middle|>current 0:26|abc <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "cursor_at_end_of_line_not_placed", @@ -1702,7 +1872,8 @@ pub mod hashline { 0:26|abc 1:2f|def <|fim_suffix|> - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, Case { name: "cursor_offset_relative_to_context_not_editable_region", @@ -1721,7 +1892,8 @@ pub mod hashline { 1:26|b<|user_cursor|>bb <|fim_suffix|> suf - <|fim_middle|>updated"}, + <|fim_middle|>updated + "}, }, ]; @@ -1894,6 +2066,18 @@ pub mod hashline { world "}, }, + Case { + name: "no_edits_command_returns_original", + original: indoc! {" + hello + world + "}, + model_output: "<|no_edits|>", + expected: indoc! {" + hello + world + "}, + }, Case { name: "wrong_hash_set_ignored", original: indoc! {" @@ -2073,21 +2257,21 @@ pub mod hashline { Case { name: "insert_before_first_and_after_line", original: indoc! {" - a - b - "}, + a + b + "}, model_output: indoc! {" - <|insert|> - HEAD - <|insert|>0:61 - MID - "}, + <|insert|> + HEAD + <|insert|>0:61 + MID + "}, expected: indoc! {" - HEAD - a - MID - b - "}, + HEAD + a + MID + b + "}, }, ]; @@ -2113,6 +2297,7 @@ pub mod hashline { ))); assert!(!hashline::output_has_edit_commands("just plain text")); assert!(!hashline::output_has_edit_commands("NO_EDITS")); + assert!(hashline::output_has_edit_commands("<|no_edits|>")); } // ---- hashline::patch_to_edit_commands round-trip tests ---- @@ -2350,35 +2535,47 @@ pub mod hashline { } "#}, patch: indoc! {r#" - @@ -1,3 +1,3 @@ - fn main() { - - println!(); - + eprintln!(""); - } - "#}, + @@ -1,3 +1,3 @@ + fn main() { + - println!(); + + eprintln!(""); + } + "#}, expected_new: indoc! {r#" - fn main() { - eprintln!("<|user_cursor|>"); - } - "#}, + fn main() { + eprintln!("<|user_cursor|>"); + } + "#}, }, Case { name: "non_local_hunk_header_pure_insertion_repro", old: indoc! {" - aaa - bbb - "}, + aaa + bbb + "}, patch: indoc! {" - @@ -20,2 +20,3 @@ - aaa - +xxx - bbb - "}, + @@ -20,2 +20,3 @@ + aaa + +xxx + bbb + "}, expected_new: indoc! {" - aaa - xxx - bbb - "}, + aaa + xxx + bbb + "}, + }, + Case { + name: "empty_patch_produces_no_edits_marker", + old: indoc! {" + aaa + bbb + "}, + patch: "@@ -20,2 +20,3 @@\n", + expected_new: indoc! {" + aaa + bbb + "}, }, ]; @@ -2492,12 +2689,30 @@ pub mod seed_coder { related_files: &[RelatedFile], max_tokens: usize, ) -> String { - let suffix_section = build_suffix_section(context, editable_range); let cursor_prefix_section = build_cursor_prefix_section(path, context, editable_range, cursor_offset); + assemble_fim_prompt( + context, + editable_range, + &cursor_prefix_section, + events, + related_files, + max_tokens, + ) + } - let suffix_tokens = estimate_tokens(suffix_section.len()); - let cursor_prefix_tokens = estimate_tokens(cursor_prefix_section.len()); + pub fn assemble_fim_prompt( + context: &str, + editable_range: &Range, + cursor_prefix_section: &str, + events: &[Arc], + related_files: &[RelatedFile], + max_tokens: usize, + ) -> String { + let suffix_section = build_suffix_section(context, editable_range); + + let suffix_tokens = estimate_tokens(suffix_section.len() + FIM_PREFIX.len()); + let cursor_prefix_tokens = estimate_tokens(cursor_prefix_section.len() + FIM_MIDDLE.len()); let budget_after_cursor = max_tokens.saturating_sub(suffix_tokens + cursor_prefix_tokens); let edit_history_section = super::format_edit_history_within_budget( @@ -2505,9 +2720,11 @@ pub mod seed_coder { FILE_MARKER, "edit_history", budget_after_cursor, + max_edit_event_count_for_format(&ZetaFormat::V0211SeedCoder), ); - let edit_history_tokens = estimate_tokens(edit_history_section.len()); - let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); + let edit_history_tokens = estimate_tokens(edit_history_section.len() + "\n".len()); + let budget_after_edit_history = + budget_after_cursor.saturating_sub(edit_history_tokens + "\n".len()); let related_files_section = super::format_related_files_within_budget( related_files, @@ -2527,8 +2744,9 @@ pub mod seed_coder { if !edit_history_section.is_empty() { prompt.push('\n'); } - prompt.push_str(&cursor_prefix_section); + prompt.push_str(cursor_prefix_section); prompt.push_str(FIM_MIDDLE); + prompt } @@ -3631,7 +3849,13 @@ pub mod zeta1 { /// Formats events in zeta1 style (oldest first). fn format_zeta1_events(events: &[Arc]) -> String { let mut result = String::new(); - for event in events { + for event in + events + .iter() + .skip(events.len().saturating_sub(max_edit_event_count_for_format( + &ZetaFormat::V0114180EditableRegion, + ))) + { let event_string = format_zeta1_event(event); if event_string.is_empty() { continue; @@ -3796,7 +4020,8 @@ mod tests { cursor_offset_in_excerpt: cursor_offset, excerpt_start_row: None, events: events.into_iter().map(Arc::new).collect(), - related_files, + related_files: Some(related_files), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: editable_range.clone(), editable_180: editable_range.clone(), @@ -3806,6 +4031,7 @@ mod tests { editable_350_context_150: context_range, ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -3825,7 +4051,8 @@ mod tests { cursor_offset_in_excerpt: cursor_offset, excerpt_start_row: None, events: vec![], - related_files: vec![], + related_files: Some(vec![]), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: editable_range.clone(), editable_180: editable_range.clone(), @@ -3835,6 +4062,7 @@ mod tests { editable_350_context_150: context_range, ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -3865,7 +4093,7 @@ mod tests { } } - fn format_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> String { + fn format_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> Option { format_prompt_with_budget_for_format(input, ZetaFormat::V0114180EditableRegion, max_tokens) } @@ -3880,7 +4108,7 @@ mod tests { ); assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>related.rs fn helper() {} @@ -3899,6 +4127,7 @@ mod tests { suffix <|fim_middle|>updated "#} + .to_string() ); } @@ -3910,18 +4139,18 @@ mod tests { 2, vec![make_event("a.rs", "-x\n+y\n")], vec![ - make_related_file("r1.rs", "a\n"), - make_related_file("r2.rs", "b\n"), + make_related_file("r1.rs", "aaaaaaa\n"), + make_related_file("r2.rs", "bbbbbbb\n"), ], ); assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>r1.rs - a + aaaaaaa <|file_sep|>r2.rs - b + bbbbbbb <|file_sep|>edit history --- a/a.rs +++ b/a.rs @@ -3934,15 +4163,18 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); assert_eq!( - format_with_budget(&input, 50), - indoc! {r#" - <|file_sep|>r1.rs - a - <|file_sep|>r2.rs - b + format_with_budget(&input, 55), + Some( + indoc! {r#" + <|file_sep|>edit history + --- a/a.rs + +++ b/a.rs + -x + +y <|file_sep|>test.rs <|fim_prefix|> <|fim_middle|>current @@ -3950,6 +4182,8 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() + ) ); } @@ -3985,7 +4219,7 @@ mod tests { ); assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>big.rs first excerpt @@ -4000,10 +4234,11 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); assert_eq!( - format_with_budget(&input, 50), + format_with_budget(&input, 50).unwrap(), indoc! {r#" <|file_sep|>big.rs first excerpt @@ -4015,6 +4250,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); } @@ -4053,7 +4289,7 @@ mod tests { // With large budget, both files included; rendered in stable lexicographic order. assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>file_a.rs low priority content @@ -4066,6 +4302,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); // With tight budget, only file_b (lower order) fits. @@ -4073,7 +4310,7 @@ mod tests { // file_b header (7) + excerpt (7) = 14 tokens, which fits. // file_a would need another 14 tokens, which doesn't fit. assert_eq!( - format_with_budget(&input, 52), + format_with_budget(&input, 52).unwrap(), indoc! {r#" <|file_sep|>file_b.rs high priority content @@ -4084,6 +4321,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); } @@ -4125,7 +4363,7 @@ mod tests { // With large budget, all three excerpts included. assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>mod.rs mod header @@ -4140,11 +4378,12 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); // With tight budget, only order<=1 excerpts included (header + important fn). assert_eq!( - format_with_budget(&input, 55), + format_with_budget(&input, 55).unwrap(), indoc! {r#" <|file_sep|>mod.rs mod header @@ -4158,6 +4397,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); } @@ -4172,7 +4412,7 @@ mod tests { ); assert_eq!( - format_with_budget(&input, 10000), + format_with_budget(&input, 10000).unwrap(), indoc! {r#" <|file_sep|>edit history --- a/old.rs @@ -4188,10 +4428,11 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); assert_eq!( - format_with_budget(&input, 55), + format_with_budget(&input, 60).unwrap(), indoc! {r#" <|file_sep|>edit history --- a/new.rs @@ -4204,6 +4445,7 @@ mod tests { <|fim_suffix|> <|fim_middle|>updated "#} + .to_string() ); } @@ -4217,25 +4459,19 @@ mod tests { vec![make_related_file("related.rs", "helper\n")], ); - assert_eq!( - format_with_budget(&input, 30), - indoc! {r#" - <|file_sep|>test.rs - <|fim_prefix|> - <|fim_middle|>current - fn <|user_cursor|>main() {} - <|fim_suffix|> - <|fim_middle|>updated - "#} - ); + assert!(format_with_budget(&input, 30).is_none()) } + #[track_caller] fn format_seed_coder(input: &ZetaPromptInput) -> String { format_prompt_with_budget_for_format(input, ZetaFormat::V0211SeedCoder, 10000) + .expect("seed coder prompt formatting should succeed") } + #[track_caller] fn format_seed_coder_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> String { format_prompt_with_budget_for_format(input, ZetaFormat::V0211SeedCoder, max_tokens) + .expect("seed coder prompt formatting should succeed") } #[test] @@ -4320,17 +4556,22 @@ mod tests { <[fim-middle]>"#} ); - // With tight budget, context is dropped but cursor section remains assert_eq!( - format_seed_coder_with_budget(&input, 30), + format_prompt_with_budget_for_format(&input, ZetaFormat::V0211SeedCoder, 24), + None + ); + + assert_eq!( + format_seed_coder_with_budget(&input, 40), indoc! {r#" <[fim-suffix]> <[fim-prefix]>test.rs <<<<<<< CURRENT co<|user_cursor|>de ======= - <[fim-middle]>"#} - ); + <[fim-middle]>"# + } + ) } #[test] @@ -4381,21 +4622,20 @@ mod tests { <[fim-middle]>"#} ); - // With tight budget, only high_prio included. - // Cursor sections cost 25 tokens, so budget 44 leaves 19 for related files. - // high_prio header (7) + excerpt (3) = 10, fits. low_prio would add 10 more = 20 > 19. + // With tight budget under the generic heuristic, context is dropped but the + // minimal cursor section still fits. assert_eq!( - format_seed_coder_with_budget(&input, 44), - indoc! {r#" - <[fim-suffix]> - <[fim-prefix]>high_prio.rs - high prio - - test.rs - <<<<<<< CURRENT - co<|user_cursor|>de - ======= - <[fim-middle]>"#} + format_prompt_with_budget_for_format(&input, ZetaFormat::V0211SeedCoder, 44), + Some( + indoc! {r#" + <[fim-suffix]> + <[fim-prefix]>test.rs + <<<<<<< CURRENT + co<|user_cursor|>de + ======= + <[fim-middle]>"#} + .to_string() + ) ); } @@ -4408,7 +4648,8 @@ mod tests { cursor_offset_in_excerpt: 30, excerpt_start_row: Some(0), events: vec![Arc::new(make_event("other.rs", "-old\n+new\n"))], - related_files: vec![], + related_files: Some(vec![]), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: 15..41, editable_180: 15..41, @@ -4418,6 +4659,7 @@ mod tests { editable_350_context_150: 0..excerpt.len(), ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -4471,7 +4713,8 @@ mod tests { cursor_offset_in_excerpt: 15, excerpt_start_row: Some(10), events: vec![], - related_files: vec![], + related_files: Some(vec![]), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: 0..28, editable_180: 0..28, @@ -4481,6 +4724,7 @@ mod tests { editable_350_context_150: 0..28, ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -4529,7 +4773,8 @@ mod tests { cursor_offset_in_excerpt: 25, excerpt_start_row: Some(0), events: vec![], - related_files: vec![], + related_files: Some(vec![]), + active_buffer_diagnostics: vec![], excerpt_ranges: ExcerptRanges { editable_150: editable_range.clone(), editable_180: editable_range.clone(), @@ -4539,6 +4784,7 @@ mod tests { editable_350_context_150: context_range.clone(), ..Default::default() }, + syntax_ranges: None, experiment: None, in_open_source_repo: false, can_collect_data: false, @@ -4578,6 +4824,87 @@ mod tests { ); } + #[test] + fn test_max_event_count() { + fn make_numbered_event(index: usize) -> Event { + return make_event( + &format!("event-{index}.rs"), + &format!("-old-{index}\n+new-{index}\n"), + ); + } + let input = make_input( + "x", + 0..1, + 0, + (0..3).map(make_numbered_event).collect(), + vec![], + ); + + let edit_history_section = format_edit_history_within_budget( + &input.events, + "<|file_sep|>", + "edit history", + usize::MAX, + 5, + ); + + assert_eq!( + &edit_history_section, + indoc!( + " + <|file_sep|>edit history + --- a/event-0.rs + +++ b/event-0.rs + -old-0 + +new-0 + --- a/event-1.rs + +++ b/event-1.rs + -old-1 + +new-1 + --- a/event-2.rs + +++ b/event-2.rs + -old-2 + +new-2 + " + ) + ); + + let edit_history_section = format_edit_history_within_budget( + &input.events, + "<|file_sep|>", + "edit history", + usize::MAX, + 2, + ); + + assert_eq!( + &edit_history_section, + indoc!( + " + <|file_sep|>edit history + --- a/event-1.rs + +++ b/event-1.rs + -old-1 + +new-1 + --- a/event-2.rs + +++ b/event-2.rs + -old-2 + +new-2 + " + ) + ); + + let edit_history_section = format_edit_history_within_budget( + &input.events, + "<|file_sep|>", + "edit history", + usize::MAX, + 0, + ); + + assert_eq!(&edit_history_section, ""); + } + #[test] fn test_clean_zeta1_model_output_basic() { let output = indoc! {" diff --git a/docs/AGENTS.md b/docs/AGENTS.md index fdd61ff6aeaf8cd09ae0b017c5199e7033fba964..54f477472b1b4d22f06623220d5fb4a3eb181db4 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -126,6 +126,59 @@ Images are hosted externally. Reference format: - With anchors: `[Custom Models](./llm-providers.md#anthropic-custom-models)` - Parent directory: `[Telemetry](../telemetry.md)` +## Voice and Tone + +### Core Principles + +- **Practical over promotional**: Focus on what users can do, not on selling Zed. Avoid marketing language like "powerful," "revolutionary," or "best-in-class." +- **Honest about limitations**: When Zed lacks a feature or doesn't match another tool's depth, say so directly. Pair limitations with workarounds or alternative workflows. +- **Direct and concise**: Use short sentences. Get to the point. Developers are scanning, not reading novels. +- **Second person**: Address the reader as "you." Avoid "the user" or "one." +- **Present tense**: "Zed opens the file" not "Zed will open the file." + +### What to Avoid + +- Superlatives without substance ("incredibly fast," "seamlessly integrated") +- Hedging language ("simply," "just," "easily")—if something is simple, the instructions will show it +- Apologetic tone for missing features—state the limitation and move on +- Comparisons that disparage other tools—be factual, not competitive +- Lots of use of em or en dashes. + +## Examples of Good Copy + +### Good: Direct and actionable + +``` +To format on save, open the Settings Editor (`Cmd+,`) and search for `format_on_save`. Set it to `on`. + +Or add this to your settings.json: +{ + "format_on_save": "on" +} +``` + +### Bad: Wordy and promotional + +``` +Zed provides a powerful and seamless formatting experience. Simply navigate to the settings and you'll find the format_on_save option which enables Zed's incredible auto-formatting capabilities. +``` + +### Good: Honest about limitations + +``` +Zed doesn't index your project like IntelliJ does. You open a folder and start working immediately—no waiting. The trade-off: cross-project analysis relies on language servers, which may not go as deep. + +**How to adapt:** +- Use `Cmd+Shift+F` for project-wide text search +- Use `Cmd+O` for symbol search (powered by your language server) +``` + +### Bad: Defensive or dismissive + +``` +While some users might miss indexing, Zed's approach is actually better because it's faster. +``` + ## Scope ### In-Scope Documentation @@ -204,13 +257,14 @@ Inherit all conventions from `docs/.rules`. Key points: ### Terminology -| Use | Instead of | -| --------------- | -------------------------------------- | -| folder | directory | -| project | workspace | -| Settings Editor | settings UI | -| command palette | command bar | -| panel | sidebar (be specific: "Project Panel") | +| Use | Instead of | +| --------------- | --------------------------------------------------------------------- | +| folder | directory | +| project | workspace | +| Settings Editor | settings UI | +| command palette | command bar | +| panel | tool window, sidebar (be specific: "Project Panel," "Terminal Panel") | +| language server | LSP (spell out first use, then LSP is fine) | ## Zed-Specific Conventions diff --git a/docs/acp-threads-in-sidebar-plan.md b/docs/acp-threads-in-sidebar-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..e4a23418d49bb3ad7cd688f5110341edc5c3abf2 --- /dev/null +++ b/docs/acp-threads-in-sidebar-plan.md @@ -0,0 +1,580 @@ +# Plan: Show ACP Threads in the Sidebar (Revised) + +## Problem + +The sidebar currently only shows **Zed-native agent threads** (from `ThreadStore`/`ThreadsDatabase`). ACP threads (Claude Code, Codex, Gemini, etc.) are invisible in the sidebar once they're no longer live. + +## Root Cause + +`ThreadStore` and `ThreadsDatabase` only persist metadata for native threads. When `rebuild_contents` populates the sidebar, it reads from `ThreadStore` for historical threads and overlays live info from the `AgentPanel` — but non-native threads never get written to `ThreadStore`, so once they stop being live, they disappear. + +## Solution Overview (Revised) + +**Key change from the original plan:** We completely remove the sidebar's dependency on `ThreadStore`. Instead, the `Sidebar` itself owns a **single, unified persistence layer** — a new `SidebarDb` domain stored in the workspace DB — that tracks metadata for _all_ thread types (native and ACP). The sidebar becomes the single source of truth for what threads appear in the list. + +### Why Remove the ThreadStore Dependency? + +1. **Single responsibility** — The sidebar is the only consumer of "which threads to show in the list." Having it depend on `ThreadStore` (which exists primarily for native agent save/load) creates an indirect coupling that makes ACP integration awkward. +2. **No merge logic** — The original plan required merging native `ThreadStore` data with a separate `AcpThreadMetadataDb` in `ThreadStore::reload`. By moving all sidebar metadata into one place, there's nothing to merge. +3. **Simpler data flow** — Writers (native agent, ACP connections) push metadata to the sidebar DB. The sidebar reads from one table. No cross-crate coordination needed. +4. **ThreadStore stays focused** — `ThreadStore` continues to manage native thread blob storage (save/load message data) without being polluted with sidebar display concerns. + +### Architecture + +``` + ┌─────────────────────┐ ┌─────────────────────────┐ + │ NativeAgent │ │ ACP Connections │ + │ (on save_thread) │ │ (on create/update/list) │ + └──────────┬──────────┘ └──────────┬──────────────┘ + │ │ + │ save_sidebar_thread() │ + └──────────┬─────────────────┘ + ▼ + ┌───────────────────┐ + │ SidebarDb │ + │ (workspace DB) │ + │ sidebar_threads │ + └────────┬──────────┘ + │ + ▼ + ┌───────────────────┐ + │ Sidebar │ + │ rebuild_contents │ + └───────────────────┘ +``` + +--- + +## Step 1: Create `SidebarDb` Domain in `sidebar.rs` + +**File:** `crates/agent_ui/src/sidebar.rs` + +Add a `SidebarDb` domain using `db::static_connection!`, co-located in the sidebar module (or a small `persistence` submodule within `sidebar.rs` if it helps organization, but keeping it in the same file is fine for now). + +### Schema + +```rust +use db::{ + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, + sqlez_macros::sql, +}; + +/// Lightweight metadata for any thread (native or ACP), enough to populate +/// the sidebar list and route to the correct load path when clicked. +#[derive(Debug, Clone)] +pub struct SidebarThreadRow { + pub session_id: acp::SessionId, + /// `None` for native Zed threads, `Some("claude-code")` etc. for ACP agents. + pub agent_name: Option, + pub title: SharedString, + pub updated_at: DateTime, + pub created_at: Option>, + pub folder_paths: PathList, +} + +pub struct SidebarDb(ThreadSafeConnection); + +impl Domain for SidebarDb { + const NAME: &str = stringify!(SidebarDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE IF NOT EXISTS sidebar_threads( + session_id TEXT PRIMARY KEY, + agent_name TEXT, + title TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT, + folder_paths TEXT, + folder_paths_order TEXT + ) STRICT; + )]; +} + +db::static_connection!(SIDEBAR_DB, SidebarDb, []); +``` + +### CRUD Methods + +```rust +impl SidebarDb { + /// Upsert metadata for a thread (native or ACP). + pub async fn save(&self, row: &SidebarThreadRow) -> Result<()> { + let id = row.session_id.0.clone(); + let agent_name = row.agent_name.clone(); + let title = row.title.to_string(); + let updated_at = row.updated_at.to_rfc3339(); + let created_at = row.created_at.map(|dt| dt.to_rfc3339()); + let serialized = row.folder_paths.serialize(); + let (fp, fpo) = if row.folder_paths.is_empty() { + (None, None) + } else { + (Some(serialized.paths), Some(serialized.order)) + }; + + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "INSERT INTO sidebar_threads(session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(session_id) DO UPDATE SET + agent_name = excluded.agent_name, + title = excluded.title, + updated_at = excluded.updated_at, + folder_paths = excluded.folder_paths, + folder_paths_order = excluded.folder_paths_order", + )?; + let mut i = stmt.bind(&id, 1)?; + i = stmt.bind(&agent_name, i)?; + i = stmt.bind(&title, i)?; + i = stmt.bind(&updated_at, i)?; + i = stmt.bind(&created_at, i)?; + i = stmt.bind(&fp, i)?; + stmt.bind(&fpo, i)?; + stmt.exec() + }) + .await + } + + /// List all sidebar thread metadata, ordered by updated_at descending. + pub fn list(&self) -> Result> { + self.select::( + "SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order + FROM sidebar_threads + ORDER BY updated_at DESC" + )?(()) + } + + /// List threads for a specific folder path set. + pub fn list_for_paths(&self, paths: &PathList) -> Result> { + let serialized = paths.serialize(); + self.select_bound::(sql!( + SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order + FROM sidebar_threads + WHERE folder_paths = ? + ORDER BY updated_at DESC + ))?(serialized.paths) + } + + /// Look up a single thread by session ID. + pub fn get(&self, session_id: &acp::SessionId) -> Result> { + let id = session_id.0.clone(); + self.select_row_bound::, SidebarThreadRow>(sql!( + SELECT session_id, agent_name, title, updated_at, created_at, folder_paths, folder_paths_order + FROM sidebar_threads + WHERE session_id = ? + ))?(id) + } + + /// Return the total number of rows in the table. + pub fn count(&self) -> Result { + let count: (i32, i32) = self.select_row(sql!( + SELECT COUNT(*) FROM sidebar_threads + ))?(())?.unwrap_or_default(); + Ok(count.0 as usize) + } + + /// Delete metadata for a single thread. + pub async fn delete(&self, session_id: acp::SessionId) -> Result<()> { + let id = session_id.0; + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "DELETE FROM sidebar_threads WHERE session_id = ?", + )?; + stmt.bind(&id, 1)?; + stmt.exec() + }) + .await + } + + /// Delete all thread metadata. + pub async fn delete_all(&self) -> Result<()> { + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "DELETE FROM sidebar_threads", + )?; + stmt.exec() + }) + .await + } +} +``` + +### `Column` Implementation + +```rust +impl Column for SidebarThreadRow { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let (id, next): (Arc, i32) = Column::column(statement, start_index)?; + let (agent_name, next): (Option, i32) = Column::column(statement, next)?; + let (title, next): (String, i32) = Column::column(statement, next)?; + let (updated_at_str, next): (String, i32) = Column::column(statement, next)?; + let (created_at_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_str, next): (Option, i32) = Column::column(statement, next)?; + let (folder_paths_order_str, next): (Option, i32) = Column::column(statement, next)?; + + let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)?.with_timezone(&Utc); + let created_at = created_at_str + .as_deref() + .map(DateTime::parse_from_rfc3339) + .transpose()? + .map(|dt| dt.with_timezone(&Utc)); + + let folder_paths = folder_paths_str + .map(|paths| { + PathList::deserialize(&util::path_list::SerializedPathList { + paths, + order: folder_paths_order_str.unwrap_or_default(), + }) + }) + .unwrap_or_default(); + + Ok(( + SidebarThreadRow { + session_id: acp::SessionId::new(id), + agent_name, + title: title.into(), + updated_at, + created_at, + folder_paths, + }, + next, + )) + } +} +``` + +**Key points:** + +- `SIDEBAR_DB` is a `LazyLock` static — initialized on first use, no manual connection management. +- The `agent_name` column is `NULL` for native Zed threads and a string like `"claude-code"` for ACP agents. This replaces the `agent_type` field from the original plan. +- The DB file lives alongside other `static_connection!` databases. +- `ThreadsDatabase` and `ThreadStore` are **completely unchanged** by this step. + +--- + +## Step 2: Replace `ThreadStore` Reads in `rebuild_contents` with `SidebarDb` Reads + +**File:** `crates/agent_ui/src/sidebar.rs` + +### Remove `ThreadStore` Dependency + +1. **Remove** `ThreadStore::global(cx)` and `ThreadStore::try_global(cx)` from `Sidebar::new` and `rebuild_contents`. +2. **Remove** the `cx.observe_in(&thread_store, ...)` subscription that triggers `update_entries` when `ThreadStore` changes. +3. **Replace** `thread_store.read(cx).threads_for_paths(&path_list)` calls with `SIDEBAR_DB.list_for_paths(&path_list)` (or read all rows once at the top of `rebuild_contents` and index them in memory, which is simpler and avoids repeated DB calls). + +### New Data Flow in `rebuild_contents` + +```rust +fn rebuild_contents(&mut self, cx: &App) { + // ... existing workspace iteration setup ... + + // Read ALL sidebar thread metadata once, index by folder_paths. + let all_sidebar_threads = SIDEBAR_DB.list().unwrap_or_default(); + let mut threads_by_paths: HashMap> = HashMap::new(); + for row in all_sidebar_threads { + threads_by_paths + .entry(row.folder_paths.clone()) + .or_default() + .push(row); + } + + for (ws_index, workspace) in workspaces.iter().enumerate() { + // ... existing absorbed-workspace logic ... + + let path_list = workspace_path_list(workspace, cx); + + if should_load_threads { + let mut seen_session_ids: HashSet = HashSet::new(); + + // Read from SidebarDb instead of ThreadStore + if let Some(rows) = threads_by_paths.get(&path_list) { + for row in rows { + seen_session_ids.insert(row.session_id.clone()); + let (agent, icon) = match &row.agent_name { + None => (Agent::NativeAgent, IconName::ZedAgent), + Some(name) => ( + Agent::Custom { name: name.clone().into() }, + IconName::ZedAgent, // placeholder, resolved in Step 5 + ), + }; + threads.push(ThreadEntry { + agent, + session_info: AgentSessionInfo { + session_id: row.session_id.clone(), + cwd: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg: None, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + highlight_positions: Vec::new(), + worktree_name: None, + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); + } + } + + // ... existing linked git worktree logic, also reading from threads_by_paths ... + // ... existing live thread overlay logic (unchanged) ... + } + } +} +``` + +### What Changes + +- `rebuild_contents` reads from `SIDEBAR_DB` instead of `ThreadStore`. +- The `ThreadEntry.agent` field now carries `Agent::Custom { name }` for ACP threads, enabling correct routing in `activate_thread`. +- The live thread overlay logic (from `all_thread_infos_for_workspace`) is **unchanged** — it still reads from `AgentPanel` to get real-time status of running threads. + +### What Stays the Same + +- The entire workspace/absorbed-workspace/git-worktree structure. +- The live thread overlay pass. +- The notification tracking logic. +- The search/filter logic. + +--- + +## Step 3: Write Native Thread Metadata to `SidebarDb` + +**File:** `crates/agent_ui/src/sidebar.rs` and/or `crates/agent_ui/src/agent_panel.rs` + +When a native thread is saved (after conversation, on title update, etc.), we also write its metadata to `SidebarDb`. There are two approaches: + +### Option A: Subscribe to `ThreadStore` Changes (Recommended) + +Keep a one-directional sync: when `ThreadStore` finishes a `save_thread` or `reload`, the sidebar syncs the metadata to `SidebarDb`. This can be done in the sidebar's workspace subscription or by observing `ThreadStore` changes purely for the purpose of syncing (not for reading). + +```rust +// In Sidebar::subscribe_to_workspace or a dedicated sync method: +fn sync_native_threads_to_sidebar_db(&self, cx: &App) { + if let Some(thread_store) = ThreadStore::try_global(cx) { + let entries: Vec<_> = thread_store.read(cx).entries().collect(); + cx.background_spawn(async move { + for meta in entries { + SIDEBAR_DB.save(&SidebarThreadRow { + session_id: meta.id, + agent_name: None, // native + title: meta.title, + updated_at: meta.updated_at, + created_at: meta.created_at, + folder_paths: meta.folder_paths, + }).await.log_err(); + } + }).detach(); + } +} +``` + +### Option B: Write at the Point of Save + +In `AgentPanel` or wherever `thread_store.save_thread()` is called, also call `SIDEBAR_DB.save(...)`. This is more direct but requires touching more call sites. + +**Recommendation:** Option A is simpler for the initial implementation. We observe `ThreadStore` changes, diff against `SidebarDb`, and sync. Later, if we want to remove `ThreadStore` entirely from the write path for native threads, we can switch to Option B. + +--- + +## Step 4: Write ACP Thread Metadata to `SidebarDb` + +**File:** `crates/agent_ui/src/connection_view.rs` (or `agent_panel.rs`) + +When ACP sessions are created, updated, or listed, write metadata directly to `SidebarDb`: + +- **On new session creation:** After `connection.new_session()` returns the `AcpThread`, call `SIDEBAR_DB.save(...)`. +- **On title update:** ACP threads receive title updates via `SessionInfoUpdate`. When these come in, call `SIDEBAR_DB.save(...)` with the new title and updated timestamp. +- **On session list refresh:** When `AgentSessionList::list_sessions` returns for an ACP agent, bulk-sync the metadata into `SidebarDb`. + +After any write, call `cx.notify()` on the `Sidebar` entity (or use a channel/event) to trigger a `rebuild_contents`. + +### Triggering Sidebar Refresh + +Since the sidebar no longer observes `ThreadStore`, we need a mechanism to trigger `rebuild_contents` after DB writes. Options: + +1. **Emit an event from `AgentPanel`** — The sidebar already subscribes to `AgentPanelEvent`. Add a new variant like `AgentPanelEvent::ThreadMetadataChanged` and emit it after saving to `SidebarDb`. +2. **Use `cx.notify()` directly** — If the save happens within a `Sidebar` method, just call `self.update_entries(cx)`. +3. **Observe a lightweight signal entity** — A simple `Entity<()>` that gets notified after DB writes. + +**Recommendation:** Option 1 (emit from `AgentPanel`) is cleanest since the sidebar already subscribes to panel events. + +--- + +## Step 5: Handle Agent Icon Resolution for ACP Threads + +**File:** `crates/agent_ui/src/sidebar.rs` + +For ACP threads in the sidebar, we need the correct agent icon. The `agent_name` string stored in `SidebarDb` maps to an agent in the `AgentServerStore`, which has icon info. + +In `rebuild_contents`, after building the initial thread list from `SidebarDb`, resolve icons for ACP threads: + +```rust +// For ACP threads, look up the icon from the agent server store +if let Some(name) = &row.agent_name { + if let Some(agent_server_store) = /* get from workspace */ { + // resolve icon from agent_server_store using name + } +} +``` + +--- + +## Step 6: Handle Delete Operations Correctly + +**File:** `crates/agent_ui/src/sidebar.rs` + +When the user deletes a thread from the sidebar: + +- **All threads** → Delete from `SidebarDb` via `SIDEBAR_DB.delete(session_id)`. +- **Native threads** → _Also_ delete from `ThreadStore`/`ThreadsDatabase` (to clean up the blob data). +- **ACP threads** → Optionally notify the ACP server via `AgentSessionList::delete_session`. + +The `agent_name` field on `SidebarThreadRow` (or the `Agent` enum on `ThreadEntry`) tells us which path to take. + +When the user clears all history: + +```rust +// Delete all sidebar metadata +SIDEBAR_DB.delete_all().await?; +// Also clear native thread blobs +thread_store.delete_threads(cx); +// Optionally notify ACP servers +``` + +--- + +## Step 7: Handle `activate_thread` Routing + +**File:** `crates/agent_ui/src/sidebar.rs`, `crates/agent_ui/src/agent_panel.rs` + +In `activate_thread`, branch on the `Agent` variant: + +- `Agent::NativeAgent` → Call `panel.load_agent_thread(Agent::NativeAgent, session_id, ...)` (current behavior). +- `Agent::Custom { name }` → Call `panel.load_agent_thread(Agent::Custom { name }, session_id, ...)` so it routes to the correct `AgentConnection::load_session`. + +This is already partially set up — `activate_thread` takes an `Agent` parameter. The key change is that `ThreadEntry` now carries the correct `Agent` variant based on `SidebarThreadRow.agent_name`. + +--- + +## Step 8: Handle `activate_archived_thread` Without ThreadStore + +**File:** `crates/agent_ui/src/sidebar.rs` + +Currently, `activate_archived_thread` looks up `saved_path_list` from `ThreadStore`: + +```rust +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()) +}); +``` + +Replace this with a targeted `SidebarDb::get` lookup (single-row SELECT, no full table scan): + +```rust +let saved_path_list = SIDEBAR_DB + .get(&session_info.session_id) + .ok() + .flatten() + .map(|row| row.folder_paths); +``` + +--- + +## Step 9: Error Handling for Offline Agents + +When an ACP thread is clicked but the agent server is not running: + +- Show a toast/notification explaining the agent is offline. +- Keep the metadata in the sidebar (don't remove it). +- Optionally offer to start the agent server. + +--- + +## Step 10: Migration — Backfill Existing Native Threads + +On first launch after this change, the `SidebarDb` will be empty while `ThreadsDatabase` has existing native threads. We need a one-time backfill: + +```rust +// In Sidebar::new or a dedicated init method: +fn backfill_native_threads_if_needed(cx: &App) { + if SIDEBAR_DB.count() > 0 { + return; // Already populated + } + + if let Some(thread_store) = ThreadStore::try_global(cx) { + let entries: Vec<_> = thread_store.read(cx).entries().collect(); + cx.background_spawn(async move { + for meta in entries { + SIDEBAR_DB.save(&SidebarThreadRow { + session_id: meta.id, + agent_name: None, + title: meta.title, + updated_at: meta.updated_at, + created_at: meta.created_at, + folder_paths: meta.folder_paths, + }).await.log_err(); + } + }).detach(); + } +} +``` + +--- + +## Summary of Files to Change + +| File | Changes | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/agent_ui/Cargo.toml` | Add `db.workspace = true`, `sqlez.workspace = true`, `sqlez_macros.workspace = true`, `chrono.workspace = true` dependencies | +| `crates/agent_ui/src/sidebar.rs` | **Main changes.** Add `SidebarDb` domain + `SIDEBAR_DB` static + `SidebarThreadRow`. Replace all `ThreadStore` reads in `rebuild_contents` with `SidebarDb` reads. Update `activate_archived_thread`. Add native thread sync logic. Add backfill on first run. | +| `crates/agent_ui/src/agent_panel.rs` | Emit `AgentPanelEvent::ThreadMetadataChanged` after thread saves. Potentially write ACP metadata to `SidebarDb` here. | +| `crates/agent_ui/src/connection_view.rs` | Write ACP metadata to `SidebarDb` on session creation, title updates, and session list refreshes. | + +## What Is NOT Changed + +| File / Area | Why | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `threads` table schema | No migration needed — native blob persistence is completely untouched | +| `ThreadsDatabase` methods | `save_thread_sync`, `load_thread`, `list_threads`, `delete_thread`, `delete_threads` — all unchanged | +| `ThreadStore` struct/methods | Stays exactly as-is. It's still used for native thread blob save/load. The sidebar just no longer reads from it for display. | +| `NativeAgent::load_thread` / `open_thread` | These deserialize `DbThread` blobs — completely unaffected | +| `crates/acp_thread/` | No new persistence module needed there (unlike the original plan) | +| `crates/agent/src/db.rs` | `DbThreadMetadata` is unchanged — no `agent_type` field added | + +## Execution Order + +1. **SidebarDb domain** (Step 1) — Create `SidebarDb`, `SidebarThreadRow`, `SIDEBAR_DB` static, CRUD methods in `sidebar.rs`. +2. **Replace reads** (Step 2) — Swap `ThreadStore` reads in `rebuild_contents` for `SidebarDb` reads. +3. **Native write path** (Step 3) — Sync native thread metadata from `ThreadStore` into `SidebarDb`. +4. **ACP write path** (Step 4) — Write ACP thread metadata to `SidebarDb` from connection views. +5. **Icon resolution** (Step 5) — Resolve ACP agent icons in the sidebar. +6. **Delete path** (Step 6) — Route deletes to `SidebarDb` + native blob cleanup + ACP server notification. +7. **Activate routing** (Step 7) — Ensure `activate_thread` routes correctly based on `Agent` variant. +8. **Archive fix** (Step 8) — Update `activate_archived_thread` to use `SidebarDb`. +9. **Migration** (Step 10) — Backfill existing native threads on first run. +10. **Polish** (Step 9) — Error handling for offline agents. + +## Key Differences from Original Plan + +| Aspect | Original Plan | Revised Plan | +| ------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| **Where ACP metadata lives** | New `AcpThreadMetadataDb` in `crates/acp_thread/` | `SidebarDb` in `crates/agent_ui/src/sidebar.rs` | +| **Where sidebar reads from** | `ThreadStore` (which merges native + ACP) | `SidebarDb` directly (single source) | +| **ThreadStore changes** | Added `agent_type` to `DbThreadMetadata`, merge logic in `reload`, new save/delete methods | **None** — ThreadStore is untouched | +| **`crates/agent/src/db.rs` changes** | Added `agent_type: Option` to `DbThreadMetadata` | **None** | +| **Merge complexity** | Two data sources merged in `ThreadStore::reload` | No merge — one table, one read | +| **Crate dependencies** | `acp_thread` gains `db` dependency | `agent_ui` gains `db` dependency (more natural — it's a UI persistence concern) | diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2b45c581685e9ecb63888edd256ec14b0da94a30..1522563d2cbeac0a2391aa30db4ab18b6522b18c 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) @@ -182,6 +183,7 @@ # Account & Privacy - [Authenticate](./authentication.md) +- [Roles](./roles.md) - [Privacy and Security](./ai/privacy-and-security.md) - [Worktree Trust](./worktree-trust.md) - [AI Improvement](./ai/ai-improvement.md) diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index 7a76e795f127651201a6483986ebbc917088bf96..dc3b246f34f28a7a0560992e64b1918f2fe69a9e 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -9,6 +9,8 @@ Zed supports many external agents, including CLI-based ones, through the [Agent Zed supports [Gemini CLI](https://github.com/google-gemini/gemini-cli) (the reference ACP implementation), [Claude Agent](https://platform.claude.com/docs/en/agent-sdk/overview), [Codex](https://developers.openai.com/codex), [GitHub Copilot](https://github.com/github/copilot-language-server-release), and [additional agents](#add-more-agents) you can configure. +For Zed's built-in agent and the full list of tools it can use natively, see [Agent Tools](./tools.md). + > Note that Zed's interaction with external agents is strictly UI-based; the billing, legal, and terms arrangement is directly between you and the agent provider. > Zed does not charge for use of external agents, and our [zero-data retention agreements/privacy guarantees](./ai-improvement.md) are **_only_** applicable for Zed's hosted models. diff --git a/docs/src/ai/plans-and-usage.md b/docs/src/ai/plans-and-usage.md index bebc4c4fb30dab6379a645209d21eccda65459d5..bc9e4854475799938dc7383e29edd84bf9493a66 100644 --- a/docs/src/ai/plans-and-usage.md +++ b/docs/src/ai/plans-and-usage.md @@ -7,9 +7,9 @@ description: Understand Zed's AI plans, token-based usage metering, spend limits ## Available Plans {#plans} -For costs and more information on pricing, visit [Zed’s pricing page](https://zed.dev/pricing). +For costs and more information on pricing, visit [Zed's pricing page](https://zed.dev/pricing). -Zed works without AI features or a subscription. No [authentication](../authentication.md) required for the editor itself. +Zed works without AI features or a subscription. No [authentication](../authentication.md) is required for the editor itself. ## Usage {#usage} @@ -17,6 +17,8 @@ Usage of Zed's hosted models is measured on a token basis, converted to dollars Zed Pro comes with $5 of monthly dollar credit. A trial of Zed Pro includes $20 of credit, usable for 14 days. Monthly included credit resets on your monthly billing date. +The [Zed Student plan](https://zed.dev/education) includes $10/month in token credits. The Student plan is available free for one year to verified university students. + To view your current usage, you can visit your account at [dashboard.zed.dev/account](https://dashboard.zed.dev/account). Information from our metering and billing provider, Orb, is embedded on that page. ## Spend Limits {#usage-spend-limits} @@ -25,7 +27,9 @@ At the top of [the Account page](https://dashboard.zed.dev/account), you'll find The default value for all Pro users is $10, for a total monthly spend with Zed of $20 ($10 for your Pro subscription, $10 in incremental token spend). This can be set to $0 to limit your spend with Zed to exactly $10/month. If you adjust this limit _higher_ than $10 and consume more than $10 of incremental token spend, you'll be billed via [threshold billing](./billing.md#threshold-billing). -Once the spend limit is hit, we’ll stop any further usage until your token spend limit resets. +Once the spend limit is hit, we'll stop any further usage until your token spend limit resets. + +> **Note:** Spend limits are a Zed Pro feature. Student plan users do not currently have the ability to configure spend limits; usage is capped at the $10/month included credit. ## Business Usage {#business-usage} diff --git a/docs/src/ai/tools.md b/docs/src/ai/tools.md index faafc76b164f7f786c91c212bf51960f24a6bb0a..bc57f3c378fbc03429fe84993c349b0a5b3ce0d0 100644 --- a/docs/src/ai/tools.md +++ b/docs/src/ai/tools.md @@ -19,10 +19,14 @@ Gets errors and warnings for either a specific file or the entire project, usefu When a path is provided, shows all diagnostics for that specific file. When no path is provided, shows a summary of error and warning counts for all files in the project. +**Example:** After editing `src/parser.rs`, call `diagnostics` with that path to check for type errors immediately. After a larger refactor touching many files, call it without a path to see a project-wide count of errors before deciding what to fix next. + ### `fetch` Fetches a URL and returns the content as Markdown. Useful for providing docs as context. +**Example:** Fetching a library's changelog page to check whether a breaking API change was introduced in a recent version before writing integration code. + ### `find_path` Quickly finds files by matching glob patterns (like "\*_/_.js"), returning matching file paths alphabetically. @@ -31,6 +35,8 @@ Quickly finds files by matching glob patterns (like "\*_/_.js"), returning match Searches file contents across the project using regular expressions, preferred for finding symbols in code without knowing exact file paths. +**Example:** To find every call site of a function before renaming it, search for `parse_config\(` — the regex matches the function name followed by an opening parenthesis, filtering out comments or variable names that happen to contain the string. + ### `list_directory` Lists files and directories in a given path, providing an overview of filesystem contents. @@ -55,6 +61,8 @@ Allows the Agent to work through problems, brainstorm ideas, or plan without exe Searches the web for information, providing results with snippets and links from relevant web pages, useful for accessing real-time information. +**Example:** Looking up whether a known bug in a dependency has been patched in a recent release, or finding the current API signature for a third-party library when the local docs are out of date. + ## Edit Tools ### `copy_path` @@ -73,6 +81,8 @@ Deletes a file or directory (including contents recursively) at the specified pa Edits files by replacing specific text with new content. +**Example:** Updating a function signature — the agent identifies the exact lines to replace and provides the updated version, leaving the surrounding code untouched. For widespread renames, it pairs this with `grep` to find every occurrence first. + ### `move_path` Moves or renames a file or directory in the project, performing a rename if only the filename differs. @@ -89,8 +99,12 @@ Saves files that have unsaved changes. Used when files need to be saved before f Executes shell commands and returns the combined output, creating a new shell process for each invocation. +**Example:** After editing a Rust file, run `cargo test --package my_crate 2>&1 | tail -30` to confirm the changes don't break existing tests. Or run `git diff --stat` to review which files have been modified before wrapping up a task. + ## Other Tools ### `spawn_agent` -Spawns a subagent with its own context window to perform a delegated task. Each subagent has access to the same tools as the parent agent. +Spawns a subagent with its own context window to perform a delegated task. Useful for running parallel investigations, completing self-contained tasks, or performing research where only the outcome matters. Each subagent has access to the same tools as the parent agent. + +**Example:** While refactoring the authentication module, spawn a subagent to investigate how session tokens are validated elsewhere in the codebase. The parent agent continues its work and reviews the subagent's findings when it completes — keeping both context windows focused on a single task. diff --git a/docs/src/appearance.md b/docs/src/appearance.md index fdf5e239ccf581988e439845d0c2f94e4bb1b95c..1c26d67100379462298c4026dbf578b936b61fb1 100644 --- a/docs/src/appearance.md +++ b/docs/src/appearance.md @@ -15,11 +15,13 @@ Here's how to make Zed feel like home: 1. **Pick a theme**: Press {#kb theme_selector::Toggle} to open the Theme Selector. Arrow through the list to preview themes in real time, and press Enter to apply. -2. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes. +2. **Toggle light/dark mode quickly**: Press {#kb theme::ToggleMode}. If you currently use a static `"theme": "..."` value, the first toggle converts it to dynamic mode settings with default themes. -3. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font. +3. **Choose an icon theme**: Run `icon theme selector: toggle` from the command palette to browse icon themes. -4. **Adjust font size**: In the same Settings Editor, search for `buffer_font_size` and `ui_font_size` to tweak the editor and interface text sizes. +4. **Set your font**: Open the Settings Editor with {#kb zed::OpenSettings} and search for `buffer_font_family`. Set it to your preferred coding font. + +5. **Adjust font size**: In the same Settings Editor, search for `buffer_font_size` and `ui_font_size` to tweak the editor and interface text sizes. That's it. You now have a personalized Zed setup. diff --git a/docs/src/development/feature-process.md b/docs/src/development/feature-process.md index 811e1a4fd6130fdf0abc687f6943f58b24e81b08..ec39c6c4b59ef5916d5f5dcfada9abf326f77a3a 100644 --- a/docs/src/development/feature-process.md +++ b/docs/src/development/feature-process.md @@ -2,7 +2,7 @@ This is for moderate-to-large features — new UI, behavior changes, or work that cuts across multiple parts of Zed. Small keybindings or settings tweaks don't need all of this. -> **Before you start:** If you're an external contributor, make sure the feature is something the team wants before investing significant effort. That said, coming prepared with background research makes it much easier for the team to understand and approve the proposal. Read the [Contributing guide](../../../CONTRIBUTING.md#sending-changes) — if there isn't already a GitHub issue with staff confirmation, start with a GitHub Discussion or a Discord message rather than a PR. +> **Before you start:** If you're an external contributor, make sure the feature is something the team wants before investing significant effort. Please read the [Contributing Guide](../../../CONTRIBUTING.md) and our [Feature Request Guidelines](https://github.com/zed-industries/zed/discussions/51422) — if there isn't already a GitHub issue with clear staff confirmation, start with a GitHub Discussion. Feature request PRs that skip this process have a _very_ low merge rate. Taking the time to follow our process significantly increases the chances your idea gets picked up and built. ## 1. Why does this matter? @@ -18,16 +18,20 @@ Write a short, concrete feature statement, then back it up with the context gath Here's an example format, though adapt it to whatever your feature needs: -> **Feature:** Inline Git Blame -> **Purpose:** Show the last commit author and message for each line directly after the editor text, so developers can understand code history without opening the git blame. -> **Background:** -> This is standard across all major code editors -> \[screenshot of VSCode] -> \[screenshot of Intellij] -> \[screenshot of Neovim] -> and has 146 thumbs up on the [github issue](https://github.com). -> **Decisions:** -> We have to decide whether to use the git CLI or a git library. Zed uses a git library but its blame implementation is too slow for a code editor, so we should use the CLI's porcelain interface. +**Feature:** Inline Git Blame + +**Purpose:** Show the last commit author and message for each line directly after the editor text, so developers can understand code history without opening the git blame. + +**Background:** +This is standard across all major code editors: + +- \[screenshot of VSCode] +- \[screenshot of Intellij] +- \[screenshot of Neovim] +- and has 146 thumbs up on this [github issue](https://github.com). + +**Decisions:** +We have to decide whether to use the git CLI or a git library. Zed uses a git library but its blame implementation is too slow for a code editor, so we should use the CLI's porcelain interface. ## 3. What else does this affect? diff --git a/docs/src/development/macos.md b/docs/src/development/macos.md index 62c2218e52751c1117da90e76ae13554b7e8f792..82d7264e2bb123b52ece8abcc44c3563d49de453 100644 --- a/docs/src/development/macos.md +++ b/docs/src/development/macos.md @@ -89,7 +89,7 @@ Before making any UI changes, generate baseline images from a known-good state: ```sh git checkout origin/main -UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests +UPDATE_BASELINE=1 cargo run -p zed --bin zed_visual_test_runner --features visual-tests git checkout - ``` 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/languages.md b/docs/src/extensions/languages.md index eee29cc57d1ce5e1a5a7608c70ece98bf4a233ee..c8e6958db683a5a3e2c9903c590f564b0ef4cb93 100644 --- a/docs/src/extensions/languages.md +++ b/docs/src/extensions/languages.md @@ -52,7 +52,7 @@ TBD: Document `language_name/config.toml` keys ## Grammar -Zed uses the [Tree-sitter](https://tree-sitter.github.io) parsing library to provide built-in language-specific features. There are grammars available for many languages, and you can also [develop your own grammar](https://tree-sitter.github.io/tree-sitter/creating-parsers#writing-the-grammar). A growing list of Zed features are built using pattern matching over syntax trees with Tree-sitter queries. As mentioned above, every language that is defined in an extension must specify the name of a Tree-sitter grammar that is used for parsing. These grammars are then registered separately in extensions' `extension.toml` file, like this: +Zed uses the [Tree-sitter](https://tree-sitter.github.io) parsing library to provide built-in language-specific features. There are grammars available for many languages, and you can also [develop your own grammar](https://tree-sitter.github.io/tree-sitter/creating-parsers/3-writing-the-grammar.html). A growing list of Zed features are built using pattern matching over syntax trees with Tree-sitter queries. As mentioned above, every language that is defined in an extension must specify the name of a Tree-sitter grammar that is used for parsing. These grammars are then registered separately in extensions' `extension.toml` file, like this: ```toml [grammars.gleam] 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/languages/python.md b/docs/src/languages/python.md index fdeabec5069ed20a9b168ab19129dde0cc6280ba..0f34fdb752143b30eb1f42a836482bd4ea1d1188 100644 --- a/docs/src/languages/python.md +++ b/docs/src/languages/python.md @@ -89,7 +89,7 @@ Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages "languages": { "Python": { "language_servers": [ - // Disable basedpyright and enable ty, and include all + // Enable ty, disable basedpyright, and enable all // other registered language servers (ruff, pylsp, pyright). "ty", "!basedpyright", diff --git a/docs/src/languages/vue.md b/docs/src/languages/vue.md index 607d2b18a5243a5b552db96308faab6aebeb8b6c..3c2336119dfceb4aeea226bb2ccc2484dd438cbc 100644 --- a/docs/src/languages/vue.md +++ b/docs/src/languages/vue.md @@ -8,7 +8,59 @@ description: "Configure Vue language support in Zed, including language servers, Vue support is available through the [Vue extension](https://github.com/zed-extensions/vue). - Tree-sitter: [tree-sitter-grammars/tree-sitter-vue](https://github.com/tree-sitter-grammars/tree-sitter-vue) -- Language Server: [vuejs/language-tools/](https://github.com/vuejs/language-tools/) +- Language Server: [vuejs/language-tools](https://github.com/vuejs/language-tools) + +## Initialization Options + +### Specifying location of TypeScript SDK + +By default, this extension assumes that you are working in a project with a `node_modules` directory, and searches for +the TypeScript SDK inside that directory. + +This may not always be true; for example, when working in a project that uses Yarn PnP, there is no `node_modules`. For +editor support, the [documented](https://yarnpkg.com/getting-started/editor-sdks) approach is to run something like +`yarn dlx @yarnpkg/sdks`. In that case, you can provide the following initialization options in your Zed settings: + +```json +{ + "lsp": { + "vue": { + "initialization_options": { + "typescript": { + "tsdk": ".yarn/sdks/typescript/lib" + } + } + } + } +} +``` + +## Settings Options + +`lsp.vue.settings` is passed through to the Vue language server (Volar / [`vuejs/language-tools`](https://github.com/vuejs/language-tools)). The following settings are enabled by default: + +```json +{ + "lsp": { + "vue": { + "settings": { + // Display inlay hints for the `$event` parameter in inline event handlers. + "vue.inlayHints.inlineHandlerLeading": true, + // Display hints when required component props are missing in templates. + "vue.inlayHints.missingProps": true, + // Display inlay hints for patterns that wrap component options. + "vue.inlayHints.optionsWrapper": true, + // Display inlay hints related to `v-bind` shorthand (`:`). + "vue.inlayHints.vBindShorthand": true + } + } + } +} +``` + +You can find the upstream settings configuration schema [`here`](https://github.com/vuejs/language-tools/blob/ee5041d27940cf6f9a5150635d3b13140a9dff54/extensions/vscode/package.json#L252). + +> Note: Some settings (e.g. `vue.editor.focusMode`) may not take effect. ## Using the Tailwind CSS Language Server with Vue 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/docs/src/roles.md b/docs/src/roles.md new file mode 100644 index 0000000000000000000000000000000000000000..6c1ce7a8928955d16f8f70c024fd4133c85837bc --- /dev/null +++ b/docs/src/roles.md @@ -0,0 +1,71 @@ +--- +title: Roles - Zed +description: Understand Zed's organization roles and what each role can access, manage, and configure. +--- + +# Roles + +Every member of a Zed organization is assigned a role that determines +what they can access and configure. + +## Role Types {#roles} + +Every member of an organization is assigned one of three roles: + +| Role | Description | +| ---------- | ------------------------------------------------------ | +| **Owner** | Full control, including billing and ownership transfer | +| **Admin** | Full control, except billing | +| **Member** | Standard access, no privileged actions | + +### Owner {#role-owner} + +An owner has full control over the organization, including: + +- Invite and remove members +- Assign and change member roles +- Manage billing, payment methods, and invoices +- Configure data-sharing policies +- Disable Zed's collaborative features +- Control whether members can use Zed-hosted models and Zed's edit predictions +- Transfer ownership to another member + +### Admin {#role-admin} + +Admins have the same capabilities as the Owner, except they cannot: + +- Access or modify billing settings +- Transfer organization ownership + +This role is suited for team leads or managers who handle day-to-day +member access without needing visibility into payment details. + +### Member {#role-member} + +Members have standard access to Zed. They cannot access billing or +organization settings. + +## Managing User Roles {#managing-users} + +Owners and Admins can manage organization members from the Zed +dashboard within the Members page. + +### Inviting Members {#inviting-members} + +1. On the Members page, select **+ Invite Member**. +2. Enter the member's company email address and choose a role. +3. The invitee receives an email with instructions to join. After + accepting, they authenticate via GitHub. + +### Changing a Member's Role {#changing-roles} + +1. On the Members page, find the member. You can filter by role or + search by name. +2. Open the three-dot menu and select a new role. + +### Removing a Member {#removing-members} + +1. On the Members page, find the member. +2. Select **Remove** and confirm. + +Removing a member removes their access to organization settings and any organization-managed features. They can continue using Zed on their own. diff --git a/docs/src/themes.md b/docs/src/themes.md index 0d3103eaab46fefff22095d14cab02f799ef851d..1dd2c144e2a2a53a50e21f6fc51f3b0c121eca25 100644 --- a/docs/src/themes.md +++ b/docs/src/themes.md @@ -44,6 +44,35 @@ You can set the mode to `"dark"` or `"light"` to ignore the current system mode. } ``` +### Toggle Theme Mode from the Keyboard + +Use {#kb theme::ToggleMode} to switch the current theme mode between light and dark. + +If your settings currently use a static theme value, like: + +```json [settings] +{ + "theme": "Any Theme" +} +``` + +the first toggle converts it to dynamic theme selection with default themes: + +```json [settings] +{ + "theme": { + "mode": "system", + "light": "One Light", + "dark": "One Dark" + } +} +``` + +You are required to set both `light` and `dark` themes manually after the first toggle. + +After that, toggling updates only `theme.mode`. +If `light` and `dark` are the same theme, the first toggle may not produce a visible UI change until you set different values for `light` and `dark`. + ## Theme Overrides To override specific attributes of a theme, use the `theme_overrides` setting. diff --git a/docs/theme/css/chrome.css b/docs/theme/css/chrome.css index 3f4fa40bc41a9c034c50c94c10fd8d0222d6b720..8f5b40cc19ecfd6cbedd0e5f76b5121afa5e5273 100644 --- a/docs/theme/css/chrome.css +++ b/docs/theme/css/chrome.css @@ -368,7 +368,10 @@ mark.fade-out { .searchbar-outer { margin-inline-start: auto; margin-inline-end: auto; + width: 100%; max-width: var(--content-max-width); + box-sizing: border-box; + padding: 16px; } #searchbar { @@ -394,21 +397,21 @@ mark.fade-out { .searchresults-header { font-weight: bold; font-size: 1em; - padding-block-start: 18px; + padding-block-start: 0; padding-block-end: 0; - padding-inline-start: 5px; - padding-inline-end: 0; color: var(--searchresults-header-fg); } ul#searchresults { list-style: none; padding-inline-start: 0; + margin-block-end: 0; } ul#searchresults li { margin: 10px 0px; padding: 2px; border-radius: 2px; + scroll-margin-block-end: 10px; } ul#searchresults li.focus { background-color: var(--searchresults-li-bg); @@ -794,8 +797,7 @@ ul#searchresults span.teaser em { max-height: 600px; display: flex; flex-direction: column; - padding: 16px; - overflow-y: auto; + overflow-y: hidden; border-radius: 8px; background: var(--popover-bg); @@ -803,8 +805,11 @@ ul#searchresults span.teaser em { box-shadow: var(--popover-shadow); } -.searchbar-outer { - width: 100%; +.searchresults-outer { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0px 22px 22px 22px; } #searchbar { diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs index 1c833ee94d428a1578b35c7944c4d300a04a21db..24378bcca6909b2e3e894c6c32db5f32d77921de 100644 --- a/docs/theme/index.hbs +++ b/docs/theme/index.hbs @@ -424,6 +424,31 @@ + + {{/if}} diff --git a/extensions/glsl/Cargo.toml b/extensions/glsl/Cargo.toml index fd39ac82debb3eabf78219a730e090c002c88395..5d7b6ce941c14f68410ac33f825d0ee0b645d6b5 100644 --- a/extensions/glsl/Cargo.toml +++ b/extensions/glsl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_glsl" -version = "0.2.0" +version = "0.2.2" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/glsl/extension.toml b/extensions/glsl/extension.toml index 867b679ea6b9cf0f42e87938e85b5b69bbd435e3..f866091b84674780e859407ebd893641a3a159ce 100644 --- a/extensions/glsl/extension.toml +++ b/extensions/glsl/extension.toml @@ -1,7 +1,7 @@ id = "glsl" name = "GLSL" description = "GLSL support." -version = "0.2.0" +version = "0.2.2" schema_version = 1 authors = ["Mikayla Maki "] repository = "https://github.com/zed-industries/zed" 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/glsl/languages/glsl/highlights.scm b/extensions/glsl/languages/glsl/highlights.scm index 9e40610ff5494102f8524b287ad2e50ec48d78db..0509d0f5ef00977a8f809baa4684a09628dd0172 100644 --- a/extensions/glsl/languages/glsl/highlights.scm +++ b/extensions/glsl/languages/glsl/highlights.scm @@ -1,108 +1,68 @@ -"break" @keyword - -"case" @keyword - -"const" @keyword - -"continue" @keyword - -"default" @keyword - -"do" @keyword - -"else" @keyword - -"enum" @keyword - -"extern" @keyword - -"for" @keyword - -"if" @keyword - -"inline" @keyword - -"return" @keyword - -"sizeof" @keyword - -"static" @keyword - -"struct" @keyword - -"switch" @keyword - -"typedef" @keyword - -"union" @keyword - -"volatile" @keyword - -"while" @keyword - -"#define" @keyword - -"#elif" @keyword - -"#else" @keyword - -"#endif" @keyword - -"#if" @keyword - -"#ifdef" @keyword - -"#ifndef" @keyword - -"#include" @keyword - -(preproc_directive) @keyword - -"--" @operator - -"-" @operator - -"-=" @operator - -"->" @operator - -"=" @operator - -"!=" @operator - -"*" @operator - -"&" @operator - -"&&" @operator - -"+" @operator - -"++" @operator - -"+=" @operator - -"<" @operator - -"==" @operator - -">" @operator - -"||" @operator - -"." @delimiter - -";" @delimiter - -(string_literal) @string +[ + "break" + "case" + "const" + "continue" + "default" + "do" + "else" + "enum" + "extern" + "for" + "if" + "inline" + "return" + "sizeof" + "static" + "struct" + "switch" + "typedef" + "union" + "volatile" + "while" + "#define" + "#elif" + "#else" + "#endif" + "#if" + "#ifdef" + "#ifndef" + "#include" + (preproc_directive) +] @keyword -(system_lib_string) @string +[ + "--" + "-" + "-=" + "->" + "=" + "!=" + "*" + "&" + "&&" + "+" + "++" + "+=" + "<" + "==" + ">" + "||" + "." + ";" +] @operator -(null) @constant +[ + (string_literal) + (system_lib_string) +] @string -(number_literal) @number +(null) @constant.builtin -(char_literal) @number +[ + (number_literal) + (char_literal) +] @number (identifier) @variable @@ -110,11 +70,11 @@ (statement_identifier) @label -(type_identifier) @type - -(primitive_type) @type - -(sized_type_specifier) @type +[ + (type_identifier) + (primitive_type) + (sized_type_specifier) +] @type (call_expression function: (identifier) @function) diff --git a/extensions/html/Cargo.toml b/extensions/html/Cargo.toml index 2c89f86cb450b7ea8476bffdff003a94b137d213..aefe0eb120b9e277d57212a9062fd2f899a08a09 100644 --- a/extensions/html/Cargo.toml +++ b/extensions/html/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_html" -version = "0.3.0" +version = "0.3.1" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/html/extension.toml b/extensions/html/extension.toml index 68ab0e4b9d3f56fca17cbd518d5990edc2ec711a..eb8fc1862197deaa82ffa28453dba007583411b5 100644 --- a/extensions/html/extension.toml +++ b/extensions/html/extension.toml @@ -1,7 +1,7 @@ id = "html" name = "HTML" description = "HTML support." -version = "0.3.0" +version = "0.3.1" schema_version = 1 authors = ["Isaac Clayton "] repository = "https://github.com/zed-industries/zed" 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/extensions/html/src/html.rs b/extensions/html/src/html.rs index 337689ebddd427769ab985ad82512f76b601e67c..a5e38c97b3613ca735fb4eea8f26472ab3f66049 100644 --- a/extensions/html/src/html.rs +++ b/extensions/html/src/html.rs @@ -95,11 +95,8 @@ impl zed::Extension for HtmlExtension { server_id: &LanguageServerId, worktree: &zed::Worktree, ) -> Result> { - let settings = LspSettings::for_worktree(server_id.as_ref(), worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.settings) - .unwrap_or_default(); - Ok(Some(settings)) + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.settings) } fn language_server_initialization_options( diff --git a/nix/build.nix b/nix/build.nix index 68f8a4acdbe83f7e8981659dd0376ec87ef52dfe..a5ced61bbbfd145c1e3f9fc9909ae69779ba133a 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -52,6 +52,7 @@ withGLES ? false, profile ? "release", + commitSha ? null, }: assert withGLES -> stdenv.hostPlatform.isLinux; let @@ -84,7 +85,10 @@ let in rec { pname = "zed-editor"; - version = zedCargoLock.package.version + "-nightly"; + version = + zedCargoLock.package.version + + "-nightly" + + lib.optionalString (commitSha != null) "+${builtins.substring 0 7 commitSha}"; src = builtins.path { path = ../.; filter = mkIncludeFilter ../.; @@ -220,6 +224,7 @@ let }; ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; RELEASE_VERSION = version; + ZED_COMMIT_SHA = lib.optionalString (commitSha != null) "${commitSha}"; LK_CUSTOM_WEBRTC = pkgs.callPackage ./livekit-libwebrtc/package.nix { }; PROTOC = "${protobuf}/bin/protoc"; diff --git a/nix/toolchain.nix b/nix/toolchain.nix index 6ef22e2a6b06882940c553b2a774f4c6f73e9ea0..2e32f00f6b56570ab9863ab0b5975e603b68f5fa 100644 --- a/nix/toolchain.nix +++ b/nix/toolchain.nix @@ -6,4 +6,5 @@ in pkgs.callPackage ./build.nix { crane = inputs.crane.mkLib pkgs; rustToolchain = rustBin.fromRustupToolchainFile ../rust-toolchain.toml; + commitSha = inputs.self.rev or null; } diff --git a/script/bundle-linux b/script/bundle-linux index c89d21082dd6c33a11ffcfc908ef87a91554dc18..3487feaf32b9e8258a88a7a1b14c2aafccc37942 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -74,7 +74,15 @@ fi export CC=${CC:-$(which clang)} # Build binary in release mode -export RUSTFLAGS="${RUSTFLAGS:-} -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib" +# We need lld to link libwebrtc.a successfully on aarch64-linux. +# NOTE: Since RUSTFLAGS env var overrides all .cargo/config.toml rustflags +# (see https://github.com/rust-lang/cargo/issues/5376), the +# [target.aarch64-unknown-linux-gnu] section in config.toml has no effect here. +if [[ "$(uname -m)" == "aarch64" ]]; then + export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-fuse-ld=lld -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib" +else + export RUSTFLAGS="${RUSTFLAGS:-} -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib" +fi cargo build --release --target "${target_triple}" --package zed --package cli # Build remote_server in separate invocation to prevent feature unification from other crates # from influencing dynamic libraries required by it. @@ -111,10 +119,12 @@ else fi fi -# Strip debug symbols and save them for upload to DigitalOcean -objcopy --strip-debug "${target_dir}/${target_triple}/release/zed" -objcopy --strip-debug "${target_dir}/${target_triple}/release/cli" -objcopy --strip-debug "${target_dir}/${remote_server_triple}/release/remote_server" +# Strip debug symbols and save them for upload to DigitalOcean. +# We use llvm-objcopy because GNU objcopy on older distros (e.g. Ubuntu 20.04) +# doesn't understand CREL sections produced by newer LLVM. +llvm-objcopy --strip-debug "${target_dir}/${target_triple}/release/zed" +llvm-objcopy --strip-debug "${target_dir}/${target_triple}/release/cli" +llvm-objcopy --strip-debug "${target_dir}/${remote_server_triple}/release/remote_server" # Ensure that remote_server does not depend on libssl nor libcrypto, as we got rid of these deps. if ldd "${target_dir}/${remote_server_triple}/release/remote_server" | grep -q 'libcrypto\|libssl'; then diff --git a/script/danger/dangerfile.ts b/script/danger/dangerfile.ts index b604a42e45ac7d276a1f278bd2e9727daa98c375..c1ca883f3e910f434f686985d2c94df22986a029 100644 --- a/script/danger/dangerfile.ts +++ b/script/danger/dangerfile.ts @@ -61,6 +61,25 @@ if (includesIssueUrl) { ); } +const MIGRATION_SCHEMA_FILES = [ + "crates/collab/migrations/20251208000000_test_schema.sql", + "crates/collab/migrations.sqlite/20221109000000_test_schema.sql", +]; + +const modifiedSchemaFiles = danger.git.modified_files.filter((file) => + MIGRATION_SCHEMA_FILES.some((schemaFilePath) => file.endsWith(schemaFilePath)), +); + +if (modifiedSchemaFiles.length > 0) { + warn( + [ + "This PR modifies database schema files.", + "", + "If you are making database changes, a migration needs to be added in the Cloud repository.", + ].join("\n"), + ); +} + const FIXTURE_CHANGE_ATTESTATION = "Changes to test fixtures are intentional and necessary."; const FIXTURES_PATHS = ["crates/assistant_tools/src/edit_agent/evals/fixtures"]; diff --git a/script/linux b/script/linux index 706fa63b037e290cd7991d3adfa42fac0c0cfe25..808841aeb39262f148399c643cc17314a9727fef 100755 --- a/script/linux +++ b/script/linux @@ -39,6 +39,8 @@ if [[ -n $apt ]]; then make cmake clang + lld + llvm jq git curl @@ -60,12 +62,21 @@ if [[ -n $apt ]]; then # Ubuntu 20.04 ships clang-10 and libstdc++-10 which lack adequate C++20 # support for building webrtc-sys (requires -std=c++20, lambdas in # unevaluated contexts from clang 17+, and working std::ranges in the - # stdlib). clang-18 is available in focal-security/universe as an official - # backport, and libstdc++-11-dev from the ubuntu-toolchain-r PPA provides - # headers with working pointer_traits/contiguous_range. + # stdlib). # Note: the prebuilt libwebrtc.a is compiled with libstdc++, so we must # use libstdc++ (not libc++) to avoid ABI mismatches at link time. - $maysudo add-apt-repository -y ppa:ubuntu-toolchain-r/test + + # libstdc++-11-dev (headers with working pointer_traits/contiguous_range) + # is only available from the ubuntu-toolchain-r PPA. Add the source list + # and GPG key manually instead of using add-apt-repository, whose HKP + # keyserver lookups (port 11371) frequently time out in CI. + $maysudo "$apt" install -y curl gnupg + codename=$(lsb_release -cs) + echo "deb https://ppa.launchpadcontent.net/ubuntu-toolchain-r/test/ubuntu $codename main" | \ + $maysudo tee /etc/apt/sources.list.d/ubuntu-toolchain-r-test.list > /dev/null + curl -fsSL 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x1E9377A2BA9EF27F' | \ + sed -n '/-----BEGIN PGP PUBLIC KEY BLOCK-----/,/-----END PGP PUBLIC KEY BLOCK-----/p' | \ + $maysudo gpg --dearmor -o /etc/apt/trusted.gpg.d/ubuntu-toolchain-r-test.gpg deps+=( clang-18 libstdc++-11-dev ) fi diff --git a/tooling/xtask/src/tasks/workflows.rs b/tooling/xtask/src/tasks/workflows.rs index 9151b9c671ef42e3dc54661f80438a4e31aff1e9..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; @@ -29,38 +30,99 @@ mod runners; mod steps; mod vars; +#[derive(Clone)] +pub(crate) struct GitSha(String); + +impl AsRef for GitSha { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[allow( + clippy::disallowed_methods, + reason = "This runs only in a CLI environment" +)] +fn parse_ref(value: &str) -> Result { + const GIT_SHA_LENGTH: usize = 40; + (value.len() == GIT_SHA_LENGTH) + .then_some(value) + .ok_or_else(|| { + format!( + "Git SHA has wrong length! \ + Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.", + len = value.len() + ) + }) + .and_then(|value| { + let mut tmp = [0; 4]; + value + .chars() + .all(|char| u16::from_str_radix(char.encode_utf8(&mut tmp), 16).is_ok()).then_some(value) + .ok_or_else(|| "Not a valid Git SHA".to_owned()) + }) + .and_then(|sha| { + std::process::Command::new("git") + .args([ + "rev-parse", + "--quiet", + "--verify", + &format!("{sha}^{{commit}}") + ]) + .output() + .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned()) + .and_then(|output| + output + .status.success() + .then_some(sha) + .ok_or_else(|| format!("SHA {sha} is not a valid Git SHA within this repository!"))) + }).map(|sha| GitSha(sha.to_owned())) +} + #[derive(Parser)] -pub struct GenerateWorkflowArgs {} +pub(crate) struct GenerateWorkflowArgs { + #[arg(value_parser = parse_ref)] + /// The Git SHA to use when invoking this + pub(crate) sha: Option, +} + +enum WorkflowSource { + Contextless(fn() -> Workflow), + WithContext(fn(&GenerateWorkflowArgs) -> Workflow), +} struct WorkflowFile { - source: fn() -> Workflow, + source: WorkflowSource, r#type: WorkflowType, } impl WorkflowFile { fn zed(f: fn() -> Workflow) -> WorkflowFile { WorkflowFile { - source: f, + source: WorkflowSource::Contextless(f), r#type: WorkflowType::Zed, } } - fn extension(f: fn() -> Workflow) -> WorkflowFile { + fn extension(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile { WorkflowFile { - source: f, + source: WorkflowSource::WithContext(f), r#type: WorkflowType::ExtensionCi, } } - fn extension_shared(f: fn() -> Workflow) -> WorkflowFile { + fn extension_shared(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile { WorkflowFile { - source: f, + source: WorkflowSource::WithContext(f), r#type: WorkflowType::ExtensionsShared, } } - fn generate_file(&self) -> Result<()> { - let workflow = (self.source)(); + fn generate_file(&self, workflow_args: &GenerateWorkflowArgs) -> Result<()> { + let workflow = match &self.source { + WorkflowSource::Contextless(f) => f(), + WorkflowSource::WithContext(f) => f(workflow_args), + }; let workflow_folder = self.r#type.folder_path(); fs::create_dir_all(&workflow_folder).with_context(|| { @@ -124,7 +186,7 @@ impl WorkflowType { } } -pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { +pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> { if !Path::new("crates/zed/").is_dir() { anyhow::bail!("xtask workflows must be ran from the project root"); } @@ -138,6 +200,7 @@ pub fn run_workflows(_: 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), @@ -154,7 +217,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> { ]; for workflow_file in workflows { - workflow_file.generate_file()?; + workflow_file.generate_file(&args)?; } workflow_checks::validate(Default::default()) diff --git a/tooling/xtask/src/tasks/workflows/deploy_collab.rs b/tooling/xtask/src/tasks/workflows/deploy_collab.rs index 300680f95b880e9adb14dffd2572d80cb08fd63c..a13e5684f615e1c219e131f7308f6e021e89ac9f 100644 --- a/tooling/xtask/src/tasks/workflows/deploy_collab.rs +++ b/tooling/xtask/src/tasks/workflows/deploy_collab.rs @@ -3,7 +3,7 @@ use indoc::indoc; use crate::tasks::workflows::runners::{self, Platform}; use crate::tasks::workflows::steps::{ - self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, + self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, use_clang, }; use crate::tasks::workflows::vars; @@ -23,7 +23,7 @@ pub(crate) fn deploy_collab() -> Workflow { } fn style() -> NamedJob { - named::job( + named::job(use_clang( dependant_job(&[]) .name("Check formatting and Clippy lints") .with_repository_owner_guard() @@ -34,7 +34,7 @@ fn style() -> NamedJob { .map(steps::install_linux_dependencies) .add_step(steps::cargo_fmt()) .add_step(steps::clippy(Platform::Linux)), - ) + )) } fn tests(deps: &[&NamedJob]) -> NamedJob { @@ -42,7 +42,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob { named::bash("cargo nextest run --package collab --no-fail-fast") } - named::job( + named::job(use_clang( dependant_job(deps) .name("Run tests") .runs_on(runners::LINUX_XL) @@ -65,7 +65,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob { .add_step(steps::cargo_install_nextest()) .add_step(steps::clear_target_dir_if_large(Platform::Linux)) .add_step(run_collab_tests()), - ) + )) } fn publish(deps: &[&NamedJob]) -> NamedJob { 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..3097611e079195d2d6244f3ab2b15d8f99e8c8a4 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, cache_rust_dependencies_namespace, 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(); @@ -39,16 +41,17 @@ pub(crate) fn extension_bump() -> Workflow { &app_id, &app_secret, ); - let create_label = create_version_label( + let (create_label, tag) = create_version_label( &dependencies, &version_changed, ¤t_version, &app_id, &app_secret, ); + let tag = tag.as_job_output(&create_label); let trigger_release = trigger_release( &[&check_version_changed, &create_label], - current_version, + tag, &app_id, &app_secret, ); @@ -59,6 +62,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 +72,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 +86,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()), @@ -108,25 +121,29 @@ fn create_version_label( current_version: &JobOutput, app_id: &WorkflowSecret, app_secret: &WorkflowSecret, -) -> NamedJob { +) -> (NamedJob, StepOutput) { let (generate_token, generated_token) = generate_token(&app_id.to_string(), &app_secret.to_string(), None); + let (determine_tag_step, tag) = determine_tag(current_version); 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'", version_changed = version_changed_output.expr(), ))) + .outputs([(tag.name.to_owned(), tag.to_string())]) .runs_on(runners::LINUX_SMALL) .timeout_minutes(1u32) .add_step(generate_token) .add_step(steps::checkout_repo()) - .add_step(create_version_tag(current_version, generated_token)); + .add_step(determine_tag_step) + .add_step(create_version_tag(&tag, generated_token)); - named::job(job) + (named::job(job), tag) } -fn create_version_tag(current_version: &JobOutput, generated_token: StepOutput) -> Step { +fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step { named::uses("actions", "github-script", "v7").with( Input::default() .add( @@ -135,7 +152,7 @@ fn create_version_tag(current_version: &JobOutput, generated_token: StepOutput) github.rest.git.createRef({{ owner: context.repo.owner, repo: context.repo.repo, - ref: 'refs/tags/v{current_version}', + ref: 'refs/tags/{tag}', sha: context.sha }})"# }, @@ -144,6 +161,26 @@ fn create_version_tag(current_version: &JobOutput, generated_token: StepOutput) ) } +fn determine_tag(current_version: &JobOutput) -> (Step, StepOutput) { + let step = named::bash(formatdoc! {r#" + EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')" + + if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then + TAG="v${{CURRENT_VERSION}}" + else + TAG="${{EXTENSION_ID}}-v${{CURRENT_VERSION}}" + fi + + echo "tag=${{TAG}}" >> "$GITHUB_OUTPUT" + "#}) + .id("determine-tag") + .add_env(("CURRENT_VERSION", current_version.to_string())) + .add_env(("WORKING_DIR", "${{ inputs.working-directory }}")); + + let tag = StepOutput::new(&step, "tag"); + (step, tag) +} + /// Compares the current and previous commit and checks whether versions changed inbetween. pub(crate) fn compare_versions() -> (Step, StepOutput, StepOutput) { let check_needs_bump = named::bash(formatdoc! { @@ -153,8 +190,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,21 +222,29 @@ 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(), version_changed = version_changed_output.expr(), ))) .runs_on(runners::LINUX_SMALL) - .timeout_minutes(3u32) + .timeout_minutes(5u32) .add_step(generate_token) .add_step(steps::checkout_repo()) + .add_step(cache_rust_dependencies_namespace()) .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 +299,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 +320,56 @@ 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<> "$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>", @@ -315,7 +384,7 @@ fn create_pull_request(new_version: StepOutput, generated_token: StepOutput) -> fn trigger_release( dependencies: &[&NamedJob], - version: JobOutput, + tag: JobOutput, app_id: &WorkflowSecret, app_secret: &WorkflowSecret, ) -> NamedJob { @@ -328,12 +397,13 @@ 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) .add_step(checkout_repo()) .add_step(get_extension_id) - .add_step(release_action(extension_id, version, generated_token)); + .add_step(release_action(extension_id, tag, generated_token)); named::job(job) } @@ -354,14 +424,18 @@ fn get_extension_id() -> (Step, StepOutput) { fn release_action( extension_id: StepOutput, - version: JobOutput, + tag: JobOutput, generated_token: StepOutput, ) -> Step { - named::uses("huacnlee", "zed-extension-action", "v2") - .add_with(("extension-name", extension_id.to_string())) - .add_with(("push-to", "zed-industries/extensions")) - .add_with(("tag", format!("v{version}"))) - .add_env(("COMMITTER_TOKEN", generated_token.to_string())) + named::uses( + "zed-extensions", + "update-action", + "1ef53b23be40fe2549be0baffaa98e9f51838fef", + ) + .add_with(("extension-name", extension_id.to_string())) + .add_with(("push-to", "zed-industries/extensions")) + .add_with(("tag", tag.to_string())) + .add_env(("COMMITTER_TOKEN", generated_token.to_string())) } fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) { 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 6f03ad1521850fb24c5bad7265ebf913228c5077..a62bb107da5228cd3ba620e47ab77dc673974696 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -6,46 +6,72 @@ use indoc::indoc; use serde_json::json; use crate::tasks::workflows::steps::CheckoutStep; +use crate::tasks::workflows::steps::cache_rust_dependencies_namespace; +use crate::tasks::workflows::vars::JobOutput; use crate::tasks::workflows::{ extension_bump::{RepositoryTarget, generate_token}, runners, steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named}, - vars::{self, StepOutput}, + vars::{self, StepOutput, WorkflowInput}, }; const ROLLOUT_TAG_NAME: &str = "extension-workflows"; +const WORKFLOW_ARTIFACT_NAME: &str = "extension-workflow-files"; pub(crate) fn extension_workflow_rollout() -> Workflow { - let fetch_repos = fetch_extension_repos(); - let rollout_workflows = rollout_workflows_to_extension(&fetch_repos); - let create_tag = create_rollout_tag(&rollout_workflows); + let filter_repos_input = WorkflowInput::string("filter-repos", Some(String::new())) + .description( + "Comma-separated list of repository names to rollout to. Leave empty for all repos.", + ); + let extra_context_input = WorkflowInput::string("change-description", Some(String::new())) + .description("Description for the changes to be expected with this rollout"); + + let (fetch_repos, removed_ci, removed_shared) = fetch_extension_repos(&filter_repos_input); + let rollout_workflows = rollout_workflows_to_extension( + &fetch_repos, + removed_ci, + removed_shared, + &extra_context_input, + ); + let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input); named::workflow() - .on(Event::default().workflow_dispatch(WorkflowDispatch::default())) + .on(Event::default().workflow_dispatch( + WorkflowDispatch::default() + .add_input(filter_repos_input.name, filter_repos_input.input()) + .add_input(extra_context_input.name, extra_context_input.input()), + )) .add_env(("CARGO_TERM_COLOR", "always")) .add_job(fetch_repos.name, fetch_repos.job) .add_job(rollout_workflows.name, rollout_workflows.job) .add_job(create_tag.name, create_tag.job) } -fn fetch_extension_repos() -> NamedJob { - fn get_repositories() -> (Step, StepOutput) { +fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) { + fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step, StepOutput) { let step = named::uses("actions", "github-script", "v7") .id("list-repos") .add_with(( "script", - indoc::indoc! {r#" - const repos = await github.paginate(github.rest.repos.listForOrg, { + formatdoc! {r#" + const repos = await github.paginate(github.rest.repos.listForOrg, {{ org: 'zed-extensions', type: 'public', per_page: 100, - }); + }}); - const filteredRepos = repos + let filteredRepos = repos .filter(repo => !repo.archived) .map(repo => repo.name); - console.log(`Found ${filteredRepos.length} extension repos`); + const filterInput = `{filter_repos_input}`.trim(); + if (filterInput.length > 0) {{ + const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0); + filteredRepos = filteredRepos.filter(name => allowedNames.includes(name)); + console.log(`Filter applied. Matched ${{filteredRepos.length}} repos from ${{allowedNames.length}} requested.`); + }} + + console.log(`Found ${{filteredRepos.length}} extension repos`); return filteredRepos; "#}, )) @@ -56,36 +82,12 @@ fn fetch_extension_repos() -> NamedJob { (step, filtered_repos) } - let (get_org_repositories, list_repos_output) = get_repositories(); - - let job = Job::default() - .cond(Expression::new(format!( - "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'" - ))) - .runs_on(runners::LINUX_SMALL) - .timeout_minutes(5u32) - .outputs([("repos".to_owned(), list_repos_output.to_string())]) - .add_step(get_org_repositories); - - named::job(job) -} - -fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { fn checkout_zed_repo() -> CheckoutStep { steps::checkout_repo() .with_full_history() - .with_path("zed") .with_custom_name("checkout_zed_repo") } - fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep { - steps::checkout_repo() - .with_custom_name("checkout_extension_repo") - .with_token(token) - .with_repository("zed-extensions/${{ matrix.repo }}") - .with_path("extension") - } - fn get_previous_tag_commit() -> (Step, StepOutput) { let step = named::bash(formatdoc! {r#" PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "") @@ -96,49 +98,127 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { echo "Found previous rollout at commit: $PREV_COMMIT" echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT" "#}) - .id("prev-tag") - .working_directory("zed"); + .id("prev-tag"); let step_output = StepOutput::new(&step, "prev_commit"); (step, step_output) } - fn get_removed_files(prev_commit: &StepOutput) -> (Step, StepOutput) { - let step = named::bash(indoc::indoc! {r#" - if [ "$MATRIX_REPO" = "workflows" ]; then - WORKFLOW_DIR="extensions/workflows" - else - WORKFLOW_DIR="extensions/workflows/shared" - fi - - echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR" + fn get_removed_files(prev_commit: &StepOutput) -> (Step, StepOutput, StepOutput) { + let step = named::bash(indoc! {r#" + for workflow_type in "ci" "shared"; do + if [ "$workflow_type" = "ci" ]; then + WORKFLOW_DIR="extensions/workflows" + else + WORKFLOW_DIR="extensions/workflows/shared" + fi + + REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ + awk '/^D/ { print $2 } /^R/ { print $2 }' | \ + xargs -I{} basename {} 2>/dev/null | \ + tr '\n' ' ' || echo "") + REMOVED=$(echo "$REMOVED" | xargs) + + echo "Removed files for $workflow_type: $REMOVED" + echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT" + done + "#}) + .id("calc-changes") + .add_env(("PREV_COMMIT", prev_commit.to_string())); - # Get deleted files (status D) and renamed files (status R - old name needs removal) - # Using -M to detect renames, then extracting files that are gone from their original location - REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \ - awk '/^D/ { print $2 } /^R/ { print $2 }' | \ - xargs -I{} basename {} 2>/dev/null | \ - tr '\n' ' ' || echo "") + // 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"); - REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs) + (step, removed_ci, removed_shared) + } - echo "Files to remove: $REMOVED_FILES" - echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT" + fn generate_workflow_files() -> Step { + named::bash(indoc! {r#" + cargo xtask workflows "$COMMIT_SHA" "#}) - .id("calc-changes") - .working_directory("zed") - .add_env(("PREV_COMMIT", prev_commit.to_string())) - .add_env(("MATRIX_REPO", "${{ matrix.repo }}")); + .add_env(("COMMIT_SHA", "${{ github.sha }}")) + } - let removed_files = StepOutput::new(&step, "removed_files"); + fn upload_workflow_files() -> Step { + named::uses( + "actions", + "upload-artifact", + "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5 + ) + .add_with(("name", WORKFLOW_ARTIFACT_NAME)) + .add_with(("path", "extensions/workflows/**/*.yml")) + .add_with(("if-no-files-found", "error")) + } - (step, removed_files) + let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input); + let (get_prev_tag, prev_commit) = get_previous_tag_commit(); + let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit); + + let job = Job::default() + .cond(Expression::new(format!( + "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'" + ))) + .runs_on(runners::LINUX_SMALL) + .timeout_minutes(10u32) + .outputs([ + ("repos".to_owned(), list_repos_output.to_string()), + ("prev_commit".to_owned(), prev_commit.to_string()), + ("removed_ci".to_owned(), removed_ci.to_string()), + ("removed_shared".to_owned(), removed_shared.to_string()), + ]) + .add_step(checkout_zed_repo()) + .add_step(get_prev_tag) + .add_step(calc_changes) + .add_step(get_org_repositories) + .add_step(cache_rust_dependencies_namespace()) + .add_step(generate_workflow_files()) + .add_step(upload_workflow_files()); + + let job = named::job(job); + let (removed_ci, removed_shared) = ( + removed_ci.as_job_output(&job), + removed_shared.as_job_output(&job), + ); + + (job, removed_ci, removed_shared) +} + +fn rollout_workflows_to_extension( + fetch_repos_job: &NamedJob, + removed_ci: JobOutput, + removed_shared: JobOutput, + extra_context_input: &WorkflowInput, +) -> NamedJob { + fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep { + steps::checkout_repo() + .with_custom_name("checkout_extension_repo") + .with_token(token) + .with_repository("zed-extensions/${{ matrix.repo }}") + .with_path("extension") + } + + fn download_workflow_files() -> Step { + named::uses( + "actions", + "download-artifact", + "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0 + ) + .add_with(("name", WORKFLOW_ARTIFACT_NAME)) + .add_with(("path", "workflow-files")) } - fn sync_workflow_files(removed_files: &StepOutput) -> Step { - named::bash(indoc::indoc! {r#" + fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step { + named::bash(indoc! {r#" mkdir -p extension/.github/workflows + + if [ "$MATRIX_REPO" = "workflows" ]; then + REMOVED_FILES="$REMOVED_CI" + else + REMOVED_FILES="$REMOVED_SHARED" + fi + cd extension/.github/workflows if [ -n "$REMOVED_FILES" ]; then @@ -152,40 +232,46 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { cd - > /dev/null if [ "$MATRIX_REPO" = "workflows" ]; then - cp zed/extensions/workflows/*.yml extension/.github/workflows/ + cp workflow-files/*.yml extension/.github/workflows/ else - cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/ + cp workflow-files/shared/*.yml extension/.github/workflows/ fi "#}) - .add_env(("REMOVED_FILES", removed_files.to_string())) + .add_env(("REMOVED_CI", removed_ci)) + .add_env(("REMOVED_SHARED", removed_shared)) .add_env(("MATRIX_REPO", "${{ matrix.repo }}")) } fn get_short_sha() -> (Step, StepOutput) { - let step = named::bash(indoc::indoc! {r#" - echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" + let step = named::bash(indoc! {r#" + echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" "#}) - .id("short-sha") - .working_directory("zed"); + .id("short-sha"); let step_output = StepOutput::new(&step, "sha_short"); (step, step_output) } - fn create_pull_request(token: &StepOutput, short_sha: &StepOutput) -> Step { + fn create_pull_request( + token: &StepOutput, + short_sha: &StepOutput, + context_input: &WorkflowInput, + ) -> Step { let title = format!("Update CI workflows to `{short_sha}`"); + let body = formatdoc! {r#" + This PR updates the CI workflow files from the main Zed repository + based on the commit zed-industries/zed@${{{{ github.sha }}}} + + {context_input} + "#, + }; + named::uses("peter-evans", "create-pull-request", "v7") .add_with(("path", "extension")) .add_with(("title", title.clone())) - .add_with(( - "body", - indoc::indoc! {r#" - This PR updates the CI workflow files from the main Zed repository - based on the commit zed-industries/zed@${{ github.sha }} - "#}, - )) + .add_with(("body", body)) .add_with(("commit-message", title)) .add_with(("branch", "update-workflows")) .add_with(( @@ -204,12 +290,12 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { } fn enable_auto_merge(token: &StepOutput) -> Step { - named::bash(indoc::indoc! {r#" + named::bash(indoc! {r#" if [ -n "$PR_NUMBER" ]; then - cd extension gh pr merge "$PR_NUMBER" --auto --squash fi "#}) + .working_directory("extension") .add_env(("GH_TOKEN", token.to_string())) .add_env(( "PR_NUMBER", @@ -228,8 +314,6 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { ]), ), ); - let (get_prev_tag, prev_commit) = get_previous_tag_commit(); - let (calc_changes, removed_files) = get_removed_files(&prev_commit); let (calculate_short_sha, short_sha) = get_short_sha(); let job = Job::default() @@ -249,19 +333,17 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob { })), ) .add_step(authenticate) - .add_step(checkout_zed_repo()) .add_step(checkout_extension_repo(&token)) - .add_step(get_prev_tag) - .add_step(calc_changes) - .add_step(sync_workflow_files(&removed_files)) + .add_step(download_workflow_files()) + .add_step(sync_workflow_files(removed_ci, removed_shared)) .add_step(calculate_short_sha) - .add_step(create_pull_request(&token, &short_sha)) + .add_step(create_pull_request(&token, &short_sha, extra_context_input)) .add_step(enable_auto_merge(&token)); named::job(job) } -fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob { +fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob { fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep { steps::checkout_repo().with_full_history().with_token(token) } @@ -297,6 +379,10 @@ fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob { let job = Job::default() .needs([rollout_job.name.clone()]) + .cond(Expression::new(format!( + "{filter_repos} == ''", + filter_repos = filter_repos_input.expr(), + ))) .runs_on(runners::LINUX_SMALL) .timeout_minutes(1u32) .add_step(authenticate) diff --git a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs index 2d82f1351f21645a77b1d13e158bd4142dbec069..4dc2560e2bea489566fb8eb5ad5d04701835de29 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs @@ -5,17 +5,18 @@ use gh_workflow::{ use indoc::indoc; use crate::tasks::workflows::{ + GenerateWorkflowArgs, GitSha, extensions::WithAppSecrets, runners, steps::{CommonJobConditions, NamedJob, named}, vars::{JobOutput, StepOutput, one_workflow_per_non_main_branch_and_token}, }; -pub(crate) fn bump_version() -> Workflow { +pub(crate) fn bump_version(args: &GenerateWorkflowArgs) -> Workflow { let (determine_bump_type, bump_type) = determine_bump_type(); let bump_type = bump_type.as_job_output(&determine_bump_type); - let call_bump_version = call_bump_version(&determine_bump_type, bump_type); + let call_bump_version = call_bump_version(args.sha.as_ref(), &determine_bump_type, bump_type); named::workflow() .on(Event::default() @@ -32,6 +33,7 @@ pub(crate) fn bump_version() -> Workflow { } pub(crate) fn call_bump_version( + target_ref: Option<&GitSha>, depending_job: &NamedJob, bump_type: JobOutput, ) -> NamedJob { @@ -51,7 +53,7 @@ pub(crate) fn call_bump_version( "zed-industries", "zed", ".github/workflows/extension_bump.yml", - "main", + target_ref.map_or("main", AsRef::as_ref), ) .add_need(depending_job.name.clone()) .with( diff --git a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs index 0c0ca696612fa57903f35c0ea69404f5dc7d1fe0..ae8000c15cad3a206b9c02f8bc389a369f4df096 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs @@ -1,12 +1,13 @@ use gh_workflow::{Event, Job, Level, Permissions, PullRequest, Push, UsesJob, Workflow}; use crate::tasks::workflows::{ + GenerateWorkflowArgs, GitSha, steps::{NamedJob, named}, vars::one_workflow_per_non_main_branch_and_token, }; -pub(crate) fn run_tests() -> Workflow { - let call_extension_tests = call_extension_tests(); +pub(crate) fn run_tests(args: &GenerateWorkflowArgs) -> Workflow { + let call_extension_tests = call_extension_tests(args.sha.as_ref()); named::workflow() .on(Event::default() .pull_request(PullRequest::default().add_branch("**")) @@ -15,14 +16,14 @@ pub(crate) fn run_tests() -> Workflow { .add_job(call_extension_tests.name, call_extension_tests.job) } -pub(crate) fn call_extension_tests() -> NamedJob { +pub(crate) fn call_extension_tests(target_ref: Option<&GitSha>) -> NamedJob { let job = Job::default() .permissions(Permissions::default().contents(Level::Read)) .uses( "zed-industries", "zed", ".github/workflows/extension_tests.yml", - "main", + target_ref.map_or("main", AsRef::as_ref), ); named::job(job) 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 4d17be81322277d0093de5d547bf4f0849e38dc3..27d3819ec72d9117347284610742a0de96d005f3 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() { @@ -131,22 +124,12 @@ impl From for Step { FetchDepth::Full => step.add_with(("fetch-depth", 0)), FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)), }) - .map(|step| match value.token { - Some(token) => step.add_with(("token", token)), - None => step, - }) - .map(|step| match value.path { - Some(path) => step.add_with(("path", path)), - None => step, - }) - .map(|step| match value.repository { - Some(repository) => step.add_with(("repository", repository)), - None => step, - }) - .map(|step| match value.ref_ { - Some(ref_) => step.add_with(("ref", ref_)), - None => step, + .when_some(value.path, |step, path| step.add_with(("path", path))) + .when_some(value.repository, |step, repository| { + step.add_with(("repository", repository)) }) + .when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_))) + .when_some(value.token, |step, token| step.add_with(("token", token))) } } @@ -279,18 +262,12 @@ pub fn setup_linux() -> Step { named::bash("./script/linux") } -fn install_mold() -> Step { - named::bash("./script/install-mold") -} - fn download_wasi_sdk() -> Step { named::bash("./script/download-wasi-sdk") } pub(crate) fn install_linux_dependencies(job: Job) -> Job { - job.add_step(setup_linux()) - .add_step(install_mold()) - .add_step(download_wasi_sdk()) + job.add_step(setup_linux()).add_step(download_wasi_sdk()) } pub fn script(name: &str) -> Step { 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 { diff --git a/typos.toml b/typos.toml index 863fea3822d62a51f737c3d7fa87a4c198710cfa..8c57caaf0417efdb01013e76f179515d9629a47c 100644 --- a/typos.toml +++ b/typos.toml @@ -92,6 +92,8 @@ extend-ignore-re = [ # AMD GPU Services "ags", # AMD GPU Services - "AGS" + "AGS", + # Yarn Plug'n'Play + "PnP" ] check-filename = true