diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a56793ad6222e5..a948bb5acad707 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,12 +1,45 @@ -Self-Review Checklist: +# Objective + +- Describe the objective or issue this PR addresses. +- If you're fixing a specific issue, use "Fixes #X" for each issue as [described in the GitHub docs](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword). + +## Solution + +- Describe the solution used to achieve the objective above. + +## Testing + +- Did you test these changes? If so, how? +- Are there any parts that need more testing? +- How can other people (reviewers) test your changes? Is there anything specific they need to know? +- If relevant, what platforms did you test these changes on, and are there any important ones you can't test? + +## Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments -- [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) +- [ ] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable -Closes #ISSUE +## Showcase + +> This section is optional. If this PR does not include a visual change or does not add a new user-facing feature, you can delete this section. + +- Help others understand the result of this PR by showcasing your awesome work! +- If this PR includes a visual change, consider adding a screenshot, GIF, or video + - A before/after comparison is very useful for changes to existing features! + +While a showcase should aim to be brief and digestible, you can use a toggleable section to save space on longer showcases: + +
+ Click to view showcase + +My super cool demos here + +
+ +--- Release Notes: diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml index b7ac11b263212e..7100909788765e 100644 --- a/.github/workflows/after_release.yml +++ b/.github/workflows/after_release.yml @@ -41,7 +41,7 @@ jobs: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') permissions: contents: read - uses: zed-industries/zed/.github/workflows/deploy_docs.yml@main + uses: zed-industries/zed/.github/workflows/deploy_docs.yml@3f16f7b9082f8828e4d6ae207d2349b1ef932517 secrets: DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }} DOCS_CONSENT_IO_INSTANCE: ${{ secrets.DOCS_CONSENT_IO_INSTANCE }} diff --git a/.github/workflows/assign-reviewers.yml b/.github/workflows/assign-reviewers.yml deleted file mode 100644 index 2a12a69defdd4f..00000000000000 --- a/.github/workflows/assign-reviewers.yml +++ /dev/null @@ -1,104 +0,0 @@ -# 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. -# -# SECURITY INVARIANTS (pull_request_target): -# This workflow runs with access to secrets for ALL PRs including forks. -# It is safe ONLY because: -# 1. The checkout is the coordinator repo at ref: main — NEVER the PR head/branch -# 2. No ${{ }} interpolation of event fields in run: blocks — all routed via env: -# 3. The script never executes, sources, or reads files from the PR branch -# Violating any of these enables remote code execution with secret access. - -name: Assign Reviewers - -on: - # zizmor: ignore[dangerous-triggers] reviewed — no PR code checkout, only coordinator repo at ref: main - pull_request_target: - 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: {} - -# Prevent duplicate runs for the same PR (e.g., rapid push + ready_for_review). -concurrency: - group: assign-reviewers-${{ github.event.pull_request.number }} - cancel-in-progress: true - -# NOTE: For ready_for_review events, the webhook payload may still carry -# draft: true due to a GitHub race condition (payload serialized before DB -# update). We trust the event type instead — the script rechecks draft status -# via a live API call as defense-in-depth. -# -# No author_association filter — external and fork PRs also get reviewer -# assignments. Assigned reviewers are inherently scoped to org team members -# by the GitHub Teams API. -jobs: - assign-reviewers: - if: >- - github.event.action == 'ready_for_review' || github.event.pull_request.draft == false - runs-on: ubuntu-latest - steps: - - name: Generate app token - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 - with: - app-id: ${{ vars.COORDINATOR_APP_ID }} - private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }} - repositories: codeowner-coordinator,zed - - # SECURITY: checks out the coordinator repo at ref: main, NOT the PR branch. - # persist-credentials: false prevents the token from leaking into .git/config. - - name: Checkout coordinator repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.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 --no-deps -q --only-binary ':all:' \ - -r /dev/stdin <<< "pyyaml==6.0.3 --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" - - - name: Assign reviewers - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - PR_URL: ${{ github.event.pull_request.html_url }} - TARGET_REPO: ${{ github.repository }} - ASSIGN_INTERNAL: ${{ vars.ASSIGN_INTERNAL || 'false' }} - ASSIGN_EXTERNAL: ${{ vars.ASSIGN_EXTERNAL || 'true' }} - 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 \ - 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/assign_contributor_issue.yml b/.github/workflows/assign_contributor_issue.yml deleted file mode 100644 index 5e968611299e26..00000000000000 --- a/.github/workflows/assign_contributor_issue.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Assign Contributor Issue — auto-assign labeled contributor issues -# -# When an issue has both a `.contrib/good *` label and an `area:` label, -# finds the least-busy contributor interested in that area (via Tally form -# responses), assigns the issue, updates the project board, and notifies -# the contributor on Slack. -# -# Errors and "no candidates" conditions are reported to the Slack activity -# channel. - -name: Assign Contributor Issue - -on: - issues: - types: [labeled] - workflow_dispatch: - inputs: - issue_number: - description: "Issue number to test against" - required: true - type: number - -permissions: - contents: read - -concurrency: - group: assign-contributor-${{ github.event.issue.number || inputs.issue_number }} - cancel-in-progress: true - -jobs: - assign-contributor: - if: >- - github.event_name == 'workflow_dispatch' || - (github.repository == 'zed-industries/zed' && - github.event.issue.state == 'open' && - (startsWith(github.event.label.name, '.contrib/good ') || startsWith(github.event.label.name, 'area:'))) - runs-on: namespace-profile-2x4-ubuntu-2404 - timeout-minutes: 5 - - steps: - - name: Generate app token - id: app-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 - with: - app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} - private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} - owner: zed-industries - - - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - sparse-checkout: script/github-assign-contributor-issue.py - sparse-checkout-cone-mode: false - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: "3.12" - - - name: Install dependencies - run: pip install requests - - - name: Assign contributor - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - TALLY_API_KEY: ${{ secrets.TALLY_API_KEY }} - TALLY_FORM_ID: ${{ vars.TALLY_CONTRIBUTOR_FORM_ID }} - SLACK_BOT_TOKEN: ${{ secrets.SLACK_CONTRIBUTOR_BOT_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }} - run: python script/github-assign-contributor-issue.py "$ISSUE_NUMBER" diff --git a/.github/workflows/background_agent_mvp.yml b/.github/workflows/background_agent_mvp.yml deleted file mode 100644 index 2f048d572df6fb..00000000000000 --- a/.github/workflows/background_agent_mvp.yml +++ /dev/null @@ -1,331 +0,0 @@ -name: background_agent_mvp - -# NOTE: Scheduled runs disabled as of 2026-02-24. The workflow can still be -# triggered manually via workflow_dispatch. See Notion doc "Background Agent -# for Zed" for current status and contact info to resume this work. -on: - # schedule: - # - cron: "0 16 * * 1-5" - workflow_dispatch: - inputs: - crash_ids: - description: "Optional comma-separated Sentry issue IDs (e.g. ZED-4VS,ZED-123)" - required: false - type: string - reviewers: - description: "Optional comma-separated GitHub reviewer handles" - required: false - type: string - top: - description: "Top N candidates when crash_ids is empty" - required: false - type: string - default: "3" - -permissions: - contents: write - pull-requests: write - -env: - FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} - DROID_MODEL: claude-opus-4-5-20251101 - SENTRY_ORG: zed-dev - -jobs: - run-mvp: - runs-on: ubuntu-latest - timeout-minutes: 180 - - steps: - - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - fetch-depth: 0 - - - name: Install Droid CLI - run: | - curl -fsSL https://app.factory.ai/cli | sh - echo "${HOME}/.local/bin" >> "$GITHUB_PATH" - echo "DROID_BIN=${HOME}/.local/bin/droid" >> "$GITHUB_ENV" - "${HOME}/.local/bin/droid" --version - - - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.12" - - - name: Resolve reviewers - id: reviewers - env: - INPUT_REVIEWERS: ${{ inputs.reviewers }} - DEFAULT_REVIEWERS: ${{ vars.BACKGROUND_AGENT_REVIEWERS }} - run: | - set -euo pipefail - if [ -z "$DEFAULT_REVIEWERS" ]; then - DEFAULT_REVIEWERS="eholk,morgankrey,osiewicz,bennetbo" - fi - REVIEWERS="${INPUT_REVIEWERS:-$DEFAULT_REVIEWERS}" - REVIEWERS="$(echo "$REVIEWERS" | tr -d '[:space:]')" - echo "reviewers=$REVIEWERS" >> "$GITHUB_OUTPUT" - - - name: Select crash candidates - id: candidates - env: - INPUT_CRASH_IDS: ${{ inputs.crash_ids }} - INPUT_TOP: ${{ inputs.top }} - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_BACKGROUND_AGENT_MVP_TOKEN }} - run: | - set -euo pipefail - - PREFETCH_DIR="/tmp/crash-data" - ARGS=(--select-only --prefetch-dir "$PREFETCH_DIR" --org "$SENTRY_ORG") - if [ -n "$INPUT_CRASH_IDS" ]; then - ARGS+=(--crash-ids "$INPUT_CRASH_IDS") - else - TARGET_DRAFT_PRS="${INPUT_TOP:-3}" - if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then - TARGET_DRAFT_PRS="3" - fi - CANDIDATE_TOP=$((TARGET_DRAFT_PRS * 5)) - if [ "$CANDIDATE_TOP" -gt 100 ]; then - CANDIDATE_TOP=100 - fi - ARGS+=(--top "$CANDIDATE_TOP" --sample-size 100) - fi - - IDS="$(python3 script/run-background-agent-mvp-local "${ARGS[@]}")" - - if [ -z "$IDS" ]; then - echo "No candidates selected" - exit 1 - fi - - echo "Using crash IDs: $IDS" - echo "ids=$IDS" >> "$GITHUB_OUTPUT" - - - name: Run background agent pipeline per crash - id: pipeline - env: - GH_TOKEN: ${{ github.token }} - REVIEWERS: ${{ steps.reviewers.outputs.reviewers }} - CRASH_IDS: ${{ steps.candidates.outputs.ids }} - TARGET_DRAFT_PRS_INPUT: ${{ inputs.top }} - run: | - set -euo pipefail - - git config user.name "factory-droid[bot]" - git config user.email "138933559+factory-droid[bot]@users.noreply.github.com" - - # Crash ID format validation regex - CRASH_ID_PATTERN='^[A-Za-z0-9]+-[A-Za-z0-9]+$' - TARGET_DRAFT_PRS="${TARGET_DRAFT_PRS_INPUT:-3}" - if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then - TARGET_DRAFT_PRS="3" - fi - CREATED_DRAFT_PRS=0 - - IFS=',' read -r -a CRASH_ID_ARRAY <<< "$CRASH_IDS" - - for CRASH_ID in "${CRASH_ID_ARRAY[@]}"; do - if [ "$CREATED_DRAFT_PRS" -ge "$TARGET_DRAFT_PRS" ]; then - echo "Reached target draft PR count ($TARGET_DRAFT_PRS), stopping candidate processing" - break - fi - - CRASH_ID="$(echo "$CRASH_ID" | xargs)" - [ -z "$CRASH_ID" ] && continue - - # Validate crash ID format to prevent injection via branch names or prompts - if ! [[ "$CRASH_ID" =~ $CRASH_ID_PATTERN ]]; then - echo "ERROR: Invalid crash ID format: '$CRASH_ID' — skipping" - continue - fi - - BRANCH="background-agent/mvp-${CRASH_ID,,}-$(date +%Y%m%d)" - echo "Running crash pipeline for $CRASH_ID on $BRANCH" - - # Deduplication: skip if a draft PR already exists for this crash - EXISTING_BRANCH_PR="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' || echo "")" - if [ -n "$EXISTING_BRANCH_PR" ]; then - echo "Draft PR #$EXISTING_BRANCH_PR already exists for $CRASH_ID — skipping" - continue - fi - - if ! git fetch origin main; then - echo "WARNING: Failed to fetch origin/main for $CRASH_ID — skipping" - continue - fi - - if ! git checkout -B "$BRANCH" origin/main; then - echo "WARNING: Failed to create checkout branch $BRANCH for $CRASH_ID — skipping" - continue - fi - - CRASH_DATA_FILE="/tmp/crash-data/crash-${CRASH_ID}.md" - if [ ! -f "$CRASH_DATA_FILE" ]; then - echo "WARNING: No pre-fetched crash data for $CRASH_ID at $CRASH_DATA_FILE — skipping" - continue - fi - - python3 -c " - import sys - crash_id, data_file = sys.argv[1], sys.argv[2] - prompt = f'''You are running the weekly background crash-fix MVP pipeline for crash {crash_id}. - - The crash report has been pre-fetched and is available at: {data_file} - Read this file to get the crash data. Do not call script/sentry-fetch. - - Required workflow: - 1. Read the crash report from {data_file} - 2. Read and follow .rules. - 3. Follow .factory/prompts/crash/investigate.md and write ANALYSIS.md - 4. Follow .factory/prompts/crash/link-issues.md and write LINKED_ISSUES.md - 5. Follow .factory/prompts/crash/fix.md to implement a minimal fix with tests - 6. Run validators required by the fix prompt for the affected code paths - 7. Write PR_BODY.md with sections: - - Crash Summary - - Root Cause - - Fix - - Validation - - Potentially Related Issues (High/Medium/Low from LINKED_ISSUES.md) - - Reviewer Checklist - - Release Notes (final section; format as Release Notes:, then a blank line, then one bullet like - N/A) - - Constraints: - - Do not merge or auto-approve. - - Keep changes narrowly scoped to this crash. - - Do not modify files in .github/, .factory/, or script/ directories. - - When investigating git history, limit your search to the last 2 weeks of commits. Do not traverse older history. - - If the crash is not solvable with available context, write a clear blocker summary to PR_BODY.md. - ''' - import textwrap - with open('/tmp/background-agent-prompt.md', 'w') as f: - f.write(textwrap.dedent(prompt)) - " "$CRASH_ID" "$CRASH_DATA_FILE" - - if ! "$DROID_BIN" exec --auto medium -m "$DROID_MODEL" -f /tmp/background-agent-prompt.md; then - echo "Droid execution failed for $CRASH_ID, continuing to next candidate" - continue - fi - - for REPORT_FILE in ANALYSIS.md LINKED_ISSUES.md PR_BODY.md; do - if [ -f "$REPORT_FILE" ]; then - echo "::group::${CRASH_ID} ${REPORT_FILE}" - cat "$REPORT_FILE" - echo "::endgroup::" - fi - done - - if git diff --quiet; then - echo "No code changes produced for $CRASH_ID" - continue - fi - - # Stage only expected file types — not git add -A - git add -- '*.rs' '*.toml' 'Cargo.lock' 'ANALYSIS.md' 'LINKED_ISSUES.md' 'PR_BODY.md' - - # Reject changes to protected paths - PROTECTED_CHANGES="$(git diff --cached --name-only | grep -E '^(\.github/|\.factory/|script/)' || true)" - if [ -n "$PROTECTED_CHANGES" ]; then - echo "ERROR: Agent modified protected paths — aborting commit for $CRASH_ID:" - echo "$PROTECTED_CHANGES" - git reset HEAD -- . - continue - fi - - if ! git diff --cached --quiet; then - git commit -m "Fix crash ${CRASH_ID}" - fi - - git push -u origin "$BRANCH" - - CRATE_PREFIX="" - CHANGED_CRATES="$(git diff --cached --name-only | awk -F/ '/^crates\/[^/]+\// {print $2}' | sort -u)" - if [ -n "$CHANGED_CRATES" ] && [ "$(printf "%s\n" "$CHANGED_CRATES" | wc -l | tr -d ' ')" -eq 1 ]; then - CRATE_PREFIX="${CHANGED_CRATES}: " - fi - - TITLE="${CRATE_PREFIX}Fix crash ${CRASH_ID}" - BODY_FILE="PR_BODY.md" - if [ ! -f "$BODY_FILE" ]; then - BODY_FILE="/tmp/pr-body-${CRASH_ID}.md" - printf "Automated draft crash-fix pipeline output for %s.\n\nNo PR_BODY.md was generated by the agent; please review commit and linked artifacts manually.\n" "$CRASH_ID" > "$BODY_FILE" - fi - - python3 -c ' - import re - import sys - - path = sys.argv[1] - body = open(path, encoding="utf-8").read() - pattern = re.compile(r"(^|\n)Release Notes:\r?\n(?:\r?\n)*(?P(?:\s*-\s+.*(?:\r?\n|$))+)", re.MULTILINE) - match = pattern.search(body) - - if match: - bullets = [ - re.sub(r"^\s*", "", bullet) - for bullet in re.findall(r"^\s*-\s+.*$", match.group("bullets"), re.MULTILINE) - ] - if not bullets: - bullets = ["- N/A"] - section = "Release Notes:\n\n" + "\n".join(bullets) - body_without_release_notes = (body[: match.start()] + body[match.end() :]).rstrip() - if body_without_release_notes: - normalized_body = f"{body_without_release_notes}\n\n{section}\n" - else: - normalized_body = f"{section}\n" - else: - normalized_body = body.rstrip() + "\n\nRelease Notes:\n\n- N/A\n" - - with open(path, "w", encoding="utf-8") as file: - file.write(normalized_body) - ' "$BODY_FILE" - - EXISTING_PR="$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')" - if [ -n "$EXISTING_PR" ]; then - gh pr edit "$EXISTING_PR" --title "$TITLE" --body-file "$BODY_FILE" - PR_NUMBER="$EXISTING_PR" - else - PR_URL="$(gh pr create --draft --base main --head "$BRANCH" --title "$TITLE" --body-file "$BODY_FILE")" - PR_NUMBER="$(basename "$PR_URL")" - fi - - if [ -n "$REVIEWERS" ]; then - IFS=',' read -r -a REVIEWER_ARRAY <<< "$REVIEWERS" - for REVIEWER in "${REVIEWER_ARRAY[@]}"; do - [ -z "$REVIEWER" ] && continue - gh pr edit "$PR_NUMBER" --add-reviewer "$REVIEWER" || true - done - fi - - CREATED_DRAFT_PRS=$((CREATED_DRAFT_PRS + 1)) - echo "Created/updated draft PRs this run: $CREATED_DRAFT_PRS/$TARGET_DRAFT_PRS" - done - - echo "created_draft_prs=$CREATED_DRAFT_PRS" >> "$GITHUB_OUTPUT" - echo "target_draft_prs=$TARGET_DRAFT_PRS" >> "$GITHUB_OUTPUT" - - - name: Cleanup pre-fetched crash data - if: always() - run: rm -rf /tmp/crash-data - - - name: Workflow summary - if: always() - env: - SUMMARY_CRASH_IDS: ${{ steps.candidates.outputs.ids }} - SUMMARY_REVIEWERS: ${{ steps.reviewers.outputs.reviewers }} - SUMMARY_CREATED_DRAFT_PRS: ${{ steps.pipeline.outputs.created_draft_prs }} - SUMMARY_TARGET_DRAFT_PRS: ${{ steps.pipeline.outputs.target_draft_prs }} - run: | - { - echo "## Background Agent MVP" - echo "" - echo "- Crash IDs: ${SUMMARY_CRASH_IDS:-none}" - echo "- Reviewer routing: ${SUMMARY_REVIEWERS:-NOT CONFIGURED}" - echo "- Draft PRs created: ${SUMMARY_CREATED_DRAFT_PRS:-0}/${SUMMARY_TARGET_DRAFT_PRS:-3}" - echo "- Pipeline: investigate -> link-issues -> fix -> draft PR" - } >> "$GITHUB_STEP_SUMMARY" - -concurrency: - group: background-agent-mvp - cancel-in-progress: false diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index cae4084c1dc643..be1d8e66d046ae 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -26,7 +26,8 @@ jobs: If you can reproduce this bug on the latest stable Zed, please let us know by leaving a comment with the Zed version, it helps us focus on the right issues. If the bug doesn't appear for you anymore, feel free to close the issue yourself; otherwise, the bot will close it in a couple of weeks. - But even after it's closed by the bot, you can leave a comment with the version where the bug is reproducible and we'll reopen the issue. + But even after it's closed by the bot, you can leave a comment **with the version where the bug is reproducible** and we'll reopen the issue. + (This bot will only ask about this issue once) Thanks! close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please leave a comment with your Zed version so that we can reopen the issue." days-before-stale: 90 @@ -38,3 +39,4 @@ jobs: debug-only: ${{ inputs.debug-only }} stale-issue-label: "stale" exempt-issue-labels: "never stale" + labels-to-add-when-unstale: "never stale" diff --git a/.github/workflows/community_pr_board.yml b/.github/workflows/community_pr_board.yml index 03bb9381739eb1..d155cf8275dbb5 100644 --- a/.github/workflows/community_pr_board.yml +++ b/.github/workflows/community_pr_board.yml @@ -13,7 +13,7 @@ name: Community PR Board on: pull_request_target: - types: [labeled, unlabeled, assigned, review_requested] + types: [labeled, unlabeled, assigned, review_requested, edited] issue_comment: types: [created] workflow_dispatch: diff --git a/.github/workflows/community_pr_board_refresh.yml b/.github/workflows/community_pr_board_refresh.yml new file mode 100644 index 00000000000000..1d77638c82d381 --- /dev/null +++ b/.github/workflows/community_pr_board_refresh.yml @@ -0,0 +1,56 @@ +# Community PR Board — daily meta information refresh +# +# Walks every open PR on the community board and recomputes its signal +# fields. Backstop for changes that don't reach the event-driven workflow, +# either because the relevant webhook isn't subscribed or doesn't fire at all. + +name: PR Board Meta Fields Refresh + +on: + schedule: + - cron: "0 9 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: community-pr-board-refresh + cancel-in-progress: true + +jobs: + refresh: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-community-pr-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Refresh all board items + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "85" + REFRESH_ALL: "1" + run: python script/github-community-pr-board.py diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml deleted file mode 100644 index 154276a7104733..00000000000000 --- a/.github/workflows/compare_perf.yml +++ /dev/null @@ -1,84 +0,0 @@ -# Generated from xtask::workflows::compare_perf -# Rebuild with `cargo xtask workflows`. -name: compare_perf -on: - workflow_dispatch: - inputs: - head: - description: head - required: true - type: string - base: - description: base - required: true - type: string - crate_name: - description: crate_name - type: string - default: '' -jobs: - run_perf: - runs-on: namespace-profile-16x32-ubuntu-2204 - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::setup_cargo_config - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - name: steps::setup_linux - run: ./script/linux - - name: steps::download_wasi_sdk - run: ./script/download-wasi-sdk - - name: compare_perf::run_perf::install_hyperfine - uses: taiki-e/install-action@b4f2d5cb8597b15997c8ede873eb6185efc5f0ad - - name: steps::git_checkout - run: git fetch origin "$REF_NAME" && git checkout "$REF_NAME" - env: - REF_NAME: ${{ inputs.base }} - - name: compare_perf::run_perf::cargo_perf_test - run: |2- - - if [ -n "$CRATE_NAME" ]; then - cargo perf-test -p "$CRATE_NAME" -- --json="$REF_NAME"; - else - cargo perf-test -p vim -- --json="$REF_NAME"; - fi - env: - REF_NAME: ${{ inputs.base }} - CRATE_NAME: ${{ inputs.crate_name }} - - name: steps::git_checkout - run: git fetch origin "$REF_NAME" && git checkout "$REF_NAME" - env: - REF_NAME: ${{ inputs.head }} - - name: compare_perf::run_perf::cargo_perf_test - run: |2- - - if [ -n "$CRATE_NAME" ]; then - cargo perf-test -p "$CRATE_NAME" -- --json="$REF_NAME"; - else - cargo perf-test -p vim -- --json="$REF_NAME"; - fi - env: - REF_NAME: ${{ inputs.head }} - CRATE_NAME: ${{ inputs.crate_name }} - - name: compare_perf::run_perf::compare_runs - run: cargo perf-compare --save=results.md "$BASE" "$HEAD" - env: - BASE: ${{ inputs.base }} - HEAD: ${{ inputs.head }} - - name: run_bundling::upload_artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: results.md - path: results.md - if-no-files-found: error - - name: steps::cleanup_cargo_config - if: always() - run: | - rm -rf ./../.cargo -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 62739b21675fec..4cb0df5735a5c8 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -2,6 +2,7 @@ # Rebuild with `cargo xtask workflows`. name: danger on: + merge_group: {} pull_request: types: - opened diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 708fad67528d4c..ef6cb399a761ce 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -71,7 +71,7 @@ jobs: run: cargo nextest run --package collab --no-fail-fast services: postgres: - image: postgres:15 + image: postgres:15@sha256:1b92e7a80c021647bf70f5d3eb66066a998e4f5cf43c07bb9dc9f729782cf88e env: POSTGRES_HOST_AUTH_METHOD: trust ports: @@ -85,7 +85,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: deploy_collab::publish::install_doctl - uses: digitalocean/action-doctl@v2 + uses: digitalocean/action-doctl@3cb3953159719656269e044e0e24ca16dd2a690f with: token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} - name: deploy_collab::publish::sign_into_registry @@ -115,7 +115,7 @@ jobs: with: clean: false - name: deploy_collab::deploy::install_doctl - uses: digitalocean/action-doctl@v2 + uses: digitalocean/action-doctl@3cb3953159719656269e044e0e24ca16dd2a690f with: token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} - name: deploy_collab::deploy::sign_into_kubernetes diff --git a/.github/workflows/deploy_nightly_docs.yml b/.github/workflows/deploy_nightly_docs.yml index 91dcc6a2773b27..acd904841bb33c 100644 --- a/.github/workflows/deploy_nightly_docs.yml +++ b/.github/workflows/deploy_nightly_docs.yml @@ -10,7 +10,7 @@ jobs: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') permissions: contents: read - uses: zed-industries/zed/.github/workflows/deploy_docs.yml@main + uses: zed-industries/zed/.github/workflows/deploy_docs.yml@3f16f7b9082f8828e4d6ae207d2349b1ef932517 secrets: DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }} DOCS_CONSENT_IO_INSTANCE: ${{ secrets.DOCS_CONSENT_IO_INSTANCE }} diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 11a3a70902218a..6e68db7af0f274 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9 + ZED_EXTENSION_CLI_SHA: 9ee3c503a4bbbc6b4a0f8a789acca4871d773223 on: workflow_call: inputs: diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index c3503590e6063f..23efa368d17653 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 2a00db06ce6d01089bfafd207b6348078e980df9 + ZED_EXTENSION_CLI_SHA: 9ee3c503a4bbbc6b4a0f8a789acca4871d773223 RUSTUP_TOOLCHAIN: stable CARGO_BUILD_TARGET: wasm32-wasip2 on: diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index 4326feb812c094..5bef991887cdba 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -5,12 +5,15 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: '0' on: - push: - tags: - - extension-cli + workflow_dispatch: + inputs: + message: + description: Describe why the extension CLI is being bumped and/or what changes are included. + required: true + type: string jobs: publish_job: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo @@ -31,10 +34,29 @@ jobs: env: DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} + - id: generate-token + name: steps::authenticate_as_zippy + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 + with: + app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} + private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} + permission-contents: write + - name: steps::update_tag + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b + with: + script: | + github.rest.git.updateRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'tags/extension-cli', + sha: context.sha, + force: true + }) + github-token: ${{ steps.generate-token.outputs.token }} update_sha_in_zed: needs: - publish_job - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' runs-on: namespace-profile-8x16-ubuntu-2204 steps: - id: generate-token @@ -69,6 +91,8 @@ jobs: body: | This PR bumps the extension CLI version used in the extension workflows to `${{ github.sha }}`. + ${{ inputs.message }} + Release Notes: - N/A @@ -84,7 +108,7 @@ jobs: update_sha_in_extensions: needs: - publish_job - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') + if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main' runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token @@ -114,6 +138,8 @@ jobs: title: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}` body: | This PR bumps the extension CLI version to https://github.com/zed-industries/zed/commit/${{ github.sha }}. + + ${{ inputs.message }} commit-message: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}` branch: update-extension-cli-sha committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> diff --git a/.github/workflows/randomized_tests.yml b/.github/workflows/randomized_tests.yml deleted file mode 100644 index 9655a81235d79e..00000000000000 --- a/.github/workflows/randomized_tests.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Randomized Tests - -concurrency: randomized-tests - -on: - push: - branches: - - randomized-tests-runner - # schedule: - # - cron: '0 * * * *' - -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - ZED_SERVER_URL: https://zed.dev - -jobs: - tests: - name: Run randomized tests - if: github.repository_owner == 'zed-industries' - runs-on: - - namespace-profile-16x32-ubuntu-2204 - steps: - - name: Install Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "18" - - - name: Checkout repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - clean: false - - - name: Run randomized tests - run: script/randomized-test-ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5934a4838dc79..f7fbff7363725e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,7 +100,7 @@ jobs: timeout-minutes: 60 services: postgres: - image: postgres:15 + image: postgres:15@sha256:1b92e7a80c021647bf70f5d3eb66066a998e4f5cf43c07bb9dc9f729782cf88e env: POSTGRES_HOST_AUTH_METHOD: trust ports: diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 1035d1ab0a4bed..cdf1ef96e15b2c 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -83,7 +83,7 @@ jobs: timeout-minutes: 60 services: postgres: - image: postgres:15 + image: postgres:15@sha256:1b92e7a80c021647bf70f5d3eb66066a998e4f5cf43c07bb9dc9f729782cf88e env: POSTGRES_HOST_AUTH_METHOD: trust ports: diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml deleted file mode 100644 index c647597b44a7d6..00000000000000 --- a/.github/workflows/run_cron_unit_evals.yml +++ /dev/null @@ -1,81 +0,0 @@ -# Generated from xtask::workflows::run_cron_unit_evals -# Rebuild with `cargo xtask workflows`. -name: run_cron_unit_evals -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: '0' - RUST_BACKTRACE: '1' - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} -on: - workflow_dispatch: {} -jobs: - cron_unit_evals: - runs-on: namespace-profile-16x32-ubuntu-2204 - strategy: - matrix: - model: - - anthropic/claude-sonnet-4-5-latest - - anthropic/claude-opus-4-5-latest - - google/gemini-3-pro - - openai/gpt-5 - fail-fast: false - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::setup_cargo_config - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 - with: - cache: rust - 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 - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - - name: steps::clear_target_dir_if_large - run: ./script/clear-target-dir-if-larger-than 350 200 - - name: steps::setup_sccache - run: ./script/setup-sccache - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - SCCACHE_BUCKET: sccache-zed - - name: ./script/run-unit-evals - run: ./script/run-unit-evals - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} - GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} - ZED_AGENT_MODEL: ${{ matrix.model }} - - name: steps::show_sccache_stats - run: sccache --show-stats || true - - name: steps::cleanup_cargo_config - if: always() - run: | - rm -rf ./../.cargo - - name: run_agent_evals::cron_unit_evals::send_failure_to_slack - if: ${{ failure() }} - uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN }} - payload: | - channel: C04UDRNNJFQ - text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}" -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/run_tests.yml b/.github/workflows/run_tests.yml index 1210b9b36b89b2..34fb9c123c91c5 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -399,7 +399,7 @@ jobs: timeout-minutes: 60 services: postgres: - image: postgres:15 + image: postgres:15@sha256:1b92e7a80c021647bf70f5d3eb66066a998e4f5cf43c07bb9dc9f729782cf88e env: POSTGRES_HOST_AUTH_METHOD: trust ports: @@ -794,12 +794,12 @@ jobs: echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV" fi - name: run_tests::check_postgres_and_protobuf_migrations::bufbuild_setup_action - uses: bufbuild/buf-setup-action@v1 + uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 with: version: v1.29.0 github_token: ${{ secrets.GITHUB_TOKEN }} - name: run_tests::check_postgres_and_protobuf_migrations::bufbuild_breaking_action - uses: bufbuild/buf-breaking-action@v1 + uses: bufbuild/buf-breaking-action@c57b3d842a5c3f3b454756ef65305a50a587c5ba with: input: crates/proto/proto/ against: https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/ diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml deleted file mode 100644 index 4f655bbb8e6ed8..00000000000000 --- a/.github/workflows/run_unit_evals.yml +++ /dev/null @@ -1,75 +0,0 @@ -# Generated from xtask::workflows::run_unit_evals -# Rebuild with `cargo xtask workflows`. -name: run_unit_evals -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: '0' - RUST_BACKTRACE: '1' - ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }} - ZED_EVAL_TELEMETRY: '1' - MODEL_NAME: ${{ inputs.model_name }} -on: - workflow_dispatch: - inputs: - model_name: - description: model_name - required: true - type: string - commit_sha: - description: commit_sha - required: true - type: string -jobs: - run_unit_evals: - runs-on: namespace-profile-16x32-ubuntu-2204 - steps: - - name: steps::checkout_repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - with: - clean: false - - name: steps::setup_cargo_config - run: | - mkdir -p ./../.cargo - cp ./.cargo/ci-config.toml ./../.cargo/config.toml - - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 - with: - cache: rust - 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 - uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - - name: steps::clear_target_dir_if_large - run: ./script/clear-target-dir-if-larger-than 350 200 - - name: steps::setup_sccache - run: ./script/setup-sccache - env: - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - SCCACHE_BUCKET: sccache-zed - - name: ./script/run-unit-evals - run: ./script/run-unit-evals - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }} - GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }} - UNIT_EVAL_COMMIT: ${{ inputs.commit_sha }} - - name: steps::show_sccache_stats - run: sccache --show-stats || true - - name: steps::cleanup_cargo_config - if: always() - run: | - rm -rf ./../.cargo -concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }} - cancel-in-progress: true -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/slack_notify_first_responders.yml b/.github/workflows/slack_notify_first_responders.yml index a6f2d557a57477..3dd9ffeabae1b7 100644 --- a/.github/workflows/slack_notify_first_responders.yml +++ b/.github/workflows/slack_notify_first_responders.yml @@ -5,25 +5,54 @@ on: types: [labeled] env: - FIRST_RESPONDER_LABELS: '["priority:P0", "priority:P1"]' + PRIORITY_LABELS: '["priority:P0", "priority:P1"]' + REPRODUCIBLE_LABEL: 'state:reproducible' + FREQUENCY_LABELS: '["frequency:always", "frequency:common"]' jobs: notify-slack: if: github.repository_owner == 'zed-industries' && github.event.issue.state == 'open' runs-on: namespace-profile-2x4-ubuntu-2404 + # Serialize per-issue so concurrent `labeled` events can't both observe + # the trifecta and double-notify. + concurrency: + group: slack-notify-first-responders-${{ github.event.issue.number }} + cancel-in-progress: false steps: - - name: Check if label requires first responder notification + - name: Check if label combination requires first responder notification id: check-label env: LABEL_NAME: ${{ github.event.label.name }} + ISSUE_LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }} run: | - if echo '${{ env.FIRST_RESPONDER_LABELS }}' | jq -e --arg label "$LABEL_NAME" 'index($label) != null' > /dev/null; then + set -euo pipefail + + # Gate on the just-added label so unrelated labeling on an + # already-qualifying issue doesn't re-fire the notification. + TRIGGER_LABELS=$(jq -cn \ + --argjson priority "$PRIORITY_LABELS" \ + --arg repro "$REPRODUCIBLE_LABEL" \ + --argjson freq "$FREQUENCY_LABELS" \ + '$priority + [$repro] + $freq') + + if ! echo "$TRIGGER_LABELS" | jq -e --arg l "$LABEL_NAME" 'index($l) != null' > /dev/null; then + echo "Added label '$LABEL_NAME' is not in the trigger set, skipping" + echo "should_notify=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + MATCHED_PRIORITY=$(echo "$ISSUE_LABELS_JSON" | jq -r --argjson priority "$PRIORITY_LABELS" 'map(select(. as $x | $priority | index($x) != null)) | first // ""') + HAS_REPRO=$(echo "$ISSUE_LABELS_JSON" | jq --arg l "$REPRODUCIBLE_LABEL" 'index($l) != null') + HAS_FREQ=$(echo "$ISSUE_LABELS_JSON" | jq --argjson freq "$FREQUENCY_LABELS" 'any(.[]; . as $x | $freq | index($x) != null)') + + if [ -n "$MATCHED_PRIORITY" ] && [ "$HAS_REPRO" = "true" ] && [ "$HAS_FREQ" = "true" ]; then + echo "Confirmed high-frequency $MATCHED_PRIORITY, notifying" + echo "notify_reason=confirmed $MATCHED_PRIORITY" >> "$GITHUB_OUTPUT" echo "should_notify=true" >> "$GITHUB_OUTPUT" - echo "Label '$LABEL_NAME' requires first responder notification" else + echo "Combination not yet satisfied (priority=$MATCHED_PRIORITY, reproducible=$HAS_REPRO, frequency=$HAS_FREQ), skipping" echo "should_notify=false" >> "$GITHUB_OUTPUT" - echo "Label '$LABEL_NAME' does not require first responder notification, skipping" fi - name: Build Slack message payload @@ -32,13 +61,13 @@ jobs: ISSUE_TITLE: ${{ github.event.issue.title }} ISSUE_URL: ${{ github.event.issue.html_url }} LABELED_BY: ${{ github.event.sender.login }} - LABEL_NAME: ${{ github.event.label.name }} + NOTIFY_REASON: ${{ steps.check-label.outputs.notify_reason }} LABELS_JSON: ${{ toJson(github.event.issue.labels.*.name) }} run: | LABELS=$(echo "$LABELS_JSON" | jq -r 'join(", ")') jq -n \ - --arg label_name "$LABEL_NAME" \ + --arg notify_reason "$NOTIFY_REASON" \ --arg issue_title "$ISSUE_TITLE" \ --arg issue_url "$ISSUE_URL" \ --arg labeled_by "$LABELED_BY" \ @@ -49,7 +78,7 @@ jobs: "type": "section", "text": { "type": "mrkdwn", - "text": " Issue labeled *\($label_name)*" + "text": " New *\($notify_reason)* issue" } }, { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7e7629825b5f4..e0a3287387b47d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,7 @@ submitted. If you'd like your PR to have the best chance of being merged: but features should be confirmed with us first if you aim to avoid wasted effort. If there isn't already a GitHub issue for your feature with staff confirmation that we want it, start with a GitHub discussion rather than a PR. + - This especially applies to any changes proposed to the Zed Extension API. - Include a clear description of **what you're solving**, and why it's important. - Include **tests**. For UI changes, consider updating visual regression tests (see [Building Zed for macOS](./docs/src/development/macos.md#visual-regression-tests)). - If it changes the UI, attach **screenshots** or screen recordings. @@ -52,7 +53,17 @@ submitted. If you'd like your PR to have the best chance of being merged: - Keep AI assistance under your judgement and responsibility: it's unlikely we'll merge a vibe-coded PR that the author doesn't understand. -The internal advice for reviewers is as follows: +### AI Policy + +We welcome the use of LLMs for coding, but we hold a high bar for all contributions, and **we expect a human in the loop who genuinely understands the work an LLM produces** on their behalf. For that reason, we **don't accept contributions from autonomous agents**. Pull requests that appear to violate this may be closed, sometimes without notice. + +**Don't rely on LLMs to write the whole thing for you when communicating with the maintainers** (meaning replies to comments, PR descriptions, and alike). The readers are humans, and we'd like to hear from you, not from a model (we have models at home). If you're a non-native English speaker using an LLM to thoroughly edit or translate your messages to the maintainers, we'd encourage you to **put the machine translation in a quote block and include the original text in your native language after it**. + +If you think it's helpful/necessary to **share context from a chat with an LLM**, please put the **relevant part of it** in a quote block (e.g., using `>`), **disclose it as AI-generated**, and add your own commentary explaining **why it's relevant and what you take from it**. + +This policy was adapted from [ripgrep's AI policy](https://github.com/BurntSushi/ripgrep/blob/f0cec341ab95c25c691ad3d5754d4bd9eedde21f/AI_POLICY.md). + +### Internal advice for reviewers - If the fix/feature is obviously great, and the code is great. Hit merge. - If the fix/feature is obviously great, and the code is nearly great. Send PR comments, or offer to pair to get things perfect. @@ -70,41 +81,49 @@ the change with code in hand. When your changes affect UI, consult this checklist: **Accessibility / Ergonomics** + - Do all keyboard shortcuts work as intended? - Are shortcuts discoverable (tooltips, menus, docs)? -- Do all mouse actions work (drag, context menus, resizing, scrolling)? -- Does the feature look great in light mode and dark mode? -- Are hover states, focus rings, and active states clear and consistent? - Is it usable without a mouse (keyboard-only navigation)? +- Do all mouse actions work (drag, context menus, resizing, scrolling)? +- Does the feature look great in light and dark mode themes? +- Are hover states and focus indicators clear and consistent? **Responsiveness** + - Does the UI scale gracefully on: - - Narrow panes (e.g., side-by-side split views)? - - Short panes (e.g., laptops with 13" displays)? - - High-DPI / Retina displays? + - Narrow panes (e.g., side-by-side split views)? + - Short panes (e.g., laptops with 13" displays)? + - High-DPI / Retina displays? - Does resizing panes or windows keep the UI usable and attractive? - Do dialogs or modals stay centered and within viewport bounds? **Platform Consistency** -- Is the feature fully usable on Windows, Linux, and Mac? + +- Is the feature fully usable on Windows, Linux, and macOS? - Does it respect system-level settings (fonts, scaling, input methods)? **Performance** + - All user interactions must have instant feedback. - - If the user requests something slow (e.g. an LLM generation) there should be some indication of the work in progress. + - If the user requests something slow (e.g. an LLM generation) there should be some indication of the work in progress. - Does it handle large files, big projects, or heavy workloads without degrading? - Frames must take no more than 8ms (120fps) **Consistency** + - Does it match Zed’s design language (spacing, typography, icons)? + - Make sure to visit [the icon design guidelines](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) - Are terminology, labels, and tone consistent with the rest of Zed? - Are interactions consistent (e.g., how tabs close, how modals dismiss, how errors show)? **Internationalization & Text** + - Are strings concise, clear, and unambiguous? - Do we avoid internal Zed jargon that only insiders would know? **User Paths & Edge Cases** + - What does the happy path look like? - What does the unhappy path look like? (errors, rejections, invalid states) - How does it work in offline vs. online states? @@ -113,17 +132,18 @@ When your changes affect UI, consult this checklist: - Are error messages actionable and consistent with Zed’s voice? **Discoverability & Learning** + - Can a first-time user figure it out without docs? - Is there an intuitive way to undo/redo actions? - Are power features discoverable but not intrusive? - Is there a path from beginner → expert usage (progressive disclosure)? - ## Things we will (probably) not merge Although there are few hard and fast rules, typically we don't merge: - Anything that can be provided by an extension. For example a new language, or theme. For adding themes or support for a new language to Zed, check out our [docs on developing extensions](https://zed.dev/docs/extensions/developing-extensions). +- Changes to the Zed Extension API submitted without prior discussion involving Zed staff. - New file icons. Zed's default icon theme consists of icons that are hand-designed to fit together in a cohesive manner, please don't submit PRs with off-the-shelf SVGs. - Features where (in our subjective opinion) the extra complexity isn't worth it for the number of people who will benefit. - Giant refactorings. diff --git a/Cargo.lock b/Cargo.lock index 77790e285517dc..2762d7aa7991ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,7 +243,6 @@ dependencies = [ "cloud_llm_client", "collections", "context_server", - "criterion", "ctor", "db", "editor", @@ -271,6 +270,7 @@ dependencies = [ "pretty_assertions", "project", "prompt_store", + "proptest", "quick-xml 0.38.3", "rand 0.9.4", "regex", @@ -306,9 +306,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d197653697b91b3a2cfb579d061a3388cda9fdc79cb6f9393da65cbad46baf8" +checksum = "5efba6592048ef8a9ac97de8d79b2d9933d8ac4d94f7a2de102348fed0c61103" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -328,9 +328,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e4fbf6733a900814fb921b2aac06612e15b42020b76a356fbc1192e725cebc" +checksum = "4d176a10d4cb06e0262a738c3c5bf21ff0968db13a666e31cbca94a3d3d72e7c" dependencies = [ "quote", "syn 2.0.117", @@ -338,9 +338,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.13.5" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d419a87e28240978e4bfdf2a5b91bccb95ae8d5b06e10721bb07c449b9f43dd" +checksum = "c290bfa00c6b52339db66f8e9cf711d5f08530800529f7d619ff24d6cba253d0" dependencies = [ "anyhow", "derive_more", @@ -519,7 +519,6 @@ dependencies = [ "serde_json", "serde_json_lenient", "settings", - "skill_creator", "streaming_diff", "task", "telemetry", @@ -2184,6 +2183,37 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "benchmarks" +version = "0.1.0" +dependencies = [ + "action_log", + "agent", + "agent_settings", + "assets", + "criterion", + "editor", + "futures 0.3.32", + "gpui", + "gpui_platform", + "itertools 0.14.0", + "language", + "language_model", + "lsp", + "multi_buffer", + "project", + "prompt_store", + "rand 0.9.4", + "serde_json", + "settings", + "text", + "theme", + "theme_settings", + "ui", + "util", + "zed_actions", +] + [[package]] name = "bigdecimal" version = "0.4.8" @@ -2501,7 +2531,6 @@ version = "0.1.0" dependencies = [ "clock", "ctor", - "futures 0.3.32", "gpui", "imara-diff", "language", @@ -3255,6 +3284,7 @@ dependencies = [ "gpui_tokio", "http_client", "parking_lot", + "serde", "serde_json", "thiserror 2.0.17", "yawc", @@ -3434,7 +3464,6 @@ dependencies = [ "fs", "futures 0.3.32", "git", - "git_graph", "git_hosting_providers", "git_ui", "gpui", @@ -3539,6 +3568,7 @@ dependencies = [ name = "collections" version = "0.1.0" dependencies = [ + "gpui_util", "indexmap 2.11.4", "rustc-hash 2.1.1", ] @@ -5297,7 +5327,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5441,8 +5471,8 @@ dependencies = [ [[package]] name = "dugong" -version = "0.4.0" -source = "git+https://github.com/zed-industries/merman?rev=1c765dcca2ef5092fcde7bebe8374819563623ef#1c765dcca2ef5092fcde7bebe8374819563623ef" +version = "0.6.2" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "dugong-graphlib", "rustc-hash 2.1.1", @@ -5452,8 +5482,8 @@ dependencies = [ [[package]] name = "dugong-graphlib" -version = "0.4.0" -source = "git+https://github.com/zed-industries/merman?rev=1c765dcca2ef5092fcde7bebe8374819563623ef#1c765dcca2ef5092fcde7bebe8374819563623ef" +version = "0.6.2" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "hashbrown 0.16.1", "rustc-hash 2.1.1", @@ -5585,6 +5615,7 @@ dependencies = [ "debug_adapter_extension", "dirs", "edit_prediction", + "edit_prediction_context", "edit_prediction_metrics", "extension", "flate2", @@ -5654,6 +5685,7 @@ dependencies = [ "serde_json", "settings", "smallvec", + "telemetry", "text", "tree-sitter", "util", @@ -5741,7 +5773,6 @@ dependencies = [ "clock", "collections", "convert_case 0.11.0", - "criterion", "ctor", "dap", "db", @@ -6093,7 +6124,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6327,6 +6358,7 @@ dependencies = [ "env_logger 0.11.8", "extension", "fs", + "futures 0.3.32", "gpui_platform", "language", "log", @@ -6924,7 +6956,7 @@ dependencies = [ "is_executable", "libc", "log", - "notify 8.2.0", + "notify 9.0.0-rc.4", "parking_lot", "paths", "proto", @@ -7352,7 +7384,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7392,40 +7424,6 @@ dependencies = [ "ztracing", ] -[[package]] -name = "git_graph" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-channel 2.5.0", - "collections", - "db", - "editor", - "fs", - "git", - "git_ui", - "gpui", - "language", - "language_model", - "menu", - "picker", - "project", - "project_panel", - "rand 0.9.4", - "release_channel", - "remote_connection", - "search", - "serde_json", - "settings", - "smallvec", - "task", - "theme", - "theme_settings", - "time", - "ui", - "workspace", -] - [[package]] name = "git_hosting_providers" version = "0.1.0" @@ -7455,6 +7453,7 @@ dependencies = [ "agent_settings", "anyhow", "askpass", + "async-channel 2.5.0", "buffer_diff", "call", "collections", @@ -7487,9 +7486,11 @@ dependencies = [ "prompt_store", "proto", "rand 0.9.4", + "release_channel", "remote", "remote_connection", "schemars 1.0.4", + "search", "serde", "serde_json", "settings", @@ -7847,6 +7848,7 @@ dependencies = [ "core-graphics 0.24.0", "core-text", "core-video", + "criterion", "ctor", "derive_more", "embed-resource", @@ -9736,7 +9738,7 @@ dependencies = [ "fs", "futures 0.3.32", "futures-lite 1.13.0", - "fuzzy", + "fuzzy_nucleo", "globset", "gpui", "http_client", @@ -9886,6 +9888,7 @@ dependencies = [ "clock", "cloud_api_client", "cloud_api_types", + "cloud_llm_client", "collections", "component", "convert_case 0.11.0", @@ -10703,8 +10706,8 @@ dependencies = [ [[package]] name = "manatee" -version = "0.4.0" -source = "git+https://github.com/zed-industries/merman?rev=1c765dcca2ef5092fcde7bebe8374819563623ef#1c765dcca2ef5092fcde7bebe8374819563623ef" +version = "0.6.2" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "indexmap 2.11.4", "nalgebra", @@ -10998,8 +11001,8 @@ dependencies = [ [[package]] name = "merman" -version = "0.4.0" -source = "git+https://github.com/zed-industries/merman?rev=1c765dcca2ef5092fcde7bebe8374819563623ef#1c765dcca2ef5092fcde7bebe8374819563623ef" +version = "0.6.2" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "merman-core", "merman-render", @@ -11008,8 +11011,8 @@ dependencies = [ [[package]] name = "merman-core" -version = "0.4.0" -source = "git+https://github.com/zed-industries/merman?rev=1c765dcca2ef5092fcde7bebe8374819563623ef#1c765dcca2ef5092fcde7bebe8374819563623ef" +version = "0.6.2" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "chrono", "euclid", @@ -11034,8 +11037,8 @@ dependencies = [ [[package]] name = "merman-render" -version = "0.4.0" -source = "git+https://github.com/zed-industries/merman?rev=1c765dcca2ef5092fcde7bebe8374819563623ef#1c765dcca2ef5092fcde7bebe8374819563623ef" +version = "0.6.2" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "base64 0.22.1", "chrono", @@ -11238,7 +11241,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11617,19 +11620,21 @@ dependencies = [ [[package]] name = "notify" -version = "8.2.0" -source = "git+https://github.com/zed-industries/notify.git?rev=ce58c24cad542c28e04ced02e20325a4ec28a31d#ce58c24cad542c28e04ced02e20325a4ec28a31d" +version = "9.0.0-rc.4" +source = "git+https://github.com/zed-industries/notify?rev=faecbc33db4f59313e5225ef766bfd9e54a54cfd#faecbc33db4f59313e5225ef766bfd9e54a54cfd" dependencies = [ "bitflags 2.10.0", - "fsevent-sys", "inotify 0.11.0", "kqueue", "libc", "log", "mio 1.2.0", "notify-types", + "objc2-core-foundation", + "objc2-core-services", "walkdir", - "windows-sys 0.60.2", + "windows-sys 0.61.2", + "xxhash-rust", ] [[package]] @@ -11645,8 +11650,11 @@ dependencies = [ [[package]] name = "notify-types" -version = "2.0.0" -source = "git+https://github.com/zed-industries/notify.git?rev=ce58c24cad542c28e04ced02e20325a4ec28a31d#ce58c24cad542c28e04ced02e20325a4ec28a31d" +version = "2.1.0" +source = "git+https://github.com/zed-industries/notify?rev=faecbc33db4f59313e5225ef766bfd9e54a54cfd#faecbc33db4f59313e5225ef766bfd9e54a54cfd" +dependencies = [ + "bitflags 2.10.0", +] [[package]] name = "ntapi" @@ -11663,7 +11671,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -12050,6 +12058,16 @@ dependencies = [ "objc2-metal 0.2.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -12533,13 +12551,12 @@ version = "0.1.0" dependencies = [ "editor", "futures 0.3.32", - "fuzzy", + "fuzzy_nucleo", "gpui", "indoc", "language", "lsp", "menu", - "ordered-float 2.10.1", "picker", "project", "rope", @@ -15517,7 +15534,7 @@ dependencies = [ [[package]] name = "roughr-merman" version = "0.12.0" -source = "git+https://github.com/zed-industries/merman?rev=1c765dcca2ef5092fcde7bebe8374819563623ef#1c765dcca2ef5092fcde7bebe8374819563623ef" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "derive_builder", "euclid", @@ -15733,7 +15750,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -16684,6 +16701,7 @@ dependencies = [ "agent_skills", "anyhow", "audio", + "cloud_api_types", "codestral", "component", "copilot", @@ -16698,6 +16716,7 @@ dependencies = [ "fuzzy", "gpui", "heck 0.5.0", + "http_client", "itertools 0.14.0", "language", "log", @@ -16714,6 +16733,7 @@ dependencies = [ "search", "serde", "serde_json", + "serde_yaml_ng", "settings", "shell_command_parser", "strum 0.27.2", @@ -16722,6 +16742,7 @@ dependencies = [ "theme_settings", "title_bar", "ui", + "ui_input", "util", "workspace", "zed_actions", @@ -16849,6 +16870,7 @@ dependencies = [ "log", "menu", "node_runtime", + "notifications", "platform_title_bar", "pretty_assertions", "project", @@ -16995,33 +17017,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" -[[package]] -name = "skill_creator" -version = "0.1.0" -dependencies = [ - "agent_skills", - "anyhow", - "editor", - "fs", - "futures 0.3.32", - "gpui", - "http_client", - "language", - "menu", - "notifications", - "platform_title_bar", - "release_channel", - "serde_json", - "serde_yaml_ng", - "settings", - "theme_settings", - "ui", - "ui_input", - "util", - "workspace", - "worktree", -] - [[package]] name = "skrifa" version = "0.37.0" @@ -17188,7 +17183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -18305,7 +18300,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -18742,6 +18737,7 @@ dependencies = [ "chrono", "client", "cloud_api_types", + "command_palette_hooks", "db", "external_websocket_sync", "feature_flags", @@ -19766,6 +19762,7 @@ dependencies = [ "markdown", "menu", "settings", + "theme", "theme_settings", "ui", "workspace", @@ -21395,7 +21392,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -22601,6 +22598,7 @@ dependencies = [ "theme", "theme_settings", "ui", + "url", "util", "uuid", "windows 0.61.3", @@ -22847,6 +22845,12 @@ dependencies = [ "toml_edit 0.22.27", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "y4m" version = "0.8.0" @@ -23039,7 +23043,7 @@ dependencies = [ [[package]] name = "zed" -version = "1.6.0" +version = "1.8.0" dependencies = [ "acp_thread", "acp_tools", @@ -23100,7 +23104,6 @@ dependencies = [ "fs", "futures 0.3.32", "git", - "git_graph", "git_hosting_providers", "git_ui", "go_to_line", @@ -23328,7 +23331,6 @@ dependencies = [ "schemars 1.0.4", "serde", "util", - "uuid", ] [[package]] @@ -23385,7 +23387,7 @@ dependencies = [ [[package]] name = "zed_glsl" -version = "0.2.3" +version = "0.2.4" dependencies = [ "zed_extension_api 0.1.0", ] diff --git a/Cargo.toml b/Cargo.toml index 80d648128f4f4d..76130034033422 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/auto_update_ui", "crates/aws_http_client", "crates/bedrock", + "crates/benchmarks", "crates/breadcrumbs", "crates/buffer_diff", "crates/call", @@ -84,7 +85,6 @@ members = [ "crates/fuzzy", "crates/fuzzy_nucleo", "crates/git", - "crates/git_graph", "crates/git_hosting_providers", "crates/git_ui", "crates/go_to_line", @@ -172,7 +172,6 @@ members = [ "crates/rope", "crates/rpc", "crates/sandbox", - "crates/skill_creator", "crates/scheduler", "crates/schema_generator", "crates/search", @@ -341,7 +340,6 @@ fs = { path = "crates/fs" } fuzzy = { path = "crates/fuzzy" } fuzzy_nucleo = { path = "crates/fuzzy_nucleo" } git = { path = "crates/git" } -git_graph = { path = "crates/git_graph" } git_hosting_providers = { path = "crates/git_hosting_providers" } git_ui = { path = "crates/git_ui" } go_to_line = { path = "crates/go_to_line" } @@ -432,7 +430,6 @@ reqwest_client = { path = "crates/reqwest_client" } rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } -skill_creator = { path = "crates/skill_creator" } scheduler = { path = "crates/scheduler" } sandbox = { path = "crates/sandbox" } search = { path = "crates/search" } @@ -506,7 +503,7 @@ accesskit = "0.24.0" accesskit_macos = "0.26.0" accesskit_unix = "0.21.0" accesskit_windows = "0.32.1" -agent-client-protocol = { version = "=0.13.1", features = ["unstable"] } +agent-client-protocol = { version = "=0.14.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" } any_vec = "0.14" @@ -595,6 +592,7 @@ fork = "0.4.0" futures = "0.3.32" futures-concurrency = "7.7.1" futures-lite = "1.13" +futures-util = "0.3.32" gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } globset = "0.4" @@ -889,12 +887,12 @@ features = [ [patch.crates-io] async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" } -notify = { git = "https://github.com/zed-industries/notify.git", rev = "ce58c24cad542c28e04ced02e20325a4ec28a31d" } -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 = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } libwebrtc = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } +notify = { git = "https://github.com/zed-industries/notify", rev = "faecbc33db4f59313e5225ef766bfd9e54a54cfd" } +notify-types = { git = "https://github.com/zed-industries/notify", rev = "faecbc33db4f59313e5225ef766bfd9e54a54cfd" } webrtc-sys = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "c3a55bbc207008f1ca3474b6037fdd3c443cad0f" } [profile.dev] @@ -940,6 +938,7 @@ cranelift-codegen = { opt-level = 3 } wasmtime-environ = { opt-level = 3 } wasmtime-internal-cranelift = { opt-level = 3 } minidumper = { opt-level = 3 } +serde_json = { opt-level = 3 } # Build single-source-file crates with cg=1 as it helps make `cargo build` of a whole workspace a bit faster activity_indicator = { codegen-units = 1 } assets = { codegen-units = 1 } diff --git a/assets/icons/ai_anthropic_compat.svg b/assets/icons/ai_anthropic_compat.svg new file mode 100644 index 00000000000000..aae16efa0556ec --- /dev/null +++ b/assets/icons/ai_anthropic_compat.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/compact.svg b/assets/icons/compact.svg new file mode 100644 index 00000000000000..68f6beb1bec81a --- /dev/null +++ b/assets/icons/compact.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/folder_share.svg b/assets/icons/folder_share.svg new file mode 100644 index 00000000000000..09a232a6898896 --- /dev/null +++ b/assets/icons/folder_share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/folder_shared.svg b/assets/icons/folder_shared.svg new file mode 100644 index 00000000000000..c511390305c5eb --- /dev/null +++ b/assets/icons/folder_shared.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/gerrit.svg b/assets/icons/gerrit.svg new file mode 100644 index 00000000000000..c2149b2c11ad5c --- /dev/null +++ b/assets/icons/gerrit.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/sourcehut.svg b/assets/icons/sourcehut.svg new file mode 100644 index 00000000000000..79f2c53aeeb0ed --- /dev/null +++ b/assets/icons/sourcehut.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/this_window.svg b/assets/icons/this_window.svg new file mode 100644 index 00000000000000..879bb5e9577761 --- /dev/null +++ b/assets/icons/this_window.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 5fa5c4f575919d..9e735bdd9d5374 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -261,7 +261,7 @@ "ctrl-n": "agent::NewThread", "ctrl--": "pane::GoBack", "ctrl-alt-p": "agent::ManageProfiles", - "ctrl-alt-l": "agent::OpenRulesLibrary", + "ctrl-alt-l": "agent::ManageSkills", "ctrl-i": "agent::ToggleProfileSelector", "shift-tab": "agent::CycleModeSelector", "ctrl-alt-/": "agent::ToggleModelSelector", @@ -602,15 +602,15 @@ { "context": "Workspace", "bindings": { - "alt-open": ["projects::OpenRecent", { "create_new_window": false }], + "alt-open": "projects::OpenRecent", // Change the default action on `menu::Confirm` by setting the parameter // "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }], - "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": false }], - "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], - "alt-shift-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + "alt-ctrl-o": "projects::OpenRecent", + "ctrl-r": "projects::OpenRecent", + "alt-shift-open": ["projects::OpenRemote", { "from_existing_connection": false }], // Change to open path modal for existing remote connection by setting the parameter - // "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]", - "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + // "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": true }], + "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false }], "alt-ctrl-shift-b": "branches::OpenRecent", "alt-ctrl-shift-w": "git::Worktree", "alt-shift-enter": "toast::RunAction", @@ -1011,7 +1011,7 @@ }, }, { - "context": "GitCommit > Editor", + "context": "GitCommit > Editor && mode == auto_height", "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", @@ -1516,6 +1516,7 @@ "ctrl--": "image_viewer::ZoomOut", "ctrl-0": "image_viewer::ResetZoom", "ctrl-1": "image_viewer::ZoomToActualSize", + "ctrl-k r": "editor::RevealInFileManager", "ctrl-shift-0": "image_viewer::FitToView", }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 79b7263639b93f..1a77bdbbcdeda0 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -306,7 +306,7 @@ "bindings": { "cmd-n": "agent::NewThread", "ctrl--": "pane::GoBack", - "cmd-alt-l": "agent::OpenRulesLibrary", + "cmd-alt-l": "agent::ManageSkills", "cmd-alt-p": "agent::ManageProfiles", "cmd-i": "agent::ToggleProfileSelector", "shift-tab": "agent::CycleModeSelector", @@ -314,6 +314,7 @@ "alt-tab": "agent::CycleFavoriteModels", "shift-alt-escape": "agent::ExpandMessageEditor", "cmd->": "agent::AddSelectionToThread", + "cmd-alt-y": "agent::AllowAlways", "cmd-y": "agent::AllowOnce", "cmd-alt-a": "agent::OpenPermissionDropdown", "cmd-alt-z": "agent::RejectOnce", @@ -667,10 +668,10 @@ "bindings": { // Change the default action on `menu::Confirm` by setting the parameter // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], - "alt-cmd-o": ["projects::OpenRecent", { "create_new_window": false }], - "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], - "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], - "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }], + "alt-cmd-o": "projects::OpenRecent", + "ctrl-r": "projects::OpenRecent", + "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false }], + "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true }], "cmd-ctrl-b": "branches::OpenRecent", "cmd-ctrl-w": "git::Worktree", "ctrl-~": "workspace::NewTerminal", @@ -1117,7 +1118,7 @@ }, }, { - "context": "GitCommit > Editor", + "context": "GitCommit > Editor && mode == auto_height", "use_key_equivalents": true, "bindings": { "enter": "editor::Newline", @@ -1570,6 +1571,7 @@ "cmd--": "image_viewer::ZoomOut", "cmd-0": "image_viewer::ResetZoom", "cmd-1": "image_viewer::ZoomToActualSize", + "cmd-k r": "editor::RevealInFileManager", "cmd-shift-0": "image_viewer::FitToView", }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 1a16a7cc27c380..f12157e68e3f61 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -261,7 +261,7 @@ "bindings": { "ctrl-n": "agent::NewThread", "ctrl--": "pane::GoBack", - "shift-alt-l": "agent::OpenRulesLibrary", + "shift-alt-l": "agent::ManageSkills", "shift-alt-p": "agent::ManageProfiles", "ctrl-i": "agent::ToggleProfileSelector", "shift-tab": "agent::CycleModeSelector", @@ -602,10 +602,10 @@ "bindings": { // Change the default action on `menu::Confirm` by setting the parameter // "ctrl-alt-o": ["projects::OpenRecent", { "create_new_window": true }], - "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], + "ctrl-r": "projects::OpenRecent", // Change to open path modal for existing remote connection by setting the parameter - // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]", - "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + // "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": true }], + "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false }], "shift-alt-b": "branches::OpenRecent", "shift-alt-w": "git::Worktree", "shift-alt-enter": "toast::RunAction", @@ -1003,7 +1003,7 @@ }, }, { - "context": "GitCommit > Editor", + "context": "GitCommit > Editor && mode == auto_height", "use_key_equivalents": true, "bindings": { "escape": "menu::Cancel", @@ -1496,6 +1496,7 @@ "ctrl--": "image_viewer::ZoomOut", "ctrl-0": "image_viewer::ResetZoom", "ctrl-1": "image_viewer::ZoomToActualSize", + "ctrl-k r": "editor::RevealInFileManager", "ctrl-shift-0": "image_viewer::FitToView", }, }, diff --git a/assets/settings/default.json b/assets/settings/default.json index 3877eb97bdd7e1..c534ddd2cacf90 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -150,6 +150,14 @@ // that are already part of an open project) // "cli_default_open_behavior": "new_window" "cli_default_open_behavior": "existing_window", + // The default behavior when opening projects from the UI. + // + // May take 2 values: + // 1. Open projects as a new workspace in the current Zed window's sidebar + // "default_open_behavior": "existing_window" + // 2. Open projects in a new window + // "default_open_behavior": "new_window" + "default_open_behavior": "existing_window", // Whether to attempt to restore previous file's state when opening it again. // The state is stored per pane. // When disabled, defaults are applied instead of the state restoration. @@ -521,14 +529,6 @@ "button_layout": "platform_default", }, "audio": { - // Automatically increase or decrease you microphone's volume. This affects how - // loud you sound to others. - // - // Recommended: off (default) - // Microphones are too quite in zed, until everyone is on experimental - // audio and has auto speaker volume on this will make you very loud - // compared to other speakers. - "experimental.auto_microphone_volume": false, // Select specific output audio device. // `null` means use system default. // Any unrecognized output device will fall back to system default. @@ -998,8 +998,8 @@ // Maximum length of the commit message title before a warning is shown. // Set to 0 to disable. // - // Default: 72 - "commit_title_max_length": 72, + // Default: 0 + "commit_title_max_length": 0, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. @@ -1106,6 +1106,22 @@ }, // When enabled, agent edits will be displayed in single-file editors for review "single_file_review": false, + // Settings for automatic agent context compaction, which summarizes earlier + // messages to free up room in the model's context window once it grows too + // large. + "auto_compact": { + // Whether to automatically compact the agent's context near the limit. + "enabled": true, + // The threshold at which auto-compaction runs. One of: + // - A percentage string ending in "%" (e.g. "90%"), measured against + // the model's context window. Decimals are allowed (e.g. "95.5%"). + // - A positive integer: compact after that many tokens have been used + // (e.g. 100000 compacts after 100,000 tokens are used). + // - A negative integer: compact once that many tokens remain in the + // context window (e.g. -20000 compacts once fewer than 20,000 remain). + // 0 is not a valid threshold. + "threshold": "90%", + }, // When enabled, show voting thumbs for feedback on agent edits. "enable_feedback": true, "default_profile": "write", @@ -1136,8 +1152,6 @@ "skill": true, "spawn_agent": true, "terminal": true, - "update_plan": true, - "update_title": true, "search_web": true, }, }, @@ -1159,8 +1173,6 @@ "grep": true, "skill": true, "spawn_agent": true, - "update_plan": true, - "update_title": true, "search_web": true, }, }, @@ -1491,6 +1503,9 @@ "diagnostics": true, // Send anonymized usage data like what languages you're using Zed with. "metrics": true, + // Allow sending requests to Anthropic models that cannot be offered with + // Zero Data Retention + "anthropic_retention": false, }, // Whether to disable all AI features in Zed. // @@ -2155,7 +2170,7 @@ }, }, "CSharp": { - "language_servers": ["roslyn", "!omnisharp", "..."], + "language_servers": ["roslyn", "!csharp-ls", "!omnisharp", "..."], }, "CSS": { "prettier": { @@ -2370,6 +2385,7 @@ "anthropic": { "api_url": "https://api.anthropic.com", }, + "anthropic_compatible": {}, "bedrock": {}, "google": { "api_url": "https://generativelanguage.googleapis.com", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index be10bc819aada0..fcf05fe84dec16 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -42,7 +42,10 @@ use text::Bias; use ui::App; use util::markdown::MarkdownEscaped; use util::path_list::PathList; -use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle}; +use util::{ + ResultExt, get_default_system_shell_preferring_bash, + paths::{PathStyle, is_absolute}, +}; use uuid::Uuid; /// Returned when the model stops because it exhausted its output token budget. @@ -74,11 +77,89 @@ pub fn meta_with_tool_name(tool_name: &str) -> acp::Meta { acp::Meta::from_iter([(TOOL_NAME_META_KEY.into(), tool_name.into())]) } +/// Key used in ACP `AvailableCommand` meta to record which source produced a +/// slash command, so the completion popup can group commands by category. +pub const COMMAND_CATEGORY_META_KEY: &str = "command_category"; + +/// The source category of a slash command, used to group commands in the +/// completion popup. Only the native Zed agent annotates its commands; commands +/// from external ACP agents carry no category and are grouped on their own. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandCategory { + /// Built-in Zed agent commands (e.g. `/compact`). + Native, + /// Commands sourced from MCP server prompts. + Mcp, +} + +impl CommandCategory { + fn as_str(self) -> &'static str { + match self { + Self::Native => "native", + Self::Mcp => "mcp", + } + } + + fn from_str(value: &str) -> Option { + match value { + "native" => Some(Self::Native), + "mcp" => Some(Self::Mcp), + _ => None, + } + } +} + +pub fn meta_with_command_category(category: CommandCategory) -> acp::Meta { + acp::Meta::from_iter([(COMMAND_CATEGORY_META_KEY.into(), category.as_str().into())]) +} + +pub fn command_category_from_meta(meta: &Option) -> Option { + meta.as_ref() + .and_then(|m| m.get(COMMAND_CATEGORY_META_KEY)) + .and_then(|v| v.as_str()) + .and_then(CommandCategory::from_str) +} + /// Key used in ACP ToolCall meta to store the session id and message indexes pub const SUBAGENT_SESSION_INFO_META_KEY: &str = "subagent_session_info"; pub const SANDBOX_AUTHORIZATION_META_KEY: &str = "sandbox_authorization"; +/// Stable `PermissionOption` ids for the sandbox-escalation approval prompt. +/// +/// These are shared across the option construction (in the agent), the outcome +/// dispatch, and the UI so the distinct grant lifetimes stay in sync. Note +/// that `AllowThread` and `AllowAlways` both use +/// `PermissionOptionKind::AllowAlways`; the id is what distinguishes them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SandboxPermission { + AllowOnce, + AllowThread, + AllowAlways, + Deny, +} + +impl SandboxPermission { + pub fn as_id(self) -> &'static str { + match self { + Self::AllowOnce => "allow", + Self::AllowThread => "allow_thread", + Self::AllowAlways => "allow_always", + Self::Deny => "deny", + } + } + + pub fn from_id(id: &str) -> Option { + match id { + "allow" => Some(Self::AllowOnce), + "allow_thread" => Some(Self::AllowThread), + "allow_always" => Some(Self::AllowAlways), + "deny" => Some(Self::Deny), + _ => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct SandboxAuthorizationDetails { #[serde(default)] @@ -220,7 +301,42 @@ pub enum AgentThreadEntry { AssistantMessage(AssistantMessage), ToolCall(ToolCall), CompletedPlan(Vec), - ContextCompaction, + ContextCompaction(ContextCompaction), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextCompactionId(pub Arc); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextCompactionStatus { + InProgress, + Completed, + Canceled, +} + +/// A point in the thread where the conversation history was compacted to free +/// up room in the model's context window. The summary can be expanded to inspect +/// what the model retained. +#[derive(Debug)] +pub struct ContextCompaction { + pub id: ContextCompactionId, + pub status: ContextCompactionStatus, + /// The compaction summary, streamed in as the model produces it. This is + /// `None` for provider-native compaction, which produces no summary to show. + pub summary: Option>, +} + +impl ContextCompaction { + pub fn is_in_progress(&self) -> bool { + self.status == ContextCompactionStatus::InProgress + } +} + +#[derive(Debug)] +pub struct ContextCompactionUpdate { + pub id: ContextCompactionId, + pub summary_delta: String, + pub status: Option, } impl AgentThreadEntry { @@ -230,7 +346,7 @@ impl AgentThreadEntry { Self::AssistantMessage(message) => message.indented, Self::ToolCall(_) => false, Self::CompletedPlan(_) => false, - Self::ContextCompaction => false, + Self::ContextCompaction(_) => false, } } @@ -247,7 +363,7 @@ impl AgentThreadEntry { } md } - Self::ContextCompaction => "--- Context Compacted ---\n\n".to_string(), + Self::ContextCompaction(_) => "--- Context Compacted ---\n\n".to_string(), } } @@ -400,7 +516,7 @@ impl ToolCall { } if let Some(status) = status { - self.status = status.into(); + self.update_acp_status(status); } if let Some(subagent_session_info) = subagent_session_info_from_meta(&meta) { @@ -483,6 +599,31 @@ impl ToolCall { Ok(()) } + fn update_status(&mut self, status: ToolCallStatus) { + match status { + ToolCallStatus::Pending => self.update_acp_status(acp::ToolCallStatus::Pending), + ToolCallStatus::InProgress => self.update_acp_status(acp::ToolCallStatus::InProgress), + ToolCallStatus::Completed => self.update_acp_status(acp::ToolCallStatus::Completed), + ToolCallStatus::Failed => self.update_acp_status(acp::ToolCallStatus::Failed), + status @ (ToolCallStatus::WaitingForConfirmation { .. } + | ToolCallStatus::Rejected + | ToolCallStatus::Canceled) => self.status = status, + } + } + + fn update_acp_status(&mut self, status: acp::ToolCallStatus) { + if let ToolCallStatus::WaitingForConfirmation { current_status, .. } = &mut self.status + && matches!( + status, + acp::ToolCallStatus::Pending | acp::ToolCallStatus::InProgress + ) + { + *current_status = status; + } else { + self.status = status.into(); + } + } + pub fn diffs(&self) -> impl Iterator> { self.content.iter().filter_map(|content| match content { ToolCallContent::Diff(diff) => Some(diff), @@ -535,9 +676,16 @@ impl ToolCall { ) -> Option { let buffer = project .update(cx, |project, cx| { - project - .project_path_for_absolute_path(&location.path, cx) - .map(|path| project.open_buffer(path, cx)) + if let Some(path) = project.project_path_for_absolute_path(&location.path, cx) { + Some(project.open_buffer(path, cx)) + } else if is_absolute( + location.path.to_string_lossy().as_ref(), + project.path_style(cx), + ) { + Some(project.open_local_buffer(&location.path, cx)) + } else { + None + } }) .ok()??; let buffer = buffer.await.log_err()?; @@ -595,7 +743,7 @@ pub enum SelectedPermissionParams { Terminal { patterns: Vec }, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SelectedPermissionOutcome { pub option_id: acp::PermissionOptionId, pub option_kind: acp::PermissionOptionKind, @@ -661,6 +809,7 @@ pub enum ToolCallStatus { Pending, /// The tool call is waiting for confirmation from the user. WaitingForConfirmation { + current_status: acp::ToolCallStatus, options: PermissionOptions, respond_tx: oneshot::Sender, kind: AuthorizationKind, @@ -689,6 +838,26 @@ impl From for ToolCallStatus { } } +impl ToolCallStatus { + fn as_acp_status(&self) -> Option { + match self { + ToolCallStatus::Pending => Some(acp::ToolCallStatus::Pending), + ToolCallStatus::WaitingForConfirmation { current_status, .. } => Some(*current_status), + ToolCallStatus::InProgress => Some(acp::ToolCallStatus::InProgress), + ToolCallStatus::Completed => Some(acp::ToolCallStatus::Completed), + ToolCallStatus::Failed => Some(acp::ToolCallStatus::Failed), + ToolCallStatus::Rejected | ToolCallStatus::Canceled => None, + } + } + + fn status_after_permission_grant(status: acp::ToolCallStatus) -> ToolCallStatus { + match ToolCallStatus::from(status) { + ToolCallStatus::Pending => ToolCallStatus::InProgress, + status => status, + } + } +} + impl Display for ToolCallStatus { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( @@ -790,6 +959,33 @@ impl ContentBlock { } } + /// Updates a Markdown block in place from a streaming text `block`, reusing + /// the existing `Markdown` entity rather than recreating it. Appends only the + /// new suffix when the update is a continuation (the common streaming case), + /// otherwise re-sets the source. Returns `false` when an in-place update isn't + /// applicable, so the caller can fall back to replacing the block wholesale. + /// + /// Recreating the entity on every streamed snapshot causes the rendered + /// element to tear down and rebuild, which flickers badly. + pub fn update_text_in_place(&mut self, block: &acp::ContentBlock, cx: &mut App) -> bool { + let ContentBlock::Markdown { markdown } = self else { + return false; + }; + let acp::ContentBlock::Text(text_content) = block else { + return false; + }; + let new_content = &text_content.text; + markdown.update(cx, |markdown, cx| { + let current = markdown.source().to_string(); + match new_content.strip_prefix(¤t) { + Some("") => {} + Some(suffix) => markdown.append(suffix, cx), + None => markdown.reset(new_content.clone().into(), cx), + } + }); + true + } + fn decode_image( image_content: &acp::ImageContent, ) -> Option<(Arc, Option>)> { @@ -958,6 +1154,17 @@ impl ToolCallContent { terminals: &HashMap>, cx: &mut App, ) -> Result { + // Update streaming text in place so the rendered markdown element is + // reused across snapshots instead of being recreated (which flickers). + if let ( + Self::ContentBlock(block), + acp::ToolCallContent::Content(acp::Content { content, .. }), + ) = (&mut *self, &new) + && block.update_text_in_place(content, cx) + { + return Ok(true); + } + let needs_update = match (&self, &new) { (Self::Diff(old_diff), acp::ToolCallContent::Diff(new_diff)) => { old_diff.read(cx).needs_update( @@ -1158,6 +1365,20 @@ pub struct RetryStatus { pub max_attempts: usize, pub started_at: Instant, pub duration: Duration, + pub meta: Option, +} + +pub const REFUSAL_FALLBACK_MODEL_META_KEY: &str = "refusal_fallback_model"; + +pub fn meta_with_refusal_fallback(model_name: &str) -> acp::Meta { + acp::Meta::from_iter([(REFUSAL_FALLBACK_MODEL_META_KEY.into(), model_name.into())]) +} + +pub fn refusal_fallback_model_from_meta(meta: &Option) -> Option { + meta.as_ref() + .and_then(|m| m.get(REFUSAL_FALLBACK_MODEL_META_KEY)) + .and_then(|v| v.as_str()) + .map(|s| SharedString::from(s.to_owned())) } struct RunningTurn { @@ -1469,6 +1690,15 @@ impl AcpThread { &self.entries } + pub fn is_compacting(&self) -> bool { + self.entries.last().is_some_and(|entry| { + matches!( + entry, + AgentThreadEntry::ContextCompaction(compaction) if compaction.is_in_progress() + ) + }) + } + pub fn invalidate_mermaid_caches(&self, cx: &mut App) { for entry in &self.entries { let chunks = match entry { @@ -1529,7 +1759,7 @@ impl AcpThread { AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) - | AgentThreadEntry::ContextCompaction => {} + | AgentThreadEntry::ContextCompaction(_) => {} } } false @@ -1558,7 +1788,7 @@ impl AcpThread { AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) - | AgentThreadEntry::ContextCompaction => {} + | AgentThreadEntry::ContextCompaction(_) => {} } } @@ -1578,7 +1808,7 @@ impl AcpThread { AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) - | AgentThreadEntry::ContextCompaction => {} + | AgentThreadEntry::ContextCompaction(_) => {} } } @@ -1591,7 +1821,7 @@ impl AcpThread { AgentThreadEntry::UserMessage(..) => return false, AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) - | AgentThreadEntry::ContextCompaction => continue, + | AgentThreadEntry::ContextCompaction(_) => continue, AgentThreadEntry::ToolCall(..) => return true, } } @@ -1660,7 +1890,7 @@ impl AcpThread { config_options, .. }) => cx.emit(AcpThreadEvent::ConfigOptionsUpdated(config_options)), - acp::SessionUpdate::UsageUpdate(update) if cx.has_flag::() => { + acp::SessionUpdate::UsageUpdate(update) => { let usage = self.token_usage.get_or_insert_with(Default::default); usage.max_tokens = update.size; usage.used_tokens = update.used; @@ -1934,8 +2164,69 @@ impl AcpThread { cx.emit(AcpThreadEvent::NewEntry); } - pub fn push_context_compaction(&mut self, cx: &mut Context) { - self.push_entry(AgentThreadEntry::ContextCompaction, cx); + pub fn push_context_compaction( + &mut self, + compaction: ContextCompaction, + cx: &mut Context, + ) { + if let Some(ix) = + self.entries + .iter() + .enumerate() + .rev() + .find_map(|(ix, entry)| match entry { + AgentThreadEntry::ContextCompaction(c) if &c.id == &compaction.id => Some(ix), + _ => None, + }) + { + self.entries[ix] = AgentThreadEntry::ContextCompaction(compaction); + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } else { + self.push_entry(AgentThreadEntry::ContextCompaction(compaction), cx); + } + } + + pub fn update_context_compaction( + &mut self, + update: ContextCompactionUpdate, + cx: &mut Context, + ) { + let language_registry = self.project.read(cx).languages().clone(); + let Some((ix, compaction)) = + self.entries + .iter_mut() + .enumerate() + .rev() + .find_map(|(ix, entry)| match entry { + AgentThreadEntry::ContextCompaction(c) if &c.id == &update.id => Some((ix, c)), + _ => None, + }) + else { + return; + }; + + if !update.summary_delta.is_empty() { + if compaction.summary.is_none() { + compaction.summary = Some(cx.new(|cx| { + Markdown::new( + update.summary_delta.into(), + Some(language_registry), + None, + cx, + ) + })); + } else if let Some(summary) = compaction.summary.clone() { + summary.update(cx, |markdown, cx| { + markdown.append(&update.summary_delta, cx) + }); + } + } + + if let Some(status) = update.status { + compaction.status = status; + } + + cx.emit(AcpThreadEvent::EntryUpdated(ix)); } pub fn can_set_title(&mut self, cx: &mut Context) -> bool { @@ -2106,7 +2397,7 @@ impl AcpThread { &self.terminals, cx, )?; - call.status = status; + call.update_status(status); cx.emit(AcpThreadEvent::EntryUpdated(ix)); } else { @@ -2257,7 +2548,13 @@ impl AcpThread { ) -> Result> { let (tx, rx) = oneshot::channel(); + let current_status = self + .tool_call(&tool_call.tool_call_id) + .and_then(|(_, tool_call)| tool_call.status.as_acp_status()) + .or(tool_call.fields.status) + .unwrap_or(acp::ToolCallStatus::Pending); let status = ToolCallStatus::WaitingForConfirmation { + current_status, options, respond_tx: tx, kind, @@ -2292,24 +2589,30 @@ impl AcpThread { return; }; - let is_action_choice = matches!( - call.status, - ToolCallStatus::WaitingForConfirmation { - kind: AuthorizationKind::ActionChoice, - .. - } - ); let new_status = - if is_action_choice { - ToolCallStatus::InProgress - } else { - match outcome.option_kind { + match &call.status { + ToolCallStatus::WaitingForConfirmation { + kind: AuthorizationKind::ActionChoice, + .. + } => ToolCallStatus::InProgress, + ToolCallStatus::WaitingForConfirmation { current_status, .. } => { + match outcome.option_kind { + acp::PermissionOptionKind::RejectOnce + | acp::PermissionOptionKind::RejectAlways => ToolCallStatus::Rejected, + acp::PermissionOptionKind::AllowOnce + | acp::PermissionOptionKind::AllowAlways => { + ToolCallStatus::status_after_permission_grant(*current_status) + } + _ => ToolCallStatus::status_after_permission_grant(*current_status), + } + } + _ => match outcome.option_kind { acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => ToolCallStatus::Rejected, acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => ToolCallStatus::InProgress, _ => ToolCallStatus::InProgress, - } + }, }; let curr_status = mem::replace(&mut call.status, new_status); @@ -2382,6 +2685,27 @@ impl AcpThread { &mut self, message: Vec, cx: &mut Context, + ) -> BoxFuture<'static, Result>> { + self.send_inner(message, true, cx) + } + + /// Sends a prompt without displaying a user-message bubble for it. + /// This is used for native slash commands (e.g. `/compact`) that run a turn + /// which produces its own thread entry (like the compaction summary). The + /// typed command isn't sent to the model as an ordinary user turn. + pub fn send_command( + &mut self, + message: Vec, + cx: &mut Context, + ) -> BoxFuture<'static, Result>> { + self.send_inner(message, false, cx) + } + + fn send_inner( + &mut self, + message: Vec, + push_user_message: bool, + cx: &mut Context, ) -> BoxFuture<'static, Result>> { let block = ContentBlock::new_combined( message.clone(), @@ -2395,32 +2719,38 @@ impl AcpThread { let message_id = UserMessageId::new(); self.run_turn(cx, async move |this, cx| { - this.update(cx, |this, cx| { - this.push_entry( - AgentThreadEntry::UserMessage(UserMessage { - id: Some(message_id.clone()), - content: block, - chunks: message, - checkpoint: None, - indented: false, - }), - cx, - ); - }) - .ok(); + if push_user_message { + this.update(cx, |this, cx| { + this.push_entry( + AgentThreadEntry::UserMessage(UserMessage { + id: Some(message_id.clone()), + content: block, + chunks: message, + checkpoint: None, + indented: false, + }), + cx, + ); + }) + .ok(); + + let old_checkpoint = git_store + .update(cx, |git, cx| git.checkpoint(cx)) + .await + .context("failed to get old checkpoint") + .log_err(); + this.update(cx, |this, _cx| { + if let Some((_ix, message)) = this.last_user_message() { + message.checkpoint = old_checkpoint.map(|git_checkpoint| Checkpoint { + git_checkpoint, + show: false, + }); + } + }) + .ok(); + } - let old_checkpoint = git_store - .update(cx, |git, cx| git.checkpoint(cx)) - .await - .context("failed to get old checkpoint") - .log_err(); this.update(cx, |this, cx| { - if let Some((_ix, message)) = this.last_user_message() { - message.checkpoint = old_checkpoint.map(|git_checkpoint| Checkpoint { - git_checkpoint, - show: false, - }); - } this.connection.prompt(message_id, request, cx) })? .await @@ -2537,8 +2867,8 @@ impl AcpThread { } let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); - if canceled { - this.mark_pending_tools_as_canceled(); + if canceled && is_same_turn { + this.mark_pending_entries_as_canceled(cx); } if !canceled { @@ -2624,7 +2954,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(); + self.mark_pending_entries_as_canceled(cx); // Emit Stopped(Cancelled) SYNCHRONOUSLY before dropping the send_task. // This guarantees message_completed is sent to the server BEFORE the @@ -2651,19 +2981,28 @@ impl AcpThread { Task::ready(()) } - fn mark_pending_tools_as_canceled(&mut self) { - for entry in self.entries.iter_mut() { - if let AgentThreadEntry::ToolCall(call) = entry { - let cancel = matches!( - call.status, - ToolCallStatus::Pending - | ToolCallStatus::WaitingForConfirmation { .. } - | ToolCallStatus::InProgress - ); - - if cancel { - call.status = ToolCallStatus::Canceled; + fn mark_pending_entries_as_canceled(&mut self, cx: &mut Context) { + for (ix, entry) in self.entries.iter_mut().enumerate() { + match entry { + AgentThreadEntry::ToolCall(call) => { + let cancel = matches!( + call.status, + ToolCallStatus::Pending + | ToolCallStatus::WaitingForConfirmation { .. } + | ToolCallStatus::InProgress + ); + if cancel { + call.status = ToolCallStatus::Canceled; + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } } + AgentThreadEntry::ContextCompaction(compaction) => { + if compaction.status == ContextCompactionStatus::InProgress { + compaction.status = ContextCompactionStatus::Canceled; + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + } + _ => {} } } } @@ -3518,6 +3857,27 @@ mod tests { }; use util::{path, path_list::PathList}; + #[test] + fn command_category_meta_round_trips() { + // Exhaustive list of variants. The match below has no wildcard arm, so + // adding a `CommandCategory` variant fails to compile here until it's + // covered, keeping the `as_str`/`from_str` wire contract in sync. + let all = [CommandCategory::Native, CommandCategory::Mcp]; + for category in all { + match category { + CommandCategory::Native | CommandCategory::Mcp => {} + } + let meta = meta_with_command_category(category); + assert_eq!(command_category_from_meta(&Some(meta)), Some(category)); + } + + // Absent meta and unknown categories both decode to `None`. + assert_eq!(command_category_from_meta(&None), None); + let unknown = + acp::Meta::from_iter([(COMMAND_CATEGORY_META_KEY.into(), "future-category".into())]); + assert_eq!(command_category_from_meta(&Some(unknown)), None); + } + fn init_test(cx: &mut TestAppContext) { env_logger::try_init().ok(); cx.update(|cx| { @@ -3956,6 +4316,80 @@ mod tests { ); } + /// `send_command` runs the turn (the connection receives the typed command) + /// but never echoes a user-message bubble, so commands like `/compact` don't + /// show a fake user message implying the text was sent to the model. + #[gpui::test] + async fn test_send_command_does_not_echo_user_message(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let received_prompt: Rc>> = Rc::new(RefCell::new(None)); + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let received_prompt = received_prompt.clone(); + move |request, thread, mut cx| { + let received_prompt = received_prompt.clone(); + async move { + if let Some(acp::ContentBlock::Text(text)) = request.prompt.first() { + *received_prompt.borrow_mut() = Some(text.text.clone()); + } + // Simulate a native command producing its own thread entry + // (here a compaction) rather than echoing a user message. + thread.update(&mut cx, |thread, cx| { + thread.push_context_compaction( + ContextCompaction { + id: ContextCompactionId("c1".into()), + status: ContextCompactionStatus::Completed, + summary: None, + }, + cx, + ); + })?; + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + .boxed_local() + } + })); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.send_command(vec!["/compact".into()], cx) + }) + }) + .await + .unwrap(); + + // The command turn ran: the connection received the typed command. + assert_eq!(received_prompt.borrow().as_deref(), Some("/compact")); + + thread.update(cx, |thread, _cx| { + assert!( + !thread + .entries + .iter() + .any(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))), + "send_command must not echo a user message" + ); + // The command's own entry (here a compaction) is still shown. + assert!( + thread + .entries + .iter() + .any(|entry| matches!(entry, AgentThreadEntry::ContextCompaction(_))), + "the command's own thread entry should still be present" + ); + }); + } + #[gpui::test] async fn test_ignore_echoed_user_message_chunks_during_active_turn( cx: &mut gpui::TestAppContext, @@ -4362,37 +4796,463 @@ mod tests { } #[gpui::test] - async fn test_no_pending_edits_if_tool_calls_are_completed(cx: &mut TestAppContext) { + async fn test_tool_call_location_resolves_external_file(cx: &mut TestAppContext) { init_test(cx); - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree(path!("/test"), json!({})).await; - let project = Project::test(fs, [path!("/test").as_ref()], cx).await; - let connection = Rc::new(FakeAgentConnection::new().on_user_message({ - move |_, thread, mut cx| { - async move { - thread - .update(&mut cx, |thread, cx| { - thread.handle_session_update( - acp::SessionUpdate::ToolCall( - acp::ToolCall::new("test", "Label") - .kind(acp::ToolKind::Edit) - .status(acp::ToolCallStatus::Completed) - .content(vec![acp::ToolCallContent::Diff(acp::Diff::new( - "/test/test.txt", - "foo", - ))]), - ), - cx, - ) - }) - .unwrap() - .unwrap(); - Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) - } - .boxed_local() - } - })); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/tmp/skills/test-skill"), + json!({ "SKILL.md": "skill body" }), + ) + .await; + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/project"))]), cx) + }) + .await + .unwrap(); + + let skill_path = std::path::PathBuf::from(path!("/tmp/skills/test-skill/SKILL.md")); + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("write_file", "Write SKILL.md") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .locations(vec![acp::ToolCallLocation::new(skill_path.clone())]), + ), + cx, + ) + }) + .unwrap(); + + cx.run_until_parked(); + + thread.read_with(cx, |thread, cx| { + let (tool_call_location, agent_location) = thread.entries[0] + .location(0) + .expect("external tool-call location should resolve"); + assert_eq!(tool_call_location.path, skill_path); + + let buffer = agent_location + .buffer + .upgrade() + .expect("resolved location should keep an open buffer"); + assert_eq!(buffer.read(cx).text(), "skill body"); + }); + } + + #[gpui::test] + async fn test_duplicate_tool_call_update_preserves_open_permission_request_until_authorized( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let tool_call_id = acp::ToolCallId::new("toolu_01duplicate"); + let allow_option_id = acp::PermissionOptionId::new("allow"); + let permission_task = thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization( + acp::ToolCall::new(tool_call_id.clone(), "Original title") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::Pending) + .content(vec!["original content".into()]) + .into(), + PermissionOptions::Flat(vec![acp::PermissionOption::new( + allow_option_id.clone(), + "Allow", + acp::PermissionOptionKind::AllowOnce, + )]), + AuthorizationKind::PermissionGrant, + cx, + ) + }) + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new(tool_call_id.clone(), "Updated title") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::Pending) + .content(vec!["updated content".into()]), + ), + cx, + ) + }) + .unwrap(); + + thread.read_with(cx, |thread, cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert_eq!(tool_call.label.read(cx).source(), "Updated title"); + assert!(matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + )); + assert_eq!(tool_call.content.len(), 1); + assert_eq!(tool_call.content[0].to_markdown(cx), "updated content"); + }); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::InProgress) + .title("Updated again") + .content(vec!["updated again".into()]), + )), + cx, + ) + }) + .unwrap(); + + thread.read_with(cx, |thread, cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert_eq!(tool_call.label.read(cx).source(), "Updated again"); + assert!(matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + )); + assert_eq!(tool_call.content.len(), 1); + assert_eq!(tool_call.content[0].to_markdown(cx), "updated again"); + }); + + let selected_outcome = SelectedPermissionOutcome::new( + allow_option_id.clone(), + acp::PermissionOptionKind::AllowOnce, + ); + thread.update(cx, |thread, cx| { + thread.authorize_tool_call(tool_call_id.clone(), selected_outcome, cx); + }); + + thread.read_with(cx, |thread, _cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert!(matches!(tool_call.status, ToolCallStatus::InProgress)); + }); + + match permission_task.await { + RequestPermissionOutcome::Selected(outcome) => { + assert_eq!(outcome.option_id, allow_option_id); + assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce); + } + RequestPermissionOutcome::Cancelled => { + panic!("permission request should remain open after duplicate tool call update") + } + } + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .title("Completed") + .content(vec!["done".into()]), + )), + cx, + ) + }) + .unwrap(); + + thread.read_with(cx, |thread, cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert_eq!(tool_call.label.read(cx).source(), "Completed"); + assert!(matches!(tool_call.status, ToolCallStatus::Completed)); + assert_eq!(tool_call.content.len(), 1); + assert_eq!(tool_call.content[0].to_markdown(cx), "done"); + }); + } + + #[gpui::test] + async fn test_permission_request_tracks_agent_status_until_resolved(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let tool_call_id = acp::ToolCallId::new("toolu_01auto_resolve"); + let permission_task = thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization( + acp::ToolCall::new(tool_call_id.clone(), "Original title") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::Pending) + .into(), + PermissionOptions::Flat(vec![acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + )]), + AuthorizationKind::PermissionGrant, + cx, + ) + }) + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), + )), + cx, + ) + }) + .unwrap(); + + thread.read_with(cx, |thread, _cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert!(matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { + current_status: acp::ToolCallStatus::InProgress, + .. + } + )); + }); + + thread.update(cx, |thread, cx| { + thread.authorize_tool_call( + tool_call_id.clone(), + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + ), + cx, + ); + }); + + thread.read_with(cx, |thread, _cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert!(matches!(tool_call.status, ToolCallStatus::InProgress)); + }); + + match permission_task.await { + RequestPermissionOutcome::Selected(outcome) => { + assert_eq!(outcome.option_id, acp::PermissionOptionId::new("allow")); + assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce); + } + RequestPermissionOutcome::Cancelled => { + panic!("resolved permission request should select an outcome") + } + } + } + + #[gpui::test] + async fn test_permission_request_sets_waiting_status_on_existing_tool_call( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let tool_call_id = acp::ToolCallId::new("toolu_01existing_permission"); + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new(tool_call_id.clone(), "Running title") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::InProgress), + ), + cx, + ) + }) + .unwrap(); + + let permission_task = thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization( + acp::ToolCall::new(tool_call_id.clone(), "Needs permission") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::Pending) + .into(), + PermissionOptions::Flat(vec![acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + )]), + AuthorizationKind::PermissionGrant, + cx, + ) + }) + .unwrap(); + + thread.read_with(cx, |thread, cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert_eq!(tool_call.label.read(cx).source(), "Needs permission"); + assert!(matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { + current_status: acp::ToolCallStatus::InProgress, + .. + } + )); + }); + + thread.update(cx, |thread, cx| { + thread.authorize_tool_call( + tool_call_id.clone(), + SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("allow"), + acp::PermissionOptionKind::AllowOnce, + ), + cx, + ); + }); + + match permission_task.await { + RequestPermissionOutcome::Selected(outcome) => { + assert_eq!(outcome.option_id, acp::PermissionOptionId::new("allow")); + assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce); + } + RequestPermissionOutcome::Cancelled => { + panic!("permission request should resolve after authorization") + } + } + } + + #[gpui::test] + async fn test_terminal_tool_call_update_closes_open_permission_request( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let tool_call_id = acp::ToolCallId::new("toolu_01completed_while_waiting"); + let permission_task = thread + .update(cx, |thread, cx| { + thread.request_tool_call_authorization( + acp::ToolCall::new(tool_call_id.clone(), "Needs permission") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::Pending) + .into(), + PermissionOptions::Flat(vec![acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + )]), + AuthorizationKind::PermissionGrant, + cx, + ) + }) + .unwrap(); + + thread + .update(cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + tool_call_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), + cx, + ) + }) + .unwrap(); + + thread.read_with(cx, |thread, _cx| { + let (_, tool_call) = thread + .tool_call(&tool_call_id) + .expect("tool call should exist"); + assert!(matches!(tool_call.status, ToolCallStatus::Completed)); + }); + + match permission_task.await { + RequestPermissionOutcome::Cancelled => {} + RequestPermissionOutcome::Selected(_) => { + panic!("terminal tool call update should close pending permission request") + } + } + } + + #[gpui::test] + async fn test_no_pending_edits_if_tool_calls_are_completed(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/test"), json!({})).await; + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + move |_, thread, mut cx| { + async move { + thread + .update(&mut cx, |thread, cx| { + thread.handle_session_update( + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("test", "Label") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff(acp::Diff::new( + "/test/test.txt", + "foo", + ))]), + ), + cx, + ) + }) + .unwrap() + .unwrap(); + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + .boxed_local() + } + })); let thread = cx .update(|cx| { @@ -5825,6 +6685,102 @@ mod tests { // (didn't hang) and emitted Stopped. } + #[gpui::test] + async fn test_stale_cancelled_response_does_not_cancel_current_compaction( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let (first_complete_tx, first_complete_rx) = futures::channel::oneshot::channel::<()>(); + let first_complete_rx = RefCell::new(Some(first_complete_rx)); + let compaction_id = ContextCompactionId("test-compaction".into()); + + let connection = Rc::new(FakeAgentConnection::new().on_user_message({ + let compaction_id = compaction_id.clone(); + move |params, thread, mut cx| { + let first_complete_rx = first_complete_rx.borrow_mut().take(); + let is_first = params.prompt.iter().any(|content| { + matches!(content, acp::ContentBlock::Text(text) if text.text.contains("first")) + }); + let compaction_id = compaction_id.clone(); + + async move { + if is_first { + if let Some(rx) = first_complete_rx { + rx.await + .expect("first completion sender should still be alive"); + } + + thread.update(&mut cx, |thread, cx| { + thread.push_context_compaction( + ContextCompaction { + id: compaction_id, + status: ContextCompactionStatus::InProgress, + summary: None, + }, + cx, + ); + })?; + + Ok(acp::PromptResponse::new(acp::StopReason::Cancelled)) + } else { + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) + } + } + .boxed_local() + } + })); + + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + let first_request = thread.update(cx, |thread, cx| thread.send_raw("first", cx)); + assert_eq!(thread.read_with(cx, |thread, _| thread.turn_id), 1); + + let second_request = thread.update(cx, |thread, cx| thread.send_raw("second", cx)); + assert_eq!(thread.read_with(cx, |thread, _| thread.turn_id), 2); + + first_complete_tx + .send(()) + .expect("first completion receiver should still be alive"); + + let response = first_request + .await + .expect("first request should complete") + .expect("first request should have response"); + assert_eq!(response.stop_reason, acp::StopReason::Cancelled); + + thread.read_with(cx, |thread, _| { + let compaction = thread + .entries + .iter() + .find_map(|entry| { + let AgentThreadEntry::ContextCompaction(compaction) = entry else { + return None; + }; + (compaction.id == compaction_id).then_some(compaction) + }) + .expect("compaction entry should exist"); + + assert_eq!( + compaction.status, + ContextCompactionStatus::InProgress, + "a stale cancelled response from an older turn should not cancel current compaction" + ); + }); + + second_request + .await + .expect("second request should complete"); + } + #[gpui::test] async fn test_send_assigns_message_id_without_truncate_support(cx: &mut TestAppContext) { init_test(cx); @@ -6102,6 +7058,71 @@ mod tests { }); } + #[gpui::test] + async fn test_context_compaction_preserves_token_usage(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let connection = Rc::new(FakeAgentConnection::new()); + let thread = cx + .update(|cx| { + connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx) + }) + .await + .unwrap(); + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::UsageUpdate( + acp::UsageUpdate::new(5000, 10000).cost(acp::Cost::new(0.42, "USD")), + ), + cx, + ) + .unwrap(); + + thread.push_context_compaction( + ContextCompaction { + id: ContextCompactionId("compaction-1".into()), + status: ContextCompactionStatus::InProgress, + summary: None, + }, + cx, + ); + }); + + thread.read_with(cx, |thread, _| { + let usage = thread + .token_usage() + .expect("context compaction should not clear token usage on its own"); + assert_eq!(usage.used_tokens, 5000); + assert_eq!(usage.max_tokens, 10000); + + let cost = thread + .cost() + .expect("context compaction should not clear cost on its own"); + assert!((cost.amount - 0.42).abs() < f64::EPSILON); + }); + + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::UsageUpdate(acp::UsageUpdate::new(1000, 10000)), + cx, + ) + .unwrap(); + }); + + thread.read_with(cx, |thread, _| { + let usage = thread + .token_usage() + .expect("token_usage should be restored by the next usage update"); + assert_eq!(usage.used_tokens, 1000); + assert_eq!(usage.max_tokens, 10000); + }); + } + #[gpui::test] async fn test_usage_update_without_cost_preserves_existing_cost(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index e679801e294955..925c933b4c5516 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -375,7 +375,7 @@ pub trait AgentSessionList { cx: &mut App, ) -> Task>; - fn supports_delete(&self, _cx: &App) -> bool { + fn supports_delete(&self) -> bool { false } diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index a6d3b86db7c980..d297b5fa98f513 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -24,6 +24,7 @@ impl Diff { ) -> Self { let multibuffer = cx.new(|_cx| MultiBuffer::without_headers(Capability::ReadOnly)); let new_buffer = cx.new(|cx| Buffer::local(new_text, cx)); + let base_text_exists = old_text.is_some(); let base_text = old_text.clone().unwrap_or(String::new()).into(); let task = cx.spawn({ let multibuffer = multibuffer.clone(); @@ -40,8 +41,8 @@ impl Diff { let diff = build_buffer_diff( old_text.unwrap_or("".into()).into(), + base_text_exists, &buffer, - Some(language_registry.clone()), cx, ) .await?; @@ -88,16 +89,7 @@ impl Diff { let language = buffer.read(cx).language().cloned(); let language_registry = buffer.read(cx).language_registry(); let buffer_diff = cx.new(|cx| { - let mut diff = BufferDiff::new_unchanged(&buffer_text_snapshot, cx); - diff.language_changed(language.clone(), language_registry.clone(), cx); - let secondary_diff = cx.new(|cx| { - // For the secondary diff buffer we skip assigning the language as we do not really need to perform any syntax highlighting on - // it. As a result, by skipping it we are potentially shaving off a lot of RSS plus we get a snappier feel for large diff - // view multibuffers. - BufferDiff::new_unchanged(&buffer_text_snapshot, cx) - }); - diff.set_secondary_diff(secondary_diff); - diff + BufferDiff::new_unchanged(&buffer_text_snapshot, language, language_registry, cx) }); let multibuffer = cx.new(|cx| { @@ -233,28 +225,20 @@ impl PendingDiff { let base_text = self.base_text.clone(); self.update_diff = cx.spawn(async move |diff, cx| { let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); - let language = buffer.read_with(cx, |buffer, _| buffer.language().cloned()); + let base_text_snapshot = buffer_diff.read_with(cx, |diff, cx| diff.base_text(cx)); let update = buffer_diff .update(cx, |diff, cx| { diff.update_diff( text_snapshot.clone(), + &base_text_snapshot, Some(base_text.clone()), - None, - language, cx, ) }) .await; - let (task1, task2) = buffer_diff.update(cx, |diff, cx| { - let task1 = diff.set_snapshot(update.clone(), &text_snapshot, cx); - let task2 = diff - .secondary_diff() - .unwrap() - .update(cx, |diff, cx| diff.set_snapshot(update, &text_snapshot, cx)); - (task1, task2) + buffer_diff.update(cx, |diff, cx| { + diff.set_snapshot(update.clone(), cx); }); - task1.await; - task2.await; diff.update(cx, |diff, cx| { if let Diff::Pending(diff) = diff { diff.update_visible_ranges(cx); @@ -272,7 +256,6 @@ impl PendingDiff { let ranges = self.excerpt_ranges(cx); let base_text = self.base_text.clone(); let new_buffer = self.new_buffer.read(cx); - let language_registry = new_buffer.language_registry(); let path = new_buffer .file() @@ -299,7 +282,7 @@ impl PendingDiff { let buffer = buffer.clone(); async move |_this, cx| { buffer.update(cx, |buffer, _| buffer.parsing_idle()).await; - build_buffer_diff(base_text, &buffer, language_registry, cx).await + build_buffer_diff(base_text, true, &buffer, cx).await } }); @@ -397,39 +380,18 @@ pub struct FinalizedDiff { async fn build_buffer_diff( old_text: Arc, + base_text_exists: bool, buffer: &Entity, - language_registry: Option>, cx: &mut AsyncApp, ) -> Result> { let language = cx.update(|cx| buffer.read(cx).language().cloned()); - let text_snapshot = cx.update(|cx| buffer.read(cx).text_snapshot()); + let language_registry = cx.update(|cx| buffer.read(cx).language_registry()); let buffer = cx.update(|cx| buffer.read(cx).snapshot()); + let base_text = base_text_exists.then(|| old_text); - let secondary_diff = cx.new(|cx| BufferDiff::new(&buffer, cx)); - - let update = secondary_diff - .update(cx, |secondary_diff, cx| { - secondary_diff.update_diff( - text_snapshot.clone(), - Some(old_text), - Some(false), - language.clone(), - cx, - ) - }) - .await; - - secondary_diff - .update(cx, |secondary_diff, cx| { - secondary_diff.set_snapshot(update.clone(), &buffer, cx) - }) - .await; - - let diff = cx.new(|cx| BufferDiff::new(&buffer, cx)); + let diff = cx.new(|cx| BufferDiff::new(&buffer, language, language_registry, cx)); diff.update(cx, |diff, cx| { - diff.language_changed(language, language_registry, cx); - diff.set_secondary_diff(secondary_diff); - diff.set_snapshot(update.clone(), &buffer, cx) + diff.set_base_text(base_text, buffer.text, cx) }) .await; Ok(diff) diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index f2423858523b3b..0ebe1712ff17b9 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -36,6 +36,14 @@ pub enum MentionUri { id: acp::SessionId, name: String, }, + /// Deprecated: kept so threads from before rules became skills still + /// deserialize. `id` (an opaque `prompt_store::PromptId`) is preserved + /// verbatim so re-saved threads stay loadable by older Zed versions. + Rule { + #[serde(default = "default_deprecated_rule_id")] + id: serde_json::Value, + name: String, + }, Diagnostics { #[serde(default = "default_include_errors")] include_errors: bool, @@ -200,6 +208,15 @@ impl MentionUri { id: acp::SessionId::new(thread_id), name, }) + } else if let Some(rule_id) = path.strip_prefix("/agent/rule/") { + // Deprecated: parses legacy rule mentions. + let name = single_query_param(&url, "name")?.context("Missing rule name")?; + let id = if rule_id.is_empty() { + default_deprecated_rule_id() + } else { + serde_json::json!({ "User": { "uuid": rule_id } }) + }; + Ok(Self::Rule { id, name }) } else if path == "/agent/diagnostics" { let mut include_errors = default_include_errors(); let mut include_warnings = false; @@ -330,6 +347,7 @@ impl MentionUri { MentionUri::PastedImage { name } => name.clone(), MentionUri::Symbol { name, .. } => name.clone(), MentionUri::Thread { name, .. } => name.clone(), + MentionUri::Rule { name, .. } => name.clone(), MentionUri::Diagnostics { .. } => "Diagnostics".to_string(), MentionUri::TerminalSelection { line_count } => { if *line_count == 1 { @@ -430,6 +448,7 @@ impl MentionUri { .unwrap_or_else(|| IconName::Folder.path().into()), MentionUri::Symbol { .. } => IconName::Code.path().into(), MentionUri::Thread { .. } => IconName::Thread.path().into(), + MentionUri::Rule { .. } => IconName::Reader.path().into(), MentionUri::Diagnostics { .. } => IconName::Warning.path().into(), MentionUri::TerminalSelection { .. } => IconName::Terminal.path().into(), MentionUri::Selection { .. } => IconName::Reader.path().into(), @@ -512,6 +531,17 @@ impl MentionUri { url.query_pairs_mut().append_pair("name", name); url } + MentionUri::Rule { id, name } => { + let mut url = Url::parse("zed:///").unwrap(); + let rule_id = id + .get("User") + .and_then(|user| user.get("uuid")) + .and_then(|uuid| uuid.as_str()) + .unwrap_or_default(); + url.set_path(&format!("/agent/rule/{rule_id}")); + url.query_pairs_mut().append_pair("name", name); + url + } MentionUri::Diagnostics { include_errors, include_warnings, @@ -573,6 +603,12 @@ fn default_include_errors() -> bool { true } +/// Placeholder rule `id` for legacy mentions missing one, shaped so older Zed +/// versions can still deserialize it as a `prompt_store::PromptId`. +fn default_deprecated_rule_id() -> serde_json::Value { + serde_json::json!({ "User": { "uuid": "00000000-0000-0000-0000-000000000000" } }) +} + fn query_param(url: &Url, name: &'static str) -> Option { url.query_pairs() .find_map(|(key, value)| (key == name).then(|| value.to_string())) @@ -604,6 +640,18 @@ pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive) -> ) } +/// Formats a 0-based, inclusive line range as a 1-based path suffix: `:5` for a +/// single line or `:5-9` for a span. Used for `path:line` mentions in text. +pub fn line_range_suffix(line_range: &RangeInclusive) -> String { + let start = *line_range.start() + 1; + let end = *line_range.end() + 1; + if start == end { + format!(":{start}") + } else { + format!(":{start}-{end}") + } +} + #[cfg(test)] mod tests { use util::{path, uri}; @@ -791,6 +839,43 @@ mod tests { assert_eq!(parsed.to_uri().to_string(), thread_uri); } + #[test] + fn test_parse_legacy_rule_uri() { + let rule_uri = "zed:///agent/rule/d8694ff2-90d5-4b6f-be33-33c1763acd52?name=Some+rule"; + let parsed = MentionUri::parse(rule_uri, PathStyle::local()).unwrap(); + match &parsed { + MentionUri::Rule { name, .. } => assert_eq!(name, "Some rule"), + _ => panic!("Expected Rule variant"), + } + // The id round-trips through the URI. + assert_eq!(parsed.to_uri().to_string(), rule_uri); + } + + #[test] + fn test_legacy_rule_mention_preserves_id() { + // The `id` older Zed versions require must survive a load + save. + let json = r#"{"Rule":{"id":{"User":{"uuid":"d8694ff2-90d5-4b6f-be33-33c1763acd52"}},"name":"Some rule"}}"#; + let parsed: MentionUri = serde_json::from_str(json).unwrap(); + match &parsed { + MentionUri::Rule { name, .. } => assert_eq!(name, "Some rule"), + _ => panic!("Expected Rule variant"), + } + let reserialized = serde_json::to_value(&parsed).unwrap(); + assert_eq!( + reserialized["Rule"]["id"]["User"]["uuid"], + "d8694ff2-90d5-4b6f-be33-33c1763acd52" + ); + } + + #[test] + fn test_legacy_rule_mention_without_id_gets_placeholder() { + // A mention missing its id still serializes a valid id for older versions. + let json = r#"{"Rule":{"name":"Some rule"}}"#; + let parsed: MentionUri = serde_json::from_str(json).unwrap(); + let reserialized = serde_json::to_value(&parsed).unwrap(); + assert!(reserialized["Rule"]["id"]["User"]["uuid"].is_string()); + } + #[test] fn test_parse_skill_uri_round_trip() { let skill_uri = MentionUri::Skill { diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 99cc0a2d79bfb1..f8b15f621e9d6b 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -159,11 +159,8 @@ impl ActionLog { let text_snapshot = buffer.read(cx).text_snapshot(); let language = buffer.read(cx).language().cloned(); let language_registry = buffer.read(cx).language_registry(); - let diff = cx.new(|cx| { - let mut diff = BufferDiff::new(&text_snapshot, cx); - diff.language_changed(language, language_registry, cx); - diff - }); + let diff = + cx.new(|cx| BufferDiff::new(&text_snapshot, language, language_registry, cx)); let (diff_update_tx, diff_update_rx) = mpsc::unbounded(); let diff_base; let unreviewed_edits; @@ -465,29 +462,15 @@ impl ActionLog { new_diff_base: Rope, cx: &mut AsyncApp, ) -> Result<()> { - let (diff, language) = this.read_with(cx, |this, cx| { + let diff = this.read_with(cx, |this, _cx| { let tracked_buffer = this .tracked_buffers .get(buffer) .context("buffer not tracked")?; - anyhow::Ok(( - tracked_buffer.diff.clone(), - buffer.read(cx).language().cloned(), - )) + anyhow::Ok(tracked_buffer.diff.clone()) })??; - let update = diff - .update(cx, |diff, cx| { - diff.update_diff( - buffer_snapshot.clone(), - Some(new_base_text), - Some(true), - language, - cx, - ) - }) - .await; diff.update(cx, |diff, cx| { - diff.set_snapshot(update.clone(), &buffer_snapshot, cx) + diff.set_base_text(Some(new_base_text), buffer_snapshot.clone(), cx) }) .await; let diff_snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx)); diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index d1f9877af3e2f5..3dd3f106336c3f 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -84,7 +84,6 @@ agent_servers = { workspace = true, "features" = ["test-support"] } client = { workspace = true, "features" = ["test-support"] } clock = { workspace = true, "features" = ["test-support"] } context_server = { workspace = true, "features" = ["test-support"] } -criterion.workspace = true ctor.workspace = true db = { workspace = true, "features" = ["test-support"] } editor = { workspace = true, "features" = ["test-support"] } @@ -99,6 +98,7 @@ language_model = { workspace = true, "features" = ["test-support"] } lsp = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, "features" = ["test-support"] } +proptest.workspace = true rand.workspace = true reqwest_client.workspace = true settings = { workspace = true, "features" = ["test-support"] } @@ -109,8 +109,3 @@ theme_settings.workspace = true unindent = { workspace = true } zlog.workspace = true - -[[bench]] -name = "edit_file_tool" -harness = false -required-features = ["test-support"] diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index c6cf4bc2c0fef3..58db0154175820 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -31,9 +31,9 @@ use acp_thread::{ use agent_client_protocol::schema as acp; use agent_skills::{ AGENTS_DIR_NAME, MAX_SKILL_DESCRIPTIONS_SIZE, MAX_SKILL_FILE_SIZE, ProjectSkillGroup, - SKILL_FILE_NAME, Skill, SkillIndex, SkillLoadError, SkillScopeId, SkillSource, SkillSummary, - builtin_skills, global_skills_dir, load_skills_from_directory, parse_skill_frontmatter, - project_skills_relative_path, read_skill_body_from_content, + SKILL_FILE_NAME, Skill, SkillIndex, SkillLoadError, SkillLoadWarning, SkillScopeId, + SkillSource, SkillSummary, builtin_skills, global_skills_dir, load_skills_from_directory, + parse_skill_frontmatter, project_skills_relative_path, read_skill_body_from_content, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; @@ -76,21 +76,67 @@ pub struct RulesLoadingError { pub message: SharedString, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SkillLoadingIssueKind { + LoadFailed, + DescriptionTooLong, + CatalogBudgetExceeded, +} + #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct SkillLoadingError { +pub struct SkillLoadingIssue { pub project_id: EntityId, pub path: PathBuf, pub message: SharedString, + pub kind: SkillLoadingIssueKind, } -/// Emitted whenever the set of skill loading errors for a project changes. -/// The `errors` field is the full replacement list; subscribers should treat -/// it as a snapshot rather than appending. An empty `errors` list means all -/// previously-reported errors have been resolved. +#[derive(Clone, Debug, PartialEq, Eq)] +struct SkillLoadingIssueData { + path: PathBuf, + message: String, + kind: SkillLoadingIssueKind, +} + +impl SkillLoadingIssueData { + fn from_load_error(error: SkillLoadError) -> Self { + Self { + path: error.path, + message: error.message, + kind: SkillLoadingIssueKind::LoadFailed, + } + } + + fn from_load_warning(skill: &Skill, warning: &SkillLoadWarning) -> Self { + let kind = match warning { + SkillLoadWarning::DescriptionTooLong { .. } => { + SkillLoadingIssueKind::DescriptionTooLong + } + }; + Self { + path: skill.skill_file_path.clone(), + message: warning.message(), + kind, + } + } + + fn catalog_budget_exceeded(path: PathBuf, message: String) -> Self { + Self { + path, + message, + kind: SkillLoadingIssueKind::CatalogBudgetExceeded, + } + } +} + +/// Emitted whenever the set of skill loading issues for a project changes. +/// The `issues` field is the full replacement list; subscribers should treat +/// it as a snapshot rather than appending. An empty `issues` list means all +/// previously-reported issues have been resolved. #[derive(Clone, Debug)] -pub struct SkillLoadingErrorsUpdated { +pub struct SkillLoadingIssuesUpdated { pub project_id: EntityId, - pub errors: Vec, + pub issues: Vec, } #[derive(Clone, Debug)] @@ -99,6 +145,7 @@ pub struct NativeAvailableSkill { pub description: String, pub source: SharedString, pub skill_file_path: PathBuf, + pub warning: Option, } impl From<&Skill> for NativeAvailableSkill { @@ -108,15 +155,40 @@ impl From<&Skill> for NativeAvailableSkill { description: skill.description.clone(), source: skill.source.display_label().to_string().into(), skill_file_path: skill.skill_file_path.clone(), + warning: skill + .load_warnings + .first() + .map(|warning| warning.message().into()), } } } +pub const COMPACT_COMMAND_NAME: &str = "compact"; + +/// Returns the set of MCP prompt names that must be server-qualified +/// (`/.`) to stay unambiguous in the slash-command popup: names +/// shared by more than one MCP prompt, or names colliding with a reserved +/// built-in command (e.g. `/compact`). A built-in always wins an unqualified +/// invocation, so colliding MCP prompts are only reachable when prefixed. +fn ambiguous_mcp_prompt_names<'a>( + reserved: impl IntoIterator, + prompt_names: impl IntoIterator, +) -> HashSet<&'a str> { + let mut counts: HashMap<&str, usize> = HashMap::default(); + for name in reserved.into_iter().chain(prompt_names) { + *counts.entry(name).or_insert(0) += 1; + } + counts + .into_iter() + .filter_map(|(name, count)| (count > 1).then_some(name)) + .collect() +} + struct ProjectState { project: Entity, project_context: Entity, skills: Arc>, - skill_loading_errors: Vec, + skill_loading_issues: Vec, project_context_needs_refresh: watch::Sender<()>, _maintain_project_context: Task>, context_server_registry: Entity, @@ -216,6 +288,10 @@ impl LanguageModels { self.refresh_models_rx.clone() } + pub fn notify_model_selection_changed(&mut self) { + self.refresh_models_tx.send(()).ok(); + } + pub fn model_from_id(&self, model_id: &AgentModelId) -> Option> { self.models.get(model_id).cloned() } @@ -357,7 +433,7 @@ enum SkillsState { Watching, } -impl gpui::EventEmitter for NativeAgent {} +impl gpui::EventEmitter for NativeAgent {} static RULES_FILE_REL_PATHS: LazyLock>> = LazyLock::new(|| { RULES_FILE_NAMES @@ -477,10 +553,15 @@ impl NativeAgent { log::debug!("Creating new NativeAgent"); cx.new(|cx| { - let subscriptions = vec![cx.subscribe( - &LanguageModelRegistry::global(cx), - Self::handle_models_updated_event, - )]; + let subscriptions = vec![ + cx.subscribe( + &LanguageModelRegistry::global(cx), + Self::handle_models_updated_event, + ), + // Flush thread content on quit so an in-flight async save + // can't leave a thread orphaned ("no thread found with ID"). + cx.on_app_quit(Self::flush_threads_on_quit), + ]; if !cx.has_global::() { cx.set_global(SkillIndex::default()); @@ -817,7 +898,7 @@ impl NativeAgent { project, project_context, skills: Arc::new(Vec::new()), - skill_loading_errors: Vec::new(), + skill_loading_issues: Vec::new(), project_context_needs_refresh: project_context_needs_refresh_tx, _maintain_project_context: cx.spawn(async move |this, cx| { Self::maintain_project_context( @@ -858,34 +939,35 @@ impl NativeAgent { cx, )) })??; - let (project_context, skills, skill_errors) = task.await; + let (project_context, skills, skill_issue_data) = task.await; let skills = Arc::new(skills); - let skill_loading_errors: Vec = skill_errors + let skill_loading_issues: Vec = skill_issue_data .into_iter() - .map(|skill_error| SkillLoadingError { + .map(|issue| SkillLoadingIssue { project_id, - path: skill_error.path, - message: skill_error.message.into(), + path: issue.path, + message: issue.message.into(), + kind: issue.kind, }) .collect(); this.update(cx, |this, cx| { - // Only emit SkillLoadingErrorsUpdated when the error list + // Only emit SkillLoadingIssuesUpdated when the issue list // actually changed. Refreshes happen frequently (prompt-store // updates, rules-file edits, worktree events, trust-state // changes), and re-emitting an unchanged list causes the UI - // to redisplay errors the user has already dismissed. + // to redisplay issues the user has already dismissed. // Transitions from non-empty to empty still count as a change, // so subscribers continue to receive an empty list to clear - // previously-displayed errors when they get resolved. - let errors_changed = this + // previously-displayed issues when they get resolved. + let issues_changed = this .projects .get(&project_id) - .map(|state| state.skill_loading_errors != skill_loading_errors) + .map(|state| state.skill_loading_issues != skill_loading_issues) .unwrap_or(true); if let Some(state) = this.projects.get_mut(&project_id) { state.skills = skills; - state.skill_loading_errors = skill_loading_errors.clone(); + state.skill_loading_issues = skill_loading_issues.clone(); // Only push the new `ProjectContext` through if it // differs from the current one. The system prompt is // re-rendered from this on every turn, so an unchanged @@ -904,10 +986,10 @@ impl NativeAgent { } }); } - if errors_changed { - cx.emit(SkillLoadingErrorsUpdated { + if issues_changed { + cx.emit(SkillLoadingIssuesUpdated { project_id, - errors: skill_loading_errors, + issues: skill_loading_issues, }); } // Skills appear in the slash-command list, so a change in @@ -927,7 +1009,7 @@ impl NativeAgent { project: &Entity, fs: Arc, cx: &mut App, - ) -> Task<(ProjectContext, Vec, Vec)> { + ) -> Task<(ProjectContext, Vec, Vec)> { let worktrees = project.read(cx).visible_worktrees(cx).collect::>(); let worktree_tasks = worktrees .iter() @@ -1086,8 +1168,20 @@ impl NativeAgent { // model-facing catalog. let global_skills = global_skills_task.await; let project_skills_results = project_skills_task.await; - let (skills, mut skill_errors) = + let (skills, skill_errors) = combine_skills(global_skills, project_skills_results.into_iter().flatten()); + let mut skill_issues = skill_errors + .into_iter() + .map(SkillLoadingIssueData::from_load_error) + .collect::>(); + for skill in &skills { + skill_issues.extend( + skill + .load_warnings + .iter() + .map(|warning| SkillLoadingIssueData::from_load_warning(skill, warning)), + ); + } // Apply project-overrides-global before catalog selection // so the model sees at most one entry per name. The full @@ -1096,13 +1190,13 @@ impl NativeAgent { let overridden = apply_skill_overrides(&skills); // Enforce the catalog size budget here so that skills which - // don't fit produce a load error in the UI rather than being + // don't fit produce an issue in the UI rather than being // silently swallowed by ProjectContext. - let (catalog_skills, budget_errors) = select_catalog_skills(&overridden); - skill_errors.extend(budget_errors); + let (catalog_skills, budget_issues) = select_catalog_skills(&overridden); + skill_issues.extend(budget_issues); let project_context = ProjectContext::new(worktrees).with_skills(catalog_skills); - (project_context, skills, skill_errors) + (project_context, skills, skill_issues) }) } @@ -1403,25 +1497,30 @@ impl NativeAgent { cx: &App, ) -> Vec { let Some(state) = project_state else { - return vec![]; + return Vec::new(); }; + let compact_command = acp::AvailableCommand::new( + COMPACT_COMMAND_NAME, + "Summarize the conversation so far to free up context", + ) + .meta(acp_thread::meta_with_command_category( + acp_thread::CommandCategory::Native, + )); + 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() { - *prompt_name_counts - .entry(context_server_prompt.prompt.name.as_str()) - .or_insert(0) += 1; - } + // Reserve the built-in command name so a same-named MCP prompt is + // force-prefixed (`/.compact`) and stays reachable: an + // unqualified `/compact` always routes to the native command. + let ambiguous_prompt_names = ambiguous_mcp_prompt_names( + [COMPACT_COMMAND_NAME], + registry.prompts().map(|p| p.prompt.name.as_str()), + ); let mcp_commands = registry.prompts().flat_map(|context_server_prompt| { let prompt = &context_server_prompt.prompt; - let should_prefix = prompt_name_counts - .get(prompt.name.as_str()) - .copied() - .unwrap_or(0) - > 1; + let should_prefix = ambiguous_prompt_names.contains(prompt.name.as_str()); let name = if should_prefix { format!("{}.{}", context_server_prompt.server_id, prompt.name) @@ -1430,7 +1529,10 @@ impl NativeAgent { }; let mut command = - acp::AvailableCommand::new(name, prompt.description.clone().unwrap_or_default()); + acp::AvailableCommand::new(name, prompt.description.clone().unwrap_or_default()) + .meta(acp_thread::meta_with_command_category( + acp_thread::CommandCategory::Mcp, + )); match prompt.arguments.as_deref() { Some([arg]) => { @@ -1450,7 +1552,9 @@ impl NativeAgent { Some(command) }); - mcp_commands.collect() + std::iter::once(compact_command) + .chain(mcp_commands) + .collect() } pub fn load_thread( @@ -1541,6 +1645,7 @@ impl NativeAgent { NativeAgentConnection::handle_thread_events( events, acp_thread.downgrade(), + None, cx, ) }) @@ -1666,6 +1771,48 @@ impl NativeAgent { }); } + /// Commits every non-empty thread's content on shutdown so the async + /// `save_thread` losing the race can't leave metadata without content. + fn flush_threads_on_quit( + &mut self, + cx: &mut Context, + ) -> impl Future + use<> { + let database_future = ThreadsDatabase::connect(cx); + + let mut saves = Vec::new(); + for session in self.sessions.values() { + let thread = session.thread.read(cx); + if thread.is_empty() { + continue; + } + let Some(state) = self.projects.get(&session.project_id) else { + continue; + }; + let folder_paths = PathList::new( + &state + .project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect::>(), + ); + saves.push((thread.id().clone(), folder_paths, thread.to_db(cx))); + } + + async move { + let Ok(database) = database_future.await else { + return; + }; + for (id, folder_paths, db_thread) in saves { + let db_thread = db_thread.await; + database + .save_thread(id, db_thread, folder_paths) + .await + .log_err(); + } + } + } + fn send_mcp_prompt( &self, message_id: UserMessageId, @@ -1758,10 +1905,47 @@ impl NativeAgent { } })?; + let connection = this.upgrade().map(NativeAgentConnection); + cx.update(|cx| { + NativeAgentConnection::handle_thread_events( + response_stream, + acp_thread.downgrade(), + connection, + cx, + ) + }) + .await + }) + } + + /// Run a summary-based context compaction in response to the built-in + /// `/compact` slash command. + fn send_compact_command( + &self, + message_id: UserMessageId, + session_id: acp::SessionId, + cx: &mut Context, + ) -> Task> { + cx.spawn(async move |this, cx| { + let (acp_thread, thread) = this.update(cx, |this, _cx| { + let session = this + .sessions + .get(&session_id) + .context("Failed to get session")?; + anyhow::Ok((session.acp_thread.clone(), session.thread.clone())) + })??; + + let response_stream = thread.update(cx, |thread, cx| thread.compact(message_id, cx))?; + acp_thread.update(cx, |acp_thread, cx| { + acp_thread.update_token_usage(None, cx); + }); + + let connection = this.upgrade().map(NativeAgentConnection); cx.update(|cx| { NativeAgentConnection::handle_thread_events( response_stream, acp_thread.downgrade(), + connection, cx, ) }) @@ -1860,10 +2044,12 @@ impl NativeAgent { let response_stream = thread.update(cx, |thread, cx| thread.send_existing(cx))?; + let connection = this.upgrade().map(NativeAgentConnection); cx.update(|cx| { NativeAgentConnection::handle_thread_events( response_stream, acp_thread.downgrade(), + connection, cx, ) }) @@ -1955,12 +2141,18 @@ impl NativeAgentConnection { Ok(stream) => stream, Err(err) => return Task::ready(Err(err)), }; - Self::handle_thread_events(response_stream, acp_thread.downgrade(), cx) + Self::handle_thread_events( + response_stream, + acp_thread.downgrade(), + Some(self.clone()), + cx, + ) } fn handle_thread_events( mut events: mpsc::UnboundedReceiver>, acp_thread: WeakEntity, + connection: Option, cx: &App, ) -> Task> { cx.spawn(async move |cx| { @@ -2018,6 +2210,14 @@ impl NativeAgentConnection { }) .detach(); } + ThreadEvent::ToolCallAuthorizationResolved { + tool_call_id, + outcome, + } => { + acp_thread.update(cx, |thread, cx| { + thread.authorize_tool_call(tool_call_id, outcome, cx); + })?; + } ThreadEvent::ToolCall(tool_call) => { acp_thread.update(cx, |thread, cx| { thread.upsert_tool_call(tool_call, cx) @@ -2028,22 +2228,35 @@ impl NativeAgentConnection { thread.update_tool_call(update, cx) })??; } - ThreadEvent::Plan(plan) => { - acp_thread.update(cx, |thread, cx| thread.update_plan(plan, cx))?; - } ThreadEvent::SubagentSpawned(session_id) => { acp_thread.update(cx, |thread, cx| { thread.subagent_spawned(session_id, cx); })?; } ThreadEvent::Retry(status) => { + if acp_thread::refusal_fallback_model_from_meta(&status.meta) + .is_some() + { + if let Some(connection) = &connection { + cx.update(|cx| { + connection.0.update(cx, |agent, _| { + agent.models.notify_model_selection_changed(); + }); + }); + } + } acp_thread.update(cx, |thread, cx| { thread.update_retry_status(status, cx) })?; } - ThreadEvent::ContextCompaction => { + ThreadEvent::ContextCompaction(compaction) => { acp_thread.update(cx, |thread, cx| { - thread.push_context_compaction(cx); + thread.push_context_compaction(compaction, cx); + })?; + } + ThreadEvent::ContextCompactionUpdate(update) => { + acp_thread.update(cx, |thread, cx| { + thread.update_context_compaction(update, cx); })?; } ThreadEvent::Stop(stop_reason) => { @@ -2082,6 +2295,12 @@ struct Command<'a> { } impl<'a> Command<'a> { + fn is_unqualified(&self, prompt_name: &str) -> bool { + self.prompt_name == prompt_name + && self.explicit_server_id.is_none() + && self.skill_scope.is_none() + } + fn parse(prompt: &'a [acp::ContentBlock]) -> Option { let acp::ContentBlock::Text(text_content) = prompt.first()? else { return None; @@ -2418,6 +2637,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection { }; if let Some(parsed_command) = Command::parse(¶ms.prompt) { + if parsed_command.is_unqualified(COMPACT_COMMAND_NAME) { + return self.0.update(cx, |agent, cx| { + agent.send_compact_command(id, session_id, cx) + }); + } + // Skill scope qualifiers (`/:` and // `/:`) use a colon separator that can't // collide with MCP's `/.` grammar. The popup @@ -2719,7 +2944,7 @@ impl AgentSessionList for NativeAgentSessionList { Task::ready(Ok(AgentSessionListResponse::new(sessions))) } - fn supports_delete(&self, _cx: &App) -> bool { + fn supports_delete(&self) -> bool { true } @@ -3236,9 +3461,9 @@ impl TerminalHandle for AcpTerminalHandle { /// Returns `SkillSummary` values rather than full `Skill`s so that the /// (potentially ~100KB) skill bodies aren't cloned just to be discarded by /// `ProjectContext::new`, which only needs the summary fields. -fn select_catalog_skills(skills: &[Skill]) -> (Vec, Vec) { +fn select_catalog_skills(skills: &[Skill]) -> (Vec, Vec) { let mut kept = Vec::new(); - let mut errors = Vec::new(); + let mut issues = Vec::new(); let mut dropped: Vec<&Skill> = Vec::new(); let mut total_size = 0usize; let mut budget_exceeded = false; @@ -3291,13 +3516,13 @@ fn select_catalog_skills(skills: &[Skill]) -> (Vec, Vec ( + Rc, + Entity, + Entity, + Entity, + ) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({ "a": {} })).await; + let project = Project::test(fs.clone(), [Path::new("/a")], cx).await; + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs, cx)); + let connection = Rc::new(NativeAgentConnection(agent.clone())); + let acp_thread = cx + .update(|cx| { + connection.clone().new_session( + project.clone(), + PathList::new(&[Path::new("/a")]), + cx, + ) + }) + .await + .unwrap(); + + (connection, agent, project, acp_thread) + } + + fn native_thread_for_session( + agent: &Entity, + session_id: &acp::SessionId, + cx: &App, + ) -> Entity { + agent.read_with(cx, |agent, _cx| { + agent.sessions.get(session_id).unwrap().thread.clone() + }) + } + + fn request_texts_after_system( + messages: &[language_model::LanguageModelRequestMessage], + ) -> Vec { + messages + .iter() + .skip(1) + .map(language_model::LanguageModelRequestMessage::string_contents) + .collect() + } + + #[gpui::test] + async fn test_compact_command_is_available(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); + + let connection = NativeAgentConnection(agent.clone()); + let acp_thread = cx + .update(|cx| { + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/")]), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + cx.update(|cx| { + let commands = acp_thread.read(cx).available_commands(); + + let compact = commands.iter().find(|command| command.name == "compact"); + let compact = compact.expect("compact command should be available"); + assert_eq!( + acp_thread::command_category_from_meta(&compact.meta), + Some(acp_thread::CommandCategory::Native), + ); + }); + } + + #[gpui::test] + async fn test_compact_prompt_routes_to_manual_compaction(cx: &mut TestAppContext) { + init_test(cx); + let (connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + let model = Arc::new(FakeLanguageModel::default()); + let old_message_id = UserMessageId::new(); + + cx.update(|cx| { + let path_style = project.read(cx).path_style(cx); + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + thread.push_acp_user_block( + old_message_id, + [acp::ContentBlock::from("old user")], + path_style, + cx, + ); + thread.push_acp_agent_block("old assistant".into(), cx); + }); + }); + + let compact_message_id = UserMessageId::new(); + let prompt_task = cx.update(|cx| { + connection.prompt( + compact_message_id, + acp::PromptRequest::new(session_id.clone(), vec!["/compact".into()]), + cx, + ) + }); + cx.run_until_parked(); + + let request = model.pending_completions().pop().unwrap(); + assert_eq!( + request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + assert_eq!( + request_texts_after_system(&request.messages), + vec![ + "old user".to_string(), + "old assistant".to_string(), + COMPACTION_PROMPT.to_string(), + ] + ); + + model.send_completion_stream_text_chunk(&request, "summary"); + model.end_completion_stream(&request); + cx.run_until_parked(); + prompt_task.await.unwrap(); + } + + #[gpui::test] + async fn test_threads_flushed_to_database_on_app_quit(cx: &mut TestAppContext) { + init_test(cx); + + let (_connection, agent, project, acp_thread) = setup_native_agent_session(cx).await; + let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone()); + let thread = cx.update(|cx| native_thread_for_session(&agent, &session_id, cx)); + + // Give the thread content so it's no longer an empty draft. + cx.update(|cx| { + let path_style = project.read(cx).path_style(cx); + thread.update(cx, |thread, cx| { + thread.push_acp_user_block( + UserMessageId::new(), + [acp::ContentBlock::from("hello from the user")], + path_style, + cx, + ); + }); + }); + cx.run_until_parked(); + + // Reproduce the orphaned state from the bug: the sidebar metadata and + // serialized panel still reference the session, but the per-session + // async content save never landed, so the content row is absent. + let database = cx.update(|cx| ThreadsDatabase::connect(cx)).await.unwrap(); + database.delete_thread(session_id.clone()).await.unwrap(); + assert!( + database + .load_thread(session_id.clone()) + .await + .unwrap() + .is_none(), + "precondition: content row should be missing before the quit flush" + ); + + // Quitting must re-commit the content so the thread can be restored. + let flush = cx.update(|cx| agent.update(cx, |agent, cx| agent.flush_threads_on_quit(cx))); + flush.await; + + let restored = database + .load_thread(session_id.clone()) + .await + .unwrap() + .expect("thread content should be persisted to the database on quit"); + assert_eq!( + restored.messages.len(), + 1, + "the user message should survive the quit flush" + ); + } + + #[test] + fn test_ambiguous_mcp_prompt_names() { + // Reserving the built-in `/compact` forces a same-named MCP prompt to be + // server-qualified so it stays reachable; unique names stay bare. + let ambiguous = ambiguous_mcp_prompt_names([COMPACT_COMMAND_NAME], ["compact", "deploy"]); + assert!(ambiguous.contains("compact")); + assert!(!ambiguous.contains("deploy")); + + // Without the reservation, a unique MCP prompt is left bare. + let ambiguous = ambiguous_mcp_prompt_names([], ["compact", "deploy"]); + assert!(ambiguous.is_empty()); + + // Two MCP prompts sharing a name are both qualified regardless of + // reservation. + let ambiguous = ambiguous_mcp_prompt_names([], ["dup", "dup", "unique"]); + assert!(ambiguous.contains("dup")); + assert!(!ambiguous.contains("unique")); + } + + #[test] + fn test_qualified_compact_commands_are_not_native_compact() { + let unqualified_blocks = [acp::ContentBlock::from("/compact")]; + let unqualified = Command::parse(&unqualified_blocks).unwrap(); + assert!(unqualified.is_unqualified("compact")); + + let mcp_blocks = [acp::ContentBlock::from("/server.compact")]; + let mcp_qualified = Command::parse(&mcp_blocks).unwrap(); + assert_eq!(mcp_qualified.prompt_name, "compact"); + assert_eq!(mcp_qualified.explicit_server_id, Some("server")); + assert!(!mcp_qualified.is_unqualified("compact")); + + let skill_blocks = [acp::ContentBlock::from("/:compact")]; + let skill_qualified = Command::parse(&skill_blocks).unwrap(); + assert_eq!(skill_qualified.prompt_name, "compact"); + assert_eq!(skill_qualified.skill_scope, Some("")); + assert!(!skill_qualified.is_unqualified("compact")); + } + fn make_project_skill(name: &str, description: &str, worktree: &str) -> Skill { Skill { name: name.to_string(), @@ -3499,6 +3952,7 @@ mod internal_tests { }, directory_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}")), skill_file_path: PathBuf::from(format!("/{worktree}/.agents/skills/{name}/SKILL.md")), + load_warnings: Vec::new(), disable_model_invocation: false, embedded_body: None, } @@ -3511,6 +3965,7 @@ mod internal_tests { source: SkillSource::BuiltIn, directory_path: PathBuf::from(format!("/builtin/{name}")), skill_file_path: PathBuf::from(format!("/builtin/{name}/SKILL.md")), + load_warnings: Vec::new(), disable_model_invocation: false, embedded_body: Some("built-in body"), } @@ -3674,10 +4129,10 @@ mod internal_tests { } #[test] - fn test_select_catalog_skills_emits_errors_for_dropped_skills() { + fn test_select_catalog_skills_emits_issue_for_dropped_skills() { // Each skill's name + description occupies ~10KB. With a 50KB // budget, only the first ~5 visible skills fit; the rest must - // appear as load errors so the UI can surface them. + // appear as loading issues so the UI can surface them. let description = "x".repeat(10 * 1024); let mut skills = Vec::new(); let total = 10; @@ -3689,12 +4144,13 @@ mod internal_tests { source: SkillSource::Global, directory_path: PathBuf::from(format!("/skills/{name}")), skill_file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")), + load_warnings: Vec::new(), disable_model_invocation: false, embedded_body: None, }); } - let (kept, errors) = select_catalog_skills(&skills); + let (kept, issues) = select_catalog_skills(&skills); assert!( kept.len() < skills.len(), @@ -3703,9 +4159,9 @@ mod internal_tests { skills.len(), ); assert_eq!( - errors.len(), + issues.len(), 1, - "all dropped skills should be consolidated into a single error, got {errors:?}", + "all dropped skills should be consolidated into a single issue, got {issues:?}", ); let kept_size: usize = kept @@ -3717,33 +4173,34 @@ mod internal_tests { "kept skills must fit in the budget (got {kept_size} bytes)", ); - let error = &errors[0]; + let issue = &issues[0]; + assert_eq!(issue.kind, SkillLoadingIssueKind::CatalogBudgetExceeded); assert!( - error.message.contains("50KB") && error.message.contains("budget"), - "error message {:?} should describe the budget", - error.message, + issue.message.contains("50KB") && issue.message.contains("budget"), + "issue message {:?} should describe the budget", + issue.message, ); assert_eq!( - error.path, + issue.path, skills[kept.len()].skill_file_path, - "error path should match the first dropped skill", + "issue path should match the first dropped skill", ); for dropped_skill in &skills[kept.len()..total] { let name = &dropped_skill.name; assert!( - error.message.contains(name.as_str()), - "error message {:?} should mention the dropped skill name {name:?}", - error.message, + issue.message.contains(name.as_str()), + "issue message {:?} should mention the dropped skill name {name:?}", + issue.message, ); let bullet_line = format!("- {name}"); assert!( - error + issue .message .lines() .any(|line| line.starts_with(&bullet_line)), - "error message {:?} should contain a bullet line starting with {bullet_line:?}", - error.message, + "issue message {:?} should contain a bullet line starting with {bullet_line:?}", + issue.message, ); } } @@ -3764,6 +4221,7 @@ mod internal_tests { source: SkillSource::Global, directory_path: PathBuf::from("/skills/skill-01-first"), skill_file_path: PathBuf::from("/skills/skill-01-first/SKILL.md"), + load_warnings: Vec::new(), disable_model_invocation: false, embedded_body: None, }; @@ -3773,6 +4231,7 @@ mod internal_tests { source: SkillSource::Global, directory_path: PathBuf::from("/skills/skill-02-overflows"), skill_file_path: PathBuf::from("/skills/skill-02-overflows/SKILL.md"), + load_warnings: Vec::new(), disable_model_invocation: false, embedded_body: None, }; @@ -3782,6 +4241,7 @@ mod internal_tests { source: SkillSource::Global, directory_path: PathBuf::from("/skills/skill-03-would-fit"), skill_file_path: PathBuf::from("/skills/skill-03-would-fit/SKILL.md"), + load_warnings: Vec::new(), disable_model_invocation: false, embedded_body: None, }; @@ -3797,29 +4257,30 @@ mod internal_tests { ); let skills = vec![first.clone(), second.clone(), third.clone()]; - let (kept, errors) = select_catalog_skills(&skills); + let (kept, issues) = select_catalog_skills(&skills); let kept_names: Vec<&str> = kept.iter().map(|s| s.name.as_str()).collect(); assert_eq!(kept_names, vec![first.name.as_str()]); - assert_eq!(errors.len(), 1, "expected a single consolidated error"); - assert_eq!(errors[0].path, second.skill_file_path); + assert_eq!(issues.len(), 1, "expected a single consolidated issue"); + assert_eq!(issues[0].kind, SkillLoadingIssueKind::CatalogBudgetExceeded); + assert_eq!(issues[0].path, second.skill_file_path); assert!( - errors[0].message.contains(second.name.as_str()), - "error message {:?} should mention {:?}", - errors[0].message, + issues[0].message.contains(second.name.as_str()), + "issue message {:?} should mention {:?}", + issues[0].message, second.name, ); assert!( - errors[0].message.contains(third.name.as_str()), - "error message {:?} should mention {:?}", - errors[0].message, + issues[0].message.contains(third.name.as_str()), + "issue message {:?} should mention {:?}", + issues[0].message, third.name, ); assert!( - errors[0].message.contains("- "), - "error message {:?} should use bullet form when multiple skills are dropped", - errors[0].message, + issues[0].message.contains("- "), + "issue message {:?} should use bullet form when multiple skills are dropped", + issues[0].message, ); } @@ -3829,7 +4290,7 @@ mod internal_tests { // must not appear in the catalog returned by `select_catalog_skills`, // even when they would otherwise fit in the budget. They also don't // count against the budget, so a hidden skill larger than the entire - // budget shouldn't generate a load error or prevent later visible + // budget shouldn't generate a loading issue or prevent later visible // skills from fitting. let huge_description = "y".repeat(MAX_SKILL_DESCRIPTIONS_SIZE * 2); let hidden = Skill { @@ -3838,6 +4299,7 @@ mod internal_tests { source: SkillSource::Global, directory_path: PathBuf::from("/skills/hidden-huge"), skill_file_path: PathBuf::from("/skills/hidden-huge/SKILL.md"), + load_warnings: Vec::new(), disable_model_invocation: true, embedded_body: None, }; @@ -3847,13 +4309,14 @@ mod internal_tests { source: SkillSource::Global, directory_path: PathBuf::from("/skills/visible"), skill_file_path: PathBuf::from("/skills/visible/SKILL.md"), + load_warnings: Vec::new(), disable_model_invocation: false, embedded_body: None, }; - let (kept, errors) = select_catalog_skills(&[hidden, visible]); + let (kept, issues) = select_catalog_skills(&[hidden, visible]); - assert!(errors.is_empty(), "expected no errors, got: {errors:?}"); + assert!(issues.is_empty(), "expected no issues, got: {issues:?}"); let kept_names: Vec<&str> = kept.iter().map(|s| s.name.as_str()).collect(); assert_eq!(kept_names, vec!["visible"]); } @@ -4011,6 +4474,101 @@ mod internal_tests { }); } + #[gpui::test] + async fn test_global_skill_with_long_description_loads_with_warning(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let skills_dir = global_skills_dir(); + let skill_dir = skills_dir.join("long-description"); + let skill_path = skill_dir.join("SKILL.md"); + let long_description = "a".repeat(agent_skills::MAX_SKILL_DESCRIPTION_LEN + 1); + fs.create_dir(&skill_dir).await.unwrap(); + fs.insert_file( + &skill_path, + format!("---\nname: long-description\ndescription: {long_description}\n---\n\nbody") + .into_bytes(), + ) + .await; + + let project = Project::test(fs.clone(), [], cx).await; + let project_id = project.entity_id(); + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let agent = + cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), fs.clone(), cx)); + + cx.update(|cx| { + agent.update(cx, |agent, cx| agent.ensure_skills_scan_started(cx)); + }); + + let connection = NativeAgentConnection(agent.clone()); + let acp_thread = cx + .update(|cx| { + Rc::new(connection.clone()).new_session( + project.clone(), + PathList::new(&[Path::new("/")]), + cx, + ) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let loaded_skill = agent.read_with(cx, |agent, cx| { + let state = agent.projects.get(&project_id).unwrap(); + let user = user_skills(&state.skills); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "long-description"); + assert_eq!(user[0].description, long_description); + + let catalog_names: Vec<&str> = state + .project_context + .read(cx) + .skills() + .iter() + .map(|skill| skill.name.as_str()) + .collect(); + assert!( + catalog_names.contains(&"long-description"), + "long-description skill should remain in the model catalog: {catalog_names:?}" + ); + + assert!( + state.skill_loading_issues.iter().any(|issue| { + issue.kind == SkillLoadingIssueKind::DescriptionTooLong + && issue.path == skill_path + && issue.message.to_string().contains("1024-byte limit") + }), + "expected a description-length warning issue, got {:?}", + state.skill_loading_issues + ); + + (*user[0]).clone() + }); + + let session_id = acp_thread.read_with(cx, |thread, _cx| thread.session_id().clone()); + cx.update(|cx| { + let available_skills = connection.available_skills(&session_id, cx); + let available_skill = available_skills + .iter() + .find(|skill| skill.name == "long-description") + .expect("long-description should appear in available skills"); + assert_eq!(available_skill.description, long_description); + assert!( + available_skill + .warning + .as_ref() + .is_some_and(|warning| warning.contains("1024-byte limit")), + "available skill should expose warning text, got {:?}", + available_skill.warning + ); + }); + + let body = agent_skills::read_skill_body(fs.as_ref(), &loaded_skill.skill_file_path) + .await + .expect("body should load despite description-length warning"); + assert_eq!(body, "body"); + } + #[gpui::test] async fn test_symlinked_global_skills_load_and_reload(cx: &mut TestAppContext) { init_test(cx); @@ -4808,11 +5366,12 @@ mod internal_tests { ); assert!( state - .skill_loading_errors + .skill_loading_issues .iter() - .any(|error| error.message.to_string().contains("maximum size")), + .any(|issue| issue.kind == SkillLoadingIssueKind::LoadFailed + && issue.message.to_string().contains("maximum size")), "expected a size-limit error, got {:?}", - state.skill_loading_errors + state.skill_loading_issues ); }); } @@ -4854,11 +5413,12 @@ mod internal_tests { assert_eq!(names, vec!["good"], "only the valid skill should load"); assert!( state - .skill_loading_errors + .skill_loading_issues .iter() - .any(|error| error.path.ends_with("bad/SKILL.md")), + .any(|issue| issue.kind == SkillLoadingIssueKind::LoadFailed + && issue.path.ends_with("bad/SKILL.md")), "expected an error for the malformed skill, got {:?}", - state.skill_loading_errors + state.skill_loading_issues ); }); } diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index aeeca37c170fe3..4a15b0920e6e0c 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -152,6 +152,10 @@ impl SharedThread { impl DbThread { pub const VERSION: &'static str = "0.3.0"; + pub fn to_markdown(&self) -> String { + crate::messages_to_markdown(&self.messages) + } + pub fn from_json(json: &[u8]) -> Result { let saved_thread_json = serde_json::from_slice::(json)?; match saved_thread_json.get("version") { diff --git a/crates/agent/src/sandboxing.rs b/crates/agent/src/sandboxing.rs index d36813c4bbef0a..6c6363c4998103 100644 --- a/crates/agent/src/sandboxing.rs +++ b/crates/agent/src/sandboxing.rs @@ -98,10 +98,13 @@ impl ThreadSandboxGrants { return true; } request.write_paths.iter().all(|requested| { - self.write_paths - .iter() - .chain(persistent.write_paths.iter()) - .any(|granted| requested.starts_with(granted)) + util::paths::path_within_subtree( + requested, + self.write_paths + .iter() + .chain(persistent.write_paths.iter()) + .map(PathBuf::as_path), + ) }) } @@ -112,7 +115,7 @@ impl ThreadSandboxGrants { self.allow_fs_write_all |= request.allow_fs_write_all; self.unsandboxed |= request.unsandboxed; for path in &request.write_paths { - add_write_path(&mut self.write_paths, path); + util::paths::insert_subtree(&mut self.write_paths, path.clone()); } } @@ -131,11 +134,8 @@ impl ThreadSandboxGrants { persistent: &SandboxPermissions, ) -> SandboxRequest { let mut write_paths = persistent.write_paths.clone(); - for path in &self.write_paths { - add_write_path(&mut write_paths, path); - } - for path in &request.write_paths { - add_write_path(&mut write_paths, path); + for path in self.write_paths.iter().chain(request.write_paths.iter()) { + util::paths::insert_subtree(&mut write_paths, path.clone()); } SandboxRequest { network: persistent.allow_network || self.network || request.network, @@ -148,17 +148,6 @@ impl ThreadSandboxGrants { } } -/// Insert `path` into a set of write-grant subtrees, keeping it minimal: -/// a no-op if already covered by a broader grant, otherwise added with any -/// now-subsumed child grants pruned. -fn add_write_path(write_paths: &mut Vec, path: &std::path::Path) { - if write_paths.iter().any(|granted| path.starts_with(granted)) { - return; - } - write_paths.retain(|granted| !granted.starts_with(path)); - write_paths.push(path.to_path_buf()); -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/agent/src/templates.rs b/crates/agent/src/templates.rs index a946c404dc7593..5d8db7833c9fdc 100644 --- a/crates/agent/src/templates.rs +++ b/crates/agent/src/templates.rs @@ -89,7 +89,7 @@ mod tests { let project = prompt_store::ProjectContext::default(); let template = SystemPromptTemplate { project: &project, - available_tools: vec!["echo".into(), "update_plan".into(), "update_title".into()], + available_tools: vec!["echo".into()], model_name: Some("test-model".to_string()), date: "2026-01-01".to_string(), user_agents_md: None, @@ -100,8 +100,6 @@ mod tests { assert!(rendered.contains("You are the Zed coding agent")); assert!(rendered.contains("Today's Date: 2026-01-01")); assert!(rendered.contains("## Fixing Diagnostics")); - assert!(rendered.contains("## Planning")); - assert!(rendered.contains("## Session Title")); assert!(rendered.contains("test-model")); } diff --git a/crates/agent/src/templates/experimental_system_prompt.hbs b/crates/agent/src/templates/experimental_system_prompt.hbs index 63a34ccdcad7b7..7a348cca062f94 100644 --- a/crates/agent/src/templates/experimental_system_prompt.hbs +++ b/crates/agent/src/templates/experimental_system_prompt.hbs @@ -30,40 +30,6 @@ You are the Zed coding agent running inside the Zed editor. You help users compl - Autonomously resolve the task to the best of your ability with the tools available rather than coming back to the user prematurely. Ask the user only when the information you need is genuinely unavailable from the project, or when proceeding without clarification would be risky. - Do not guess or make up an answer. -{{#if (contains available_tools 'update_plan') }} -## Planning - -- You have access to an `update_plan` tool that tracks steps and progress and renders them to the user. -- Use it to show that you understand the task and to make complex, ambiguous, or multi-phase work easier to follow. -- A good plan is short, concrete, logically ordered, and easy to verify. Each step should describe a real unit of work. -- Mark completed steps promptly before moving to the next phase. -- Do not use plans for simple or single-step queries that you can answer or complete immediately. -- Do not pad plans with filler steps, obvious actions, or work you are not capable of doing. -- After calling `update_plan`, do not repeat the full plan in your response. The UI already displays it. Briefly summarize any important change and continue. -- You can mark multiple steps completed in a single `update_plan` call. -- If the task changes midway through, update the plan so it reflects the new approach. - -Use a plan when: - -- The task is non-trivial and will require multiple actions over a longer horizon. -- There are logical phases or dependencies where sequencing matters. -- The work has ambiguity that benefits from outlining high-level goals. -- You want intermediate checkpoints for feedback and validation. -- The user asked you to do more than one thing in a single prompt. -- You discover additional steps while working and intend to complete them before yielding to the user. - -{{/if}} -{{#if (contains available_tools 'update_title') }} -## Session Title - -- Use the `update_title` tool to set the title shown to the user for the current session. -- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one. -- Update the title again whenever the goal changes materially. -- Titles are very important to communicate to the user what you are working on. A session should always have a title. -- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes. -- Do not mention that you changed the title unless it is directly relevant to the user. - -{{/if}} ## Searching and Reading If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions. diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index 38d96632f27c79..da18674b25558b 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -50,40 +50,6 @@ Mermaid diagrams are automatically color-coded using the user's theme accent pal - Autonomously resolve the task to the best of your ability with the tools available rather than coming back to the user prematurely. Ask the user only when the information you need is genuinely unavailable from the project, or when proceeding without clarification would be risky. - Do not guess or make up an answer. -{{#if (contains available_tools 'update_plan') }} -## Planning - -- You have access to an `update_plan` tool that tracks steps and progress and renders them to the user. -- Use it to show that you understand the task and to make complex, ambiguous, or multi-phase work easier to follow. -- A good plan is short, concrete, logically ordered, and easy to verify. Each step should describe a real unit of work. -- Mark completed steps promptly before moving to the next phase. -- Do not use plans for simple or single-step queries that you can answer or complete immediately. -- Do not pad plans with filler steps, obvious actions, or work you are not capable of doing. -- After calling `update_plan`, do not repeat the full plan in your response. The UI already displays it. Briefly summarize any important change and continue. -- You can mark multiple steps completed in a single `update_plan` call. -- If the task changes midway through, update the plan so it reflects the new approach. - -Use a plan when: - -- The task is non-trivial and will require multiple actions over a longer horizon. -- There are logical phases or dependencies where sequencing matters. -- The work has ambiguity that benefits from outlining high-level goals. -- You want intermediate checkpoints for feedback and validation. -- The user asked you to do more than one thing in a single prompt. -- You discover additional steps while working and intend to complete them before yielding to the user. - -{{/if}} -{{#if (contains available_tools 'update_title') }} -## Session Title - -- Use the `update_title` tool to set the title shown to the user for the current session. -- You MUST set a title at least once, even for small tasks. Do it early in the conversation, after the first user message, before you start working. There is no title to begin with, so you are responsible for setting one. -- Update the title again whenever the goal changes materially. -- Titles are very important to communicate to the user what you are working on. A session should always have a title. -- Keep titles concise and specific. Prefer a short noun phrase over a full sentence, and do not wrap the title in quotes. -- Do not mention that you changed the title unless it is directly relevant to the user. - -{{/if}} ## Searching and Reading If you are unsure how to fulfill the user's request, gather more information with tool calls and/or clarifying questions. diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 45baa2cf69ac6d..b75485420eb4ef 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -481,6 +481,32 @@ async fn test_thinking(cx: &mut TestAppContext) { assert_eq!(stop_events(events), vec![acp::StopReason::EndTurn]); } +#[gpui::test] +async fn test_thinking_allowed_when_model_cannot_disable_thinking(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + fake_model.set_supports_thinking(true); + + // With thinking toggled off, a model that can disable thinking honors + // the toggle... + thread.update(cx, |thread, cx| { + thread.set_thinking_enabled(false, cx); + let request = thread + .build_completion_request(CompletionIntent::UserPrompt, cx) + .unwrap(); + assert!(!request.thinking_allowed); + }); + + // ...but a model that always thinks ignores the stale toggle state. + fake_model.set_supports_disabling_thinking(false); + thread.update(cx, |thread, cx| { + let request = thread + .build_completion_request(CompletionIntent::UserPrompt, cx) + .unwrap(); + assert!(request.thinking_allowed); + }); +} + #[gpui::test] async fn test_system_prompt(cx: &mut TestAppContext) { let ThreadTest { @@ -1030,20 +1056,6 @@ async fn expect_tool_call_update_fields( } } -async fn expect_plan(events: &mut UnboundedReceiver>) -> acp::Plan { - let event = events - .next() - .await - .expect("no plan event received") - .unwrap(); - match event { - ThreadEvent::Plan(plan) => plan, - event => { - panic!("Unexpected event {event:?}"); - } - } -} - async fn next_tool_call_authorization( events: &mut UnboundedReceiver>, ) -> ToolCallAuthorization { @@ -3206,6 +3218,146 @@ async fn test_latest_token_usage_counts_cached_input_tokens(cx: &mut TestAppCont }); } +#[gpui::test] +async fn test_cumulative_token_usage(cx: &mut TestAppContext) { + let ThreadTest { + model, + thread, + project_context, + .. + } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread + .update(cx, |thread, cx| { + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["Use the echo tool"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // The first request emits two cumulative snapshots; only the final values + // must be counted, exactly once. + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + TokenUsage { + input_tokens: 100, + output_tokens: 10, + ..Default::default() + }, + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + TokenUsage { + input_tokens: 100, + output_tokens: 50, + ..Default::default() + }, + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: "tool_1".into(), + name: EchoTool::NAME.into(), + raw_input: json!({"text": "hello"}).to_string(), + input: json!({"text": "hello"}), + is_input_complete: true, + thought_signature: None, + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + // The second request (after the tool call) is counted in addition to the first. + fake_model.send_last_completion_stream_text_chunk("Done"); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + TokenUsage { + input_tokens: 200, + output_tokens: 30, + ..Default::default() + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + let expected = TokenUsage { + input_tokens: 300, + output_tokens: 80, + ..Default::default() + }; + thread.read_with(cx, |thread, _| { + assert_eq!(thread.cumulative_token_usage(), expected); + }); + + let db_thread = thread.read_with(cx, |thread, cx| thread.to_db(cx)).await; + assert_eq!(db_thread.cumulative_token_usage, expected); + + cx.update(|cx| { + LanguageModelRegistry::test(cx); + }); + let restored = cx.update(|cx| { + let thread = thread.read(cx); + let project = thread.project.clone(); + let context_server_registry = thread.context_server_registry.clone(); + let templates = thread.templates.clone(); + cx.new(|cx| { + Thread::from_db( + acp::SessionId::new("restored"), + db_thread, + project, + project_context.clone(), + context_server_registry, + templates, + cx, + ) + }) + }); + restored.read_with(cx, |thread, _| { + assert_eq!(thread.cumulative_token_usage(), expected); + }); +} + +#[gpui::test] +async fn test_cumulative_token_usage_keeps_accounted_usage_monotonic(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + TokenUsage { + input_tokens: 100, + output_tokens: 10, + ..Default::default() + }, + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + TokenUsage::default(), + )); + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + TokenUsage { + input_tokens: 100, + output_tokens: 50, + ..Default::default() + }, + )); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _| { + assert_eq!( + thread.cumulative_token_usage(), + TokenUsage { + input_tokens: 100, + output_tokens: 50, + ..Default::default() + } + ); + }); +} + #[gpui::test] async fn test_truncate_second_message(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -3371,6 +3523,72 @@ async fn test_title_generation(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_stream_thread_title_keeps_only_first_line(cx: &mut TestAppContext) { + let model = Arc::new(FakeLanguageModel::default()); + let request = LanguageModelRequest::default(); + + let title_task = cx.spawn({ + let model = model.clone(); + async move |cx| crate::stream_thread_title(model, request, &cx).await + }); + + cx.run_until_parked(); + + model.send_last_completion_stream_text_chunk("Hello world\nGoodnight Moon"); + model.end_last_completion_stream(); + + let title = title_task.await.unwrap(); + assert_eq!(title, "Hello world"); +} + +#[gpui::test] +async fn test_stream_thread_title_stops_when_newline_ends_chunk(cx: &mut TestAppContext) { + let model = Arc::new(FakeLanguageModel::default()); + let request = LanguageModelRequest::default(); + + let title_task = cx.spawn({ + let model = model.clone(); + async move |cx| crate::stream_thread_title(model, request, &cx).await + }); + + cx.run_until_parked(); + + model.send_last_completion_stream_text_chunk("Hello world\n"); + model.send_last_completion_stream_text_chunk("Goodnight Moon"); + model.end_last_completion_stream(); + + let title = title_task.await.unwrap(); + assert_eq!(title, "Hello world"); +} + +// `Thread::to_markdown` (live native) and `DbThread::to_markdown` (persisted +// native) must stay byte-for-byte identical for the same messages, since both +// back the sidebar's native "Open Thread as Markdown" action. This pins that +// they share a single rendering path. +#[gpui::test] +async fn test_db_thread_markdown_matches_live_thread(cx: &mut TestAppContext) { + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let send = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hey there!"); + fake_model.end_last_completion_stream(); + send.collect::>().await; + cx.run_until_parked(); + + let db_thread = thread.update(cx, |thread, cx| thread.to_db(cx)).await; + let live_markdown = thread.read_with(cx, |thread, _| thread.to_markdown()); + + assert!(!live_markdown.is_empty()); + assert_eq!(db_thread.to_markdown(), live_markdown); +} + #[gpui::test] async fn test_title_generation_failure_allows_retry(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; @@ -3713,267 +3931,6 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { ); } -#[gpui::test] -async fn test_update_plan_tool_updates_thread_events(cx: &mut TestAppContext) { - let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; - thread.update(cx, |thread, _cx| thread.add_tool(UpdatePlanTool)); - let fake_model = model.as_fake(); - - let mut events = thread - .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Make a plan"], cx) - }) - .unwrap(); - cx.run_until_parked(); - - let input = json!({ - "plan": [ - { - "step": "Inspect the code", - "status": "completed", - }, - { - "step": "Implement the tool", - "status": "in_progress" - }, - { - "step": "Run tests", - "status": "pending", - } - ] - }); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: "plan_1".into(), - name: UpdatePlanTool::NAME.into(), - raw_input: input.to_string(), - input, - is_input_complete: true, - thought_signature: None, - }, - )); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - let tool_call = expect_tool_call(&mut events).await; - assert_eq!( - tool_call, - acp::ToolCall::new("plan_1", "Update plan") - .kind(acp::ToolKind::Think) - .raw_input(json!({ - "plan": [ - { - "step": "Inspect the code", - "status": "completed", - }, - { - "step": "Implement the tool", - "status": "in_progress" - }, - { - "step": "Run tests", - "status": "pending", - } - ] - })) - .meta(acp::Meta::from_iter([( - "tool_name".into(), - "update_plan".into() - )])) - ); - - let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!( - update, - acp::ToolCallUpdate::new( - "plan_1", - acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) - ) - ); - - let plan = expect_plan(&mut events).await; - assert_eq!( - plan, - acp::Plan::new(vec![ - acp::PlanEntry::new( - "Inspect the code", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::Completed, - ), - acp::PlanEntry::new( - "Implement the tool", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::InProgress, - ), - acp::PlanEntry::new( - "Run tests", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::Pending, - ), - ]) - ); - - let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!( - update, - acp::ToolCallUpdate::new( - "plan_1", - acp::ToolCallUpdateFields::new() - .status(acp::ToolCallStatus::Completed) - .raw_output("Plan updated") - ) - ); -} - -#[gpui::test] -async fn test_update_title_tool_sets_thread_title(cx: &mut TestAppContext) { - let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - let summary_model = Arc::new(FakeLanguageModel::default()); - - cx.update(|cx| { - cx.update_flags(true, vec!["update-title-tool".to_string()]); - }); - thread.update(cx, |thread, cx| { - thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); - thread.set_summarization_model(Some(summary_model.clone()), cx); - }); - - let mut events = thread - .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Explore title tooling"], cx) - }) - .unwrap(); - cx.run_until_parked(); - - let input = json!({ - "title": "Session title tool" - }); - fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( - LanguageModelToolUse { - id: "title_1".into(), - name: UpdateTitleTool::NAME.into(), - raw_input: input.to_string(), - input, - is_input_complete: true, - thought_signature: None, - }, - )); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - let tool_call = expect_tool_call(&mut events).await; - assert_eq!( - tool_call, - acp::ToolCall::new("title_1", "Update title: Session title tool") - .kind(acp::ToolKind::Think) - .raw_input(json!({ - "title": "Session title tool" - })) - .meta(acp::Meta::from_iter([( - "tool_name".into(), - "update_title".into() - )])) - ); - - let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!( - update, - acp::ToolCallUpdate::new( - "title_1", - acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) - ) - ); - - let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!( - update, - acp::ToolCallUpdate::new( - "title_1", - acp::ToolCallUpdateFields::new() - .status(acp::ToolCallStatus::Completed) - .raw_output("Session title updated") - ) - ); - - thread.read_with(cx, |thread, _| { - assert_eq!(thread.title(), Some("Session title tool".into())); - }); - assert_eq!(summary_model.pending_completions(), Vec::new()); -} - -#[gpui::test] -async fn test_update_title_availability_suppresses_summary_title_generation( - cx: &mut TestAppContext, -) { - let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - let summary_model = Arc::new(FakeLanguageModel::default()); - - cx.update(|cx| { - cx.update_flags(true, vec!["update-title-tool".to_string()]); - }); - thread.update(cx, |thread, cx| { - thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); - thread.set_summarization_model(Some(summary_model.clone()), cx); - }); - - let send = thread - .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Explore title tooling"], cx) - }) - .unwrap(); - cx.run_until_parked(); - - fake_model.send_last_completion_stream_text_chunk("Done"); - fake_model.end_last_completion_stream(); - send.collect::>().await; - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { - assert_eq!(thread.title(), None); - }); - assert_eq!(summary_model.pending_completions(), Vec::new()); -} - -#[gpui::test] -async fn test_update_title_flag_without_available_tool_falls_back_to_summary_title_generation( - cx: &mut TestAppContext, -) { - let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; - let fake_model = model.as_fake(); - let summary_model = Arc::new(FakeLanguageModel::default()); - - cx.update(|cx| { - cx.update_flags(true, vec!["update-title-tool".to_string()]); - }); - thread.update(cx, |thread, cx| { - thread.set_summarization_model(Some(summary_model.clone()), cx); - }); - - let send = thread - .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Explore title tooling"], cx) - }) - .unwrap(); - cx.run_until_parked(); - - fake_model.send_last_completion_stream_text_chunk("Done"); - fake_model.end_last_completion_stream(); - cx.run_until_parked(); - - assert_eq!(summary_model.pending_completions().len(), 1); - - summary_model.send_last_completion_stream_text_chunk("Fallback title"); - summary_model.end_last_completion_stream(); - send.collect::>().await; - cx.run_until_parked(); - - thread.read_with(cx, |thread, _| { - assert_eq!(thread.title(), Some("Fallback title".into())); - }); -} - #[gpui::test] async fn test_send_no_retry_on_success(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; @@ -4486,8 +4443,6 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { StreamingJsonErrorContextTool::NAME: true, StreamingFailingEchoTool::NAME: true, TerminalTool::NAME: true, - UpdatePlanTool::NAME: true, - UpdateTitleTool::NAME: true, } } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index ba60f2e6292eef..2b212bb9e73e75 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -4,23 +4,18 @@ use crate::{ FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SandboxedTerminalTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, - TerminalTool, ToolPermissionDecision, UpdatePlanTool, UpdateTitleTool, WebSearchTool, - WriteFileTool, decide_permission_from_settings, + TerminalTool, ToolPermissionDecision, WebSearchTool, WriteFileTool, + decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; use agent_settings::UserAgentsMd; -use feature_flags::{ - CreateThreadToolFeatureFlag, FeatureFlagAppExt as _, HandoffFeatureFlag, LspToolFeatureFlag, - RenameToolFeatureFlag, UpdatePlanToolFeatureFlag, UpdateTitleToolFeatureFlag, -}; -use zed_env_vars::{EnvVar, env_var}; use crate::sandboxing::{SandboxRequest, ThreadSandboxGrants, sandboxing_enabled}; use agent_client_protocol::schema as acp; use agent_settings::{ - AgentProfileId, AgentSettings, COMPACTION_PROMPT, SUMMARIZE_THREAD_DETAILED_PROMPT, - SUMMARIZE_THREAD_PROMPT, + AgentProfileId, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, + SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Local, Utc}; @@ -73,17 +68,12 @@ const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; pub const MAX_TOOL_NAME_LENGTH: usize = 64; pub const MAX_SUBAGENT_DEPTH: u8 = 1; -const AGENT_COMPACTION_REMAINING_TOKEN_BUDGET: u64 = 40_000; - /// Auto-compaction is only available for models whose context window is at least /// this large. For smaller models there isn't enough headroom for a compaction /// pass to be worthwhile, so we leave the thread uncompacted and let the UI warn /// the user instead. pub const MIN_COMPACTION_CONTEXT_WINDOW: u64 = 80_000; -static AGENT_COMPACTION_REMAINING_TOKEN_BUDGET_ENV_VAR: std::sync::LazyLock = - env_var!("AGENT_COMPACTION_REMAINING_TOKEN_BUDGET"); - // Using the heuristic that 1 token is about 4 bytes, keep the last 80K bytes of user-message content (~20k tokens). const COMPACTION_RETAINED_USER_MESSAGES_BYTE_BUDGET: usize = 80_000; @@ -365,6 +355,18 @@ impl UserMessage { MentionUri::Thread { .. } => { write!(&mut thread_context, "\n{}\n", content).ok(); } + MentionUri::Rule { .. } => { + // Deprecated: keeps legacy rule mentions as context. + write!( + &mut rules_context, + "\n{}", + MarkdownCodeBlock { + tag: "", + text: content + } + ) + .ok(); + } MentionUri::Fetch { url } => { write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok(); } @@ -824,11 +826,15 @@ pub enum ThreadEvent { AgentThinking(String), ToolCall(acp::ToolCall), ToolCallUpdate(acp_thread::ToolCallUpdate), - Plan(acp::Plan), ToolCallAuthorization(ToolCallAuthorization), + ToolCallAuthorizationResolved { + tool_call_id: acp::ToolCallId, + outcome: acp_thread::SelectedPermissionOutcome, + }, SubagentSpawned(acp::SessionId), Retry(acp_thread::RetryStatus), - ContextCompaction, + ContextCompaction(acp_thread::ContextCompaction), + ContextCompactionUpdate(acp_thread::ContextCompactionUpdate), Stop(acp::StopReason), } @@ -851,6 +857,7 @@ pub struct ToolPermissionContext { pub enum ToolPermissionScope { ToolInput, SymlinkTarget, + AgentSkills, } impl ToolPermissionContext { @@ -870,6 +877,11 @@ impl ToolPermissionContext { } } + pub fn for_agent_skills(mut self) -> Self { + self.scope = ToolPermissionScope::AgentSkills; + self + } + /// Builds the permission options for this tool context. /// /// This is the canonical source for permission option generation. @@ -915,6 +927,22 @@ impl ToolPermissionContext { ]); } + // Skills always prompt, so offer only once-only allow/deny. + if self.scope == ToolPermissionScope::AgentSkills { + return acp_thread::PermissionOptions::Flat(vec![ + acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("deny"), + "Deny", + acp::PermissionOptionKind::RejectOnce, + ), + ]); + } + // Check if the user's shell supports POSIX-like command chaining. // See the doc comment above for the full explanation of why this is needed. let shell_supports_always_allow = if tool_name == TerminalTool::NAME { @@ -1079,6 +1107,25 @@ pub struct ToolCallAuthorization { pub kind: acp_thread::AuthorizationKind, } +fn auto_resolve_permission_outcome( + options: &acp_thread::PermissionOptions, + is_allow: bool, +) -> Result { + let kind = if is_allow { + acp::PermissionOptionKind::AllowOnce + } else { + acp::PermissionOptionKind::RejectOnce + }; + let option = options + .first_option_of_kind(kind) + .ok_or_else(|| anyhow!("permission prompt has no auto-resolution option"))?; + + Ok(acp_thread::SelectedPermissionOutcome::new( + option.option_id.clone(), + option.kind, + )) +} + #[derive(Debug, thiserror::Error)] enum CompletionError { #[error("max tokens")] @@ -1110,8 +1157,12 @@ pub struct Thread { pending_message: Option, pub(crate) tools: BTreeMap>, request_token_usage: HashMap, - #[allow(unused)] cumulative_token_usage: TokenUsage, + /// The per-field maximum usage snapshot already added to + /// `cumulative_token_usage` for the in-flight completion request. Reset at + /// the start of each request. + current_request_token_usage: TokenUsage, + pending_compaction_telemetry: Option, #[allow(unused)] initial_project_snapshot: Shared>>>, pub(crate) context_server_registry: Entity, @@ -1244,6 +1295,8 @@ impl Thread { tools: BTreeMap::default(), request_token_usage: HashMap::default(), cumulative_token_usage: TokenUsage::default(), + current_request_token_usage: TokenUsage::default(), + pending_compaction_telemetry: None, initial_project_snapshot: { let project_snapshot = Self::project_snapshot(project.clone(), cx); cx.foreground_executor() @@ -1349,7 +1402,7 @@ impl Thread { ) -> mpsc::UnboundedReceiver> { let (tx, rx) = mpsc::unbounded(); let stream = ThreadEventStream(tx); - for message in &self.messages { + for (message_ix, message) in self.messages.iter().enumerate() { match &**message { Message::User(user_message) => stream.send_user_message(user_message), Message::Agent(assistant_message) => { @@ -1372,7 +1425,26 @@ impl Thread { } } Message::Resume => {} - Message::Compaction(_) => stream.send_context_compaction(), + Message::Compaction(info) => { + let compaction_id = acp_thread::ContextCompactionId( + format!("replay-compaction-{message_ix}").into(), + ); + match info { + CompactionInfo::Summary(summary) => { + stream.send_context_compaction( + compaction_id.clone(), + acp_thread::ContextCompactionStatus::Completed, + ); + stream.send_context_compaction_update(compaction_id.clone(), summary); + } + CompactionInfo::ProviderNative { .. } => { + stream.send_context_compaction( + compaction_id, + acp_thread::ContextCompactionStatus::Completed, + ); + } + } + } } } rx @@ -1385,6 +1457,13 @@ impl Thread { stream: &ThreadEventStream, cx: &mut Context, ) { + // A tool call left only with the canceled sentinel produced nothing useful + // (the sentinel is model-facing only, and is inserted exactly when a tool + // had no real result). Don't replay it into the UI at all. + if tool_result.is_some_and(Self::is_canceled_tool_result) { + return; + } + let output = tool_result .as_ref() .and_then(|result| result.output.clone()); @@ -1399,6 +1478,12 @@ impl Thread { } }); + // Recorded tool calls use the model-facing name, so a terminal call is + // always keyed as `terminal` and resolves to the non-sandboxed + // `TerminalTool` here, even if it originally ran under + // `SandboxedTerminalTool`. That's safe because both variants share the + // same `replay` behavior; replay only reconstructs UI state and never + // re-runs the command or re-applies sandbox policy. let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| { self.context_server_registry .read(cx) @@ -1417,11 +1502,10 @@ impl Thread { // but still display the saved result if available. // We need to send both ToolCall and ToolCallUpdate events because the UI // only converts raw_output to displayable content in update_fields, not from_acp. - let title = Self::title_for_replayed_tool_use(tool_use); stream .0 .unbounded_send(Ok(ThreadEvent::ToolCall( - acp::ToolCall::new(tool_use.id.to_string(), title.clone()) + acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) .status(status) .raw_input(tool_use.input.clone()), ))) @@ -1429,9 +1513,6 @@ impl Thread { let mut fields = acp::ToolCallUpdateFields::new() .status(status) .raw_output(output); - if tool_use.name.as_ref() == UpdateTitleTool::NAME { - fields = fields.title(title); - } if let Some(content) = replay_content { fields = fields.content(content); } @@ -1480,14 +1561,16 @@ impl Thread { ); } - fn title_for_replayed_tool_use(tool_use: &LanguageModelToolUse) -> String { - if tool_use.name.as_ref() == UpdateTitleTool::NAME { - let input = serde_json::from_value(tool_use.input.clone()) - .map_err(|_| serde_json::Value::String(tool_use.raw_input.clone())); - UpdateTitleTool::title_for_input(input).to_string() - } else { - tool_use.name.to_string() - } + /// A canceled tool result carries only the model-facing `TOOL_CANCELED_MESSAGE` + /// sentinel (inserted exactly when a tool had no real result). It's never + /// meaningful to the user, so we detect it to skip replaying the tool call. + fn is_canceled_tool_result(tool_result: &LanguageModelToolResult) -> bool { + tool_result.is_error + && matches!( + tool_result.content.as_slice(), + [LanguageModelToolResultContent::Text(text)] + if text.as_ref() == TOOL_CANCELED_MESSAGE + ) } fn tool_result_content_for_replay( @@ -1591,6 +1674,8 @@ impl Thread { tools: BTreeMap::default(), request_token_usage: db_thread.request_token_usage.clone(), cumulative_token_usage: db_thread.cumulative_token_usage, + current_request_token_usage: TokenUsage::default(), + pending_compaction_telemetry: None, initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), context_server_registry, profile_id, @@ -1855,12 +1940,6 @@ impl Thread { self.add_tool(GrepTool::new(self.project.clone())); self.add_tool(ListDirectoryTool::new(self.project.clone())); self.add_tool(MovePathTool::new(self.project.clone())); - if cx.has_flag::() { - self.add_tool(UpdatePlanTool); - } - if cx.has_flag::() { - self.add_tool(UpdateTitleTool::new(cx.weak_entity())); - } self.add_tool(ReadFileTool::new( self.project.clone(), self.action_log.clone(), @@ -1971,7 +2050,43 @@ impl Thread { self.has_queued_message } + fn accumulate_token_usage(&mut self, update: language_model::TokenUsage) { + let previous_accounted_usage = self.current_request_token_usage; + let current_accounted_usage = TokenUsage { + input_tokens: previous_accounted_usage + .input_tokens + .max(update.input_tokens), + output_tokens: previous_accounted_usage + .output_tokens + .max(update.output_tokens), + cache_creation_input_tokens: previous_accounted_usage + .cache_creation_input_tokens + .max(update.cache_creation_input_tokens), + cache_read_input_tokens: previous_accounted_usage + .cache_read_input_tokens + .max(update.cache_read_input_tokens), + }; + self.current_request_token_usage = current_accounted_usage; + self.cumulative_token_usage = self.cumulative_token_usage + + TokenUsage { + input_tokens: current_accounted_usage + .input_tokens + .saturating_sub(previous_accounted_usage.input_tokens), + output_tokens: current_accounted_usage + .output_tokens + .saturating_sub(previous_accounted_usage.output_tokens), + cache_creation_input_tokens: current_accounted_usage + .cache_creation_input_tokens + .saturating_sub(previous_accounted_usage.cache_creation_input_tokens), + cache_read_input_tokens: current_accounted_usage + .cache_read_input_tokens + .saturating_sub(previous_accounted_usage.cache_read_input_tokens), + }; + } + fn update_token_usage(&mut self, update: language_model::TokenUsage, cx: &mut Context) { + self.accumulate_token_usage(update); + let Some(last_user_message) = self.last_user_message() else { return; }; @@ -2012,6 +2127,10 @@ impl Thread { Some(*tokens) } + pub fn cumulative_token_usage(&self) -> language_model::TokenUsage { + self.cumulative_token_usage + } + pub fn latest_token_usage(&self) -> Option { let usage = self.latest_request_token_usage()?; let model = self.model.clone()?; @@ -2125,6 +2244,100 @@ impl Thread { self.run_turn(cx) } + /// Force a manual context compaction using the summary strategy, + /// regardless of the current token usage or context window size. + pub fn compact( + &mut self, + id: UserMessageId, + cx: &mut Context, + ) -> Result>> { + let model = self + .model + .clone() + .ok_or_else(|| anyhow!(NoModelConfiguredError))?; + + // Flush any pending message and cancel an in-flight turn before we + // start, mirroring `run_turn` so a stray completion can't race with the + // compaction we're about to perform. + self.flush_pending_message(cx); + self.cancel(cx).detach(); + + let compaction = self.forced_compaction_target_ix().map(|request_end_ix| { + self.advance_prompt_id(); + let request = self.build_compaction_request(request_end_ix, &model, cx); + self.current_request_token_usage = TokenUsage::default(); + (model, request) + }); + + if compaction.is_some() { + self.pending_compaction_telemetry = self.build_compaction_telemetry("manual", cx); + } + + self.clear_summary(); + cx.notify(); + + let (events_tx, events_rx) = mpsc::unbounded::>(); + let event_stream = ThreadEventStream(events_tx); + let (cancellation_tx, mut cancellation_rx) = watch::channel(false); + let task = cx.spawn({ + let event_stream = event_stream.clone(); + async move |this, cx| { + let result = if let Some((model, request)) = compaction { + Self::stream_compaction( + &this, + &event_stream, + cancellation_rx.clone(), + model, + request, + CompactionInsertion::Manual { marker_id: id }, + cx, + ) + .await + } else { + Ok(ControlFlow::Continue(())) + }; + + // If we were cancelled, `cancel()` already took `running_turn` + // (possibly for a new turn), so leave it alone. + if *cancellation_rx.borrow() { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome("canceled", None) + }) + .log_err(); + return; + } + + match result { + // On success, the telemetry event is deferred until the next + // completion reports usage (see `handle_completion_event`), + // so we leave `pending_compaction_telemetry` in place here. + Ok(_) => event_stream.send_stop(acp::StopReason::EndTurn), + Err(error) => { + log::error!("Manual compaction failed: {:?}", error); + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome( + "failed", + Some(error.to_string()), + ) + }) + .log_err(); + event_stream.send_error(error); + } + } + + _ = this.update(cx, |this, _| this.running_turn.take()); + } + }); + self.running_turn = Some(RunningTurn::new( + event_stream, + BTreeMap::default(), + cancellation_tx, + task, + )); + + Ok(events_rx) + } + pub fn push_acp_user_block( &mut self, id: UserMessageId, @@ -2176,13 +2389,11 @@ impl Thread { let event_stream = ThreadEventStream(events_tx); let message_ix = self.messages.len().saturating_sub(1); self.clear_summary(); + let tools = self.enabled_tools(cx); let (cancellation_tx, mut cancellation_rx) = watch::channel(false); - self.running_turn = Some(RunningTurn { - event_stream: event_stream.clone(), - tools: self.enabled_tools(cx), - cancellation_tx, - streaming_tool_inputs: HashMap::default(), - _task: cx.spawn(async move |this, cx| { + let task = cx.spawn({ + let event_stream = event_stream.clone(); + async move |this, cx| { log::debug!("Starting agent turn execution"); let turn_result = @@ -2222,8 +2433,9 @@ impl Thread { } _ = this.update(cx, |this, _| this.running_turn.take()); - }), + } }); + self.running_turn = Some(RunningTurn::new(event_stream, tools, cancellation_tx, task)); Ok(events_rx) } @@ -2235,37 +2447,79 @@ impl Thread { ) -> Result<()> { let mut attempt = 0; let mut intent = CompletionIntent::UserPrompt; + // Set when a refusal fallback occurs so subsequent iterations use the fallback model. + let mut refusal_fallback_model: Option> = None; loop { - if cx.update(|cx| cx.has_flag::()) { - match Self::perform_compaction_if_needed( - this, - event_stream, - cancellation_rx.clone(), - cx, - ) - .await - { - Ok(ControlFlow::Continue(())) => {} - Ok(ControlFlow::Break(())) => return Ok(()), - Err(error) => { - log::error!("Compaction failed: {}", error); - match error.downcast::() { - Ok(error) => { - match Self::retry_completion_error( - this, - event_stream, - &mut cancellation_rx, - error, - attempt, - cx, - ) - .await? - { - ControlFlow::Break(()) => return Ok(()), - ControlFlow::Continue(()) => continue, + match Self::perform_compaction_if_needed( + this, + event_stream, + cancellation_rx.clone(), + cx, + ) + .await + { + // On success the telemetry event is deferred until the + // completion below reports usage, so we can record an + // accurate post-compaction context size (see + // `handle_completion_event`). + Ok(ControlFlow::Continue(())) => {} + Ok(ControlFlow::Break(())) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome("canceled", None) + })?; + return Ok(()); + } + Err(error) => { + log::error!("Compaction failed: {}", error); + let error_message = error.to_string(); + match error.downcast::() { + Ok(error) => { + attempt += 1; + match Self::retry_completion_error( + this, + event_stream, + &mut cancellation_rx, + error, + attempt, + cx, + ) + .await + { + Ok(ControlFlow::Break(())) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome("canceled", None) + })?; + return Ok(()); + } + Ok(ControlFlow::Continue(())) => { + this.update(cx, |this, _| { + if let Some(telemetry) = + this.pending_compaction_telemetry.as_mut() + { + telemetry.retries += 1; + } + })?; + continue; + } + Err(retry_error) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome( + "failed", + Some(error_message), + ) + })?; + return Err(retry_error); } } - Err(error) => return Err(error), + } + Err(error) => { + this.update(cx, |this, _| { + this.emit_compaction_telemetry_outcome( + "failed", + Some(error_message), + ) + })?; + return Err(error); } } } @@ -2274,13 +2528,15 @@ impl Thread { // Re-read the model and refresh tools on each iteration so that // mid-turn changes (e.g. the user switches model, toggles tools, // or changes profile) take effect between tool-call rounds. + // If a refusal fallback is active, use that model instead. let (model, request) = this.update(cx, |this, cx| { - let model = this - .model + let model = refusal_fallback_model .clone() + .or_else(|| this.model.clone()) .ok_or_else(|| anyhow!(NoModelConfiguredError))?; this.refresh_turn_tools(cx); let request = this.build_completion_request(intent, cx)?; + this.current_request_token_usage = TokenUsage::default(); anyhow::Ok((model, request)) })??; @@ -2306,6 +2562,7 @@ impl Thread { FuturesUnordered::new(); let mut early_tool_results: Vec = Vec::new(); let mut cancelled = false; + let mut had_refusal = false; loop { // Race between getting the first event, tool completion, and cancellation. let first_event = futures::select! { @@ -2387,6 +2644,14 @@ impl Thread { tool_results.extend(batch_result.0); if let Some(err) = batch_result.1 { + let is_refusal = err + .downcast_ref::() + .is_some_and(|e| matches!(e, CompletionError::Refusal)); + if is_refusal { + log::info!("Model refused request; checking for fallback model"); + had_refusal = true; + break; + } error = Some(err.downcast()?); break; } @@ -2412,6 +2677,59 @@ impl Thread { } })?; + if had_refusal { + let maybe_fallback = this.update(cx, |this, cx| -> Option> { + let current_model = refusal_fallback_model.as_ref().or(this.model.as_ref())?; + let fallback_id = match current_model.refusal_fallback_model_id() { + Some(id) => id, + None => { + log::info!( + "Refusal fallback: no fallback configured for model {} (provider {})", + current_model.id().0, + current_model.provider_id() + ); + return None; + } + }; + let provider_id = current_model.provider_id(); + let found = LanguageModelRegistry::global(cx) + .read(cx) + .available_models(cx) + .find(|m| { + m.provider_id() == provider_id && m.id().0.as_ref() == fallback_id + }); + if found.is_none() { + log::info!( + "Refusal fallback: fallback model {}/{} not found in available models", + provider_id, + fallback_id + ); + } + found + })?; + + if let Some(fallback) = maybe_fallback { + log::info!("Refusal fallback: retrying with {}", fallback.id().0); + let fallback_name = fallback.name().0.clone(); + this.update(cx, |this, cx| { + this.pending_message = None; + this.set_model(fallback.clone(), cx); + })?; + event_stream.send_retry(acp_thread::RetryStatus { + last_error: "Safety filter triggered".into(), + attempt: 1, + max_attempts: 1, + started_at: Instant::now(), + duration: Duration::MAX, + meta: Some(acp_thread::meta_with_refusal_fallback(&fallback_name)), + }); + refusal_fallback_model = Some(fallback); + continue; + } + log::info!("Request refused with no fallback model available"); + return Err(CompletionError::Refusal.into()); + } + let end_turn = tool_results.is_empty() && early_tool_results.is_empty(); for tool_result in early_tool_results { @@ -2503,22 +2821,52 @@ impl Thread { async fn perform_compaction_if_needed( this: &WeakEntity, event_stream: &ThreadEventStream, - mut cancellation_rx: watch::Receiver, + cancellation_rx: watch::Receiver, cx: &mut AsyncApp, ) -> Result> { let Some((model, request, insertion_ix)) = this.update(cx, |this, cx| { - let Some(insertion_ix) = this.compaction_message_target_ix() else { - return None; - }; + let insertion_ix = this.compaction_message_target_ix(cx)?; let model = this.model.clone()?; let request = this.build_compaction_request(insertion_ix, &model, cx); + this.current_request_token_usage = TokenUsage::default(); + // Preserve telemetry across retries so the retry count keeps + // accumulating rather than resetting on each attempt. + if this.pending_compaction_telemetry.is_none() { + this.pending_compaction_telemetry = this.build_compaction_telemetry("auto", cx); + } Some((model, request, insertion_ix)) })? else { return Ok(ControlFlow::Continue(())); }; + Self::stream_compaction( + this, + event_stream, + cancellation_rx, + model, + request, + CompactionInsertion::Auto { insertion_ix }, + cx, + ) + .await + } + + async fn stream_compaction( + this: &WeakEntity, + event_stream: &ThreadEventStream, + mut cancellation_rx: watch::Receiver, + model: Arc, + request: LanguageModelRequest, + insertion: CompactionInsertion, + cx: &mut AsyncApp, + ) -> Result> { log::debug!("Running compaction"); + let compaction_id = acp_thread::ContextCompactionId(Uuid::new_v4().to_string().into()); + event_stream.send_context_compaction( + compaction_id.clone(), + acp_thread::ContextCompactionStatus::InProgress, + ); let stream = futures::select! { result = model.stream_completion(request, cx).fuse() => result, _ = cancellation_rx.changed().fuse() => { @@ -2549,11 +2897,18 @@ impl Thread { }; match event? { - LanguageModelCompletionEvent::Text(text) => summary.push_str(&text), + LanguageModelCompletionEvent::Text(text) => { + summary.push_str(&text); + event_stream.send_context_compaction_update(compaction_id.clone(), &text); + } + LanguageModelCompletionEvent::UsageUpdate(usage) => { + this.update(cx, |this, _cx| { + this.accumulate_token_usage(usage); + })?; + } LanguageModelCompletionEvent::Stop(_) | LanguageModelCompletionEvent::Started | LanguageModelCompletionEvent::Queued { .. } - | LanguageModelCompletionEvent::UsageUpdate(_) | LanguageModelCompletionEvent::Thinking { .. } | LanguageModelCompletionEvent::RedactedThinking { .. } | LanguageModelCompletionEvent::ReasoningDetails(_) @@ -2575,15 +2930,29 @@ impl Thread { } log::debug!("Compaction succeeded:\n{summary}"); + event_stream.update_context_compaction_status( + compaction_id, + acp_thread::ContextCompactionStatus::Completed, + ); this.update(cx, |this, cx| { let compaction = Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into()))); - if insertion_ix <= this.messages.len() { - this.messages.insert(insertion_ix, compaction); - } else { - this.messages.push(compaction); + match insertion { + CompactionInsertion::Auto { insertion_ix } => { + if insertion_ix <= this.messages.len() { + this.messages.insert(insertion_ix, compaction); + } else { + this.messages.push(compaction); + } + } + CompactionInsertion::Manual { marker_id } => { + this.messages.push(Arc::new(Message::User(UserMessage { + id: marker_id, + content: Arc::from([]), + }))); + this.messages.push(compaction); + } } - event_stream.send_context_compaction(); cx.notify(); })?; @@ -2665,6 +3034,7 @@ impl Thread { max_attempts: max_attempts as usize, started_at: Instant::now(), duration: delay, + meta: None, }) } @@ -2735,6 +3105,12 @@ impl Thread { cache_creation_input_tokens = usage.cache_creation_input_tokens, cache_read_input_tokens = usage.cache_read_input_tokens, ); + // A successful compaction defers its telemetry until the first + // completion that follows it, so `tokens_after` reflects the + // real post-compaction context size. + if let Some(telemetry) = self.pending_compaction_telemetry.take() { + telemetry.emit("succeeded", None, Some(total_input_tokens(usage))); + } self.update_token_usage(usage, cx); } Stop(StopReason::Refusal) => return Err(CompletionError::Refusal.into()), @@ -3071,18 +3447,8 @@ impl Thread { self.title_generation_failed } - pub fn can_generate_title(&self, cx: &App) -> bool { - self.pending_title_generation.is_none() - && self.summarization_model.is_some() - && !self.update_title_tool_available(cx) - } - - fn update_title_tool_available(&self, cx: &App) -> bool { - if let Some(running_turn) = self.running_turn.as_ref() { - running_turn.tools.contains_key(UpdateTitleTool::NAME) - } else { - self.enabled_tools(cx).contains_key(UpdateTitleTool::NAME) - } + pub fn can_generate_title(&self) -> bool { + self.pending_title_generation.is_none() && self.summarization_model.is_some() } pub fn summary(&mut self, cx: &mut Context) -> Shared>> { @@ -3146,67 +3512,66 @@ impl Thread { } pub fn generate_title(&mut self, cx: &mut Context) { - if !self.can_generate_title(cx) { + if !self.can_generate_title() { return; } - - self.title_generation_failed = false; let Some(model) = self.summarization_model.clone() else { return; }; + self.spawn_title_generation(model, None, cx); + } - log::debug!( - "Generating title with model: {:?}", - self.summarization_model.as_ref().map(|model| model.name()) - ); - let mut request = LanguageModelRequest { - intent: Some(CompletionIntent::ThreadSummarization), - temperature: AgentSettings::temperature_for_model(&model, cx), - ..Default::default() - }; + pub fn regenerate_title(&mut self, cx: &mut Context) -> bool { + self.regenerate_title_with_callback(cx, |_title, _cx| {}) + } - for message in &self.messages { - request.messages.extend(message.to_request()); + pub fn regenerate_title_with_callback( + &mut self, + cx: &mut Context, + on_generated_title: impl FnOnce(SharedString, &mut Context) + 'static, + ) -> bool { + if self.pending_title_generation.is_some() { + return false; } - request.messages.push(LanguageModelRequestMessage { - role: Role::User, - content: vec![SUMMARIZE_THREAD_PROMPT.into()], - cache: false, - reasoning_details: None, - }); - self.pending_title_generation = Some(cx.spawn(async move |this, cx| { - let mut title = String::new(); + let Some(model) = self.summarization_model.clone() else { + return false; + }; - let generate = async { - let mut messages = model.stream_completion(request, cx).await?; - while let Some(event) = messages.next().await { - let event = event?; - let text = match event { - LanguageModelCompletionEvent::Text(text) => text, - _ => continue, - }; + self.spawn_title_generation(model, Some(Box::new(on_generated_title)), cx); - let mut lines = text.lines(); - title.extend(lines.next()); + true + } - // Stop if the LLM generated multiple lines. - if lines.next().is_some() { - break; - } - } - anyhow::Ok(()) - }; + fn spawn_title_generation( + &mut self, + model: Arc, + on_generated_title: Option)>>, + cx: &mut Context, + ) { + self.title_generation_failed = false; + log::debug!("Generating title with model: {:?}", model.name()); - let succeeded = generate + let temperature = AgentSettings::temperature_for_model(&model, cx); + let request = build_thread_title_request(&self.messages, temperature); + + let title_generation = cx.spawn(async move |_this, cx| { + stream_thread_title(model, request, cx) .await .context("failed to generate thread title") + .map(SharedString::from) .log_err() - .is_some(); + }); + + self.pending_title_generation = Some(cx.spawn(async move |this, cx| { + let title = title_generation.await; _ = this.update(cx, |this, cx| { this.pending_title_generation = None; - if succeeded { - this.set_title(title.into(), cx); + if let Some(title) = title { + this.set_title(title.clone(), cx); + if let Some(on_generated_title) = on_generated_title { + on_generated_title(title, cx); + } } else { this.title_generation_failed = true; cx.emit(TitleUpdated); @@ -3214,6 +3579,7 @@ impl Thread { } }); })); + cx.notify(); } pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { @@ -3335,7 +3701,10 @@ impl Thread { tool_choice: None, stop: Vec::new(), temperature: AgentSettings::temperature_for_model(model, cx), - thinking_allowed: self.thinking_enabled, + // Models that can't run with thinking disabled ignore the + // toggle state, which may be stale from a previously selected + // model that could. + thinking_allowed: self.thinking_enabled || !model.supports_disabling_thinking(), thinking_effort: self.thinking_effort.clone(), speed: self.speed(), }; @@ -3394,17 +3763,7 @@ impl Thread { None } }) - .filter(|(tool_name, _)| match tool_name.as_ref() { - RenameTool::NAME => cx.has_flag::(), - FindReferencesTool::NAME - | GetCodeActionsTool::NAME - | ApplyCodeActionTool::NAME - | GoToDefinitionTool::NAME => cx.has_flag::(), - CreateThreadTool::NAME | ListAgentsAndModelsTool::NAME => { - cx.has_flag::() - } - _ => true, - }) + .filter(|(tool_name, _)| crate::tools::tool_feature_flag_enabled(tool_name, cx)) .collect::>(); let mut context_server_tools = Vec::new(); @@ -3595,11 +3954,59 @@ impl Thread { .rposition(|message| matches!(&**message, Message::Compaction(_))) } - fn compaction_message_target_ix(&self) -> Option { + /// Captures the data for an `"Agent Compaction Completed"` telemetry event + /// at the moment a compaction starts. Returns `None` if there's no model. + fn build_compaction_telemetry( + &self, + trigger: &'static str, + cx: &App, + ) -> Option { + let model = self.model.as_ref()?; + let auto_compact = AgentSettings::get_global(cx).auto_compact; + let max_tokens = model.max_token_count(); + let tokens_before = self + .latest_request_token_usage() + .map(|usage| total_input_tokens(usage).saturating_add(usage.output_tokens)); + Some(CompactionTelemetry { + trigger, + thread_id: self.id.to_string(), + parent_thread_id: self.parent_thread_id().map(|id| id.to_string()), + prompt_id: self.prompt_id.to_string(), + model: model.telemetry_id(), + model_provider: model.provider_id().to_string(), + thinking_effort: self.thinking_effort.clone(), + max_tokens, + tokens_before, + auto_compact_enabled: auto_compact.enabled, + auto_compact_threshold: auto_compact.threshold.to_string(), + auto_compact_threshold_tokens: auto_compact_threshold_token_count( + auto_compact.threshold, + max_tokens, + ), + retries: 0, + }) + } + + /// Emits a pending compaction telemetry event for a non-success outcome + /// (`"failed"` or `"canceled"`), with no post-compaction token count. A + /// no-op if no compaction telemetry is pending. + fn emit_compaction_telemetry_outcome(&mut self, status: &'static str, error: Option) { + if let Some(telemetry) = self.pending_compaction_telemetry.take() { + telemetry.emit(status, error, None); + } + } + + fn compaction_message_target_ix(&self, cx: &App) -> Option { + let auto_compact = AgentSettings::get_global(cx).auto_compact; + if !auto_compact.enabled { + return None; + } + let model = self.model.as_ref()?; + let max_token_count = model.max_token_count(); // Models with a small context window don't leave enough headroom for a // compaction pass; the UI warns the user about the token limit instead. - if model.max_token_count() < MIN_COMPACTION_CONTEXT_WINDOW { + if max_token_count < MIN_COMPACTION_CONTEXT_WINDOW { return None; } let (usage_ix, usage) = { @@ -3626,14 +4033,8 @@ impl Thread { } let active_tokens = total_input_tokens(usage).saturating_add(usage.output_tokens); - - let remaining_budget = AGENT_COMPACTION_REMAINING_TOKEN_BUDGET_ENV_VAR - .value - .as_ref() - .and_then(|v| v.parse().ok()) - .unwrap_or(AGENT_COMPACTION_REMAINING_TOKEN_BUDGET); - - let compaction_threshold = model.max_token_count().saturating_sub(remaining_budget); + let compaction_threshold = + auto_compact_threshold_token_count(auto_compact.threshold, max_token_count); if active_tokens < compaction_threshold { return None; } @@ -3652,6 +4053,18 @@ impl Thread { Some(insertion_ix) } + /// Insertion point for a manually-triggered compaction. + /// Returns `None` only when there is nothing to summarize (no messages, or the thread already ends in a compaction). + fn forced_compaction_target_ix(&self) -> Option { + if matches!( + self.messages.last().map(|message| &**message), + None | Some(Message::Compaction(_)) + ) { + return None; + } + Some(self.messages.len()) + } + fn build_compaction_request( &self, insertion_ix: usize, @@ -3713,18 +4126,7 @@ impl Thread { } pub fn to_markdown(&self) -> String { - let mut markdown = String::new(); - for (ix, message) in self.messages.iter().enumerate() { - if ix > 0 { - markdown.push('\n'); - } - match &**message { - Message::User(_) => markdown.push_str("## User\n\n"), - Message::Agent(_) => markdown.push_str("## Assistant\n\n"), - Message::Resume | Message::Compaction(_) => {} - } - markdown.push_str(&message.to_markdown()); - } + let mut markdown = messages_to_markdown(&self.messages); if let Some(message) = self.pending_message.as_ref() { markdown.push_str("\n## Assistant\n\n"); @@ -3830,10 +4232,11 @@ impl Thread { max_attempts: 3, }) } - Other(err) if err.is::() => { - // Retrying won't help for Payment Required errors. - None - } + // Retrying won't help for Payment Required errors. + PaymentRequired => None, + // Retrying won't help until the user consents to data retention + // or switches models. + DataRetentionConsentRequired { .. } => None, // Conservatively assume that any other errors are non-retryable HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, @@ -3850,13 +4253,74 @@ fn total_input_tokens(usage: language_model::TokenUsage) -> u64 { .saturating_add(usage.cache_read_input_tokens) } -fn user_message_byte_len(message: &LanguageModelRequestMessage) -> usize { - message - .content - .iter() - .map(|content| match content { - MessageContent::Text(text) => text.len(), - MessageContent::Image(image) => image.len(), +fn auto_compact_threshold_token_count( + threshold: AutoCompactThreshold, + max_token_count: u64, +) -> u64 { + match threshold { + AutoCompactThreshold::Percentage(percent) => { + ((max_token_count as f64) * percent).ceil() as u64 + } + AutoCompactThreshold::TokensUsed(tokens) => tokens, + AutoCompactThreshold::TokensRemaining(tokens) => { + max_token_count.saturating_sub(tokens).saturating_add(1) + } + } +} + +/// Snapshot of the data needed to report an `"Agent Compaction Completed"` +/// telemetry event, captured when a compaction starts. +struct CompactionTelemetry { + /// `"auto"` for threshold-triggered compaction, `"manual"` for `/compact`. + trigger: &'static str, + thread_id: String, + parent_thread_id: Option, + prompt_id: String, + model: String, + model_provider: String, + thinking_effort: Option, + max_tokens: u64, + /// Tokens in the context window immediately before compaction. + tokens_before: Option, + auto_compact_enabled: bool, + auto_compact_threshold: String, + auto_compact_threshold_tokens: u64, + /// Number of times the compaction request was retried before the final + /// outcome. + retries: u32, +} + +impl CompactionTelemetry { + fn emit(self, status: &'static str, error: Option, tokens_after: Option) { + telemetry::event!( + "Agent Compaction Completed", + trigger = self.trigger, + status = status, + error = error, + thread_id = self.thread_id, + parent_thread_id = self.parent_thread_id, + prompt_id = self.prompt_id, + model = self.model, + model_provider = self.model_provider, + thinking_effort = self.thinking_effort, + max_tokens = self.max_tokens, + tokens_before = self.tokens_before, + tokens_after = tokens_after, + auto_compact_enabled = self.auto_compact_enabled, + auto_compact_threshold = self.auto_compact_threshold, + auto_compact_threshold_tokens = self.auto_compact_threshold_tokens, + retries = self.retries, + ); + } +} + +fn user_message_byte_len(message: &LanguageModelRequestMessage) -> usize { + message + .content + .iter() + .map(|content| match content { + MessageContent::Text(text) => text.len(), + MessageContent::Image(image) => image.len(), // These can never occur in a user message MessageContent::Thinking { .. } | MessageContent::RedactedThinking(_) @@ -3927,6 +4391,16 @@ fn take_text_within_byte_budget(text: String, remaining_bytes: &mut usize) -> Op if text.is_empty() { None } else { Some(text) } } +/// Describes where a streamed compaction summary should land in the thread +/// once it completes successfully. +enum CompactionInsertion { + /// Automatic compaction inserts the summary at an index computed up front + /// (which may be before a trailing not-yet-answered user message). + Auto { insertion_ix: usize }, + /// Manual `/compact` appends a zero-content user message followed by the summary. + Manual { marker_id: UserMessageId }, +} + struct RunningTurn { /// Holds the task that handles agent interaction until the end of the turn. /// Survives across multiple requests as the model performs tool calls and @@ -3947,6 +4421,21 @@ struct RunningTurn { } impl RunningTurn { + fn new( + event_stream: ThreadEventStream, + tools: BTreeMap>, + cancellation_tx: watch::Sender, + task: Task<()>, + ) -> Self { + Self { + _task: task, + event_stream, + tools, + cancellation_tx, + streaming_tool_inputs: HashMap::default(), + } + } + fn cancel(mut self) -> Task<()> { log::debug!("Cancelling in progress turn"); self.cancellation_tx.send(true).ok(); @@ -3955,6 +4444,63 @@ impl RunningTurn { } } +pub(crate) fn messages_to_markdown(messages: &[Arc]) -> String { + let mut markdown = String::new(); + for (ix, message) in messages.iter().enumerate() { + if ix > 0 { + markdown.push('\n'); + } + match &**message { + Message::User(_) => markdown.push_str("## User\n\n"), + Message::Agent(_) => markdown.push_str("## Assistant\n\n"), + Message::Resume | Message::Compaction(_) => {} + } + markdown.push_str(&message.to_markdown()); + } + markdown +} + +pub fn build_thread_title_request( + messages: &[Arc], + temperature: Option, +) -> LanguageModelRequest { + let mut request = LanguageModelRequest { + intent: Some(CompletionIntent::ThreadSummarization), + temperature, + ..Default::default() + }; + for message in messages { + request.messages.extend(message.to_request()); + } + request.messages.push(LanguageModelRequestMessage { + role: Role::User, + content: vec![SUMMARIZE_THREAD_PROMPT.into()], + cache: false, + reasoning_details: None, + }); + request +} + +pub async fn stream_thread_title( + model: Arc, + request: LanguageModelRequest, + cx: &AsyncApp, +) -> Result { + let mut title = String::new(); + let mut events = model.stream_completion(request, cx).await?; + while let Some(event) = events.next().await { + let LanguageModelCompletionEvent::Text(text) = event? else { + continue; + }; + if let Some(newline_ix) = text.find(|ch| ch == '\n' || ch == '\r') { + title.push_str(&text[..newline_ix]); + break; + } + title.push_str(&text); + } + Ok(title) +} + pub struct TokenUsageUpdated(pub Option); impl EventEmitter for Thread {} @@ -4366,17 +4912,68 @@ impl ThreadEventStream { .ok(); } - fn send_plan(&self, plan: acp::Plan) { - self.0.unbounded_send(Ok(ThreadEvent::Plan(plan))).ok(); + fn resolve_tool_call_authorization( + &self, + tool_use_id: &LanguageModelToolUseId, + outcome: acp_thread::SelectedPermissionOutcome, + ) { + self.0 + .unbounded_send(Ok(ThreadEvent::ToolCallAuthorizationResolved { + tool_call_id: acp::ToolCallId::new(tool_use_id.to_string()), + outcome, + })) + .ok(); } fn send_retry(&self, status: acp_thread::RetryStatus) { self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); } - fn send_context_compaction(&self) { + fn send_context_compaction( + &self, + id: acp_thread::ContextCompactionId, + status: acp_thread::ContextCompactionStatus, + ) { self.0 - .unbounded_send(Ok(ThreadEvent::ContextCompaction)) + .unbounded_send(Ok(ThreadEvent::ContextCompaction( + acp_thread::ContextCompaction { + id, + status, + summary: None, + }, + ))) + .ok(); + } + + fn send_context_compaction_update( + &self, + id: acp_thread::ContextCompactionId, + summary_delta: &str, + ) { + self.0 + .unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate( + acp_thread::ContextCompactionUpdate { + id, + summary_delta: summary_delta.to_string(), + status: None, + }, + ))) + .ok(); + } + + fn update_context_compaction_status( + &self, + id: acp_thread::ContextCompactionId, + status: acp_thread::ContextCompactionStatus, + ) { + self.0 + .unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate( + acp_thread::ContextCompactionUpdate { + id, + summary_delta: String::new(), + status: Some(status), + }, + ))) .ok(); } @@ -4496,6 +5093,11 @@ impl ToolCallEventStream { .update_tool_call_fields(&self.tool_use_id, fields, meta); } + pub fn resolve_authorization(&self, outcome: acp_thread::SelectedPermissionOutcome) { + self.stream + .resolve_tool_call_authorization(&self.tool_use_id, outcome); + } + pub fn update_diff(&self, diff: Entity) { self.stream .0 @@ -4516,10 +5118,6 @@ impl ToolCallEventStream { .ok(); } - pub fn update_plan(&self, plan: acp::Plan) { - self.stream.send_plan(plan); - } - /// Authorize a third-party tool (e.g., MCP tool from a context server). /// /// Unlike built-in tools, third-party tools don't support pattern-based permissions. @@ -4660,22 +5258,22 @@ impl ToolCallEventStream { }; let options = acp_thread::PermissionOptions::Flat(vec![ acp::PermissionOption::new( - acp::PermissionOptionId::new("allow"), + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()), "Allow once", acp::PermissionOptionKind::AllowOnce, ), acp::PermissionOption::new( - acp::PermissionOptionId::new("allow_thread"), + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), "Allow for this thread", acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( - acp::PermissionOptionId::new("allow_always"), + acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowAlways.as_id()), "Allow always", acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( - acp::PermissionOptionId::new("deny"), + acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), "Deny", acp::PermissionOptionKind::RejectOnce, ), @@ -4685,6 +5283,10 @@ impl ToolCallEventStream { let stream = self.stream.clone(); let tool_use_id = self.tool_use_id.clone(); let sandbox_grants = self.sandbox_grants.clone(); + let auto_allow_outcome = match auto_resolve_permission_outcome(&options, true) { + Ok(outcome) => outcome, + Err(error) => return Task::ready(Err(error)), + }; cx.spawn(async move |cx| { let (response_tx, mut response_rx) = oneshot::channel(); if let Err(error) = stream @@ -4741,11 +5343,9 @@ impl ToolCallEventStream { cx, )) { drop(response_rx); - stream.update_tool_call_fields( + stream.resolve_tool_call_authorization( &tool_use_id, - acp::ToolCallUpdateFields::new() - .status(acp::ToolCallStatus::InProgress), - None, + auto_allow_outcome.clone(), ); return Ok(()); } @@ -4778,19 +5378,22 @@ impl ToolCallEventStream { "unexpected params for sandbox permission" ); - match outcome.option_id.0.as_ref() { - "allow" => Ok(()), - "allow_thread" => { + match acp_thread::SandboxPermission::from_id(outcome.option_id.0.as_ref()) { + Some(acp_thread::SandboxPermission::AllowOnce) => Ok(()), + Some(acp_thread::SandboxPermission::AllowThread) => { sandbox_grants.borrow_mut().record(request); Ok(()) } - "allow_always" => { + Some(acp_thread::SandboxPermission::AllowAlways) => { sandbox_grants.borrow_mut().record(request); Self::persist_sandbox_always_permission(request, fs, cx); Ok(()) } - "deny" => Err(anyhow!("Permission to run tool denied by user")), - other => { + Some(acp_thread::SandboxPermission::Deny) => { + Err(anyhow!("Permission to run tool denied by user")) + } + None => { + let other = outcome.option_id.0.as_ref(); debug_assert!(false, "unexpected sandbox permission option_id: {other}"); Err(anyhow!("Permission to run tool denied by user")) } @@ -4803,6 +5406,9 @@ impl ToolCallEventStream { cx: &AsyncApp, ) { let Some(fs) = fs else { + log::error!( + "Cannot persist \"allow always\" sandbox permission: no filesystem available" + ); return; }; @@ -4928,6 +5534,17 @@ impl ToolCallEventStream { let fs = self.fs.clone(); let stream = self.stream.clone(); let tool_use_id = self.tool_use_id.clone(); + let auto_resolution_outcomes = if check_settings.is_some() { + match ( + auto_resolve_permission_outcome(&options, true), + auto_resolve_permission_outcome(&options, false), + ) { + (Ok(allow), Ok(deny)) => Some((allow, deny)), + (Err(error), _) | (_, Err(error)) => return Task::ready(Err(error)), + } + } else { + None + }; cx.spawn(async move |cx| { let (response_tx, mut response_rx) = oneshot::channel(); if let Err(error) = stream @@ -4956,6 +5573,9 @@ impl ToolCallEventStream { return Self::persist_permission_outcome(&outcome, fs, cx); }; + let Some((auto_allow_outcome, auto_deny_outcome)) = auto_resolution_outcomes else { + return Err(anyhow!("missing auto-resolution outcomes")); + }; let (mut settings_tx, mut settings_rx) = watch::channel(()); let _settings_subscription = cx.update(|cx| { @@ -4983,28 +5603,24 @@ impl ToolCallEventStream { } _ = settings_changed.fuse() => { // On auto-resolve, we dismiss the prompt UI by - // replacing the tool call's `WaitingForConfirmation` - // status with `InProgress` (or `Failed`). Dropping - // `response_rx` closes the `oneshot` held by the - // UI, so any late click by the user is a no-op. + // resolving the tool call's `WaitingForConfirmation` + // status with an internal selected outcome. Dropping + // `response_rx` prevents the synthetic response from + // being delivered back into this loop. match cx.update(|cx| check_settings(cx)) { ToolPermissionDecision::Allow => { drop(response_rx); - stream.update_tool_call_fields( + stream.resolve_tool_call_authorization( &tool_use_id, - acp::ToolCallUpdateFields::new() - .status(acp::ToolCallStatus::InProgress), - None, + auto_allow_outcome.clone(), ); return Ok(()); } ToolPermissionDecision::Deny(reason) => { drop(response_rx); - stream.update_tool_call_fields( + stream.resolve_tool_call_authorization( &tool_use_id, - acp::ToolCallUpdateFields::new() - .status(acp::ToolCallStatus::Failed), - None, + auto_deny_outcome.clone(), ); return Err(anyhow!(reason)); } @@ -5153,6 +5769,21 @@ impl ToolCallEventStreamReceiver { } } + pub async fn expect_authorization_resolved( + &mut self, + ) -> (acp::ToolCallId, acp_thread::SelectedPermissionOutcome) { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallAuthorizationResolved { + tool_call_id, + outcome, + })) = event + { + (tool_call_id, outcome) + } else { + panic!("Expected authorization resolved but got: {:?}", event); + } + } + pub async fn expect_diff(&mut self) -> Entity { let event = self.0.next().await; if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff( @@ -5176,15 +5807,6 @@ impl ToolCallEventStreamReceiver { panic!("Expected terminal but got: {:?}", event); } } - - pub async fn expect_plan(&mut self) -> acp::Plan { - let event = self.0.next().await; - if let Some(Ok(ThreadEvent::Plan(plan))) = event { - plan - } else { - panic!("Expected plan but got: {:?}", event); - } - } } #[cfg(any(test, feature = "test-support"))] @@ -5337,6 +5959,12 @@ mod tests { }) } + fn set_auto_compact_settings(cx: &mut App, auto_compact: agent_settings::AutoCompactSettings) { + let mut settings = AgentSettings::get_global(cx).clone(); + settings.auto_compact = auto_compact; + AgentSettings::override_global(settings, cx); + } + #[test] fn test_summary_compaction_renders_for_request_and_markdown() { let message = Message::Compaction(CompactionInfo::Summary("Older context".into())); @@ -5392,12 +6020,54 @@ mod tests { } #[gpui::test] - async fn test_compaction_threshold_uses_latest_reported_usage(cx: &mut TestAppContext) { + async fn test_compaction_threshold_uses_percentage_setting(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let model = Arc::new(FakeLanguageModel::default()); + let user_message_id = UserMessageId::new(); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(model, cx); + thread + .messages + .push(user_text_message(user_message_id.clone(), "below limit")); + thread.request_token_usage.insert( + user_message_id.clone(), + language_model::TokenUsage { + input_tokens: 899_999, + ..Default::default() + }, + ); + + assert_eq!(thread.compaction_message_target_ix(cx), None); + + thread.request_token_usage.insert( + user_message_id.clone(), + language_model::TokenUsage { + input_tokens: 900_000, + ..Default::default() + }, + ); + + assert_eq!(thread.compaction_message_target_ix(cx), Some(1)); + }); + }); + } + + #[gpui::test] + async fn test_compaction_threshold_respects_enabled_setting(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); let user_message_id = UserMessageId::new(); cx.update(|cx| { + set_auto_compact_settings( + cx, + agent_settings::AutoCompactSettings { + enabled: false, + threshold: AutoCompactThreshold::Percentage(0.9), + }, + ); thread.update(cx, |thread, cx| { thread.set_model(model, cx); thread @@ -5411,7 +6081,77 @@ mod tests { }, ); - assert_eq!(thread.compaction_message_target_ix(), Some(1)); + assert_eq!(thread.compaction_message_target_ix(cx), None); + }); + }); + } + + #[gpui::test] + async fn test_compaction_threshold_respects_token_settings(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let model = Arc::new(FakeLanguageModel::default()); + let user_message_id = UserMessageId::new(); + + cx.update(|cx| { + set_auto_compact_settings( + cx, + agent_settings::AutoCompactSettings { + enabled: true, + threshold: AutoCompactThreshold::TokensUsed(100_000), + }, + ); + thread.update(cx, |thread, cx| { + thread.set_model(model, cx); + thread.messages.push(user_text_message( + user_message_id.clone(), + "fixed token limit", + )); + thread.request_token_usage.insert( + user_message_id.clone(), + language_model::TokenUsage { + input_tokens: 99_999, + ..Default::default() + }, + ); + + assert_eq!(thread.compaction_message_target_ix(cx), None); + + thread.request_token_usage.insert( + user_message_id.clone(), + language_model::TokenUsage { + input_tokens: 100_000, + ..Default::default() + }, + ); + + assert_eq!(thread.compaction_message_target_ix(cx), Some(1)); + + set_auto_compact_settings( + cx, + agent_settings::AutoCompactSettings { + enabled: true, + threshold: AutoCompactThreshold::TokensRemaining(20_000), + }, + ); + thread.request_token_usage.insert( + user_message_id.clone(), + language_model::TokenUsage { + input_tokens: 980_000, + ..Default::default() + }, + ); + + assert_eq!(thread.compaction_message_target_ix(cx), None); + + thread.request_token_usage.insert( + user_message_id.clone(), + language_model::TokenUsage { + input_tokens: 980_001, + ..Default::default() + }, + ); + + assert_eq!(thread.compaction_message_target_ix(cx), Some(1)); }); }); } @@ -5438,7 +6178,7 @@ mod tests { }, ); - assert_eq!(thread.compaction_message_target_ix(), None); + assert_eq!(thread.compaction_message_target_ix(cx), None); }); }); } @@ -5453,7 +6193,6 @@ mod tests { let new_user_message_id = UserMessageId::new(); cx.update(|cx| { - cx.update_flags(true, vec!["handoff".to_string()]); thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread @@ -5522,6 +6261,330 @@ mod tests { }); } + #[gpui::test] + async fn test_manual_compact_forces_summary(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let model = Arc::new(FakeLanguageModel::default()); + // A context window below the minimum and no recorded token usage would + // both disable *automatic* compaction. Manual compaction forces it anyway. + model.set_max_token_count(MIN_COMPACTION_CONTEXT_WINDOW - 1); + let user_message_id = UserMessageId::new(); + let compact_message_id = UserMessageId::new(); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + thread + .messages + .push(user_text_message(user_message_id.clone(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + // Auto-compaction would be a no-op here. + assert_eq!(thread.compaction_message_target_ix(cx), None); + }); + }); + + let _events = cx + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.compact(compact_message_id.clone(), cx) + }) + }) + .unwrap(); + cx.run_until_parked(); + + let compaction_request = model.pending_completions().pop().unwrap(); + assert_eq!( + compaction_request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + let compaction_texts = request_texts_after_system(&compaction_request.messages); + assert_eq!(compaction_texts.len(), 3); + assert_eq!(compaction_texts[0], "old user"); + assert_eq!(compaction_texts[1], "old assistant"); + assert_eq!(compaction_texts[2], COMPACTION_PROMPT); + + model.send_completion_stream_text_chunk(&compaction_request, "summary of old context"); + model.end_completion_stream(&compaction_request); + cx.run_until_parked(); + + // The compaction summary is appended after a zero-content user message + // marker, and no follow-up model turn is requested — `/compact` only + // compacts. + assert!(model.pending_completions().is_empty()); + cx.update(|cx| { + thread.read_with(cx, |thread, _cx| { + assert!(matches!(&*thread.messages[0], Message::User(_))); + assert!(matches!(&*thread.messages[1], Message::Agent(_))); + assert!(matches!( + &*thread.messages[2], + Message::User(UserMessage { id, content }) if id == &compact_message_id && content.is_empty() + )); + assert!(matches!( + &*thread.messages[3], + Message::Compaction(CompactionInfo::Summary(summary)) if summary.as_ref() == "summary of old context" + )); + // Re-running `/compact` with nothing new to summarize is a + // no-op: the thread already ends in a compaction. + assert_eq!(thread.forced_compaction_target_ix(), None); + }); + + thread + .update(cx, |thread, cx| thread.truncate(compact_message_id.clone(), cx)) + .unwrap(); + + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.messages.len(), 2); + assert!(matches!(&*thread.messages[0], Message::User(_))); + assert!(matches!(&*thread.messages[1], Message::Agent(_))); + }); + }); + } + + /// Cancelling an in-flight manual compaction must not leave the zero-content + /// rewind marker (or a partial summary) dangling at the end of the thread. + #[gpui::test] + async fn test_manual_compact_cancelled_leaves_no_marker(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + thread + .messages + .push(user_text_message(UserMessageId::new(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + }); + }); + + let _events = cx + .update(|cx| thread.update(cx, |thread, cx| thread.compact(UserMessageId::new(), cx))) + .unwrap(); + cx.run_until_parked(); + // The compaction request is in flight but hasn't streamed a summary. + assert_eq!(model.pending_completions().len(), 1); + + cx.update(|cx| thread.update(cx, |thread, cx| thread.cancel(cx))) + .await; + cx.run_until_parked(); + + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.messages.len(), 2); + assert!(matches!(&*thread.messages[0], Message::User(_))); + assert!(matches!(&*thread.messages[1], Message::Agent(_))); + }); + } + + /// A failed compaction (here, an empty summary) reports an error and leaves + /// the thread untouched — no marker, no compaction. + #[gpui::test] + async fn test_manual_compact_empty_summary_leaves_no_marker(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + thread + .messages + .push(user_text_message(UserMessageId::new(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + }); + }); + + let mut events = cx + .update(|cx| thread.update(cx, |thread, cx| thread.compact(UserMessageId::new(), cx))) + .unwrap(); + cx.run_until_parked(); + + let request = model.pending_completions().pop().unwrap(); + // End the stream without emitting any summary text. + model.end_completion_stream(&request); + cx.run_until_parked(); + + // An error is surfaced, and the thread is left exactly as it was. The + // compaction task drops the event stream after failing, so the channel + // closes and this drain terminates. + let mut saw_error = false; + while let Some(event) = events.next().await { + if event.is_err() { + saw_error = true; + } + } + assert!(saw_error, "expected an error event for the empty summary"); + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.messages.len(), 2); + assert!(matches!(&*thread.messages[0], Message::User(_))); + assert!(matches!(&*thread.messages[1], Message::Agent(_))); + }); + } + + /// `/compact` on an empty thread (nothing to summarize) is a no-op: it + /// issues no model request and adds no marker. + #[gpui::test] + async fn test_manual_compact_noop_on_empty_thread(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let model = Arc::new(FakeLanguageModel::default()); + cx.update(|cx| thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx))); + + let _events = cx + .update(|cx| thread.update(cx, |thread, cx| thread.compact(UserMessageId::new(), cx))) + .unwrap(); + cx.run_until_parked(); + + assert!(model.pending_completions().is_empty()); + thread.read_with(cx, |thread, _cx| { + assert!(thread.messages.is_empty()); + }); + } + + /// The zero-content marker replays as an empty user message, which the UI + /// drops (it renders content blocks, of which there are none), so reloading + /// a compacted thread doesn't surface an empty `/compact` bubble. + #[gpui::test] + async fn test_manual_compact_marker_replays_as_empty_user_message(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let marker_id = UserMessageId::new(); + + let mut replay_events = cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread + .messages + .push(user_text_message(UserMessageId::new(), "before")); + thread.messages.push(agent_text_message("answer")); + thread.messages.push(Arc::new(Message::User(UserMessage { + id: marker_id.clone(), + content: Arc::from([]), + }))); + thread.messages.push(summary_compaction("summary")); + thread.replay(cx) + }) + }); + + // Skip the leading "before"/"answer" replay events. + let _ = replay_events.next().await; + let _ = replay_events.next().await; + + let event = replay_events.next().await; + match event { + Some(Ok(ThreadEvent::UserMessage(message))) => { + assert_eq!(message.id, marker_id); + assert!( + message.content.is_empty(), + "marker should replay with no content so the UI renders nothing" + ); + } + _ => panic!("expected the marker to replay as a user message, got {event:?}"), + } + + let event = replay_events.next().await; + assert!( + matches!(&event, Some(Ok(ThreadEvent::ContextCompaction(_)))), + "expected the compaction to replay after the marker, got {event:?}" + ); + } + + #[gpui::test] + async fn test_compaction_usage_counts_toward_cumulative_usage(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let model = Arc::new(FakeLanguageModel::default()); + let old_user_message_id = UserMessageId::new(); + let new_user_message_id = UserMessageId::new(); + let prior_usage = TokenUsage { + input_tokens: 960_000, + output_tokens: 25, + ..Default::default() + }; + let compaction_usage = TokenUsage { + input_tokens: 40, + output_tokens: 9, + cache_creation_input_tokens: 2, + cache_read_input_tokens: 3, + }; + let final_usage = TokenUsage { + input_tokens: 500, + output_tokens: 50, + ..Default::default() + }; + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(model.clone(), cx); + thread + .messages + .push(user_text_message(old_user_message_id.clone(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + thread + .request_token_usage + .insert(old_user_message_id.clone(), prior_usage); + thread.cumulative_token_usage = prior_usage; + thread.current_request_token_usage = prior_usage; + }); + }); + + let _events = cx + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.send(new_user_message_id.clone(), vec!["new prompt"], cx) + }) + }) + .unwrap(); + cx.run_until_parked(); + + let compaction_request = model.pending_completions().pop().unwrap(); + assert_eq!( + compaction_request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + + model.send_completion_stream_event( + &compaction_request, + LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: 40, + output_tokens: 4, + ..Default::default() + }), + ); + model.send_completion_stream_event( + &compaction_request, + LanguageModelCompletionEvent::UsageUpdate(compaction_usage), + ); + model.send_completion_stream_text_chunk(&compaction_request, "compacted old context"); + model.end_completion_stream(&compaction_request); + cx.run_until_parked(); + + let expected_after_compaction = prior_usage + compaction_usage; + thread.read_with(cx, |thread, _cx| { + assert_eq!(thread.cumulative_token_usage(), expected_after_compaction); + assert!( + !thread + .request_token_usage + .contains_key(&new_user_message_id) + ); + }); + + let final_request = model.pending_completions().pop().unwrap(); + assert_eq!(final_request.intent, Some(CompletionIntent::UserPrompt)); + + model.send_completion_stream_event( + &final_request, + LanguageModelCompletionEvent::UsageUpdate(final_usage), + ); + model.end_completion_stream(&final_request); + cx.run_until_parked(); + + thread.read_with(cx, |thread, _cx| { + assert_eq!( + thread.cumulative_token_usage(), + expected_after_compaction + final_usage + ); + assert_eq!( + thread.request_token_usage.get(&new_user_message_id), + Some(&final_usage) + ); + }); + } + #[gpui::test] async fn test_replay_emits_context_compaction(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; @@ -5548,10 +6611,20 @@ mod tests { "expected replayed user message, got {event:?}" ); + let event = replay_events.next().await; + let compaction_id = match &event { + Some(Ok(ThreadEvent::ContextCompaction(compaction))) => compaction.id.clone(), + _ => panic!("expected context compaction event, got {event:?}"), + }; + let event = replay_events.next().await; assert!( - matches!(&event, Some(Ok(ThreadEvent::ContextCompaction))), - "expected context compaction event, got {event:?}" + matches!( + &event, + Some(Ok(ThreadEvent::ContextCompactionUpdate(update))) + if update.id == compaction_id && update.summary_delta == "summary" + ), + "expected context compaction summary event, got {event:?}" ); let event = replay_events.next().await; @@ -5816,6 +6889,48 @@ mod tests { ); } + #[test] + fn test_auto_resolve_permission_outcome_uses_once_only_options() { + let options = acp_thread::PermissionOptions::Dropdown(vec![ + acp_thread::PermissionOptionChoice { + allow: acp::PermissionOption::new( + acp::PermissionOptionId::new("always_allow:test_tool"), + "Always allow", + acp::PermissionOptionKind::AllowAlways, + ), + deny: acp::PermissionOption::new( + acp::PermissionOptionId::new("always_deny:test_tool"), + "Always deny", + acp::PermissionOptionKind::RejectAlways, + ), + sub_patterns: vec![], + }, + acp_thread::PermissionOptionChoice { + allow: acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow once", + acp::PermissionOptionKind::AllowOnce, + ), + deny: acp::PermissionOption::new( + acp::PermissionOptionId::new("deny"), + "Deny once", + acp::PermissionOptionKind::RejectOnce, + ), + sub_patterns: vec![], + }, + ]); + + let allow = auto_resolve_permission_outcome(&options, true) + .expect("allow auto-resolve should use once-only option"); + assert_eq!(allow.option_id, acp::PermissionOptionId::new("allow")); + assert_eq!(allow.option_kind, acp::PermissionOptionKind::AllowOnce); + + let deny = auto_resolve_permission_outcome(&options, false) + .expect("deny auto-resolve should use once-only option"); + assert_eq!(deny.option_id, acp::PermissionOptionId::new("deny")); + assert_eq!(deny.option_kind, acp::PermissionOptionKind::RejectOnce); + } + #[gpui::test] async fn test_replay_tool_call_replays_image_content(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; @@ -5911,134 +7026,6 @@ mod tests { assert!(tool_use_ids_with_image_content.contains(&missing_tool_use_id.to_string())); } - #[gpui::test] - async fn test_update_title_tool_replay_does_not_reenter_thread(cx: &mut TestAppContext) { - let (thread, _event_stream) = setup_thread_for_test(cx).await; - - let tool_use_id = LanguageModelToolUseId::from("title_tool_id"); - let mut replay_events = cx.update(|cx| { - thread.update(cx, |thread, cx| { - thread.add_tool(UpdateTitleTool::new(cx.weak_entity())); - push_completed_update_title_tool_call(thread, tool_use_id.clone()); - - thread.replay(cx) - }) - }); - - let mut saw_tool_call_title = false; - let mut saw_replayed_title_update = false; - let mut saw_completed_update = false; - while let Some(event) = replay_events.next().await { - let event = event.unwrap(); - match event { - ThreadEvent::ToolCall(tool_call) - if tool_call.tool_call_id.to_string() == tool_use_id.to_string() - && tool_call.title == "Update title: Replayed title" => - { - saw_tool_call_title = true; - } - ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) - if update.tool_call_id.to_string() == tool_use_id.to_string() => - { - if update.fields.title == Some("Update title: Replayed title".to_string()) { - saw_replayed_title_update = true; - } - if update.fields.status == Some(acp::ToolCallStatus::Completed) { - saw_completed_update = true; - } - } - _ => {} - } - } - - assert!(saw_tool_call_title); - assert!(saw_replayed_title_update); - assert!(saw_completed_update); - thread.read_with(cx, |thread, _cx| { - assert_eq!(thread.title(), None); - }); - } - - #[gpui::test] - async fn test_update_title_tool_replay_title_when_tool_not_registered(cx: &mut TestAppContext) { - let (thread, _event_stream) = setup_thread_for_test(cx).await; - - let tool_use_id = LanguageModelToolUseId::from("title_tool_id"); - let mut replay_events = cx.update(|cx| { - thread.update(cx, |thread, cx| { - push_completed_update_title_tool_call(thread, tool_use_id.clone()); - thread.replay(cx) - }) - }); - - let mut saw_tool_call_title = false; - let mut saw_replayed_title_update = false; - let mut saw_completed_update = false; - while let Some(event) = replay_events.next().await { - let event = event.unwrap(); - match event { - ThreadEvent::ToolCall(tool_call) - if tool_call.tool_call_id.to_string() == tool_use_id.to_string() - && tool_call.title == "Update title: Replayed title" => - { - saw_tool_call_title = true; - } - ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) - if update.tool_call_id.to_string() == tool_use_id.to_string() => - { - if update.fields.title == Some("Update title: Replayed title".to_string()) { - saw_replayed_title_update = true; - } - if update.fields.status == Some(acp::ToolCallStatus::Completed) { - saw_completed_update = true; - } - } - _ => {} - } - } - - assert!(saw_tool_call_title); - assert!(saw_replayed_title_update); - assert!(saw_completed_update); - thread.read_with(cx, |thread, _cx| { - assert_eq!(thread.title(), None); - }); - } - - fn push_completed_update_title_tool_call( - thread: &mut Thread, - tool_use_id: LanguageModelToolUseId, - ) { - let tool_use = LanguageModelToolUse { - id: tool_use_id.clone(), - name: UpdateTitleTool::NAME.into(), - raw_input: json!({ "title": "Replayed title" }).to_string(), - input: json!({ "title": "Replayed title" }), - is_input_complete: true, - thought_signature: None, - }; - - let mut tool_results = IndexMap::default(); - tool_results.insert( - tool_use_id.clone(), - LanguageModelToolResult { - tool_use_id, - tool_name: UpdateTitleTool::NAME.into(), - is_error: false, - content: vec![LanguageModelToolResultContent::Text( - "Session title updated".into(), - )], - output: Some(json!("Session title updated")), - }, - ); - - thread.messages.push(Arc::new(Message::Agent(AgentMessage { - content: vec![AgentMessageContent::ToolUse(tool_use)], - tool_results, - reasoning_details: None, - }))); - } - #[gpui::test] async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) { let (parent, _event_stream) = setup_thread_for_test(cx).await; diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 20dc68f3bd3b69..59d52f563d89a1 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -590,6 +590,10 @@ mod tests { play_sound_when_agent_done: PlaySoundWhenAgentDone::default(), single_file_review: false, model_parameters: vec![], + auto_compact: agent_settings::AutoCompactSettings { + enabled: false, + threshold: agent_settings::AutoCompactThreshold::DEFAULT, + }, enable_feedback: false, expand_edit_card: true, expand_terminal_card: true, diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 282d55314937f9..f2e92dd28570fd 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -25,12 +25,14 @@ mod spawn_agent_tool; mod symbol_locator; mod terminal_tool; mod tool_permissions; -mod update_plan_tool; -mod update_title_tool; mod web_search_tool; mod write_file_tool; use crate::AgentTool; +use feature_flags::{ + CreateThreadToolFeatureFlag, FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag, +}; +use gpui::App; use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat}; use serde::{ Deserialize, Deserializer, @@ -84,8 +86,6 @@ pub use spawn_agent_tool::*; pub use symbol_locator::*; pub use terminal_tool::*; pub use tool_permissions::*; -pub use update_plan_tool::*; -pub use update_title_tool::*; pub use web_search_tool::*; pub use write_file_tool::*; @@ -158,7 +158,7 @@ macro_rules! tools { } // Adding a tool here (and constructing it in `Thread::add_default_tools`) is -// not enough to make the model actually receive it. Two further gates will +// not enough to make the model actually receive it. Three further gates will // silently drop the tool rather than fail to compile: // // 1. `assets/settings/default.json`: the `write` and `ask` agent profiles each @@ -169,6 +169,9 @@ macro_rules! tools { // `crates/settings_ui/src/pages/tool_permissions_setup.rs`: every tool must // be in the permission-UI `TOOLS` list (if it calls // `decide_permission_from_settings`) or in `EXCLUDED_TOOLS`. +// 3. `tool_feature_flag_enabled`: some tools are gated behind a feature flag and +// are dropped unless it is active. The agent-profile UI uses the same gate so +// it never offers a tool the agent can't actually use. tools! { ApplyCodeActionTool, CopyPathTool, @@ -191,8 +194,27 @@ tools! { SkillTool, SpawnAgentTool, TerminalTool, - UpdatePlanTool, - UpdateTitleTool, WebSearchTool, WriteFileTool, } + +/// Some built-in tools are gated behind a feature flag and only become usable +/// once that flag is active. Tools without a flag are always available. +/// +/// This is the single source of truth for that gating: `Thread::enabled_tools` +/// uses it to decide what the model receives, and the agent-profile +/// configuration UI uses it to decide what to offer — so the UI can never list +/// a tool the agent would silently drop (see #56778). +pub fn tool_feature_flag_enabled(tool_name: &str, cx: &App) -> bool { + match tool_name { + RenameTool::NAME => cx.has_flag::(), + FindReferencesTool::NAME + | GetCodeActionsTool::NAME + | ApplyCodeActionTool::NAME + | GoToDefinitionTool::NAME => cx.has_flag::(), + CreateThreadTool::NAME | ListAgentsAndModelsTool::NAME => { + cx.has_flag::() + } + _ => true, + } +} diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index 6d300551a59827..42a7311c572593 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -328,6 +328,13 @@ mod tests { title.contains("agent skills"), "Authorization title should mention agent skills, got: {title}", ); + assert!( + auth.options + .first_option_of_kind(acp::PermissionOptionKind::AllowAlways) + .is_none(), + "agent skills prompt must not offer an \"Always allow\" option: {:?}", + auth.options, + ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), @@ -387,6 +394,13 @@ mod tests { title.contains("agent skills"), "Authorization title should mention agent skills, got: {title}", ); + assert!( + auth.options + .first_option_of_kind(acp::PermissionOptionKind::AllowAlways) + .is_none(), + "agent skills prompt must not offer an \"Always allow\" option: {:?}", + auth.options, + ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index dcd051c2a72249..fdec1da2b75a50 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -243,6 +243,13 @@ mod tests { title.contains("agent skills"), "Authorization title should mention agent skills, got: {title}", ); + assert!( + auth.options + .first_option_of_kind(acp::PermissionOptionKind::AllowAlways) + .is_none(), + "agent skills prompt must not offer an \"Always allow\" option: {:?}", + auth.options, + ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index e791e6feb51f7e..bae19d7692e040 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -329,6 +329,13 @@ mod tests { title.contains("agent skills"), "Authorization title should mention agent skills, got: {title}", ); + assert!( + auth.options + .first_option_of_kind(acp::PermissionOptionKind::AllowAlways) + .is_none(), + "agent skills prompt must not offer an \"Always allow\" option: {:?}", + auth.options, + ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 2801c8878d111e..fc513d904a3445 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1320,6 +1320,20 @@ mod tests { event.tool_call.fields.title, Some("Edit `root/.agents/skills/my-skill/SKILL.md` (agent skills)".into()) ); + // Skills always prompt, so no "Always allow" option is offered. + assert!( + event + .options + .first_option_of_kind(acp::PermissionOptionKind::AllowAlways) + .is_none(), + "agent skills prompt must not offer an \"Always allow\" option: {:?}", + event.options, + ); + assert!( + matches!(event.options, acp_thread::PermissionOptions::Flat(_)), + "agent skills prompt should use flat allow/deny options: {:?}", + event.options, + ); // 5.6: The global .agents/skills directory is sensitive — still prompts let global_skill_path = agent_skills::global_skills_dir() @@ -2442,10 +2456,10 @@ mod tests { .unwrap(); // The prompt's response channel should drop without a click; the - // tool dismisses the prompt by transitioning the tool call status - // to `InProgress`. - let dismiss = stream_rx.expect_update_fields().await; - assert_eq!(dismiss.status, Some(acp::ToolCallStatus::InProgress)); + // tool dismisses the prompt by resolving the pending authorization. + let (_, outcome) = stream_rx.expect_authorization_resolved().await; + assert_eq!(outcome.option_id, acp::PermissionOptionId::new("save")); + assert_eq!(outcome.option_kind, acp::PermissionOptionKind::AllowOnce); drop(auth); let EditFileToolOutput::Success { new_text, .. } = task.await.unwrap() else { diff --git a/crates/agent/src/tools/edit_session.rs b/crates/agent/src/tools/edit_session.rs index 016058318bfcac..f817a99a500732 100644 --- a/crates/agent/src/tools/edit_session.rs +++ b/crates/agent/src/tools/edit_session.rs @@ -1058,9 +1058,17 @@ async fn resolve_dirty_buffer( }; let Some(decision) = decision else { - event_stream.update_fields( - acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), - ); + let outcome = match mode { + EditSessionMode::Edit => acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("save"), + acp::PermissionOptionKind::AllowOnce, + ), + EditSessionMode::Write => acp_thread::SelectedPermissionOutcome::new( + acp::PermissionOptionId::new("keep"), + acp::PermissionOptionKind::RejectOnce, + ), + }; + event_stream.resolve_authorization(outcome); return match mode { EditSessionMode::Edit => Ok(()), EditSessionMode::Write => Err( diff --git a/crates/agent/src/tools/edit_session/streaming_parser.rs b/crates/agent/src/tools/edit_session/streaming_parser.rs index 71dbc2c9bba89d..b210dd76e98480 100644 --- a/crates/agent/src/tools/edit_session/streaming_parser.rs +++ b/crates/agent/src/tools/edit_session/streaming_parser.rs @@ -169,8 +169,9 @@ impl StreamingParser { let mut events = SmallVec::new(); let safe_end = safe_emit_end(content); - if safe_end > self.content_emitted_len { - let chunk = content[self.content_emitted_len..safe_end].to_string(); + let safe_start = find_char_boundary(content, self.content_emitted_len); + if safe_end > safe_start { + let chunk = content[safe_start..safe_end].to_string(); self.content_emitted_len = safe_end; events.push(WriteEvent::ContentChunk { chunk }); } @@ -228,7 +229,7 @@ impl StreamingParser { } if !state.old_text_done { - let start = state.old_text_emitted_len.min(edit.old_text.len()); + let start = find_char_boundary(&edit.old_text, state.old_text_emitted_len); let chunk = normalize_done_chunk(edit.old_text[start..].to_string()); state.old_text_done = true; state.old_text_emitted_len = edit.old_text.len(); @@ -240,7 +241,7 @@ impl StreamingParser { } if !state.new_text_done { - let start = state.new_text_emitted_len.min(edit.new_text.len()); + let start = find_char_boundary(&edit.new_text, state.new_text_emitted_len); let chunk = normalize_done_chunk(edit.new_text[start..].to_string()); state.new_text_done = true; state.new_text_emitted_len = edit.new_text.len(); @@ -259,7 +260,7 @@ impl StreamingParser { pub fn finalize_content(&mut self, content: &str) -> SmallVec<[WriteEvent; 1]> { let mut events = SmallVec::new(); - let start = self.content_emitted_len.min(content.len()); + let start = find_char_boundary(content, self.content_emitted_len); if content.len() > start { let chunk = content[start..].to_string(); self.content_emitted_len = content.len(); @@ -313,7 +314,7 @@ impl StreamingParser { if !state.old_text_done { let old_text = old_text.unwrap_or_default(); - let start = state.old_text_emitted_len.min(old_text.len()); + let start = find_char_boundary(old_text, state.old_text_emitted_len); state.old_text_done = true; state.old_text_emitted_len = old_text.len(); events.push(EditEvent::OldTextChunk { @@ -325,7 +326,7 @@ impl StreamingParser { if !state.new_text_done { let new_text = new_text.unwrap_or_default(); - let start = state.new_text_emitted_len.min(new_text.len()); + let start = find_char_boundary(new_text, state.new_text_emitted_len); state.new_text_done = true; state.new_text_emitted_len = new_text.len(); state.buffer_new_text_until_old_text_done = false; @@ -396,6 +397,44 @@ fn normalize_done_chunk(mut chunk: String) -> String { #[cfg(test)] mod tests { use super::*; + use proptest::prelude::*; + + fn emitted_len_inside_multibyte_char() -> impl Strategy { + (1usize..8, prop::sample::select(&["。", "—", "é", "🦀"])).prop_map( + |(emitted_len, multibyte_char)| { + let first = "a".repeat(emitted_len); + let second = format!("{}{}", "a".repeat(emitted_len - 1), multibyte_char); + (first, second) + }, + ) + } + + fn boundary_sensitive_text() -> impl Strategy { + prop_oneof![ + emitted_len_inside_multibyte_char().prop_map(|(first, _)| first), + emitted_len_inside_multibyte_char().prop_map(|(_, second)| second), + prop::sample::select(&[ + "", + "a", + "ab", + "ab\\", + "a。", + "a—", + "hello,\\", + "hello,\n", + "hello,\nworld", + ]) + .prop_map(ToString::to_string), + ] + } + + fn partial_edit() -> impl Strategy { + ( + prop::option::of(boundary_sensitive_text()), + prop::option::of(boundary_sensitive_text()), + ) + .prop_map(|(old_text, new_text)| PartialEdit { old_text, new_text }) + } #[test] fn test_first_edit_with_new_text_in_first_chunk_is_held_until_finalize() { @@ -761,6 +800,30 @@ mod tests { ); } + proptest! { + #[test] + fn test_content_finalize_does_not_panic_when_emitted_len_lands_inside_multibyte_char( + pair in emitted_len_inside_multibyte_char() + ) { + let (first, second) = pair; + let mut parser = StreamingParser::default(); + + parser.push_content(&first); + parser.finalize_content(&second); + } + + #[test] + fn test_push_edits_does_not_panic_on_boundary_sensitive_sequences( + partials in prop::collection::vec(prop::collection::vec(partial_edit(), 0..4), 1..12) + ) { + let mut parser = StreamingParser::default(); + + for edits in partials { + parser.push_edits(&edits); + } + } + } + #[test] fn test_no_partials_direct_finalize() { let mut parser = StreamingParser::default(); diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index 000f17a38c9037..8156d0714a7e73 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -354,6 +354,13 @@ mod tests { title.contains("agent skills"), "Authorization title should mention agent skills, got: {title}", ); + assert!( + auth.options + .first_option_of_kind(acp::PermissionOptionKind::AllowAlways) + .is_none(), + "agent skills prompt must not offer an \"Always allow\" option: {:?}", + auth.options, + ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), @@ -413,6 +420,13 @@ mod tests { title.contains("agent skills"), "Authorization title should mention agent skills, got: {title}", ); + assert!( + auth.options + .first_option_of_kind(acp::PermissionOptionKind::AllowAlways) + .is_none(), + "agent skills prompt must not offer an \"Always allow\" option: {:?}", + auth.options, + ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 2644be6b0e21cc..241299dbf8126d 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -489,16 +489,22 @@ fn resolve_write_paths( /// Pure path-joining step of [`resolve_write_paths`], split out so it can be /// unit-tested without a `Project`/`App`. +/// +/// Each path is lexically normalized (resolving `.`/`..`) so that later +/// subtree-containment checks and the user-facing approval prompt operate on +/// the same path the sandbox will ultimately enforce. Relative paths with no +/// base, and paths that traverse above the filesystem root, are dropped. fn join_write_paths(raw_paths: &[String], base: Option<&Path>) -> Vec { raw_paths .iter() .filter_map(|raw| { let path = Path::new(raw); - if path.is_absolute() { - Some(path.to_path_buf()) + let absolute = if path.is_absolute() { + path.to_path_buf() } else { - base.map(|base| base.join(path)) - } + base?.join(path) + }; + util::paths::normalize_lexically(&absolute).ok() }) .collect() } @@ -2300,6 +2306,40 @@ mod tests { assert_eq!(joined, vec![PathBuf::from(abs)]); } + #[test] + fn test_join_write_paths_normalizes_parent_traversal() { + let base = PathBuf::from(if cfg!(windows) { + "C:\\project" + } else { + "/project" + }); + // `..` is resolved lexically so containment checks and the approval + // prompt see the real target rather than a traversal that the sandbox + // would canonicalize differently. + let joined = join_write_paths( + &[ + "build/../../escape".to_string(), + if cfg!(windows) { + "C:\\abs\\a\\..\\b".to_string() + } else { + "/abs/a/../b".to_string() + }, + ], + Some(base.as_path()), + ); + let expected_escape = if cfg!(windows) { + PathBuf::from("C:\\escape") + } else { + PathBuf::from("/escape") + }; + let expected_abs = if cfg!(windows) { + PathBuf::from("C:\\abs\\b") + } else { + PathBuf::from("/abs/b") + }; + assert_eq!(joined, vec![expected_escape, expected_abs]); + } + #[test] fn test_sandbox_approval_title_unsandboxed() { let mut request = sandbox_request(true, true, &["/tmp/build"]); diff --git a/crates/agent/src/tools/tool_permissions.rs b/crates/agent/src/tools/tool_permissions.rs index 7dd0972f0ab26a..5fb1ade6abcbc4 100644 --- a/crates/agent/src/tools/tool_permissions.rs +++ b/crates/agent/src/tools/tool_permissions.rs @@ -509,9 +509,11 @@ pub fn authorize_with_sensitive_settings( Some(SensitiveSettingsKind::Global) => { event_stream.authorize_always_prompt(format!("{title} (settings)"), context, cx) } - Some(SensitiveSettingsKind::AgentSkills) => { - event_stream.authorize_always_prompt(format!("{title} (agent skills)"), context, cx) - } + Some(SensitiveSettingsKind::AgentSkills) => event_stream.authorize_always_prompt( + format!("{title} (agent skills)"), + context.for_agent_skills(), + cx, + ), None => event_stream.authorize(title, context, cx), } } @@ -761,7 +763,8 @@ pub fn authorize_file_edit( let context = ToolPermissionContext::new( &tool_name, vec![path_owned.to_string_lossy().to_string()], - ); + ) + .for_agent_skills(); event_stream.authorize_always_prompt( format!("{title} (agent skills)"), context, diff --git a/crates/agent/src/tools/update_plan_tool.rs b/crates/agent/src/tools/update_plan_tool.rs deleted file mode 100644 index ebc84ad03186fc..00000000000000 --- a/crates/agent/src/tools/update_plan_tool.rs +++ /dev/null @@ -1,221 +0,0 @@ -use crate::{AgentTool, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; -use gpui::{App, SharedString, Task}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[schemars(inline)] -pub enum PlanEntryStatus { - /// The task has not started yet. - Pending, - /// The task is currently being worked on. - InProgress, - /// The task has been successfully completed. - Completed, -} - -impl From for acp::PlanEntryStatus { - fn from(value: PlanEntryStatus) -> Self { - match value { - PlanEntryStatus::Pending => acp::PlanEntryStatus::Pending, - PlanEntryStatus::InProgress => acp::PlanEntryStatus::InProgress, - PlanEntryStatus::Completed => acp::PlanEntryStatus::Completed, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct PlanItem { - /// Human-readable description of what this task aims to accomplish. - pub step: String, - /// The current status of this task. - pub status: PlanEntryStatus, -} - -impl From for acp::PlanEntry { - fn from(value: PlanItem) -> Self { - acp::PlanEntry::new( - value.step, - acp::PlanEntryPriority::Medium, - value.status.into(), - ) - } -} - -/// Updates the task plan. -/// -/// Provide a list of plan entries, each with a step and status. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct UpdatePlanToolInput { - /// The list of plan entries and their current statuses. - pub plan: Vec, -} - -pub struct UpdatePlanTool; - -impl UpdatePlanTool { - fn to_plan(input: UpdatePlanToolInput) -> acp::Plan { - acp::Plan::new(input.plan.into_iter().map(Into::into).collect()) - } -} - -impl AgentTool for UpdatePlanTool { - type Input = UpdatePlanToolInput; - type Output = String; - - const NAME: &'static str = "update_plan"; - - fn kind() -> acp::ToolKind { - acp::ToolKind::Think - } - - fn initial_title( - &self, - input: Result, - _cx: &mut App, - ) -> SharedString { - match input { - Ok(input) if input.plan.is_empty() => "Clear plan".into(), - Ok(_) | Err(_) => "Update plan".into(), - } - } - - fn run( - self: Arc, - input: ToolInput, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - cx.spawn(async move |_cx| { - let input = input.recv().await.map_err(|e| e.to_string())?; - - event_stream.update_plan(Self::to_plan(input)); - - Ok("Plan updated".to_string()) - }) - } - - fn replay( - &self, - input: Self::Input, - _output: Self::Output, - event_stream: ToolCallEventStream, - _cx: &mut App, - ) -> anyhow::Result<()> { - event_stream.update_plan(Self::to_plan(input)); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ToolCallEventStream; - use gpui::TestAppContext; - use pretty_assertions::assert_eq; - - fn sample_input() -> UpdatePlanToolInput { - UpdatePlanToolInput { - plan: vec![ - PlanItem { - step: "Inspect the existing tool wiring".to_string(), - status: PlanEntryStatus::Completed, - }, - PlanItem { - step: "Implement the update_plan tool".to_string(), - status: PlanEntryStatus::InProgress, - }, - PlanItem { - step: "Add tests".to_string(), - status: PlanEntryStatus::Pending, - }, - ], - } - } - - #[gpui::test] - async fn test_run_emits_plan_event(cx: &mut TestAppContext) { - let tool = Arc::new(UpdatePlanTool); - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - - let input = sample_input(); - let result = cx - .update(|cx| tool.run(ToolInput::resolved(input.clone()), event_stream, cx)) - .await - .expect("tool should succeed"); - - assert_eq!(result, "Plan updated".to_string()); - - let plan = event_rx.expect_plan().await; - assert_eq!( - plan, - acp::Plan::new(vec![ - acp::PlanEntry::new( - "Inspect the existing tool wiring", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::Completed, - ), - acp::PlanEntry::new( - "Implement the update_plan tool", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::InProgress, - ), - acp::PlanEntry::new( - "Add tests", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::Pending, - ), - ]) - ); - } - - #[gpui::test] - async fn test_replay_emits_plan_event(cx: &mut TestAppContext) { - let tool = UpdatePlanTool; - let (event_stream, mut event_rx) = ToolCallEventStream::test(); - - let input = sample_input(); - - cx.update(|cx| { - tool.replay(input.clone(), "Plan updated".to_string(), event_stream, cx) - .expect("replay should succeed"); - }); - - let plan = event_rx.expect_plan().await; - assert_eq!( - plan, - acp::Plan::new(vec![ - acp::PlanEntry::new( - "Inspect the existing tool wiring", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::Completed, - ), - acp::PlanEntry::new( - "Implement the update_plan tool", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::InProgress, - ), - acp::PlanEntry::new( - "Add tests", - acp::PlanEntryPriority::Medium, - acp::PlanEntryStatus::Pending, - ), - ]) - ); - } - - #[gpui::test] - async fn test_initial_title(cx: &mut TestAppContext) { - let tool = UpdatePlanTool; - - let title = cx.update(|cx| tool.initial_title(Ok(sample_input()), cx)); - assert_eq!(title, SharedString::from("Update plan")); - - let title = - cx.update(|cx| tool.initial_title(Ok(UpdatePlanToolInput { plan: Vec::new() }), cx)); - assert_eq!(title, SharedString::from("Clear plan")); - } -} diff --git a/crates/agent/src/tools/update_title_tool.rs b/crates/agent/src/tools/update_title_tool.rs deleted file mode 100644 index b86b82f9ac03d0..00000000000000 --- a/crates/agent/src/tools/update_title_tool.rs +++ /dev/null @@ -1,140 +0,0 @@ -use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput}; -use agent_client_protocol::schema as acp; -use gpui::{App, SharedString, Task, WeakEntity}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -const MAX_TITLE_LEN: usize = 200; - -/// Updates the current session title. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -pub struct UpdateTitleToolInput { - /// A concise, human-readable title for the current session. - pub title: String, -} - -pub struct UpdateTitleTool { - thread: WeakEntity, -} - -impl UpdateTitleTool { - pub fn new(thread: WeakEntity) -> Self { - Self { thread } - } - - pub(crate) fn title_for_input( - input: Result, - ) -> SharedString { - let Ok(input) = input else { - return "Update title".into(); - }; - let Ok(title) = normalize_title(&input.title) else { - return "Update title".into(); - }; - format!("Update title: {title}").into() - } -} - -impl AgentTool for UpdateTitleTool { - type Input = UpdateTitleToolInput; - type Output = String; - - const NAME: &'static str = "update_title"; - - fn kind() -> acp::ToolKind { - acp::ToolKind::Think - } - - fn initial_title( - &self, - input: Result, - _cx: &mut App, - ) -> SharedString { - Self::title_for_input(input) - } - - fn run( - self: Arc, - input: ToolInput, - _event_stream: ToolCallEventStream, - cx: &mut App, - ) -> Task> { - let thread = self.thread.clone(); - cx.spawn(async move |cx| { - let input = input.recv().await.map_err(|error| error.to_string())?; - let title = normalize_title(&input.title)?; - - thread - .update(cx, |thread, cx| { - thread.set_title(title.into(), cx); - }) - .map_err(|error| error.to_string())?; - - Ok("Session title updated".to_string()) - }) - } - - fn replay( - &self, - input: Self::Input, - _output: Self::Output, - event_stream: ToolCallEventStream, - cx: &mut App, - ) -> anyhow::Result<()> { - let title = self.initial_title(Ok(input), cx).to_string(); - event_stream.update_fields(acp::ToolCallUpdateFields::new().title(title)); - Ok(()) - } -} - -fn normalize_title(title: &str) -> Result { - let title = title.lines().next().unwrap_or("").trim(); - if title.is_empty() { - return Err("Title cannot be empty".to_string()); - } - Ok(util::truncate_and_trailoff(title, MAX_TITLE_LEN)) -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::TestAppContext; - - #[test] - fn test_normalize_title() { - assert_eq!( - normalize_title(" Title from model\nignored").unwrap(), - "Title from model" - ); - assert!(normalize_title(" \nignored").is_err()); - } - - #[gpui::test] - async fn test_initial_title(cx: &mut TestAppContext) { - let tool = UpdateTitleTool::new(WeakEntity::new_invalid()); - - let title = cx.update(|cx| { - tool.initial_title( - Ok(UpdateTitleToolInput { - title: "Investigate title updates".to_string(), - }), - cx, - ) - }); - assert_eq!( - title, - SharedString::from("Update title: Investigate title updates") - ); - - let title = cx.update(|cx| { - tool.initial_title( - Ok(UpdateTitleToolInput { - title: " ".to_string(), - }), - cx, - ) - }); - assert_eq!(title, SharedString::from("Update title")); - } -} diff --git a/crates/agent/src/tools/write_file_tool.rs b/crates/agent/src/tools/write_file_tool.rs index 735a9d23a91673..0f8d96db0e4004 100644 --- a/crates/agent/src/tools/write_file_tool.rs +++ b/crates/agent/src/tools/write_file_tool.rs @@ -373,6 +373,13 @@ mod tests { title.contains("agent skills"), "Authorization title should mention agent skills, got: {title}", ); + assert!( + auth.options + .first_option_of_kind(acp::PermissionOptionKind::AllowAlways) + .is_none(), + "agent skills prompt must not offer an \"Always allow\" option: {:?}", + auth.options, + ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), @@ -1338,9 +1345,10 @@ mod tests { .await .unwrap(); - // The prompt is dismissed by transitioning to InProgress. - let dismiss = stream_rx.expect_update_fields().await; - assert_eq!(dismiss.status, Some(acp::ToolCallStatus::InProgress)); + // The prompt is dismissed by resolving the pending authorization. + let (_, outcome) = stream_rx.expect_authorization_resolved().await; + assert_eq!(outcome.option_id, acp::PermissionOptionId::new("keep")); + assert_eq!(outcome.option_kind, acp::PermissionOptionKind::RejectOnce); drop(auth); // The overwrite is cancelled with an error. diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 930dd219ddd8de..4fd6938072c43e 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -617,12 +617,12 @@ impl AgentSessionList for AcpSessionList { }) } - fn supports_delete(&self, cx: &App) -> bool { - self.supports_delete && cx.has_flag::() + fn supports_delete(&self) -> bool { + self.supports_delete } fn delete_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task> { - if !self.supports_delete(cx) { + if !self.supports_delete() { return Task::ready(Err(anyhow::anyhow!("delete_session not supported"))); } @@ -869,7 +869,7 @@ impl AcpConnection { project.remote_client().and_then(|client| { let template = client .read(cx) - .build_command_with_options( + .build_command( Some(command.path.display().to_string()), &command.args, &command.env.clone().into_iter().flatten().collect(), @@ -2593,10 +2593,7 @@ pub mod test_support { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use feature_flags::FeatureFlag as _; - use super::*; - use gpui::UpdateGlobal as _; use settings::Settings as _; #[test] @@ -2897,25 +2894,6 @@ mod tests { ); } - fn set_acp_beta_override(cx: &mut App, value: &str) { - let store = settings::SettingsStore::test(cx); - cx.set_global(store); - settings::SettingsStore::update_global(cx, |store, _| { - store.register_setting::(); - }); - feature_flags::FeatureFlagStore::init(cx); - - let value = value.to_string(); - settings::SettingsStore::update_global(cx, |store, cx| { - store.update_user_settings(cx, |content| { - content - .feature_flags - .get_or_insert_default() - .insert(AcpBetaFeatureFlag::NAME.to_string(), value); - }); - }); - } - async fn connect_session_list_test_agent( sessions: Vec, cx: &mut gpui::TestAppContext, @@ -3000,31 +2978,6 @@ mod tests { ); } - #[gpui::test] - async fn session_delete_support_requires_beta_flag_and_capability( - cx: &mut gpui::TestAppContext, - ) { - let deleted_sessions = Arc::new(std::sync::Mutex::new(Vec::new())); - let connection = connect_session_delete_test_agent(deleted_sessions, cx).await; - let session_list = AcpSessionList::new(connection.clone(), true); - let missing_capability = AcpSessionList::new(connection, false); - - cx.update(|cx| { - let store = settings::SettingsStore::test(cx); - cx.set_global(store); - - assert_eq!( - session_list.supports_delete(cx), - cx.has_flag::() - ); - assert!(!missing_capability.supports_delete(cx)); - - cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); - assert!(session_list.supports_delete(cx)); - assert!(!missing_capability.supports_delete(cx)); - }); - } - async fn connect_session_delete_test_agent( deleted_sessions: Arc>>, cx: &mut gpui::TestAppContext, @@ -3131,11 +3084,6 @@ mod tests { let session_list = AcpSessionList::new(connection, true); let session_id = acp::SessionId::new("session-to-delete"); - cx.update(|cx| { - let store = settings::SettingsStore::test(cx); - cx.set_global(store); - cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); - }); cx.update(|cx| session_list.delete_session(&session_id, cx)) .await .expect("delete_session failed"); @@ -3155,11 +3103,6 @@ mod tests { let session_list = AcpSessionList::new(connection, false); let session_id = acp::SessionId::new("session-to-delete"); - cx.update(|cx| { - let store = settings::SettingsStore::test(cx); - cx.set_global(store); - cx.update_flags(false, vec![AcpBetaFeatureFlag::NAME.to_string()]); - }); let error = cx .update(|cx| session_list.delete_session(&session_id, cx)) .await @@ -3177,36 +3120,6 @@ mod tests { ); } - #[gpui::test] - async fn logout_support_requires_agent_capability(cx: &mut gpui::TestAppContext) { - cx.update(|cx| set_acp_beta_override(cx, "off")); - assert!(!cx.update(|cx| cx.has_flag::())); - - let fs = fs::FakeFs::new(cx.executor()); - fs.insert_tree("/", serde_json::json!({ "a": {} })).await; - let project = project::Project::test(fs, [std::path::Path::new("/a")], cx).await; - let mut harness = test_support::connect_fake_acp_connection(project, cx).await; - - assert!(!harness.connection.supports_logout()); - let unsupported_logout = cx.update(|cx| harness.connection.logout(cx)); - let error = unsupported_logout - .await - .expect_err("logout should be rejected when the agent does not advertise support"); - assert_eq!(error.to_string(), "Logout is not supported by this agent."); - assert_eq!(harness.logout_count.load(Ordering::SeqCst), 0); - - Rc::get_mut(&mut harness.connection) - .expect("test harness should own the only ACP connection handle") - .agent_capabilities - .auth = acp::AgentAuthCapabilities::new().logout(acp::LogoutCapabilities::new()); - - assert!(harness.connection.supports_logout()); - cx.update(|cx| harness.connection.logout(cx)) - .await - .expect("logout should be sent when the agent advertises support"); - assert_eq!(harness.logout_count.load(Ordering::SeqCst), 1); - } - #[cfg(not(windows))] #[gpui::test] async fn startup_returns_error_when_agent_exits_before_initialization( @@ -3615,7 +3528,7 @@ mod tests { acp_thread::AgentThreadEntry::AssistantMessage(_) => "assistant", acp_thread::AgentThreadEntry::ToolCall(_) => "tool_call", acp_thread::AgentThreadEntry::CompletedPlan(_) => "plan", - acp_thread::AgentThreadEntry::ContextCompaction => "compaction", + acp_thread::AgentThreadEntry::ContextCompaction(_) => "compaction", }) .collect::>() }); diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 701211916933c6..1b0a83376ca212 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -1,9 +1,12 @@ mod agent_profile; mod user_agents_md; +use std::cmp::Ordering::{Equal, Greater, Less}; +use std::fmt; use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, LazyLock}; +use anyhow::Context as _; use collections::{HashSet, IndexMap}; use fs::Fs; use futures::channel::oneshot; @@ -18,6 +21,7 @@ use settings::{ SettingsStore, SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode, update_settings_file, update_settings_file_with_completion, }; +use util::ResultExt as _; pub use crate::agent_profile::*; pub use crate::user_agents_md::{UserAgentsMd, UserAgentsMdState, init as init_user_agents_md}; @@ -135,6 +139,68 @@ impl WindowLayout { } } +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum AutoCompactThreshold { + /// Compact once the context window is at least this full, as a fraction in + /// the range `(0.0, 1.0]`. + Percentage(f64), + /// Compact once at least this many tokens have been used. + TokensUsed(u64), + /// Compact once fewer than this many tokens remain in the context window. + TokensRemaining(u64), +} + +impl AutoCompactThreshold { + /// The threshold used when none is configured, or when the configured value + /// is invalid (90% of the context window). + pub const DEFAULT: Self = Self::Percentage(0.9); +} + +impl fmt::Display for AutoCompactThreshold { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Percentage(percent) => write!(formatter, "{}%", percent * 100.0), + Self::TokensUsed(tokens) => write!(formatter, "{tokens}"), + Self::TokensRemaining(tokens) => write!(formatter, "-{tokens}"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AutoCompactSettings { + pub enabled: bool, + pub threshold: AutoCompactThreshold, +} + +fn parse_auto_compact_threshold(raw: &str) -> anyhow::Result { + let trimmed = raw.trim(); + if let Some(percent) = trimmed.strip_suffix('%') { + let value: f64 = percent + .trim_end() + .parse() + .with_context(|| format!("invalid auto_compact threshold percentage {raw:?}"))?; + anyhow::ensure!( + value > 0.0 && value <= 100.0, + "auto_compact threshold percentage must be between 0% and 100%, got {raw:?}" + ); + Ok(AutoCompactThreshold::Percentage(value / 100.0)) + } else { + let tokens: i64 = trimmed.parse().with_context(|| { + format!( + "invalid auto_compact threshold {raw:?}; \ + expected a percentage like \"90%\" or an integer number of tokens" + ) + })?; + match tokens.cmp(&0) { + Greater => Ok(AutoCompactThreshold::TokensUsed(tokens as u64)), + Less => Ok(AutoCompactThreshold::TokensRemaining(tokens.unsigned_abs())), + Equal => { + anyhow::bail!("auto_compact threshold of 0 is not valid") + } + } + } +} + #[derive(Clone, Debug, RegisterSetting)] pub struct AgentSettings { pub enabled: bool, @@ -161,6 +227,7 @@ pub struct AgentSettings { pub play_sound_when_agent_done: PlaySoundWhenAgentDone, pub single_file_review: bool, pub model_parameters: Vec, + pub auto_compact: AutoCompactSettings, pub enable_feedback: bool, pub expand_edit_card: bool, pub expand_terminal_card: bool, @@ -340,6 +407,13 @@ impl Default for AgentProfileId { } } +/// Persistent "allow always" sandbox grants for agent-run terminal commands. +/// +/// Coverage decisions for these grants are made in +/// `agent::sandboxing::ThreadSandboxGrants::covers_with_persistent`, which +/// combines them with the in-memory per-thread grants. `write_paths` are +/// stored as minimal, lexically-normalized subtrees (see +/// [`compile_sandbox_permissions`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct SandboxPermissions { pub allow_network: bool, @@ -348,34 +422,6 @@ pub struct SandboxPermissions { pub write_paths: Vec, } -impl SandboxPermissions { - pub fn covers( - &self, - network: bool, - allow_fs_write_all: bool, - unsandboxed: bool, - write_paths: &[PathBuf], - ) -> bool { - if unsandboxed { - return self.allow_unsandboxed; - } - if network && !self.allow_network { - return false; - } - if allow_fs_write_all && !self.allow_fs_write_all { - return false; - } - if self.allow_fs_write_all { - return true; - } - write_paths.iter().all(|requested| { - self.write_paths - .iter() - .any(|granted| requested.starts_with(granted)) - }) - } -} - #[derive(Clone, Debug, Default)] pub struct ToolPermissions { /// Global default permission when no tool-specific rules or patterns match. @@ -705,6 +751,16 @@ impl Settings for AgentSettings { play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap_or_default(), single_file_review: agent.single_file_review.unwrap(), model_parameters: agent.model_parameters, + auto_compact: { + let auto_compact = agent.auto_compact.unwrap(); + let threshold = parse_auto_compact_threshold(&auto_compact.threshold.unwrap().0) + .log_err() + .unwrap_or(AutoCompactThreshold::DEFAULT); + AutoCompactSettings { + enabled: auto_compact.enabled.unwrap(), + threshold, + } + }, enable_feedback: agent.enable_feedback.unwrap(), expand_edit_card: agent.expand_edit_card.unwrap(), expand_terminal_card: agent.expand_terminal_card.unwrap(), @@ -731,7 +787,11 @@ fn compile_sandbox_permissions( let mut write_paths = Vec::new(); for path in content.write_paths.map(|paths| paths.0).unwrap_or_default() { - add_sandbox_write_path(&mut write_paths, &path); + // Normalize away `..`/`.` before storing, since coverage checks are + // purely lexical; drop paths that escape the filesystem root. + if let Ok(normalized) = util::paths::normalize_lexically(&path) { + util::paths::insert_subtree(&mut write_paths, normalized); + } } SandboxPermissions { @@ -742,14 +802,6 @@ fn compile_sandbox_permissions( } } -fn add_sandbox_write_path(write_paths: &mut Vec, path: &Path) { - if write_paths.iter().any(|granted| path.starts_with(granted)) { - return; - } - write_paths.retain(|granted| !granted.starts_with(path)); - write_paths.push(path.to_path_buf()); -} - fn compile_tool_permissions(content: Option) -> ToolPermissions { let Some(content) = content else { return ToolPermissions::default(); @@ -852,6 +904,52 @@ mod tests { use settings::ToolPermissionMode; use settings::ToolPermissionsContent; + #[test] + fn test_parse_auto_compact_threshold() { + use AutoCompactThreshold::*; + + assert_eq!( + parse_auto_compact_threshold("90%").unwrap(), + Percentage(0.9) + ); + assert_eq!(AutoCompactThreshold::DEFAULT, Percentage(0.9)); + assert_eq!( + parse_auto_compact_threshold(" 92.5% ").unwrap(), + Percentage(0.925) + ); + assert_eq!( + parse_auto_compact_threshold("95.5%").unwrap(), + Percentage(0.955) + ); + assert_eq!( + parse_auto_compact_threshold("100%").unwrap(), + Percentage(1.0) + ); + // Token counts must be integers; a non-integer token value is invalid. + assert!(parse_auto_compact_threshold("100.5").is_err()); + assert_eq!( + parse_auto_compact_threshold("100000").unwrap(), + TokensUsed(100_000) + ); + assert_eq!( + parse_auto_compact_threshold("-20000").unwrap(), + TokensRemaining(20_000) + ); + + assert_eq!(Percentage(0.9).to_string(), "90%"); + assert_eq!(Percentage(0.925).to_string(), "92.5%"); + assert_eq!(TokensUsed(100_000).to_string(), "100000"); + assert_eq!(TokensRemaining(20_000).to_string(), "-20000"); + + // 0 is invalid in every form. + assert!(parse_auto_compact_threshold("0").is_err()); + assert!(parse_auto_compact_threshold("0%").is_err()); + // Out-of-range percentages and bare decimals are invalid. + assert!(parse_auto_compact_threshold("150%").is_err()); + assert!(parse_auto_compact_threshold("0.8").is_err()); + assert!(parse_auto_compact_threshold("eighty percent").is_err()); + } + #[test] fn test_compiled_regex_case_insensitive() { let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap(); @@ -928,10 +1026,6 @@ mod tests { fn test_sandbox_permissions_empty() { let permissions = compile_sandbox_permissions(None); assert_eq!(permissions, SandboxPermissions::default()); - assert!(!permissions.covers(true, false, false, &[])); - assert!(!permissions.covers(false, true, false, &[])); - assert!(!permissions.covers(false, false, true, &[])); - assert!(!permissions.covers(false, false, false, &[PathBuf::from("/tmp/build")])); } #[test] @@ -956,20 +1050,23 @@ mod tests { permissions.write_paths, vec![PathBuf::from("/tmp/build"), PathBuf::from("/var/log")] ); - assert!(permissions.covers(true, false, true, &[PathBuf::from("/tmp/build/cache")])) } #[test] - fn test_sandbox_permissions_all_write_covers_paths() { + fn test_sandbox_permissions_normalizes_and_prunes_parent_traversal() { let json = json!({ - "allow_fs_write_all": true, + "write_paths": [ + "/tmp/build/../build/cache", + "/tmp/build", + ] }); let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap(); let permissions = compile_sandbox_permissions(Some(content)); - assert!(permissions.covers(false, true, false, &[])); - assert!(permissions.covers(false, false, false, &[PathBuf::from("/anywhere")])) + // `/tmp/build/../build/cache` normalizes to `/tmp/build/cache`, which is + // then pruned as a redundant child of `/tmp/build`. + assert_eq!(permissions.write_paths, vec![PathBuf::from("/tmp/build")]); } #[test] diff --git a/crates/agent_skills/agent_skills.rs b/crates/agent_skills/agent_skills.rs index e545aaed6dd9b6..731a1cb23c7354 100644 --- a/crates/agent_skills/agent_skills.rs +++ b/crates/agent_skills/agent_skills.rs @@ -2,9 +2,10 @@ use anyhow::{Context as _, Result}; use const_format::{concatcp, formatcp}; use fs::Fs; use futures::StreamExt; -use gpui::{Global, SharedString}; +use gpui::{App, Global, SharedString}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use std::rc::Rc; use std::sync::Arc; use url::Url; use util::paths::component_matches_ignore_ascii_case; @@ -51,6 +52,24 @@ pub const MAX_SKILL_DESCRIPTIONS_SIZE: usize = 50 * 1024; /// The name of the skill definition file pub const SKILL_FILE_NAME: &str = "SKILL.md"; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SkillLoadWarning { + DescriptionTooLong { actual_len: usize, max_len: usize }, +} + +impl SkillLoadWarning { + pub fn message(&self) -> String { + match self { + Self::DescriptionTooLong { + actual_len, + max_len, + } => format!( + "Skill description is {actual_len} bytes, exceeding the {max_len}-byte limit. The skill was loaded, but long descriptions may consume more model-context tokens." + ), + } + } +} + /// Represents a loaded skill with all its metadata and content. #[derive(Debug, Clone)] pub struct Skill { @@ -61,6 +80,8 @@ pub struct Skill { pub directory_path: PathBuf, /// Absolute path to the SKILL.md file pub skill_file_path: PathBuf, + /// Non-fatal issues found while loading this skill. + pub load_warnings: Vec, /// When `true`, this skill is hidden from the model's catalog and the /// `skill` tool refuses to load it. The user can still invoke it as a /// slash command. @@ -175,6 +196,11 @@ pub struct ProjectSkillGroup { impl Global for SkillIndex {} +/// Rescan skill agent skill directories when skills are created or modified via UI +pub struct SkillsUpdatedHook(pub Rc); + +impl Global for SkillsUpdatedHook {} + /// Just the frontmatter, used for parsing #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillMetadata { @@ -184,7 +210,7 @@ pub struct SkillMetadata { pub disable_model_invocation: bool, } -/// Minimal skill info for system prompt (not full content). +/// Minimal skill info for system prompt. /// /// `Serialize` is required for handlebars rendering of the system prompt /// template (see `ProjectContext` in `prompt_store`). `PartialEq, Eq` lets @@ -242,7 +268,7 @@ pub fn parse_skill_frontmatter( content: &str, source: SkillSource, ) -> Result { - let (metadata, _body) = parse_skill_file_content(content)?; + let (metadata, _body, load_warnings) = parse_skill_file_content_for_loading(content)?; let directory_path = skill_file_path .parent() @@ -255,6 +281,7 @@ pub fn parse_skill_frontmatter( source, directory_path, skill_file_path: skill_file_path.to_path_buf(), + load_warnings, disable_model_invocation: metadata.disable_model_invocation, embedded_body: None, }) @@ -283,6 +310,36 @@ pub fn parse_skill_file_content(content: &str) -> Result<(SkillMetadata, &str)> Ok((metadata, body)) } +fn parse_skill_file_content_for_loading( + content: &str, +) -> Result<(SkillMetadata, &str, Vec)> { + let (metadata, body) = extract_skill_frontmatter(content)?; + + validate_name(&metadata.name).map_err(anyhow::Error::msg)?; + let load_warnings = + validate_description_for_loading(&metadata.description).map_err(anyhow::Error::msg)?; + + Ok((metadata, body, load_warnings)) +} + +fn validate_description_for_loading( + description: &str, +) -> Result, &'static str> { + if description.trim().is_empty() { + return Err("Skill description cannot be empty"); + } + + let mut warnings = Vec::new(); + if description.len() > MAX_SKILL_DESCRIPTION_LEN { + warnings.push(SkillLoadWarning::DescriptionTooLong { + actual_len: description.len(), + max_len: MAX_SKILL_DESCRIPTION_LEN, + }); + } + + Ok(warnings) +} + fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> { let content = content.trim_start(); @@ -359,8 +416,9 @@ fn extract_frontmatter(content: &str) -> Result<(SkillMetadata, &str)> { /// by [`validate_name`]. pub const MAX_SKILL_NAME_LEN: usize = 64; -/// Maximum length (in bytes) for a valid skill description. Mirrors the -/// upper bound enforced by [`validate_description`]. +/// Maximum recommended length (in bytes) for a skill description. The +/// create-skill UI enforces this as a hard limit, while the loader emits a +/// warning and still loads longer descriptions. /// /// Byte-based rather than char-based because that's what `.len()` returns /// and what every caller currently measures; the UI also surfaces this @@ -468,8 +526,8 @@ pub fn validate_name(name: &str) -> Result<(), &'static str> { Ok(()) } -/// Validate a skill description against the rules enforced by both the -/// loader and the create-skill UI. +/// Validate a skill description against the strict rules enforced by the +/// create-skill UI and imported/shared skill parsing. pub fn validate_description(description: &str) -> Result<(), &'static str> { if description.trim().is_empty() { return Err("Skill description cannot be empty"); @@ -627,10 +685,11 @@ pub fn read_skill_body_from_content( skill_file_path: &Path, content: &str, ) -> Result { - let (_metadata, body) = parse_skill_file_content(content).map_err(|e| SkillLoadError { - path: skill_file_path.to_path_buf(), - message: e.to_string(), - })?; + let (_metadata, body, _load_warnings) = + parse_skill_file_content_for_loading(content).map_err(|e| SkillLoadError { + path: skill_file_path.to_path_buf(), + message: e.to_string(), + })?; Ok(body.trim().to_string()) } @@ -663,6 +722,7 @@ fn parse_builtin_skill(name: &str, content: &'static str) -> Result { source: SkillSource::BuiltIn, directory_path: synthetic_dir, skill_file_path: synthetic_path, + load_warnings: Vec::new(), disable_model_invocation: metadata.disable_model_invocation, embedded_body: Some(body.trim()), }) @@ -1314,8 +1374,8 @@ Content. } #[test] - fn test_parse_description_too_long() { - let long_desc = "a".repeat(1025); + fn test_parse_description_too_long_loads_with_warning() { + let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); let content = format!( r#"--- name: test @@ -1326,11 +1386,38 @@ Content. "# ); - let result = parse_skill_frontmatter( + let skill = parse_skill_frontmatter( Path::new("/skills/test/SKILL.md"), &content, SkillSource::Global, + ) + .expect("long descriptions should load with a warning"); + + assert_eq!(skill.description, long_desc); + assert_eq!(skill.load_warnings.len(), 1); + assert_eq!( + skill.load_warnings[0], + SkillLoadWarning::DescriptionTooLong { + actual_len: MAX_SKILL_DESCRIPTION_LEN + 1, + max_len: MAX_SKILL_DESCRIPTION_LEN, + } + ); + } + + #[test] + fn test_parse_skill_file_content_rejects_description_too_long() { + let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let content = format!( + r#"--- +name: test +description: {long_desc} +--- + +Content. +"# ); + + let result = parse_skill_file_content(&content); assert!(result.is_err()); let expected = format!("at most {MAX_SKILL_DESCRIPTION_LEN} bytes"); assert!(result.unwrap_err().to_string().contains(&expected)); @@ -1740,6 +1827,7 @@ description: A skill with no body content source: SkillSource::Global, directory_path: PathBuf::from("/skills/test-skill"), skill_file_path: PathBuf::from("/skills/test-skill/SKILL.md"), + load_warnings: Vec::new(), disable_model_invocation: false, embedded_body: None, }; @@ -1879,6 +1967,27 @@ description: A skill with no body content assert_eq!(body, "# Instructions\n\nDo the thing."); } + #[gpui::test] + async fn test_read_skill_body_accepts_description_too_long(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + let long_desc = "a".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + fs.insert_tree( + "/skills", + serde_json::json!({ + "long-description": { + "SKILL.md": format!("---\nname: long-description\ndescription: {long_desc}\n---\n\nBody") + } + }), + ) + .await; + + let body = read_skill_body(fs.as_ref(), Path::new("/skills/long-description/SKILL.md")) + .await + .expect("body should load despite description-length warning"); + + assert_eq!(body, "Body"); + } + #[gpui::test] async fn test_read_skill_body_for_skill_without_body(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); @@ -2028,8 +2137,8 @@ description: A skill with no body content // "é" is 2 bytes in UTF-8. A string of MAX/2 + 1 "é" characters has // only ~MAX/2 + 1 chars but exceeds MAX bytes, so it must be // rejected by a byte-based validator (and accepted by a char-based - // one). This regression-tests the byte semantics that the loader - // and UI both rely on. + // one). This regression-tests the byte semantics that strict + // validation and load-time warnings both rely on. let chars = MAX_SKILL_DESCRIPTION_LEN / 2 + 1; let description = "é".repeat(chars); assert!(description.chars().count() <= MAX_SKILL_DESCRIPTION_LEN); diff --git a/crates/agent_skills/builtin/create-skill/SKILL.md b/crates/agent_skills/builtin/create-skill/SKILL.md index e388d84f708550..c88991aeeb7855 100644 --- a/crates/agent_skills/builtin/create-skill/SKILL.md +++ b/crates/agent_skills/builtin/create-skill/SKILL.md @@ -67,6 +67,7 @@ The body of the SKILL.md (after the frontmatter) contains the instructions the a 3. **Include when-to-use guidance**: Help the agent understand the right context for this skill. 4. **Reference supporting files**: Skills can include additional files in their directory. Reference them with relative paths (e.g., `templates/component.tsx`). The agent can read these files when the skill is activated. 5. **Keep descriptions actionable**: The `description` field is the agent's primary signal for whether to load this skill. "Helps with code" is too vague. "Generate React components following the project's design system patterns" is specific. +6. **Keep instructions focused**: Limit instructions to those relevant to the skill itself. Avoid duplicating instructions from AGENTS.md and other skills in the current conversation if they are not relevant to the skill being created ## Supporting Files diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 687051baf790d3..2c282e0c378c91 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -91,7 +91,6 @@ release_channel.workspace = true remote.workspace = true remote_connection.workspace = true rope.workspace = true -skill_creator.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index f53b6752a0bd20..65ddcafff48b03 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -31,7 +31,7 @@ use project::{ agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource}, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, }; -use settings::{Settings, SettingsStore, update_settings_file}; +use settings::{Settings, SettingsContent, SettingsStore, update_settings_file}; use ui::{ AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu, ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu, @@ -396,14 +396,7 @@ impl AgentConfiguration { update_settings_file(fs.clone(), cx, { let provider_id = provider_id.clone(); move |settings, _| { - if let Some(ref mut openai_compatible) = settings - .language_models - .as_mut() - .and_then(|lm| lm.openai_compatible.as_mut()) - { - let key_to_remove: Arc = Arc::from(provider_id.0.as_ref()); - openai_compatible.remove(&key_to_remove); - } + remove_compatible_provider(settings, provider_id.0.as_ref()); } }); }) @@ -444,22 +437,24 @@ impl AgentConfiguration { .menu({ let workspace = self.workspace.clone(); move |window, cx| { + let open_modal = |provider: LlmCompatibleProvider| { + let workspace = workspace.clone(); + move |window: &mut Window, cx: &mut App| { + workspace + .update(cx, |workspace, cx| { + AddLlmProviderModal::toggle(provider, workspace, window, cx); + }) + .log_err(); + } + }; Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.header("Compatible APIs").entry("OpenAI", None, { - let workspace = workspace.clone(); - move |window, cx| { - workspace - .update(cx, |workspace, cx| { - AddLlmProviderModal::toggle( - LlmCompatibleProvider::OpenAi, - workspace, - window, - cx, - ); - }) - .log_err(); - } - }) + menu.header("Compatible APIs") + .entry("OpenAI", None, open_modal(LlmCompatibleProvider::OpenAi)) + .entry( + "Anthropic", + None, + open_modal(LlmCompatibleProvider::Anthropic), + ) })) } }) @@ -1535,13 +1530,144 @@ fn find_text_in_buffer( } } -// OpenAI-compatible providers are user-configured and can be removed, +// API-compatible providers are user-configured and can be removed, // whereas built-in providers (like Anthropic, OpenAI, Google, etc.) can't. // // If in the future we have more "API-compatible-type" of providers, // they should be included here as removable providers. fn is_removable_provider(provider_id: &LanguageModelProviderId, cx: &App) -> bool { - AllLanguageModelSettings::get_global(cx) + let settings = AllLanguageModelSettings::get_global(cx); + settings .openai_compatible .contains_key(provider_id.0.as_ref()) + || settings + .anthropic_compatible + .contains_key(provider_id.0.as_ref()) +} + +fn remove_compatible_provider(settings: &mut SettingsContent, provider_id: &str) { + // Mirrors the OpenAI-wins precedence used at registration time: only the + // entry that is actually registered gets removed. A shadowed + // `anthropic_compatible` entry with the same name takes over instead of + // being silently deleted. + let Some(language_models) = settings.language_models.as_mut() else { + return; + }; + let removed_from_openai = language_models + .openai_compatible + .as_mut() + .and_then(|providers| providers.remove(provider_id)) + .is_some(); + if !removed_from_openai && let Some(providers) = language_models.anthropic_compatible.as_mut() { + providers.remove(provider_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use settings::{AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent}; + + fn settings_with_compatible_providers(openai: &[&str], anthropic: &[&str]) -> SettingsContent { + let mut settings = SettingsContent::default(); + let language_models = settings.language_models.get_or_insert_default(); + language_models.openai_compatible = Some( + openai + .iter() + .map(|id| { + ( + Arc::from(*id), + OpenAiCompatibleSettingsContent { + api_url: "https://example.com".to_string(), + available_models: Vec::new(), + custom_headers: None, + }, + ) + }) + .collect(), + ); + language_models.anthropic_compatible = Some( + anthropic + .iter() + .map(|id| { + ( + Arc::from(*id), + AnthropicCompatibleSettingsContent { + api_url: "https://example.com".to_string(), + available_models: Vec::new(), + custom_headers: None, + }, + ) + }) + .collect(), + ); + settings + } + + fn compatible_provider_keys(settings: &SettingsContent) -> (Vec<&str>, Vec<&str>) { + fn keys(providers: Option<&HashMap, T>>) -> Vec<&str> { + providers + .map(|providers| providers.keys().map(|key| key.as_ref()).collect()) + .unwrap_or_default() + } + + let language_models = settings + .language_models + .as_ref() + .expect("language_models settings should exist"); + ( + keys(language_models.openai_compatible.as_ref()), + keys(language_models.anthropic_compatible.as_ref()), + ) + } + + #[test] + fn test_remove_compatible_provider_openai_only() { + let mut settings = settings_with_compatible_providers(&["acme"], &[]); + remove_compatible_provider(&mut settings, "acme"); + let (openai, anthropic) = compatible_provider_keys(&settings); + assert_eq!(openai, Vec::<&str>::new()); + assert_eq!(anthropic, Vec::<&str>::new()); + } + + #[test] + fn test_remove_compatible_provider_anthropic_only() { + let mut settings = settings_with_compatible_providers(&[], &["acme"]); + remove_compatible_provider(&mut settings, "acme"); + let (openai, anthropic) = compatible_provider_keys(&settings); + assert_eq!(openai, Vec::<&str>::new()); + assert_eq!(anthropic, Vec::<&str>::new()); + } + + #[test] + fn test_remove_compatible_provider_collision_removes_only_openai_entry() { + let mut settings = settings_with_compatible_providers(&["acme"], &["acme"]); + + remove_compatible_provider(&mut settings, "acme"); + let (openai, anthropic) = compatible_provider_keys(&settings); + assert_eq!( + openai, + Vec::<&str>::new(), + "the registered (OpenAI-compatible) entry should be removed" + ); + assert_eq!( + anthropic, + vec!["acme"], + "the shadowed anthropic_compatible entry should survive" + ); + + // A second removal deletes the entry that took over. + remove_compatible_provider(&mut settings, "acme"); + let (_, anthropic) = compatible_provider_keys(&settings); + assert_eq!(anthropic, Vec::<&str>::new()); + } + + #[test] + fn test_remove_compatible_provider_leaves_other_providers_untouched() { + let mut settings = settings_with_compatible_providers(&["acme", "globex"], &["initech"]); + remove_compatible_provider(&mut settings, "acme"); + let (openai, anthropic) = compatible_provider_keys(&settings); + assert_eq!(openai, vec!["globex"]); + assert_eq!(anthropic, vec!["initech"]); + } } 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 99413e10638c2b..86e556e2e5d67c 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 @@ -1,14 +1,20 @@ use std::sync::Arc; use anyhow::Result; -use collections::HashSet; use fs::Fs; use gpui::{ DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, TaskExt, }; +use itertools::Itertools as _; use language_model::LanguageModelRegistry; -use language_models::provider::open_ai_compatible::{AvailableModel, ModelCapabilities}; -use settings::{OpenAiCompatibleSettingsContent, update_settings_file}; +use language_models::provider::open_ai_compatible::{ + AvailableModel as OpenAiCompatibleAvailableModel, + ModelCapabilities as OpenAiCompatibleModelCapabilities, +}; +use settings::{ + AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities, + AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, update_settings_file, +}; use ui::{ Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, WithScrollbar, prelude::*, @@ -40,20 +46,36 @@ fn single_line_input( #[derive(Clone, Copy)] pub enum LlmCompatibleProvider { OpenAi, + Anthropic, } impl LlmCompatibleProvider { fn name(&self) -> &'static str { match self { LlmCompatibleProvider::OpenAi => "OpenAI", + LlmCompatibleProvider::Anthropic => "Anthropic", } } fn api_url(&self) -> &'static str { match self { LlmCompatibleProvider::OpenAi => "https://api.openai.com/v1", + LlmCompatibleProvider::Anthropic => "https://api.anthropic.com", + } + } + + fn description(&self) -> &'static str { + match self { + LlmCompatibleProvider::OpenAi => "This provider will use an OpenAI compatible API.", + LlmCompatibleProvider::Anthropic => { + "This provider will use an Anthropic Messages compatible API." + } } } + + fn is_open_ai(&self) -> bool { + matches!(self, LlmCompatibleProvider::OpenAi) + } } struct AddLlmProviderInput { @@ -151,14 +173,14 @@ impl ModelInput { cx, ); - let ModelCapabilities { + let OpenAiCompatibleModelCapabilities { tools, images, parallel_tool_calls, prompt_cache_key, chat_completions, .. - } = ModelCapabilities::default(); + } = OpenAiCompatibleModelCapabilities::default(); Self { name: model_name, @@ -175,36 +197,34 @@ impl ModelInput { } } - fn parse(&self, cx: &App) -> Result { + fn parse_name(&self, cx: &App) -> Result { let name = self.name.read(cx).text(cx); if name.is_empty() { return Err(SharedString::from("Model Name cannot be empty")); } - Ok(AvailableModel { - name, + Ok(name) + } + + fn parse_open_ai_compatible( + &self, + cx: &App, + ) -> Result { + Ok(OpenAiCompatibleAvailableModel { + name: self.parse_name(cx)?, display_name: None, - max_completion_tokens: Some( - self.max_completion_tokens - .read(cx) - .text(cx) - .parse::() - .map_err(|_| SharedString::from("Max Completion Tokens must be a number"))?, - ), - max_output_tokens: Some( - self.max_output_tokens - .read(cx) - .text(cx) - .parse::() - .map_err(|_| SharedString::from("Max Output Tokens must be a number"))?, - ), - max_tokens: self - .max_tokens - .read(cx) - .text(cx) - .parse::() - .map_err(|_| SharedString::from("Max Tokens must be a number"))?, + max_completion_tokens: Some(parse_u64_field( + &self.max_completion_tokens, + "Max Completion Tokens", + cx, + )?), + max_output_tokens: Some(parse_u64_field( + &self.max_output_tokens, + "Max Output Tokens", + cx, + )?), + max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, reasoning_effort: None, - capabilities: ModelCapabilities { + capabilities: OpenAiCompatibleModelCapabilities { tools: self.capabilities.supports_tools.selected(), images: self.capabilities.supports_images.selected(), parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), @@ -214,9 +234,65 @@ impl ModelInput { }, }) } + + fn parse_anthropic_compatible( + &self, + cx: &App, + ) -> Result { + Ok(AnthropicCompatibleAvailableModel { + name: self.parse_name(cx)?, + display_name: None, + max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, + tool_override: None, + max_output_tokens: Some(parse_u64_field( + &self.max_output_tokens, + "Max Output Tokens", + cx, + )?), + default_temperature: None, + extra_beta_headers: Vec::new(), + mode: None, + capabilities: AnthropicCompatibleModelCapabilities { + tools: self.capabilities.supports_tools.selected(), + images: self.capabilities.supports_images.selected(), + prompt_caching: false, + }, + }) + } +} + +fn parse_u64_field( + field: &Entity, + field_name: &str, + cx: &App, +) -> Result { + field + .read(cx) + .text(cx) + .parse::() + .map_err(|_| SharedString::from(format!("{field_name} must be a number"))) +} + +enum ParsedModels { + OpenAi(Vec), + Anthropic(Vec), +} + +impl ParsedModels { + fn model_names(&self) -> impl Iterator { + match self { + ParsedModels::OpenAi(models) => { + itertools::Either::Left(models.iter().map(|model| model.name.as_str())) + } + ParsedModels::Anthropic(models) => { + itertools::Either::Right(models.iter().map(|model| model.name.as_str())) + } + } + } } fn save_provider_to_settings( + provider: LlmCompatibleProvider, input: &AddLlmProviderInput, cx: &mut App, ) -> Task> { @@ -248,18 +324,27 @@ fn save_provider_to_settings( return Task::ready(Err("API Key cannot be empty".into())); } - let mut models = Vec::new(); - let mut model_names: HashSet = HashSet::default(); - for model in &input.models { - match model.parse(cx) { - Ok(model) => { - if !model_names.insert(model.name.clone()) { - return Task::ready(Err("Model Names must be unique".into())); - } - models.push(model) - } - Err(err) => return Task::ready(Err(err)), - } + let models = match provider { + LlmCompatibleProvider::OpenAi => input + .models + .iter() + .map(|model| model.parse_open_ai_compatible(cx)) + .collect::, _>>() + .map(ParsedModels::OpenAi), + LlmCompatibleProvider::Anthropic => input + .models + .iter() + .map(|model| model.parse_anthropic_compatible(cx)) + .collect::, _>>() + .map(ParsedModels::Anthropic), + }; + let models = match models { + Ok(models) => models, + Err(error) => return Task::ready(Err(error)), + }; + + if !models.model_names().all_unique() { + return Task::ready(Err("Model Names must be unique".into())); } let fs = ::global(cx); @@ -268,20 +353,36 @@ fn save_provider_to_settings( task.await .map_err(|_| SharedString::from("Failed to write API key to keychain"))?; cx.update(|cx| { - update_settings_file(fs, cx, |settings, _cx| { - settings - .language_models - .get_or_insert_default() - .openai_compatible - .get_or_insert_default() - .insert( - provider_name, - OpenAiCompatibleSettingsContent { - api_url, - available_models: models, - custom_headers: None, - }, - ); + update_settings_file(fs, cx, move |settings, _cx| { + let language_models = settings.language_models.get_or_insert_default(); + match models { + ParsedModels::OpenAi(available_models) => { + language_models + .openai_compatible + .get_or_insert_default() + .insert( + provider_name, + OpenAiCompatibleSettingsContent { + api_url, + available_models, + custom_headers: None, + }, + ); + } + ParsedModels::Anthropic(available_models) => { + language_models + .anthropic_compatible + .get_or_insert_default() + .insert( + provider_name, + AnthropicCompatibleSettingsContent { + api_url, + available_models, + custom_headers: None, + }, + ); + } + } }); }); Ok(()) @@ -317,7 +418,7 @@ impl AddLlmProviderModal { } fn confirm(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context) { - let task = save_provider_to_settings(&self.input, cx); + let task = save_provider_to_settings(self.provider, &self.input, cx); cx.spawn(async move |this, cx| { let result = task.await; this.update(cx, |this, cx| match result { @@ -370,6 +471,7 @@ impl AddLlmProviderModal { fn render_model(&self, ix: usize, cx: &mut Context) -> impl IntoElement + use<> { let has_more_than_one_model = self.input.models.len() > 1; + let is_open_ai = self.provider.is_open_ai(); let model = &self.input.models[ix]; v_flex() @@ -384,7 +486,9 @@ impl AddLlmProviderModal { .child( h_flex() .gap_2() - .child(model.max_completion_tokens.clone()) + .when(is_open_ai, |parent| { + parent.child(model.max_completion_tokens.clone()) + }) .child(model.max_output_tokens.clone()), ) .child(model.max_tokens.clone()) @@ -407,49 +511,54 @@ impl AddLlmProviderModal { cx.notify(); })), ) - .child( - Checkbox::new( - ("supports-parallel-tool-calls", ix), - model.capabilities.supports_parallel_tool_calls, - ) - .label("Supports parallel_tool_calls") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_parallel_tool_calls = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-prompt-cache-key", ix), - model.capabilities.supports_prompt_cache_key, - ) - .label("Supports prompt_cache_key") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_prompt_cache_key = - *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-chat-completions", ix), - model.capabilities.supports_chat_completions, - ) - .label("Supports /chat/completions") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_chat_completions = - *checked; - cx.notify(); - }, - )), - ), + .when(is_open_ai, |parent| { + parent + .child( + Checkbox::new( + ("supports-parallel-tool-calls", ix), + model.capabilities.supports_parallel_tool_calls, + ) + .label("Supports parallel_tool_calls") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .supports_parallel_tool_calls = *checked; + cx.notify(); + }, + )), + ) + .child( + Checkbox::new( + ("supports-prompt-cache-key", ix), + model.capabilities.supports_prompt_cache_key, + ) + .label("Supports prompt_cache_key") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .supports_prompt_cache_key = *checked; + cx.notify(); + }, + )), + ) + .child( + Checkbox::new( + ("supports-chat-completions", ix), + model.capabilities.supports_chat_completions, + ) + .label("Supports /chat/completions") + .on_click(cx.listener( + move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .supports_chat_completions = *checked; + cx.notify(); + }, + )), + ) + }), ) .when(has_more_than_one_model, |this| { this.child( @@ -521,13 +630,11 @@ impl Render for AddLlmProviderModal { })) .child( Modal::new("configure-context-server", None) - .header(ModalHeader::new().headline("Add LLM Provider").description( - match self.provider { - LlmCompatibleProvider::OpenAi => { - "This provider will use an OpenAI compatible API." - } - }, - )) + .header( + ModalHeader::new() + .headline("Add LLM Provider") + .description(self.provider.description()), + ) .when_some(self.last_error.clone(), |this, error| { this.section( Section::new().child( @@ -615,82 +722,110 @@ mod tests { async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) { let cx = setup_test(cx).await; - assert_eq!( - save_provider_validation_errors("", "someurl", "somekey", vec![], cx,).await, - Some("Provider Name cannot be empty".into()) - ); + for provider in [ + LlmCompatibleProvider::OpenAi, + LlmCompatibleProvider::Anthropic, + ] { + assert_eq!( + save_provider_validation_errors(provider, "", "someurl", "somekey", vec![], cx) + .await, + Some("Provider Name cannot be empty".into()) + ); - assert_eq!( - save_provider_validation_errors("someprovider", "", "somekey", vec![], cx,).await, - Some("API URL cannot be empty".into()) - ); + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "", + "somekey", + vec![], + cx + ) + .await, + Some("API URL cannot be empty".into()) + ); - assert_eq!( - save_provider_validation_errors("someprovider", "someurl", "", vec![], cx,).await, - Some("API Key cannot be empty".into()) - ); + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "", + vec![], + cx + ) + .await, + Some("API Key cannot be empty".into()) + ); - assert_eq!( - save_provider_validation_errors( - "someprovider", - "someurl", - "somekey", - vec![("", "200000", "200000", "32000")], - cx, - ) - .await, - Some("Model Name cannot be empty".into()) - ); + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "somekey", + vec![("", "200000", "200000", "32000")], + cx, + ) + .await, + Some("Model Name cannot be empty".into()) + ); - assert_eq!( - save_provider_validation_errors( - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "abc", "200000", "32000")], - cx, - ) - .await, - Some("Max Tokens must be a number".into()) - ); + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "somekey", + vec![("somemodel", "abc", "200000", "32000")], + cx, + ) + .await, + Some("Max Tokens must be a number".into()) + ); - assert_eq!( - save_provider_validation_errors( - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "abc", "32000")], - cx, - ) - .await, - Some("Max Completion Tokens must be a number".into()) - ); + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "somekey", + vec![("somemodel", "200000", "200000", "abc")], + cx, + ) + .await, + Some("Max Output Tokens must be a number".into()) + ); - assert_eq!( - save_provider_validation_errors( - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "200000", "abc")], - cx, - ) - .await, - Some("Max Output Tokens must be a number".into()) - ); + assert_eq!( + save_provider_validation_errors( + provider, + "someprovider", + "someurl", + "somekey", + vec![ + ("somemodel", "200000", "200000", "32000"), + ("somemodel", "200000", "200000", "32000"), + ], + cx, + ) + .await, + Some("Model Names must be unique".into()) + ); + } + // Max Completion Tokens is only used by OpenAI-compatible providers. assert_eq!( save_provider_validation_errors( + LlmCompatibleProvider::OpenAi, "someprovider", "someurl", "somekey", - vec![ - ("somemodel", "200000", "200000", "32000"), - ("somemodel", "200000", "200000", "32000"), - ], + vec![("somemodel", "200000", "abc", "32000")], cx, ) .await, - Some("Model Names must be unique".into()) + Some("Max Completion Tokens must be a number".into()) ); } @@ -712,6 +847,7 @@ mod tests { assert_eq!( save_provider_validation_errors( + LlmCompatibleProvider::OpenAi, "someprovider", "someurl", "someapikey", @@ -753,7 +889,7 @@ mod tests { ToggleState::Selected ); - let parsed_model = model_input.parse(cx).unwrap(); + let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert!(parsed_model.capabilities.tools); assert!(!parsed_model.capabilities.images); assert!(!parsed_model.capabilities.parallel_tool_calls); @@ -778,7 +914,7 @@ mod tests { model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; model_input.capabilities.supports_chat_completions = ToggleState::Unselected; - let parsed_model = model_input.parse(cx).unwrap(); + let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert!(!parsed_model.capabilities.tools); assert!(!parsed_model.capabilities.images); assert!(!parsed_model.capabilities.parallel_tool_calls); @@ -803,7 +939,7 @@ mod tests { model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; model_input.capabilities.supports_chat_completions = ToggleState::Selected; - let parsed_model = model_input.parse(cx).unwrap(); + let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert_eq!(parsed_model.name, "somemodel"); assert!(parsed_model.capabilities.tools); assert!(!parsed_model.capabilities.images); @@ -813,6 +949,32 @@ mod tests { }); } + #[gpui::test] + async fn test_model_input_parse_anthropic_compatible(cx: &mut TestAppContext) { + let cx = setup_test(cx).await; + + cx.update(|window, cx| { + let mut model_input = ModelInput::new(0, window, cx); + model_input.name.update(cx, |input, cx| { + input.set_text("somemodel", window, cx); + }); + + let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); + assert_eq!(parsed_model.name, "somemodel"); + assert_eq!(parsed_model.max_tokens, 200000); + assert_eq!(parsed_model.max_output_tokens, Some(32000)); + assert!(parsed_model.capabilities.tools); + assert!(!parsed_model.capabilities.images); + + model_input.capabilities.supports_tools = ToggleState::Unselected; + model_input.capabilities.supports_images = ToggleState::Selected; + + let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); + assert!(!parsed_model.capabilities.tools); + assert!(parsed_model.capabilities.images); + }); + } + async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext { cx.update(|cx| { let store = SettingsStore::test(cx); @@ -834,6 +996,7 @@ mod tests { } async fn save_provider_validation_errors( + provider: LlmCompatibleProvider, provider_name: &str, api_url: &str, api_key: &str, @@ -847,7 +1010,7 @@ mod tests { } let task = cx.update(|window, cx| { - let mut input = AddLlmProviderInput::new(LlmCompatibleProvider::OpenAi, window, cx); + let mut input = AddLlmProviderInput::new(provider, window, cx); set_text(&input.provider_name, provider_name, window, cx); set_text(&input.api_url, api_url, window, cx); set_text(&input.api_key, api_key, window, cx); @@ -869,7 +1032,7 @@ mod tests { ); set_text(&model.max_output_tokens, max_output_tokens, window, cx); } - save_provider_to_settings(&input, cx) + save_provider_to_settings(provider, &input, cx) }); task.await.err() diff --git a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs index e81c14ca0e5153..7cafc7ad57bf7d 100644 --- a/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs +++ b/crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs @@ -378,7 +378,10 @@ impl ManageProfilesModal { let supported_by_provider = provider.as_ref().map_or(true, |provider| { agent::tool_supports_provider(name, provider) }); - supported_by_provider + // Don't offer tools the agent can't actually use: tools gated + // behind an inactive feature flag are silently dropped before + // they reach the model (#56778). + supported_by_provider && agent::tool_feature_flag_enabled(name, cx) }) .map(Arc::from) .collect(); diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 9a820db7b20806..316c7aaeeb54d1 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -102,6 +102,7 @@ impl AgentDiffPane { ); diff_display_editor .set_render_diff_hunk_controls(diff_hunk_controls(&thread, workspace.clone()), cx); + diff_display_editor.set_render_diff_hunks_as_unstaged(cx); diff_display_editor.update_editors(cx, |editor, _cx| { editor.register_addon(AgentDiffAddon); }); @@ -683,7 +684,10 @@ impl Render for AgentDiffPane { .on_action(cx.listener(Self::reject)) .on_action(cx.listener(Self::reject_all)) .on_action(cx.listener(Self::keep_all)) - .bg(cx.theme().colors().editor_background) + // Only paint the background for the empty state. When the diff editor + // is shown it already paints `editor_background`; painting it again + // here double-composites into a darker patch on transparent windows. + .when(is_empty, |el| el.bg(cx.theme().colors().editor_background)) .flex() .items_center() .justify_center() @@ -755,6 +759,9 @@ fn render_diff_hunk_controls( cx: &mut App, ) -> AnyElement { let editor = editor.clone(); + // Drop shadows render as a dark halo on transparent windows. + let opaque_window = + cx.theme().window_background_appearance() == gpui::WindowBackgroundAppearance::Opaque; h_flex() .h(line_height) @@ -769,7 +776,7 @@ fn render_diff_hunk_controls( .bg(cx.theme().colors().editor_background) .gap_1() .block_mouse_except_scroll() - .shadow_md() + .when(opaque_window, |this| this.shadow_md()) .children(vec![ Button::new(("reject", row as u64), "Reject") .disabled(is_created_file) @@ -1572,6 +1579,7 @@ impl AgentDiff { diff_hunk_controls(&thread, workspace.clone()), cx, ); + editor.set_render_diff_hunks_as_unstaged(true, cx); editor.set_expand_all_diff_hunks(cx); editor.register_addon(EditorAgentDiffAddon); }); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 2fdf67fc17dad6..89e6b958548f81 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -10,7 +10,7 @@ use std::{ time::Duration, }; -use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus}; +use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus, line_range_suffix}; use agent::{ContextServerRegistry, SharedThread, ThreadStore}; use agent_client_protocol::schema as acp; use agent_servers::AgentServer; @@ -18,7 +18,7 @@ use agent_settings::UserAgentsMd; use collections::HashSet; use db::kvp::{Dismissable, KeyValueStore}; use itertools::Itertools; -use project::AgentId; +use project::{AgentId, ProjectItem}; use serde::{Deserialize, Serialize}; use settings::{LanguageModelProviderSetting, LanguageModelSelection}; @@ -30,15 +30,15 @@ use zed_actions::{ ResolveConflictsWithAgent, ReviewBranchDiff, }, assistant::{ - CreateSkillFromUrl, FocusAgent, OpenGlobalAgentsMdRules, OpenProjectAgentsMdRules, - OpenRulesLibrary, OpenSkillCreator, Toggle, ToggleFocus, + FocusAgent, ManageSkills, OpenGlobalAgentsMdRules, OpenProjectAgentsMdRules, Toggle, + ToggleFocus, }, }; use crate::ExpandMessageEditor; use crate::ManageProfiles; use crate::agent_connection_store::AgentConnectionStore; -use crate::completion_provider::AgentContextSource; +use crate::completion_provider::{AgentContextSelection, AgentContextSource}; use crate::terminal_thread_metadata_store::{ TerminalThreadMetadata, TerminalThreadMetadataStore, compose_terminal_thread_title, terminal_title_without_prefix, @@ -85,15 +85,17 @@ use gpui::{ }; use language::LanguageRegistry; use language_model::LanguageModelRegistry; +use notifications::status_toast::StatusToast; use project::{Project, ProjectPath, Worktree}; use settings::TerminalDockPosition; use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file}; -use skill_creator::{SkillCreatorOpenMode, is_supported_skill_url, open_skill_creator}; + use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings}; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; +use text::OffsetRangeExt; use theme_settings::ThemeSettings; use ui::{ - Button, ContextMenu, ContextMenuEntry, GradientFade, IconButton, KeyBinding, PopoverMenu, + ContextMenu, ContextMenuEntry, GradientFade, IconButton, KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::ResultExt as _; @@ -203,6 +205,11 @@ struct LastCreatedEntryKind { entry_kind: AgentPanelEntryKind, } +struct SourcePanelInitialization { + agent: Agent, + initial_content: Option, +} + /// Reads the most recently used agent across all workspaces. Used as a fallback /// when opening a workspace that has no per-workspace agent preference yet. fn read_global_last_used_agent(kvp: &KeyValueStore) -> Option { @@ -324,6 +331,14 @@ fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option(&json).log_err()) } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ThreadTitleRegenerationResult { + NotOpen, + Started, + NoModel, + AlreadyGenerating, +} + #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] enum AgentPanelEntryKind { #[default] @@ -411,12 +426,10 @@ pub fn init(cx: &mut App) { }); } }) - .register_action(|workspace, action: &OpenRulesLibrary, window, cx| { + .register_action(|workspace, action: &ManageSkills, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| { - panel.deploy_rules_library(action, window, cx) - }); + panel.update(cx, |panel, cx| panel.manage_skills(action, window, cx)); } }) .register_action(|workspace, _: &OpenGlobalAgentsMdRules, window, cx| { @@ -425,22 +438,6 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenProjectAgentsMdRules, window, cx| { open_project_rules(workspace, window, cx); }) - .register_action(|workspace, action: &OpenSkillCreator, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| { - panel.deploy_skill_creator(action, window, cx) - }); - } - }) - .register_action(|workspace, action: &CreateSkillFromUrl, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| { - panel.deploy_skill_creator_from_url(action, window, cx) - }); - } - }) .register_action(|workspace, _: &Follow, window, cx| { workspace.follow(CollaboratorId::Agent, window, cx); }) @@ -712,6 +709,32 @@ pub fn init(cx: &mut App) { conversation_view.update(cx, |conversation_view, cx| { conversation_view.insert_selection(selection, window, cx); }); + } else if let Some(terminal_id) = panel.active_terminal_id() + && let Some(agent_terminal) = panel.terminals.get(&terminal_id) + { + // Resolve mentions against the cwd: live cwd, else spawn dir. + let working_directory = agent_terminal + .view + .read(cx) + .terminal() + .read(cx) + .working_directory() + .or_else(|| agent_terminal.working_directory.clone()); + let text = format_selection_for_terminal( + &selection, + &panel.project, + working_directory.as_deref(), + cx, + ); + if !text.is_empty() { + let view = agent_terminal.view.clone(); + view.update(cx, |view, cx| { + view.terminal().update(cx, |terminal, _| { + terminal.paste(&text); + }); + window.focus(&view.focus_handle(cx), cx); + }); + } } }); }); @@ -722,6 +745,63 @@ pub fn init(cx: &mut App) { .detach(); } +fn format_selection_for_terminal( + selection: &AgentContextSelection, + project: &Entity, + working_directory: Option<&std::path::Path>, + cx: &App, +) -> String { + match selection { + AgentContextSelection::Editor(ranges) => { + let path_style = project.read(cx).path_style(cx); + let mut parts: Vec = Vec::new(); + for (buffer, range) in ranges { + let buffer = buffer.read(cx); + let Some(project_path) = buffer.project_path(cx) else { + continue; + }; + let snapshot = buffer.snapshot(); + let point_range = range.to_point(&snapshot); + let line_range = point_range.start.row..=point_range.end.row; + let path = mention_path_for_terminal( + project, + &project_path, + working_directory, + path_style, + cx, + ); + parts.push(format!("{path}{}", line_range_suffix(&line_range))); + } + if parts.is_empty() { + String::new() + } else { + // Trailing space so the mention doesn't fuse with the next input. + format!("{} ", parts.join(" ")) + } + } + AgentContextSelection::Terminal(texts) => texts.join("\n"), + } +} + +/// Path for a terminal mention: relative to the terminal cwd if possible, else absolute. +fn mention_path_for_terminal( + project: &Entity, + project_path: &ProjectPath, + working_directory: Option<&std::path::Path>, + path_style: util::paths::PathStyle, + cx: &App, +) -> String { + let abs_path = project.read(cx).absolute_path(project_path, cx); + match (abs_path, working_directory) { + (Some(abs_path), Some(working_directory)) => path_style + .strip_prefix(&abs_path, working_directory) + .map(|relative| relative.display(path_style).into_owned()) + .unwrap_or_else(|| abs_path.to_string_lossy().into_owned()), + (Some(abs_path), None) => abs_path.to_string_lossy().into_owned(), + (None, _) => project_path.path.display(path_style).into_owned(), + } +} + fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock { let mention_uri = MentionUri::MergeConflict { file_path: conflict.file_path.clone(), @@ -2170,7 +2250,7 @@ impl AgentPanel { Err(error) => { log::error!("failed to spawn agent panel terminal: {error:#}"); workspace - .update(cx, |workspace, cx| workspace.show_error(&error, cx)) + .update(cx, |workspace, cx| workspace.show_error(error, cx)) .log_err(); this.update(cx, |this, cx| { if this.pending_terminal_spawn == Some(terminal_id) { @@ -2509,14 +2589,15 @@ impl AgentPanel { workspace: Option<&Workspace>, cx: &App, ) -> Option { - metadata - .working_directory - .clone() - .or_else(|| { - workspace - .and_then(|workspace| terminal_view::default_working_directory(workspace, cx)) - }) - .or_else(|| self.default_terminal_working_directory(cx)) + if let Some(working_directory) = metadata.working_directory.clone() { + return Some(working_directory); + } + + if let Some(workspace) = workspace { + return terminal_view::default_working_directory(workspace, cx); + } + + self.default_terminal_working_directory(cx) } fn terminal_restore_initial_title(metadata: &TerminalThreadMetadata) -> Option { @@ -3544,90 +3625,46 @@ impl AgentPanel { self.set_base_view(thread.into(), focus, window, cx); } - fn deploy_rules_library( + fn manage_skills( &mut self, - _action: &OpenRulesLibrary, + _action: &ManageSkills, window: &mut Window, cx: &mut Context, ) { - // The legacy Rules action is rerouted to the skill creator so the - // existing keyboard shortcut (still bound to `OpenRulesLibrary` in - // the default keymaps) and any persisted user keymap entries keep - // working. - self.deploy_skill_creator(&OpenSkillCreator, window, cx); - } - - fn deploy_skill_creator( - &mut self, - _action: &OpenSkillCreator, - _window: &mut Window, - cx: &mut Context, - ) { - self.open_skill_creator(SkillCreatorOpenMode::Form, cx); - } - - fn deploy_skill_creator_from_url( - &mut self, - _action: &CreateSkillFromUrl, - _window: &mut Window, - cx: &mut Context, - ) { - let initial_url = cx - .read_from_clipboard() - .and_then(|clipboard| clipboard.text()) - .map(|text| text.trim().to_string()) - .filter(|text| is_supported_skill_url(text)); - - self.open_skill_creator(SkillCreatorOpenMode::Url { initial_url }, cx); - } - - /// Open the skill creator pre-filled with a skill received from a - /// `zed://skill` share link, so the user can review it and choose a scope - /// before installing. - pub fn install_shared_skill(&mut self, content: String, cx: &mut Context) { - self.open_skill_creator(SkillCreatorOpenMode::Install { content }, cx); + window.dispatch_action( + Box::new(zed_actions::OpenSettingsAt { + path: zed_actions::AGENT_SKILLS_SETTINGS_PATH.to_string(), + target: None, + }), + cx, + ); } - fn open_skill_creator(&mut self, open_mode: SkillCreatorOpenMode, cx: &mut Context) { - let this = cx.weak_entity(); - let on_saved: Rc = Rc::new(move |cx: &mut App| { - this.update(cx, |this, cx| { - if !this.has_open_project(cx) { - return; - } - - this.ensure_native_agent_connection(cx); - let Some(connect_task) = this.connection_store.update(cx, |store, cx| { - store - .entry(&Agent::NativeAgent) - .map(|entry| entry.read(cx).wait_for_connection()) - }) else { - return; - }; - let project = this.project.clone(); - cx.spawn(async move |_this, cx| -> Result<()> { - let connected = connect_task.await?; - if let Some(native_connection) = connected - .connection - .downcast::() - { - cx.update(|cx| native_connection.refresh_skills_for_project(project, cx)); - } - Ok(()) - }) - .detach_and_log_err(cx); - }) - .log_err(); - }); + /// Refresh the native agent's view of available skills + pub fn refresh_skills(&mut self, cx: &mut Context) { + if !self.has_open_project(cx) { + return; + } - open_skill_creator( - Some(self.workspace.clone()), - self.language_registry.clone(), - self.fs.clone(), - open_mode, - Some(on_saved), - cx, - ) + self.ensure_native_agent_connection(cx); + let Some(connect_task) = self.connection_store.update(cx, |store, cx| { + store + .entry(&Agent::NativeAgent) + .map(|entry| entry.read(cx).wait_for_connection()) + }) else { + return; + }; + let project = self.project.clone(); + cx.spawn(async move |_this, cx| -> Result<()> { + let connected = connect_task.await?; + if let Some(native_connection) = connected + .connection + .downcast::() + { + cx.update(|cx| native_connection.refresh_skills_for_project(project, cx)); + } + Ok(()) + }) .detach_and_log_err(cx); } @@ -3826,6 +3863,27 @@ impl AgentPanel { } } + pub fn open_thread_as_markdown( + &mut self, + thread_id: ThreadId, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(conversation_view) = self.conversation_view_for_id(&thread_id, cx).cloned() else { + return false; + }; + let Some(thread_view) = conversation_view.read(cx).root_thread_view() else { + return false; + }; + thread_view.update(cx, |thread, cx| { + thread + .open_thread_as_markdown(workspace, window, cx) + .detach_and_log_err(cx); + }); + true + } + fn copy_thread_to_clipboard(&mut self, window: &mut Window, cx: &mut Context) { let Some(thread) = self.active_native_agent_thread(cx) else { Self::show_deferred_toast(&self.workspace, "No active native thread to copy", cx); @@ -4152,6 +4210,42 @@ impl AgentPanel { }) } + pub fn regenerate_thread_title( + &mut self, + thread_id: ThreadId, + cx: &mut Context, + ) -> ThreadTitleRegenerationResult { + let Some(conversation_view) = self.conversation_view_for_id(&thread_id, cx).cloned() else { + return ThreadTitleRegenerationResult::NotOpen; + }; + Self::regenerate_conversation_thread_title(conversation_view, cx) + } + + fn regenerate_conversation_thread_title( + conversation_view: Entity, + cx: &mut App, + ) -> ThreadTitleRegenerationResult { + let Some(thread) = conversation_view.read(cx).as_native_thread(cx) else { + return ThreadTitleRegenerationResult::NotOpen; + }; + let thread_id = conversation_view.read(cx).parent_id(); + thread.update(cx, |thread, cx| { + if thread.is_generating_title() { + ThreadTitleRegenerationResult::AlreadyGenerating + } else if thread.summarization_model().is_none() { + ThreadTitleRegenerationResult::NoModel + } else if thread.regenerate_title_with_callback(cx, move |title, cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.set_generated_title(thread_id, title, cx); + }); + }) { + ThreadTitleRegenerationResult::Started + } else { + ThreadTitleRegenerationResult::AlreadyGenerating + } + }) + } + pub fn conversation_views(&self) -> Vec> { self.active_conversation_view() .into_iter() @@ -5494,17 +5588,14 @@ impl AgentPanel { fn source_panel_initialization( source_workspace: &WeakEntity, cx: &App, - ) -> Option<(Agent, AgentInitialContent)> { + ) -> Option { let source_workspace = source_workspace.upgrade()?; let source_panel = source_workspace.read(cx).panel::(cx)?; let source_panel = source_panel.read(cx); - let initial_content = source_panel.active_initial_content(cx)?; - let agent = if source_panel.project.read(cx).is_via_collab() { - Agent::NativeAgent - } else { - source_panel.selected_agent.clone() - }; - Some((agent, initial_content)) + Some(SourcePanelInitialization { + agent: source_panel.selected_agent(cx), + initial_content: source_panel.active_initial_content(cx), + }) } pub fn initialize_from_source_workspace_if_needed( @@ -5521,28 +5612,50 @@ impl AgentPanel { return false; } - let Some((agent, initial_content)) = - Self::source_panel_initialization(&source_workspace, cx) - else { + let Some(initialization) = Self::source_panel_initialization(&source_workspace, cx) else { return false; }; - let thread = self.create_agent_thread_with_server( - agent, - None, - None, - None, - None, - Some(initial_content), - None, - AgentThreadSource::AgentPanel, - window, - cx, - ); - self.draft_thread = Some(thread.conversation_view.clone()); - self.observe_draft_editor(&thread.conversation_view, cx); - self.set_base_view(thread.into(), false, window, cx); - true + let mut initialized = false; + if self.selected_agent != initialization.agent { + self.selected_agent = initialization.agent.clone(); + self.serialize(cx); + initialized = true; + } + + if let Some(initial_content) = initialization.initial_content { + let thread = self.create_agent_thread_with_server( + initialization.agent, + None, + None, + None, + None, + Some(initial_content), + None, + AgentThreadSource::AgentPanel, + window, + cx, + ); + self.draft_thread = Some(thread.conversation_view.clone()); + self.observe_draft_editor(&thread.conversation_view, cx); + self.set_base_view(thread.into(), false, window, cx); + true + } else { + if initialized + && matches!( + &self.base_view, + BaseView::AgentThread { conversation_view } + if self.draft_thread.as_ref().is_some_and(|draft| { + draft.entity_id() == conversation_view.entity_id() + }) + ) + { + self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); + } else if initialized { + cx.notify(); + } + initialized + } } fn is_title_editor_focused(&self, window: &Window, cx: &Context) -> bool { @@ -5629,9 +5742,11 @@ impl AgentPanel { .tooltip(Tooltip::text("Title generation failed. Retry")) .on_click({ let conversation_view = conversation_view.clone(); + let workspace = self.workspace.clone(); move |_event, _window, cx| { Self::handle_regenerate_thread_title( conversation_view.clone(), + workspace.clone(), cx, ); } @@ -5701,6 +5816,10 @@ impl AgentPanel { .width(px(64.0)) .right(px(0.0)) .gradient_stop(0.75); + // The fade gradient renders as a visible patch on transparent windows + // (the title already truncates). + let opaque_window = + cx.theme().window_background_appearance() == gpui::WindowBackgroundAppearance::Opaque; h_flex() .key_context("TitleEditor") @@ -5712,36 +5831,59 @@ impl AgentPanel { .overflow_x_hidden() .child(content) .when(self.should_show_title_edit(window, cx), |this| { - this.child(gradient_overlay).child( - h_flex() - .visible_on_hover("title_editor") - .absolute() - .right_0() - .h_full() - .bg(cx.theme().colors().tab_bar_background) - .child( - IconButton::new("edit_tile", IconName::Pencil) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Edit Thread Title")), - ), - ) + this.when(opaque_window, |this| this.child(gradient_overlay)) + .child( + h_flex() + .visible_on_hover("title_editor") + .absolute() + .right_0() + .h_full() + .bg(cx.theme().colors().tab_bar_background) + .child( + IconButton::new("edit_tile", IconName::Pencil) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Edit Thread Title")), + ), + ) }) .into_any() } - 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| { - if thread.can_generate_title(cx) { - thread.generate_title(cx); - cx.notify(); - } - }); - } + fn show_no_thread_summary_model_toast(workspace: Entity, cx: &mut App) { + workspace.update(cx, |workspace, cx| { + let toast = StatusToast::new( + "No model is configured for summarizing thread titles.", + cx, + |this, _cx| { + this.icon( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .dismiss_button(true) + }, + ); + workspace.toggle_status_toast(toast, cx); }); } + fn handle_regenerate_thread_title( + conversation_view: Entity, + workspace: WeakEntity, + cx: &mut App, + ) { + match Self::regenerate_conversation_thread_title(conversation_view, cx) { + ThreadTitleRegenerationResult::NoModel => { + if let Some(workspace) = workspace.upgrade() { + Self::show_no_thread_summary_model_toast(workspace, cx); + } + } + ThreadTitleRegenerationResult::NotOpen + | ThreadTitleRegenerationResult::Started + | ThreadTitleRegenerationResult::AlreadyGenerating => {} + } + } + fn render_panel_options_menu( &self, _window: &mut Window, @@ -5749,7 +5891,7 @@ impl AgentPanel { ) -> impl IntoElement { let focus_handle = self.focus_handle(cx); // Resolve menu shortcuts at the thread root; the active editor can - // shadow panel-level commands such as OpenRulesLibrary. + // shadow panel-level commands such as ManageSkills. let menu_action_context = match &self.base_view { BaseView::AgentThread { conversation_view } => conversation_view .read(cx) @@ -5771,7 +5913,7 @@ impl AgentPanel { conversation_view.has_user_submitted_prompt(cx) && conversation_view .as_native_thread(cx) - .is_some_and(|thread| thread.read(cx).can_generate_title(cx)) + .is_some_and(|thread| !thread.read(cx).is_generating_title()) }); let has_auth_methods = match &self.base_view { @@ -5819,9 +5961,11 @@ impl AgentPanel { menu = menu .entry("Regenerate Thread Title", None, { let conversation_view = conversation_view.clone(); + let workspace = workspace.clone(); move |_, cx| { Self::handle_regenerate_thread_title( conversation_view.clone(), + workspace.clone(), cx, ); } @@ -5844,27 +5988,10 @@ impl AgentPanel { }), ) .separator() - .header("Skills") - .entry( - "Create Skill…", - Some(Box::new(OpenRulesLibrary::default())), - |window, cx| { - window.dispatch_action(Box::new(OpenSkillCreator), cx); - }, - ) - .entry("Manage Skills…", None, |window, cx| { - window.dispatch_action( - Box::new(zed_actions::OpenSettingsAt { - path: "agent.skills".to_string(), - }), - cx, - ); - }) - .separator(); + .header("Context") + .action("Skills", Box::new(ManageSkills)); if project_agents_md_path.is_some() || global_agents_md_loaded { - menu = menu.header("Rules"); - if global_agents_md_loaded { let workspace = workspace.clone(); @@ -6226,7 +6353,6 @@ impl AgentPanel { .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 = if showing_terminal { Some(IconName::Terminal) } else { @@ -6321,74 +6447,14 @@ impl AgentPanel { .flex_none() .justify_between(); - let toolbar_content = if can_create_entries && matches!(mode, ToolbarMode::EmptyThread) { - let (chevron_icon, icon_color, label_color) = - if self.new_thread_menu_handle.is_deployed() { - (IconName::ChevronUp, Color::Accent, Color::Accent) - } else { - (IconName::ChevronDown, Color::Muted, Color::Default) - }; - - 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 = 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") - .trigger_with_tooltip(agent_selector_button, { - move |_window, cx| { - Tooltip::for_action_in( - "New Thread…", - &ToggleNewThreadMenu, - &focus_handle, - cx, - ) - } - }) - .menu({ - let builder = new_thread_menu_builder.clone(); - move |window, cx| builder(window, cx) - }) - .with_handle(self.new_thread_menu_handle.clone()) - .anchor(Anchor::TopLeft) - .offset(gpui::Point { - x: px(1.0), - y: px(1.0), - }); - - base_container - .child( - h_flex() - .size_full() - .gap(DynamicSpacing::Base04.rems(cx)) - .pl(DynamicSpacing::Base04.rems(cx)) - .child(agent_selector_menu), - ) - .child( - h_flex() - .h_full() - .flex_none() - .gap_1() - .pl_1() - .pr_1() - .child(full_screen_button) - .child(self.render_panel_options_menu(window, cx)), - ) + let empty_thread_title = matches!(mode, ToolbarMode::EmptyThread).then(|| { + Label::new(format!("New {} Thread", selected_agent_label)) + .color(Color::Muted) + .truncate() .into_any_element() - } else { + }); + + let toolbar_content = { let new_thread_menu = PopoverMenu::new("new_thread_menu") .trigger_with_tooltip( IconButton::new("new_thread_menu_btn", IconName::Plus) @@ -6423,7 +6489,10 @@ impl AgentPanel { } else { selected_agent.into_any_element() }) - .child(self.render_title_view(window, cx)), + .child(match empty_thread_title { + Some(title) => title, + None => self.render_title_view(window, cx), + }), ) .child( h_flex() @@ -6762,8 +6831,7 @@ impl Render for AgentPanel { this.open_configuration(window, cx); })) .on_action(cx.listener(Self::open_active_thread_as_markdown)) - .on_action(cx.listener(Self::deploy_rules_library)) - .on_action(cx.listener(Self::deploy_skill_creator)) + .on_action(cx.listener(Self::manage_skills)) .on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::toggle_options_menu)) .on_action(cx.listener(Self::increase_font_size)) @@ -7102,6 +7170,7 @@ mod tests { use gpui::{App, TestAppContext, UpdateGlobal, VisualTestContext}; use parking_lot::Mutex; use project::{Project, WorktreePaths}; + use settings::{SettingsStore, WorkingDirectory}; use std::any::Any; use serde_json::json; @@ -7528,6 +7597,74 @@ mod tests { }); } + #[gpui::test] + async fn test_terminal_restore_working_directory_does_not_read_leased_workspace( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .terminal + .get_or_insert_default() + .project + .working_directory = Some(WorkingDirectory::AlwaysHome); + }); + }); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + project.update(cx, |project, _cx| { + project.mark_as_collab_for_testing(); + }); + project.read_with(cx, |project, _cx| { + assert!(project.is_remote()); + }); + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .expect("multi workspace should have an active workspace"); + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + cx.new(|cx| AgentPanel::new(workspace, window, cx)) + }); + + assert_eq!( + workspace.read_with(cx, |workspace, cx| { + terminal_view::default_working_directory(workspace, cx) + }), + None + ); + + let metadata = TerminalThreadMetadata { + terminal_id: TerminalId::new(), + title: "Dev Server".into(), + custom_title: None, + created_at: Utc::now(), + worktree_paths: project.read_with(cx, |project, cx| project.worktree_paths(cx)), + remote_connection: None, + working_directory: None, + }; + assert_eq!(metadata.working_directory, None); + + let working_directory = workspace.update_in(cx, |workspace, _window, cx| { + panel + .read(cx) + .terminal_restore_working_directory(&metadata, Some(workspace), cx) + }); + + assert_eq!(working_directory, None); + } + #[gpui::test] async fn test_pending_terminal_restore_prevents_initial_terminal_creation( cx: &mut TestAppContext, @@ -8983,6 +9120,139 @@ mod tests { }); } + #[gpui::test] + async fn test_add_selection_to_terminal_thread_pastes_mention(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ "file.rs": "line one\nline two\nline three\n" }), + ) + .await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(&mut cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + // Make a terminal thread the active conversation. A display-only terminal + // avoids spawning a real shell; its working directory is supplied directly + // so the mention resolves relative to it. No agent is started inside it. + let terminal_id = TerminalId::new(); + panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_display_only_terminal( + terminal_id, + Some(PathBuf::from("/project")), + Some("Terminal".into()), + None, + None, + true, + true, + AgentThreadSource::AgentPanel, + window, + cx, + ) + }) + .expect("display-only terminal should be inserted"); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(terminal_id)); + assert!(panel.active_conversation_view().is_none()); + }); + + // Open the file in the center pane so the selection comes from a + // worktree-backed editor (with a project path). + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.open_paths( + vec![PathBuf::from("/project/file.rs")], + workspace::OpenOptions::default(), + None, + window, + cx, + ) + }) + .await; + cx.run_until_parked(); + + let editor = workspace.update(&mut cx, |workspace, cx| { + workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .expect("opened file should be an editor") + }); + + cx.focus(&editor); + cx.run_until_parked(); + + let terminal = panel.read_with(&cx, |panel, cx| { + panel + .terminals + .get(&terminal_id) + .expect("terminal should exist") + .view + .read(cx) + .terminal() + .clone() + }); + // Drop any input the terminal may have received during setup. + terminal.update(&mut cx, |terminal, _| { + terminal.take_input_log(); + }); + + // With only a cursor and nothing highlighted, the action is a no-op and + // must not paste anything into the terminal. + workspace.update_in(&mut cx, |_, window, cx| { + window.dispatch_action(AddSelectionToThread.boxed_clone(), cx); + }); + cx.run_until_parked(); + let pasted_without_selection = + terminal.update(&mut cx, |terminal, _| terminal.take_input_log()); + assert!( + pasted_without_selection.is_empty(), + "no selection should paste nothing, got {pasted_without_selection:?}" + ); + + // Now highlight a portion of the file: from the start of line 2 into line 3. + editor.update_in(&mut cx, |editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |selections| { + selections.select_ranges([text::Point::new(1, 0)..text::Point::new(2, 4)]); + }); + }); + cx.run_until_parked(); + + workspace.update_in(&mut cx, |_, window, cx| { + window.dispatch_action(AddSelectionToThread.boxed_clone(), cx); + }); + cx.run_until_parked(); + + let pasted: String = terminal + .update(&mut cx, |terminal, _| terminal.take_input_log()) + .into_iter() + .map(|bytes| String::from_utf8(bytes).expect("pasted bytes should be valid UTF-8")) + .collect(); + + // Lines are 1-based and inclusive; the path is presented as + // `:-`, with a trailing space. + assert_eq!(pasted, "file.rs:2-3 "); + } + async fn setup_panel(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { init_test(cx); cx.update(|cx| { @@ -9267,7 +9537,7 @@ mod tests { } #[gpui::test] - async fn test_skills_menu_entry_shows_rules_shortcut(cx: &mut TestAppContext) { + async fn test_skills_menu_entry_shows_manage_skills_shortcut(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure( @@ -9311,12 +9581,12 @@ mod tests { cx.run_until_parked(); assert!( - cx.debug_bounds("MENU_ITEM-Create Skill…").is_some(), - "Create Skill… menu item should be visible" + cx.debug_bounds("MENU_ITEM-Skills").is_some(), + "Skills menu item should be visible" ); assert!( cx.debug_bounds("KEY_BINDING-l").is_some(), - "Create Skill… menu item should show the OpenRulesLibrary shortcut" + "Skills menu item should show the ManageSkills shortcut" ); } @@ -13035,6 +13305,162 @@ mod tests { }); } + #[gpui::test] + async fn test_initialize_from_source_inherits_agent_without_draft_content( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project_a", json!({ "file.txt": "" })) + .await; + fs.insert_tree("/project_b", json!({ "file.txt": "" })) + .await; + let project_a = Project::test(fs.clone(), [Path::new("/project_a")], cx).await; + let project_b = Project::test(fs.clone(), [Path::new("/project_b")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + panel_a.update(cx, |panel, _cx| { + panel.selected_agent = Agent::Stub; + }); + + let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + let initialized = panel_b.update_in(cx, |panel, window, cx| { + panel.initialize_from_source_workspace_if_needed(workspace_a.downgrade(), window, cx) + }); + assert!( + initialized, + "fresh destination panel should inherit the source agent" + ); + + panel_b.read_with(cx, |panel, _cx| { + assert_eq!( + panel.selected_agent, + Agent::Stub, + "destination panel should inherit the source panel's selected agent" + ); + assert!( + panel.active_conversation_view().is_none(), + "agent-only initialization should not create a draft thread" + ); + }); + } + + #[gpui::test] + async fn test_initialize_from_source_retargets_empty_destination_draft_agent( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + fs.insert_tree("/project_a", json!({ "file.txt": "" })) + .await; + fs.insert_tree("/project_b", json!({ "file.txt": "" })) + .await; + let project_a = Project::test(fs.clone(), [Path::new("/project_a")], cx).await; + let project_b = Project::test(fs.clone(), [Path::new("/project_b")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + panel_a.update(cx, |panel, _cx| { + panel.selected_agent = Agent::Stub; + }); + + let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + panel_b.update_in(cx, |panel, window, cx| { + panel.activate_new_thread(false, AgentThreadSource::AgentPanel, window, cx); + }); + + let original_draft = panel_b.read_with(cx, |panel, cx| { + let draft = panel.draft_thread.as_ref().expect("draft should exist"); + assert_eq!( + *draft.read(cx).agent_key(), + Agent::NativeAgent, + "destination draft should start on the default agent" + ); + draft.entity_id() + }); + + let initialized = panel_b.update_in(cx, |panel, window, cx| { + panel.initialize_from_source_workspace_if_needed(workspace_a.downgrade(), window, cx) + }); + assert!( + initialized, + "fresh destination draft should inherit the source agent" + ); + + panel_b.read_with(cx, |panel, cx| { + let draft = panel.draft_thread.as_ref().expect("draft should exist"); + assert_ne!( + draft.entity_id(), + original_draft, + "empty destination draft should be replaced when the inherited agent differs" + ); + assert_eq!( + *draft.read(cx).agent_key(), + Agent::Stub, + "empty destination draft should be rebound to the inherited agent" + ); + }); + } + #[gpui::test] async fn test_initialize_from_source_does_not_overwrite_existing_content( cx: &mut TestAppContext, diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index bc2a8f4147e245..71b52a4243207a 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -71,12 +71,15 @@ use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModa pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore}; pub use crate::agent_panel::{ AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId, + ThreadTitleRegenerationResult, }; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; +pub use crate::message_editor::MessageEditorEvent; pub use crate::thread_metadata_store::ThreadId; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; -pub use conversation_view::ConversationView; +pub use conversation_view::open_markdown_in_workspace; +pub use conversation_view::{ConversationView, StateChange}; pub use external_source_prompt::ExternalSourcePrompt; pub(crate) use mode_selector::ModeSelector; pub(crate) use model_selector::ModelSelector; @@ -553,7 +556,26 @@ pub fn init( ) { agent::ThreadStore::init_global(cx); prompt_store::init(cx); - skill_creator::init(cx); + + cx.set_global(agent_skills::SkillsUpdatedHook(std::rc::Rc::new(|cx| { + let workspaces: Vec<_> = workspace::AppState::global(cx) + .workspace_store + .read(cx) + .workspaces() + .cloned() + .collect(); + + for workspace in workspaces { + workspace + .update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| panel.refresh_skills(cx)); + } + }) + .ok(); + } + }))); + if !is_eval { // Initializing the language model from the user settings messes with the eval, so we only initialize them when // we're not running inside of the eval. @@ -760,7 +782,7 @@ fn update_command_palette_filter(cx: &mut App) { TypeId::of::(), ]; - let open_rules_library_action = [TypeId::of::()]; + let manage_skills_action = [TypeId::of::()]; let skill_creator_actions = [ TypeId::of::(), TypeId::of::(), @@ -815,16 +837,15 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("multi_workspace"); } - // Hide `assistant: open rules library` — Rules are surfaced - // through the Skills UI now. Applied after the disable-ai / - // agent-enabled branches so it overrides the - // `show_namespace("assistant")` call above without affecting the - // rest of that namespace's actions. + // Hide `agent: manage skills` — skills are surfaced through the + // settings UI now. Applied after the disable-ai / agent-enabled + // branches so it overrides the `show_namespace("assistant")` call + // above without affecting the rest of that namespace's actions. if !disable_ai { - filter.hide_action_types(&open_rules_library_action); + filter.hide_action_types(&manage_skills_action); filter.show_action_types(skill_creator_actions.iter()); } else { - filter.show_action_types(open_rules_library_action.iter()); + filter.show_action_types(manage_skills_action.iter()); filter.hide_action_types(&skill_creator_actions); } }); @@ -936,6 +957,10 @@ mod tests { play_sound_when_agent_done: PlaySoundWhenAgentDone::Never, single_file_review: false, model_parameters: vec![], + auto_compact: agent_settings::AutoCompactSettings { + enabled: false, + threshold: agent_settings::AutoCompactThreshold::DEFAULT, + }, enable_feedback: false, expand_edit_card: true, expand_terminal_card: true, diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index acc08541100f64..58b4bb9bbe9123 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -12,7 +12,9 @@ use anyhow::Result; use editor::{CompletionProvider, Editor, code_context_menus::COMPLETION_MENU_MAX_WIDTH}; use futures::FutureExt as _; use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; -use gpui::{App, BackgroundExecutor, Entity, Focusable, SharedString, Task, WeakEntity, Window}; +use gpui::{ + App, BackgroundExecutor, Entity, Focusable, Hsla, SharedString, Task, WeakEntity, Window, +}; use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; use multi_buffer::ToOffset as _; @@ -299,6 +301,31 @@ pub struct AvailableSkill { /// worktree root name for project-local skills. pub source: SharedString, pub skill_file_path: PathBuf, + pub warning: Option, +} + +fn skill_completion_icon_path( + skill: &AvailableSkill, + uri: &MentionUri, + cx: &mut App, +) -> SharedString { + if skill.warning.is_some() { + IconName::Warning.path().into() + } else { + uri.icon_path(cx) + } +} + +fn skill_completion_icon_color(skill: &AvailableSkill, cx: &App) -> Option { + skill.warning.is_some().then(|| cx.theme().status().warning) +} + +fn skill_completion_documentation(skill: &AvailableSkill) -> CompletionDocumentation { + let text = match &skill.warning { + Some(warning) => warning.clone(), + None => skill.description.to_string().into(), + }; + CompletionDocumentation::MultiLinePlainText(text) } #[derive(Debug, Clone)] @@ -307,6 +334,32 @@ pub struct AvailableCommand { pub description: Arc, pub requires_argument: bool, pub source: Option, + /// Source category used to group the command in the slash popup. `None` + /// means the command came from an external ACP agent. + pub category: Option, +} + +impl AvailableCommand { + fn category_order(&self) -> u8 { + match self.category { + Some(acp_thread::CommandCategory::Native) => 0, + Some(acp_thread::CommandCategory::Mcp) => 1, + None => 2, + } + } + + /// Completion group key and header label for this command's category. + fn group(&self) -> CompletionGroup { + let (key, label) = match self.category { + Some(acp_thread::CommandCategory::Native) => ("commands", "Commands"), + Some(acp_thread::CommandCategory::Mcp) => ("mcp-commands", "MCP Server Commands"), + None => ("acp-commands", "Commands"), + }; + CompletionGroup { + key: key.into(), + label: Some(label.into()), + } + } } #[derive(Debug, Clone)] @@ -324,6 +377,29 @@ impl SlashCompletionCandidate { } } +/// Stable group identity for a slash completion: skills are one group, commands +/// are grouped by category. This identifies which section header an entry sits +/// under; the order the groups appear in is decided by relevance (see +/// [`group_by_relevance`]). +fn slash_completion_group_key(candidate: &SlashCompletionCandidate) -> u32 { + match candidate { + SlashCompletionCandidate::Skill(_) => 0, + SlashCompletionCandidate::Command(command) => 1 + command.category_order() as u32, + } +} + +/// Reorders `items` (which must already be in relevance/score order, best +/// first) so that each group's entries stay contiguous while the groups +/// themselves are ordered by their best-ranked member. The sort is stable, so +/// within a group the original order is preserved. +fn group_by_relevance(items: &mut [T], group_key: impl Fn(&T) -> u32) { + let mut group_best_rank: collections::HashMap = collections::HashMap::default(); + for (rank, item) in items.iter().enumerate() { + group_best_rank.entry(group_key(item)).or_insert(rank); + } + items.sort_by_key(|item| group_best_rank[&group_key(item)]); +} + pub trait PromptCompletionProviderDelegate: Send + Sync + 'static { fn supports_context(&self, mode: PromptContextType, cx: &App) -> bool { self.supported_modes(cx).contains(&mode) @@ -380,6 +456,7 @@ impl PromptCompletionProvider { new_text: format!("@{} ", mode.keyword()), label: CodeLabel::plain(mode.label().to_string(), None), icon_path: Some(mode.icon().path().into()), + icon_color: None, documentation: None, source: project::CompletionSource::Custom, match_start: None, @@ -437,6 +514,7 @@ impl PromptCompletionProvider { match_start: None, snippet_deduplication_key: None, icon_path: Some(icon_for_completion), + icon_color: None, confirm: Some(confirm_completion_callback( title, source_range.start, @@ -467,7 +545,7 @@ impl PromptCompletionProvider { }; let new_text = format!("{} ", uri.as_link()); let new_text_len = new_text.len(); - let icon_path = uri.icon_path(cx); + let icon_path = skill_completion_icon_path(&skill, &uri, cx); let crease_text: SharedString = uri.name().into(); let source_highlight_id = cx .theme() @@ -479,14 +557,13 @@ impl PromptCompletionProvider { replace_range: source_range.clone(), new_text, label, - documentation: Some(CompletionDocumentation::MultiLinePlainText( - skill.description.into(), - )), + documentation: Some(skill_completion_documentation(&skill)), insert_text_mode: None, source: project::CompletionSource::Custom, match_start: None, snippet_deduplication_key: None, icon_path: Some(icon_path), + icon_color: skill_completion_icon_color(&skill, cx), confirm: Some(confirm_completion_callback( crease_text, source_range.start, @@ -551,6 +628,7 @@ impl PromptCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(completion_icon_path), + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, @@ -617,6 +695,7 @@ impl PromptCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, @@ -658,6 +737,7 @@ impl PromptCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, @@ -708,6 +788,7 @@ impl PromptCompletionProvider { new_text, label: CodeLabel::plain(action.label().to_string(), None), icon_path: Some(action.icon().path().into()), + icon_color: None, documentation: None, source: project::CompletionSource::Custom, match_start: None, @@ -802,6 +883,7 @@ impl PromptCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, @@ -844,6 +926,7 @@ impl PromptCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: Some(icon_path), + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, @@ -1293,11 +1376,13 @@ impl CompletionProvider for PromptCompletio PromptCompletion::SlashCommand(SlashCommandCompletion { command, argument, .. }) => { - let show_section_headers = command.is_none() && argument.is_none(); let search_task = self.search_slash_commands(command.unwrap_or_default(), cx); - // Resolve the muted-text highlight up front: the - // completion build happens on a background thread where - // `cx.theme()` isn't available. + // Keep the category section headers visible while the user is + // still narrowing the command name (`/c`); only drop them once + // they've moved on to typing the command's argument, where + // grouping no longer applies. + let show_section_headers = argument.is_none(); + let source_highlight_id = cx .theme() .syntax() @@ -1307,6 +1392,7 @@ impl CompletionProvider for PromptCompletio type SkillInfo = ( String, SharedString, + Option, Arc bool + Send + Sync>, ); let slash_candidates: Task)>> = { @@ -1325,7 +1411,8 @@ impl CompletionProvider for PromptCompletio }; let new_text = format!("{} ", uri.as_link()); let new_text_len = new_text.len(); - let icon_path = uri.icon_path(cx); + let icon_path = skill_completion_icon_path(skill, &uri, cx); + let icon_color = skill_completion_icon_color(skill, cx); let crease_text: SharedString = uri.name().into(); let confirm = confirm_completion_callback( crease_text, @@ -1337,7 +1424,10 @@ impl CompletionProvider for PromptCompletio mention_set.clone(), workspace.clone(), ); - (candidate, Some((new_text, icon_path, confirm))) + ( + candidate, + Some((new_text, icon_path, icon_color, confirm)), + ) } SlashCompletionCandidate::Command(_) => (candidate, None), }) @@ -1348,9 +1438,16 @@ impl CompletionProvider for PromptCompletio cx.background_spawn(async move { let mut slash_candidates = slash_candidates.await; - slash_candidates.sort_by_key(|(candidate, _)| match candidate { - SlashCompletionCandidate::Skill(_) => 0, - SlashCompletionCandidate::Command(_) => 1, + // `slash_candidates` arrives in fuzzy-match order (best + // first). Keep each group's items contiguous so section + // headers render once, but order the groups by their + // best-scoring member. That way an exact/prefix match (e.g. + // `/compa` -> `compact`) floats its whole section to the top + // and becomes the default selection, instead of being + // buried under a less relevant skill. Within a group, the + // fuzzy-match order is preserved (the sort is stable). + group_by_relevance(&mut slash_candidates, |(candidate, _)| { + slash_completion_group_key(candidate) }); let completions = slash_candidates .into_iter() @@ -1361,20 +1458,18 @@ impl CompletionProvider for PromptCompletio Some(&skill.source), source_highlight_id, ); - let Some((new_text, icon_path, confirm)) = skill_info else { + let Some((new_text, icon_path, icon_color, confirm)) = skill_info + else { unreachable!("skill candidates always have confirm callbacks") }; Completion { replace_range: source_range.clone(), new_text, label, - documentation: Some( - CompletionDocumentation::MultiLinePlainText( - skill.description.into(), - ), - ), + documentation: Some(skill_completion_documentation(&skill)), source: project::CompletionSource::Custom, icon_path: Some(icon_path), + icon_color, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, @@ -1403,6 +1498,12 @@ impl CompletionProvider for PromptCompletio let is_missing_argument = command.requires_argument && argument.is_none(); + let group = show_section_headers.then(|| command.group()); + + let icon_path = (command.category + == Some(acp_thread::CommandCategory::Native) + && command.name.as_ref() == agent::COMPACT_COMMAND_NAME) + .then(|| IconName::Compact.path().into()); Completion { replace_range: source_range.clone(), @@ -1414,7 +1515,8 @@ impl CompletionProvider for PromptCompletio ), ), source: project::CompletionSource::Custom, - icon_path: None, + icon_path, + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, @@ -1437,10 +1539,7 @@ impl CompletionProvider for PromptCompletio false } })), - group: show_section_headers.then(|| CompletionGroup { - key: "agent-commands".into(), - label: Some("Agent Commands".into()), - }), + group, } } }) @@ -2364,9 +2463,7 @@ fn build_slash_item_label( }; let mut builder = CodeLabelBuilder::default(); builder.push_str(name, None); - // Two spaces gives a touch of breathing room between the name and - // the muted source label. - builder.push_str(" ", None); + builder.push_str(" ", None); builder.push_str(source, source_highlight_id); // The filter range defaults to the entire label after `build()`, // which would let the source text participate in fuzzy filtering. @@ -2762,6 +2859,51 @@ mod tests { assert_eq!(SlashCommandCompletion::try_parse("/ ", 0), None); } + #[test] + fn test_section_headers_visible_until_argument() { + // Section headers stay visible while the user narrows the command name + // (`/`, `/comp`, `/compact `) and only disappear once they start typing + // the command's argument, where category grouping no longer applies. + let show_section_headers = |input: &str| { + SlashCommandCompletion::try_parse(input, 0) + .unwrap() + .argument + .is_none() + }; + + assert!(show_section_headers("/")); + assert!(show_section_headers("/comp")); + assert!(show_section_headers("/compact")); + assert!(show_section_headers("/compact ")); + assert!(!show_section_headers("/compact now")); + } + + #[test] + fn test_group_by_relevance_floats_best_group_and_keeps_groups_contiguous() { + // Items arrive in fuzzy-score order (best first). The group containing + // the best match floats to the top, groups stay contiguous, and the + // within-group order is preserved. + let mut items = [ + ("compact", 1u32), // best match, group 1 + ("skill-a", 0u32), // group 0 + ("deploy", 2u32), // group 2 + ("skill-b", 0u32), // group 0 (after skill-a in score order) + ("native-b", 1u32), // group 1 (after compact) + ]; + group_by_relevance(&mut items, |(_, key)| *key); + let order: Vec<&str> = items.iter().map(|(name, _)| *name).collect(); + assert_eq!( + order, + vec!["compact", "native-b", "skill-a", "skill-b", "deploy"] + ); + + // When the best match is a skill, the skill group leads instead. + let mut items = [("skill-a", 0u32), ("compact", 1u32)]; + group_by_relevance(&mut items, |(_, key)| *key); + let order: Vec<&str> = items.iter().map(|(name, _)| *name).collect(); + assert_eq!(order, vec!["skill-a", "compact"]); + } + #[test] fn test_mention_completion_parse() { let supported_modes = vec![PromptContextType::File, PromptContextType::Symbol]; diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index e140ec1741cc24..80f10d1e7b3eb6 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -26,7 +26,7 @@ use editor::scroll::Autoscroll; use editor::{ Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects, SizingBehavior, }; -use feature_flags::{AgentSharingFeatureFlag, FeatureFlagAppExt as _, HandoffFeatureFlag}; +use feature_flags::{AgentSharingFeatureFlag, FeatureFlagAppExt as _}; use file_icons::FileIcons; use fs::Fs; use futures::FutureExt as _; @@ -35,7 +35,7 @@ use gpui::{ ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit, PlatformDisplay, ScrollHandle, SharedString, StyledText, Subscription, Task, TaskExt, TextRun, TextStyle, WeakEntity, Window, WindowHandle, div, ease_in_out, img, - linear_color_stop, linear_gradient, list, point, pulsating_between, + linear_color_stop, linear_gradient, list, pulsating_between, }; use language::{Buffer, Language, Rope}; use language_model::{LanguageModelCompletionError, LanguageModelRegistry}; @@ -125,6 +125,7 @@ enum ThreadFeedback { #[derive(Debug)] pub(crate) enum ThreadError { PaymentRequired, + DataRetentionConsentRequired, Refusal, AuthenticationRequired(SharedString), RateLimitExceeded { @@ -164,8 +165,6 @@ impl From for ThreadError { Self::MaxOutputTokens } else if error.is::() { Self::NoModelSelected - } else if error.is::() { - Self::PaymentRequired } else if let Some(acp_error) = error.downcast_ref::() && acp_error.code == acp::ErrorCode::AuthRequired { @@ -182,6 +181,7 @@ impl From for ThreadError { } } PromptTooLarge { .. } => Self::PromptTooLarge, + PaymentRequired => Self::PaymentRequired, NoApiKey { provider } => Self::NoApiKey { provider: provider.to_string().into(), }, @@ -198,6 +198,7 @@ impl From for ThreadError { provider: provider.to_string().into(), }, UpstreamProviderError { .. } => Self::RequestFailed, + DataRetentionConsentRequired { .. } => Self::DataRetentionConsentRequired, BadRequestFormat { provider, .. } | HttpResponseError { provider, .. } | ApiEndpointNotFound { provider } => Self::ApiError { @@ -463,9 +464,9 @@ fn permission_option_for_action( ) -> Option<&acp::PermissionOption> { if kind == acp::PermissionOptionKind::AllowAlways && let PermissionOptions::Flat(options) = options - && let Some(option) = options - .iter() - .find(|option| option.option_id.0.as_ref() == "allow_always") + && let Some(option) = options.iter().find(|option| { + option.option_id.0.as_ref() == acp_thread::SandboxPermission::AllowAlways.as_id() + }) { return Some(option); } @@ -473,6 +474,10 @@ fn permission_option_for_action( options.first_option_of_kind(kind) } +pub struct StateChange; + +impl EventEmitter for ConversationView {} + fn resolve_outcome_from_selection( options: &PermissionOptions, selection: Option<&thread_view::PermissionSelection>, @@ -1116,6 +1121,7 @@ impl ConversationView { } self.server_state = state; + cx.emit(StateChange); cx.emit(AcpServerViewEvent::ActiveThreadChanged); if matches!(&self.server_state, ServerState::Connected(_)) { cx.emit(RootThreadUpdated); @@ -1728,6 +1734,7 @@ impl ConversationView { }; if let Some(connected) = this.as_connected_mut() { connected.auth_state = auth_state; + cx.emit(StateChange); if let Some(view) = connected.active_view() && view .read(cx) @@ -1912,6 +1919,7 @@ impl ConversationView { }); active.update(cx, |active, cx| { active.sync_editor_mode_for_empty_state(cx); + active.sync_generating_indicator(cx); }); } @@ -1928,6 +1936,7 @@ impl ConversationView { list_state.remeasure_items(*index..*index + 1); active.update(cx, |active, cx| { active.auto_expand_streaming_thought(cx); + active.sync_generating_indicator(cx); }); } @@ -2250,6 +2259,7 @@ impl ConversationView { pending_auth_method.replace(method.clone()); let project = self.project.clone(); + cx.emit(StateChange); cx.notify(); self.auth_task = Some(cx.spawn_in(window, { async move |this, cx| { @@ -2295,6 +2305,7 @@ impl ConversationView { }) = this.as_connected_mut() { pending_auth_method.take(); + cx.emit(StateChange); } if let Some(active) = this.root_thread_view() { active.update(cx, |active, cx| { @@ -2316,6 +2327,7 @@ impl ConversationView { pending_auth_method.replace(method.clone()); let authenticate = connection.authenticate(method, cx); + cx.emit(StateChange); cx.notify(); self.auth_task = Some(cx.spawn_in(window, { async move |this, cx| { @@ -2343,6 +2355,7 @@ impl ConversationView { }) = this.as_connected_mut() { pending_auth_method.take(); + cx.emit(StateChange); } if let Some(active) = this.root_thread_view() { active.update(cx, |active, cx| active.handle_thread_error(err, cx)); @@ -3426,6 +3439,7 @@ impl ConversationView { pending_auth_method: None, _subscription: None, }; + cx.emit(StateChange); if let Some(view) = connected.active_view() && view .read(cx) @@ -3466,6 +3480,7 @@ fn native_available_skills( description: skill.description.into(), source: skill.source, skill_file_path: skill.skill_file_path, + warning: skill.warning, }) .collect() } @@ -3816,7 +3831,7 @@ pub(crate) mod tests { use editor::MultiBufferOffset; use editor::actions::Paste; use fs::FakeFs; - use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, size}; + use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext, point, size}; use parking_lot::Mutex; use project::Project; use serde_json::json; @@ -3834,6 +3849,21 @@ pub(crate) mod tests { use super::*; + #[test] + fn test_data_retention_error_maps_from_provider_error() { + // The agent wraps the provider error in a fresh `anyhow::Error`, so + // the mapping must downcast to `LanguageModelCompletionError` rather + // than matching on the anyhow error directly. + let provider_error = LanguageModelCompletionError::DataRetentionConsentRequired { + model_name: "Claude Fable 5".to_string(), + }; + let error = ThreadError::from(anyhow!(provider_error)); + assert!( + matches!(error, ThreadError::DataRetentionConsentRequired), + "expected ThreadError::DataRetentionConsentRequired, got: {error:?}" + ); + } + #[gpui::test] async fn test_drop(cx: &mut TestAppContext) { init_test(cx); @@ -6202,6 +6232,178 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_regenerate_keeps_pending_subagent_edits(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + "file.txt": "original content" + }), + ) + .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 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))); + + let connection = Rc::new(StubAgentConnection::new()); + let conversation_view = cx.update(|window, cx| { + cx.new(|cx| { + ConversationView::new( + Rc::new(StubAgentServer::new(connection.as_ref().clone())), + connection_store, + Agent::Custom { id: "Test".into() }, + None, + None, + None, + None, + None, + workspace.downgrade(), + project.clone(), + Some(thread_store.clone()), + AgentThreadSource::AgentPanel, + window, + cx, + ) + }) + }); + + cx.run_until_parked(); + + let thread = conversation_view + .read_with(cx, |view, cx| { + view.active_thread().map(|r| r.read(cx).thread.clone()) + }) + .unwrap(); + + // First turn: a subagent tool call. Subagent edits never appear as + // diffs in the parent thread's entries; they are only forwarded to the + // parent's action log through the linked-log mechanism. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("spawn1", "Subagent task") + .kind(acp::ToolKind::Other) + .status(acp::ToolCallStatus::Completed) + .meta(acp_thread::meta_with_tool_name("spawn_agent")), + )]); + + thread + .update(cx, |thread, cx| thread.send_raw("Use a subagent", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + // Simulate the subagent editing a file: edits performed through a + // child action log are forwarded to the parent thread's action log, + // just like `Thread::new_subagent` wires it up. + let parent_action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + let subagent_action_log = cx.update(|_, cx| { + cx.new(|_| { + ActionLog::new(project.clone()).with_linked_action_log(parent_action_log.clone()) + }) + }); + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path("file.txt", cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + cx.update(|_, cx| { + subagent_action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer.set_text("edited by subagent", cx); + }); + subagent_action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + cx.run_until_parked(); + + parent_action_log.read_with(cx, |log, cx| { + assert_eq!( + log.changed_buffers(cx).count(), + 1, + "the subagent edit should be pending review in the parent's action log" + ); + }); + + // Second turn: a plain follow-up. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Response".into()), + )]); + thread + .update(cx, |thread, cx| thread.send_raw("Follow-up", cx)) + .await + .unwrap(); + cx.run_until_parked(); + + let follow_up_ix = thread.read_with(cx, |thread, cx| { + thread + .entries() + .iter() + .position(|entry| entry.to_markdown(cx) == "## User\n\nFollow-up\n\n") + .unwrap() + }); + + // Edit and regenerate the follow-up message. + let user_message_editor = conversation_view.read_with(cx, |view, cx| { + view.active_thread() + .unwrap() + .read(cx) + .entry_view_state + .read(cx) + .entry(follow_up_ix) + .unwrap() + .message_editor() + .unwrap() + .clone() + }); + user_message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Edited follow-up", window, cx); + }); + + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("New response".into()), + )]); + active_thread(&conversation_view, cx).update_in(cx, |view, window, cx| { + view.regenerate(follow_up_ix, user_message_editor.clone(), window, cx); + }); + cx.run_until_parked(); + + // The thread should have been rewound and the edited message resent. + thread.read_with(cx, |thread, cx| { + let entries = thread.entries(); + assert_eq!(entries.len(), 4); + assert_eq!( + entries[2].to_markdown(cx), + "## User\n\nEdited follow-up\n\n" + ); + }); + + // The subagent's edits predate the regenerated prompt, so they must be + // auto-kept rather than rejected by the rewind. + buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.text(), + "edited by subagent", + "pending subagent edits should be kept when regenerating a later prompt" + ); + }); + parent_action_log.read_with(cx, |log, cx| { + assert_eq!( + log.changed_buffers(cx).count(), + 0, + "the subagent edit should have been auto-kept" + ); + }); + } + #[gpui::test] async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) { init_test(cx); @@ -8678,6 +8880,71 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_permission_row_does_not_flicker_when_activity_bar_squeezes_list( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (_view, thread_view, _entry_ix, cx) = + setup_pending_permission_thread("perm-flicker", cx).await; + + // Give the pending tool call tall content (like a full plan awaiting + // approval), so the floating row embedding it dwarfs the panel. + let thread = thread_view.read_with(cx, |view, _cx| view.thread.clone()); + thread.update(cx, |thread, cx| { + thread + .handle_session_update( + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + acp::ToolCallId::new("perm-flicker"), + acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::Text(acp::TextContent::new( + "Plan step\n\n".repeat(100), + )), + )), + ]), + )), + cx, + ) + .expect("tool call content update should be accepted"); + }); + cx.run_until_parked(); + + // Park the inline prompt below the viewport so the floating row renders. + thread_view.read_with(cx, |view, _cx| { + view.list_state.scroll_to(ListOffset { + item_ix: 0, + offset_in_item: px(0.0), + }); + }); + + // Drive several real window draws. Each draw lays out the activity bar + // (containing the floating row) and the conversation list together, so + // the row's height feeds back into the list viewport height that the + // next frame's visibility decision is based on. Since showing the row + // squeezes the list to zero height, a decision that treats a + // zero-height viewport as "unknown" makes the row's visibility + // oscillate from frame to frame, flickering between the conversation + // and the permission prompt. + let mut row_visibility = Vec::new(); + for _ in 0..4 { + thread_view.update(cx, |_, cx| cx.notify()); + cx.run_until_parked(); + thread_view.update_in(cx, |view, window, cx| { + row_visibility.push( + view.render_main_agent_awaiting_permission(window, cx) + .is_some(), + ); + }); + } + assert_eq!( + row_visibility, + vec![true; 4], + "Floating row visibility must be stable across frames (false entries mean flicker)" + ); + } + #[gpui::test] async fn test_permission_row_shown_when_inline_prompt_is_above_viewport( cx: &mut TestAppContext, @@ -8946,6 +9213,65 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_move_up_in_empty_editor_restores_last_queued_message(cx: &mut TestAppContext) { + init_test(cx); + + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; + add_to_workspace(conversation_view.clone(), cx); + + active_thread(&conversation_view, cx).update(cx, |thread, cx| { + thread.add_to_queue( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "first queued".to_string(), + ))], + vec![], + cx, + ); + thread.add_to_queue( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "second queued".to_string(), + ))], + vec![], + cx, + ); + }); + cx.run_until_parked(); + + let editor = message_editor(&conversation_view, cx); + cx.focus(&editor); + + editor.update_in(cx, |_editor, window, cx| { + window.dispatch_action(Box::new(zed_actions::editor::MoveUp), cx); + }); + cx.run_until_parked(); + + let queue_len = active_thread(&conversation_view, cx) + .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + assert_eq!( + queue_len, 1, + "Up arrow should pull the last queued message out of the queue" + ); + let text = editor.update(cx, |editor, cx| editor.text(cx)); + assert_eq!( + text, "second queued", + "Main editor should contain the last queued message" + ); + + // With a non-empty editor, another MoveUp must not consume the queue. + editor.update_in(cx, |_editor, window, cx| { + window.dispatch_action(Box::new(zed_actions::editor::MoveUp), cx); + }); + cx.run_until_parked(); + + let queue_len = active_thread(&conversation_view, cx) + .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + assert_eq!(queue_len, 1, "Queue should be untouched"); + let text = editor.update(cx, |editor, cx| editor.text(cx)); + assert_eq!(text, "second queued"); + } + #[gpui::test] async fn test_paste_text_into_queued_message_promotes_to_main_editor(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index b734e25dee30af..b0549b7df7199a 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -8,30 +8,36 @@ use agent_client_protocol::schema as acp; use std::cell::RefCell; use acp_thread::{ContentBlock, PlanEntry, SandboxAuthorizationDetails}; -use agent::{SkillLoadingError, SkillLoadingErrorsUpdated}; +use agent::{SkillLoadingIssue, SkillLoadingIssueKind, SkillLoadingIssuesUpdated}; use agent_settings::UserAgentsMd; +use agent_skills::MAX_SKILL_DESCRIPTION_LEN; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; -use feature_flags::AcpBetaFeatureFlag; use crate::completion_provider::AvailableSkill; use crate::message_editor::SharedSessionCapabilities; use db::kvp::KeyValueStore; use gpui::List; +use gpui::Stateful; use gpui::TaskExt; use heapless::Vec as ArrayVec; use language_model::{ - FastModeConfirmation, LanguageModelEffortLevel, LanguageModelId, LanguageModelProviderId, - LanguageModelRegistry, Speed, + FastModeConfirmation, LanguageModel, LanguageModelEffortLevel, LanguageModelId, + LanguageModelProviderId, LanguageModelRegistry, Speed, +}; +use settings::{update_settings_file, update_settings_file_with_completion}; +use ui::{ + ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, + Tab, }; -use settings::update_settings_file; -use ui::{ButtonLike, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, Tab}; -use workspace::SERIALIZATION_THROTTLE_TIME; use workspace::notifications::NotificationId; +use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME}; use super::*; +const DATA_RETENTION_LEARN_MORE_URL: &str = "https://support.claude.com/en/articles/15425996-data-retention-practices-for-mythos-class-models"; + #[derive(Default)] struct ThreadFeedbackState { feedback: Option, @@ -575,6 +581,9 @@ pub struct ThreadView { pub expanded_thinking_blocks: HashSet<(usize, usize)>, auto_expanded_thinking_block: Option<(usize, usize)>, user_toggled_thinking_blocks: HashSet<(usize, usize)>, + /// Tracks which context compaction entries (by entry index) have their + /// summary expanded. + expanded_compactions: HashSet, pub subagent_scroll_handles: RefCell>, pub edits_expanded: bool, pub plan_expanded: bool, @@ -614,13 +623,13 @@ pub struct ThreadView { pub show_codex_windows_warning: bool, pub multi_root_callout_dismissed: bool, pub generating_indicator_in_list: bool, - pub skill_loading_errors: Vec, - /// Errors the user has explicitly dismissed. Each entry is matched against - /// emitted errors by full equality; when an error no longer appears in the - /// emitted list (i.e. the underlying file was fixed or removed), it's + pub skill_loading_issues: Vec, + /// Issues the user has explicitly dismissed. Each entry is matched against + /// emitted issues by full equality; when an issue no longer appears in the + /// latest replacement list (because the underlying file was fixed/removed), it's /// dropped from this set so a future regression of the same kind would /// re-show. - dismissed_skill_loading_errors: HashSet, + dismissed_skill_loading_issues: HashSet, } impl Focusable for ThreadView { fn focus_handle(&self, cx: &App) -> FocusHandle { @@ -646,11 +655,15 @@ pub struct TurnFields { /// /// `Standalone` draws its own border/margin/location header. `Embedded` is /// hosted by a container that provides its own framing (e.g. the subagent -/// card or the main-agent awaiting-permission row). +/// card). `Floating` is like `Embedded`, but used for the floating +/// awaiting-permission row above the message editor: the tool call's content +/// is height-capped and scrollable so the row can never grow to consume the +/// entire panel and squeeze the conversation list out of view. #[derive(Copy, Clone, PartialEq, Eq)] enum ToolCallLayout { Standalone, Embedded, + Floating, } fn full_path_for_empty_project_path(file: &dyn language::File, cx: &App) -> Option { @@ -662,6 +675,69 @@ fn full_path_for_empty_project_path(file: &dyn language::File, cx: &App) -> Opti (!full_path.is_empty()).then_some(full_path) } +fn skill_issue_file_label(path: &std::path::Path) -> String { + let file_name = path.file_name().and_then(|name| name.to_str()); + let parent_name = path + .parent() + .and_then(|parent| parent.file_name()) + .and_then(|name| name.to_str()); + + match (parent_name, file_name) { + (Some(parent_name), Some(file_name)) => format!("{parent_name}/{file_name}"), + (_, Some(file_name)) => file_name.to_string(), + _ => path.display().to_string(), + } +} + +pub fn open_markdown_in_workspace( + title: String, + markdown: String, + workspace: Entity, + window: &mut Window, + cx: &mut App, +) -> Task> { + let markdown_language_task = workspace + .read(cx) + .app_state() + .languages + .language_for_name("Markdown"); + let project = workspace.read(cx).project().clone(); + + window.spawn(cx, async move |cx| { + let markdown_language = markdown_language_task.await?; + + let buffer = project + .update(cx, |project, cx| { + project.create_buffer(Some(markdown_language), false, cx) + }) + .await?; + + buffer.update(cx, |buffer, cx| { + buffer.set_text(markdown, cx); + buffer.set_capability(language::Capability::ReadWrite, cx); + }); + + workspace.update_in(cx, |workspace, window, cx| { + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.clone())); + + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); + editor.set_breadcrumb_header(title); + editor.disable_mouse_wheel_zoom(); + editor + })), + None, + true, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) +} + impl ThreadView { pub(crate) fn new( root_thread_id: ThreadId, @@ -796,9 +872,9 @@ impl ThreadView { )); // If this thread is backed by a NativeAgent, listen for skill loading - // errors so we can surface them as banners. The agent emits a single + // issues so we can surface them as banners. The agent emits a single // replacement-style event per project refresh, so we overwrite our - // local list rather than appending — this also clears stale errors + // local list rather than appending — this also clears stale issues // once a user resolves them. if let Some(native_connection) = thread .read(cx) @@ -809,21 +885,21 @@ impl ThreadView { let project_id = thread.read(cx).project().entity_id(); subscriptions.push(cx.subscribe( &native_connection.0, - move |this: &mut Self, _agent, event: &SkillLoadingErrorsUpdated, cx| { + move |this: &mut Self, _agent, event: &SkillLoadingIssuesUpdated, cx| { if event.project_id != project_id { return; } - // Drop dismissals for errors that no longer appear in the emitted + // Drop dismissals for issues that no longer appear in the emitted // list — the underlying file must have been fixed or removed, so a // future regression should re-show. - this.dismissed_skill_loading_errors - .retain(|dismissed| event.errors.contains(dismissed)); + this.dismissed_skill_loading_issues + .retain(|dismissed| event.issues.contains(dismissed)); - // Show only errors that haven't been dismissed. - this.skill_loading_errors = event - .errors + // Show only issues that haven't been dismissed. + this.skill_loading_issues = event + .issues .iter() - .filter(|e| !this.dismissed_skill_loading_errors.contains(e)) + .filter(|issue| !this.dismissed_skill_loading_issues.contains(issue)) .cloned() .collect(); cx.notify(); @@ -890,6 +966,7 @@ impl ThreadView { expanded_thinking_blocks: HashSet::default(), auto_expanded_thinking_block: None, user_toggled_thinking_blocks: HashSet::default(), + expanded_compactions: HashSet::default(), subagent_scroll_handles: RefCell::new(HashMap::default()), edits_expanded: false, plan_expanded: false, @@ -924,8 +1001,8 @@ impl ThreadView { show_codex_windows_warning, multi_root_callout_dismissed: false, generating_indicator_in_list: false, - skill_loading_errors: Vec::new(), - dismissed_skill_loading_errors: HashSet::default(), + skill_loading_issues: Vec::new(), + dismissed_skill_loading_issues: HashSet::default(), }; this.sync_generating_indicator(cx); @@ -1013,6 +1090,7 @@ impl ThreadView { MessageEditorEvent::LostFocus => {} MessageEditorEvent::SlashAutocompleteOpened => {} MessageEditorEvent::InputAttempted { .. } => {} + MessageEditorEvent::Edited => {} } } @@ -1161,6 +1239,7 @@ impl ThreadView { } ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SlashAutocompleteOpened) => { } + ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Edited) => {} ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {} ViewEvent::OpenDiffLocation { path, @@ -1383,10 +1462,82 @@ impl ThreadView { } } + // A built-in command (e.g. `/compact`): run the bare command without + // echoing it as a user message, and queue any trailing text the user + // typed so it isn't silently dropped. + let native_command = + leading_native_command(text, self.session_capabilities.read().available_commands()); + if let Some(command_name) = native_command { + cx.emit(AcpThreadViewEvent::Interacted); + self.send_command_queueing_remainder(message_editor, command_name, window, cx); + return; + } + cx.emit(AcpThreadViewEvent::Interacted); self.send_impl(message_editor, window, cx) } + /// Sends a bare `/command` turn and queues everything the user typed after + /// it as a follow-up message. The queued remainder auto-processes when the + /// command turn stops, so e.g. `/compact do X` compacts and then runs `do X` + /// rather than discarding it. + fn send_command_queueing_remainder( + &mut self, + message_editor: Entity, + command_name: String, + window: &mut Window, + cx: &mut Context, + ) { + // Resolve the editor contents before clearing it: the resolve task + // reads the editor lazily, so clearing first would wipe the contents. + let contents = self.resolve_message_contents(&message_editor, cx); + self.thread_error.take(); + self.thread_feedback.clear(); + self.editing_message.take(); + + cx.spawn_in(window, async move |this, cx| { + let (mut content, tracked_buffers) = contents.await?; + + cx.update(|window, cx| { + message_editor.update(cx, |message_editor, cx| { + message_editor.clear(window, cx); + }); + })?; + + // Strip the leading `/command` from the first text block; whatever + // remains (including any later mention blocks) becomes the queued + // follow-up message. + if let Some(acp::ContentBlock::Text(text_content)) = content.first_mut() { + text_content.text = strip_leading_command(&text_content.text, &command_name); + } + if matches!( + content.first(), + Some(acp::ContentBlock::Text(text)) if text.text.trim().is_empty() + ) { + content.remove(0); + } + + let command_block = + acp::ContentBlock::Text(acp::TextContent::new(format!("/{command_name}"))); + + this.update_in(cx, |this, window, cx| { + // Queue the remainder first, then start the command turn; the + // queue auto-processes when the command turn stops. + if !content.is_empty() { + this.add_to_queue(content, tracked_buffers, cx); + } + this.send_content( + Task::ready(Ok(Some((vec![command_block], Vec::new())))), + true, + window, + cx, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + pub fn send_impl( &mut self, message_editor: Entity, @@ -1423,12 +1574,13 @@ impl ThreadView { Ok(Some((contents, tracked_buffers))) }); - self.send_content(contents_task, window, cx); + self.send_content(contents_task, false, window, cx); } pub fn send_content( &mut self, contents_task: Task, Vec>)>>>, + is_native_command: bool, window: &mut Window, cx: &mut Context, ) { @@ -1523,7 +1675,11 @@ impl ThreadView { side = side ); - thread.send(contents, cx) + if is_native_command { + thread.send_command(contents, cx) + } else { + thread.send(contents, cx) + } })?; let _ = this.update(cx, |this, cx| { @@ -1645,6 +1801,13 @@ impl ThreadView { ); ("refusal", None, message.into()) } + ThreadError::DataRetentionConsentRequired => { + let message = format!( + "{} is not available with Zero Data Retention.", + self.current_model_name(cx) + ); + ("data_retention_consent_required", None, message.into()) + } ThreadError::AuthenticationRequired(message) => { ("authentication_required", None, message.clone()) } @@ -1787,12 +1950,19 @@ impl ThreadView { // // If editing the prompt that generated the edits, they are auto-rejected // through the `rewind` function in the `acp_thread`. + // + // Subagent edits never show up as diffs in the parent thread's entries (they + // are only forwarded to the parent's action log), so treat any earlier + // subagent tool call as potentially having edits. Keeping all edits is a + // no-op when the subagent didn't make any. let has_earlier_edits = thread.read_with(cx, |thread, _| { - thread - .entries() - .iter() - .take(entry_ix) - .any(|entry| entry.diffs().next().is_some()) + thread.entries().iter().take(entry_ix).any(|entry| { + entry.diffs().next().is_some() + || matches!( + entry, + AgentThreadEntry::ToolCall(tool_call) if tool_call.is_subagent() + ) + }) }); if has_earlier_edits { @@ -1907,6 +2077,21 @@ impl ThreadView { let content = queued.content; let tracked_buffers = queued.tracked_buffers; + // A queued message can itself be a built-in command (e.g. the user typed + // `/compact` while a turn was generating). Detect that so we run it as a + // command turn without echoing it as a user message, matching the + // non-queued path. + let is_native_command = content + .first() + .and_then(|block| match block { + acp::ContentBlock::Text(text) => Some(text.text.as_str()), + _ => None, + }) + .and_then(|text| { + leading_native_command(text, self.session_capabilities.read().available_commands()) + }) + .is_some(); + // Only increment skip count for "Send Now" operations (out-of-order sends) // Normal auto-processing from the Stopped handler doesn't need to skip. // We only skip the Stopped event from the cancelled generation, NOT the @@ -1935,7 +2120,7 @@ impl ThreadView { Ok(Some((content, tracked_buffers))) }); - self.send_content(contents_task, window, cx); + self.send_content(contents_task, is_native_command, window, cx); } pub fn move_queued_message_to_main_editor( @@ -1987,6 +2172,20 @@ impl ThreadView { true } + fn handle_message_editor_move_up( + &mut self, + _: &zed_actions::editor::MoveUp, + window: &mut Window, + cx: &mut Context, + ) { + if !self.message_editor.read(cx).is_empty(cx) || self.local_queued_messages.is_empty() { + cx.propagate(); + return; + } + let last_index = self.local_queued_messages.len() - 1; + self.move_queued_message_to_main_editor(last_index, None, None, window, cx); + } + // editor methods pub fn expand_message_editor( @@ -2544,11 +2743,36 @@ impl ThreadView { telemetry::event!("Follow Agent Selected", following = !following); } - // other + fn callout_border_position(&self) -> CalloutBorderPosition { + if self.list_state.item_count() > 0 { + CalloutBorderPosition::Top + } else { + CalloutBorderPosition::Bottom + } + } - pub fn render_thread_retry_status_callout(&self) -> Option { + pub fn render_thread_retry_status_callout(&self, cx: &mut Context) -> Option { let state = self.thread_retry_status.as_ref()?; + if let Some(fallback_model) = acp_thread::refusal_fallback_model_from_meta(&state.meta) { + return Some( + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title(state.last_error.clone()) + .description(format!("Retrying with {fallback_model}")) + .dismiss_action( + IconButton::new("dismiss-refusal-fallback", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(cx.listener(|this, _, _, cx| { + this.thread_retry_status = None; + cx.notify(); + })), + ), + ); + } + let next_attempt_in = state .duration .saturating_sub(Instant::now().saturating_duration_since(state.started_at)); @@ -2578,6 +2802,7 @@ impl ThreadView { Some( Callout::new() + .border_position(self.callout_border_position()) .icon(IconName::Warning) .severity(Severity::Warning) .title(state.last_error.clone()) @@ -2628,6 +2853,10 @@ impl ThreadView { let queue_expanded = self.queue_expanded; let max_content_width = AgentSettings::get_global(cx).max_content_width; + // Drop shadows have no opaque surface to blend into on a transparent + // window, so they render as a dark halo; only apply them when opaque. + let opaque_window = + cx.theme().window_background_appearance() == gpui::WindowBackgroundAppearance::Opaque; h_flex() .w_full() @@ -2645,13 +2874,12 @@ impl ThreadView { .border_b_0() .border_color(cx.theme().colors().border) .rounded_t_md() - .shadow(vec![gpui::BoxShadow { - color: gpui::black().opacity(0.12), - offset: point(px(1.), px(-1.)), - blur_radius: px(2.), - spread_radius: px(0.), - inset: false, - }]) + .when(opaque_window, |this| { + this.shadow(vec![ + gpui::BoxShadow::new(px(1.), px(-1.), gpui::black().opacity(0.12)) + .blur_radius(px(2.)), + ]) + }) .when_some(awaiting_permission, |this, element| this.child(element)) .when( has_awaiting_permission @@ -3090,7 +3318,7 @@ impl ThreadView { entry_ix, tool_call, &focus_handle, - ToolCallLayout::Embedded, + ToolCallLayout::Floating, window, cx, ); @@ -3445,6 +3673,115 @@ impl ThreadView { .into_any() } + fn render_context_compaction( + &self, + entry_ix: usize, + compaction: &acp_thread::ContextCompaction, + window: &Window, + cx: &Context, + ) -> AnyElement { + let is_compacting = compaction.is_in_progress(); + let summary = compaction.summary.clone(); + let is_expanded = self.expanded_compactions.contains(&entry_ix); + + let id = format!("context-compaction-{entry_ix}"); + let header_label = match compaction.status { + acp_thread::ContextCompactionStatus::InProgress => "Compacting Context…", + acp_thread::ContextCompactionStatus::Completed => "Context Compacted", + acp_thread::ContextCompactionStatus::Canceled => "Compaction Canceled", + }; + let chevron_end = if is_expanded { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + let header = h_flex() + .gap_1() + .w_full() + .child(Divider::horizontal()) + .child( + Button::new(id, header_label) + .label_size(LabelSize::Small) + .loading(is_compacting) + .disabled(is_compacting) + .start_icon( + Icon::new(IconName::Compact) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .when(!is_compacting, |this| { + this.end_icon( + Icon::new(chevron_end) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .on_click(cx.listener( + move |this, _event: &ClickEvent, _window, cx| { + this.toggle_compaction_expansion(entry_ix, cx); + }, + )) + }), + ) + .child(Divider::horizontal()); + + div() + .px_5() + .w_full() + .child( + v_flex() + .pt_1p5() + .mb_1p5() + .gap_1p5() + .border_1() + .border_color(gpui::transparent_black()) + .rounded_sm() + .child(header) + .when_some(summary.filter(|_| is_expanded), |this, summary| { + this.border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background.opacity(0.2)) + .child( + div() + .id(("compaction-summary", entry_ix)) + .p_2() + .text_ui(cx) + .child(self.render_markdown( + summary, + MarkdownStyle::themed(MarkdownFont::Agent, window, cx), + cx, + )), + ) + .child( + h_flex() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .child( + IconButton::new( + ("compaction-summary-collapse", entry_ix), + IconName::ChevronUp, + ) + .full_width() + .on_click( + cx.listener( + move |this, _event: &ClickEvent, _window, cx| { + this.expanded_compactions.remove(&entry_ix); + cx.notify(); + }, + ), + ), + ), + ) + }), + ) + .into_any() + } + + fn toggle_compaction_expansion(&mut self, entry_ix: usize, cx: &mut Context) { + if !self.expanded_compactions.remove(&entry_ix) { + self.expanded_compactions.insert(entry_ix); + } + cx.notify(); + } + fn render_edits_summary( &self, changed_buffers: &[(Entity, Entity)], @@ -3724,6 +4061,7 @@ impl ThreadView { .p_2() .bg(editor_bg_color) .justify_center() + .on_action(cx.listener(Self::handle_message_editor_move_up)) .map(|this| { if has_messages { this.on_action(cx.listener(Self::expand_message_editor)) @@ -4010,18 +4348,14 @@ impl ThreadView { let usage = thread.token_usage()?; let show_split = self.supports_split_token_display(cx); - let cost_label = if cx.has_flag::() { - thread.cost().map(|cost| { - let precision = if cost.amount > 0.0 && cost.amount < 0.01 { - 4 - } else { - 2 - }; - format!("{:.prec$} {}", cost.amount, cost.currency, prec = precision) - }) - } else { - None - }; + let cost_label = thread.cost().map(|cost| { + let precision = if cost.amount > 0.0 && cost.amount < 0.01 { + 4 + } else { + 2 + }; + format!("{:.prec$} {}", cost.amount, cost.currency, prec = precision) + }); let progress_color = |ratio: f32| -> Hsla { if ratio >= 0.85 { @@ -4350,6 +4684,24 @@ impl ThreadView { return None; } + // A toggle would be dishonest for models that always think: only + // offer the effort selector. + if !model.supports_disabling_thinking() { + let effort_levels = model.supported_effort_levels(); + if effort_levels.is_empty() { + return None; + } + return Some( + self.render_effort_selector( + effort_levels, + thread.thinking_effort().cloned(), + true, + cx, + ) + .into_any_element(), + ); + } + let thinking = thread.thinking_enabled(); let (tooltip_label, icon, color) = if thinking { @@ -4414,6 +4766,7 @@ impl ThreadView { let right_btn = self.render_effort_selector( model.supported_effort_levels(), thread.thinking_effort().cloned(), + false, cx, ); @@ -4428,6 +4781,7 @@ impl ThreadView { &self, supported_effort_levels: Vec, selected_effort: Option, + standalone: bool, cx: &Context, ) -> impl IntoElement { let weak_self = cx.weak_entity(); @@ -4493,12 +4847,27 @@ impl ThreadView { } }); - PopoverMenu::new("effort-selector") - .trigger_with_tooltip( - ButtonLike::new_rounded_right("effort-selector-trigger") - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + let trigger = if standalone { + ButtonLike::new("effort-selector-trigger").child( + h_flex() + .gap_1() + .child( + Icon::new(IconName::ThinkingMode) + .size(IconSize::Small) + .color(label_color), + ) .child(Label::new(label).size(LabelSize::Small).color(label_color)) .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)), + ) + } else { + ButtonLike::new_rounded_right("effort-selector-trigger") + .child(Label::new(label).size(LabelSize::Small).color(label_color)) + .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) + }; + + PopoverMenu::new("effort-selector") + .trigger_with_tooltip( + trigger.selected_style(ButtonStyle::Tinted(TintColor::Accent)), tooltip, ) .menu(move |window, cx| { @@ -5145,6 +5514,9 @@ impl ThreadView { let editing = self.editing_message == Some(entry_ix); let editor_focus = editor.focus_handle(cx).is_focused(window); let focus_border = cx.theme().colors().border_focused; + // Drop shadows render as a dark halo on transparent windows. + let opaque_window = cx.theme().window_background_appearance() + == gpui::WindowBackgroundAppearance::Opaque; let has_checkpoint_button = message .checkpoint @@ -5203,7 +5575,9 @@ impl ThreadView { .bg(cx.theme().colors().editor_background) .border_1() .when(is_indented, |this| { - this.py_2().px_2().shadow_sm() + this.py_2().px_2().when(opaque_window, |this| { + this.shadow_sm() + }) }) .border_color(cx.theme().colors().border) .map(|this| { @@ -5219,9 +5593,10 @@ impl ThreadView { if editing && !editor_focus { return this.border_dashed() } - this.shadow_md().hover(|s| { - s.border_color(focus_border.opacity(0.8)) - }) + this.when(opaque_window, |this| this.shadow_md()) + .hover(|s| { + s.border_color(focus_border.opacity(0.8)) + }) }) .text_xs() .child(editor.clone().into_any_element()) @@ -5379,6 +5754,28 @@ impl ThreadView { } } AgentThreadEntry::ToolCall(tool_call) => { + // A canceled tool call that produced visible output is still worth + // showing, but one that was canceled before producing anything just + // renders as a useless "Canceled" card — hide those entirely. + if matches!(tool_call.status, ToolCallStatus::Canceled) { + let has_visible_content = + tool_call.content.iter().any(|content| match content { + ToolCallContent::ContentBlock(block) => match block { + ContentBlock::Empty => false, + ContentBlock::Markdown { markdown } => { + !markdown.read(cx).source().trim().is_empty() + } + ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. } => { + true + } + }, + ToolCallContent::Diff(_) | ToolCallContent::Terminal(_) => true, + }); + if !has_visible_content { + return Empty.into_any(); + } + } + let tool_call = self.render_any_tool_call( self.thread.read(cx).session_id(), entry_ix, @@ -5403,19 +5800,9 @@ impl ThreadView { AgentThreadEntry::CompletedPlan(entries) => { self.render_completed_plan(entries, window, cx) } - AgentThreadEntry::ContextCompaction => h_flex() - .id(("context_compaction", entry_ix)) - .px_5() - .py_1() - .gap_2() - .child(Divider::horizontal()) - .child( - Label::new("Context Compacted") - .size(LabelSize::Custom(self.tool_name_font_size())) - .color(Color::Muted), - ) - .child(Divider::horizontal()) - .into_any(), + AgentThreadEntry::ContextCompaction(compaction) => { + self.render_context_compaction(entry_ix, compaction, window, cx) + } }; let is_subagent_output = self.is_subagent() @@ -5920,12 +6307,6 @@ impl ThreadView { window: &mut Window, cx: &mut App, ) -> Task> { - let markdown_language_task = workspace - .read(cx) - .app_state() - .languages - .language_for_name("Markdown"); - let thread = self.thread.read(cx); let thread_title = thread .title() @@ -5933,41 +6314,7 @@ impl ThreadView { .to_string(); let markdown = thread.to_markdown(cx); - let project = workspace.read(cx).project().clone(); - window.spawn(cx, async move |cx| { - let markdown_language = markdown_language_task.await?; - - let buffer = project - .update(cx, |project, cx| { - project.create_buffer(Some(markdown_language), false, cx) - }) - .await?; - - buffer.update(cx, |buffer, cx| { - buffer.set_text(markdown, cx); - buffer.set_capability(language::Capability::ReadWrite, cx); - }); - - workspace.update_in(cx, |workspace, window, cx| { - let buffer = cx - .new(|cx| MultiBuffer::singleton(buffer, cx).with_title(thread_title.clone())); - - workspace.add_item_to_active_pane( - Box::new(cx.new(|cx| { - let mut editor = - Editor::for_multibuffer(buffer, Some(project.clone()), window, cx); - editor.set_breadcrumb_header(thread_title); - editor.disable_mouse_wheel_zoom(); - editor - })), - None, - true, - window, - cx, - ); - })?; - anyhow::Ok(()) - }) + open_markdown_in_workspace(thread_title, markdown, workspace, window, cx) } pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) { @@ -5993,7 +6340,10 @@ impl ThreadView { /// Ensures the list item count includes (or excludes) an extra item for the generating indicator pub(crate) fn sync_generating_indicator(&mut self, cx: &App) { - let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating); + let thread = self.thread.read(cx); + + let is_generating = + matches!(thread.status(), ThreadStatus::Generating) && !thread.is_compacting(); if is_generating && !self.generating_indicator_in_list { let entries_count = self.thread.read(cx).entries().len(); @@ -6526,7 +6876,7 @@ impl ThreadView { AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) | AgentThreadEntry::CompletedPlan(_) - | AgentThreadEntry::ContextCompaction => {} + | AgentThreadEntry::ContextCompaction(_) => {} } } @@ -7013,14 +7363,11 @@ impl ThreadView { let tool_output_display = if is_open { match &tool_call.status { - ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex() - .w_full() - .children( - tool_call - .content - .iter() - .enumerate() - .map(|(content_ix, content)| { + ToolCallStatus::WaitingForConfirmation { options, .. } => { + let confirmation_content = v_flex() + .w_full() + .children(tool_call.content.iter().enumerate().map( + |(content_ix, content)| { div() .child(self.render_tool_call_content( active_session_id, @@ -7035,94 +7382,119 @@ impl ThreadView { cx, )) .into_any_element() - }), - ) - .when_some( - tool_call.sandbox_authorization_details.as_ref(), - |this, details| { - this.child(self.render_sandbox_authorization_details( - entry_ix, - &tool_call.id, - details, - cx, - )) - }, - ) - .when(should_show_raw_input, |this| { - let is_raw_input_expanded = - self.expanded_tool_call_raw_inputs.contains(&tool_call.id); + }, + )) + .when_some( + tool_call.sandbox_authorization_details.as_ref(), + |this, details| { + this.child(self.render_sandbox_authorization_details( + entry_ix, + &tool_call.id, + details, + cx, + )) + }, + ) + .when(should_show_raw_input, |this| { + let is_raw_input_expanded = + self.expanded_tool_call_raw_inputs.contains(&tool_call.id); - let input_header = if is_raw_input_expanded { - "Raw Input:" - } else { - "View Raw Input" - }; + let input_header = if is_raw_input_expanded { + "Raw Input:" + } else { + "View Raw Input" + }; - this.child( - v_flex() - .p_2() - .gap_1() - .border_t_1() - .border_color(self.tool_card_border_color(cx)) - .child( - h_flex() - .id("disclosure_container") - .pl_0p5() - .gap_1() - .justify_between() - .rounded_xs() - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .child(input_output_header(input_header.into())) - .child( - Disclosure::new( - ("raw-input-disclosure", entry_ix), - is_raw_input_expanded, + this.child( + v_flex() + .p_2() + .gap_1() + .border_t_1() + .border_color(self.tool_card_border_color(cx)) + .child( + h_flex() + .id("disclosure_container") + .pl_0p5() + .gap_1() + .justify_between() + .rounded_xs() + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .child(input_output_header(input_header.into())) + .child( + Disclosure::new( + ("raw-input-disclosure", entry_ix), + is_raw_input_expanded, + ) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown), ) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - - move |this: &mut Self, _, _, cx| { - if this.expanded_tool_call_raw_inputs.contains(&id) - { - this.expanded_tool_call_raw_inputs.remove(&id); - } else { - this.expanded_tool_call_raw_inputs - .insert(id.clone()); + .on_click(cx.listener({ + let id = tool_call.id.clone(); + + move |this: &mut Self, _, _, cx| { + if this + .expanded_tool_call_raw_inputs + .contains(&id) + { + this.expanded_tool_call_raw_inputs + .remove(&id); + } else { + this.expanded_tool_call_raw_inputs + .insert(id.clone()); + } + cx.notify(); } - cx.notify(); - } - })), - ) - .when(is_raw_input_expanded, |this| { - this.children(tool_call.raw_input_markdown.clone().map( - |input| { - self.render_markdown( - input, - MarkdownStyle::themed( - MarkdownFont::Agent, - window, + })), + ) + .when(is_raw_input_expanded, |this| { + this.children(tool_call.raw_input_markdown.clone().map( + |input| { + self.render_markdown( + input, + MarkdownStyle::themed( + MarkdownFont::Agent, + window, + cx, + ), cx, - ), - cx, - ) - }, - )) - }), - ) - }) - .child(self.render_permission_buttons( - self.thread.read(cx).session_id().clone(), - self.is_first_tool_call(active_session_id, &tool_call.id, cx), - options, - entry_ix, - tool_call.id.clone(), - focus_handle, - cx, - )) - .into_any(), + ) + }, + )) + }), + ) + }); + + v_flex() + .w_full() + .map(|this| { + if layout == ToolCallLayout::Floating { + // Cap the content (e.g. a full plan awaiting + // approval) so the floating row can never + // consume the entire panel and squeeze the + // conversation list to zero height, while the + // permission buttons below stay visible. + this.child( + div() + .id(("floating-confirmation-content", entry_ix)) + .max_h_40() + .overflow_y_scroll() + .child(confirmation_content), + ) + } else { + this.child(confirmation_content) + } + }) + .child(self.render_permission_buttons( + self.thread.read(cx).session_id().clone(), + self.is_first_tool_call(active_session_id, &tool_call.id, cx), + options, + entry_ix, + tool_call.id.clone(), + focus_handle, + cx, + )) + .into_any() + } ToolCallStatus::Pending | ToolCallStatus::InProgress if is_edit && tool_call.content.is_empty() @@ -7215,7 +7587,10 @@ impl ThreadView { v_flex() .map(|this| { - if layout == ToolCallLayout::Embedded { + if matches!( + layout, + ToolCallLayout::Embedded | ToolCallLayout::Floating + ) { this } else if use_card_layout { this.my_1p5() @@ -7427,35 +7802,47 @@ impl ThreadView { let is_open = !self .collapsed_sandbox_authorization_details .contains(tool_call_id); - let paths = details - .write_paths - .iter() - .map(|path| path.display().to_string()) - .collect::>(); + let mut paths = details.write_paths.clone(); + paths.sort(); v_flex() - .p_2() - .gap_1() .border_t_1() .border_color(self.tool_card_border_color(cx)) .child( h_flex() .id(("sandbox-authorization-details-header", entry_ix)) - .gap_1() - .pl_0p5() - .rounded_xs() + .p_1() + .justify_between() + .cursor_pointer() .hover(|style| style.bg(cx.theme().colors().element_hover)) + .child( + h_flex() + .gap_1() + .child( + Label::new("Write access") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("•") + .size(LabelSize::XSmall) + .color(Color::Disabled), + ) + .child( + Label::new(format!( + "{} {}", + paths.len(), + if paths.len() == 1 { "path" } else { "paths" } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) .child( Disclosure::new(("sandbox-authorization-details", entry_ix), is_open) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown), ) - .child( - Label::new("Paths") - .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), - ) .on_click(cx.listener({ let tool_call_id = tool_call_id.clone(); move |this, _event, _window, cx| { @@ -7476,30 +7863,87 @@ impl ThreadView { .when(is_open, |this| { this.child( v_flex() - .gap_0p5() - .pl_5() - .children(paths.into_iter().enumerate().map(|(path_ix, path)| { - h_flex() - .gap_1() - .child(Label::new("•").size(LabelSize::Small).color(Color::Muted)) - .child( - div() - .id(format!( - "sandbox-authorization-path-{entry_ix}-{path_ix}" - )) - .w_full() - .max_w_full() - .overflow_x_scroll() - .child( - Label::new(path).buffer_font(cx).size(LabelSize::Small), - ), - ) + .id(("sandbox-authorization-paths-list", entry_ix)) + .max_h_40() + .overflow_y_scroll() + .children(paths.iter().enumerate().map(|(path_ix, path)| { + self.render_sandbox_authorization_path_row( + entry_ix, + path_ix, + path, + path_ix < paths.len() - 1, + cx, + ) })), ) }) .into_any_element() } + fn render_sandbox_authorization_path_row( + &self, + entry_ix: usize, + path_ix: usize, + path: &Path, + show_border: bool, + cx: &Context, + ) -> Stateful
{ + let display_path = path.display().to_string(); + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| display_path.clone()); + let parent_path = path.parent().and_then(|parent| { + let parent = parent.display().to_string(); + (!parent.is_empty()).then_some(parent) + }); + let path_icon = FileIcons::get_icon(path, cx) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted).size(IconSize::Small)) + .unwrap_or_else(|| { + Icon::new(IconName::Folder) + .color(Color::Muted) + .size(IconSize::Small) + }); + + h_flex() + .id(SharedString::from(format!( + "sandbox-authorization-path-{entry_ix}-{path_ix}" + ))) + .min_w_0() + .p_1p5() + .gap_2() + .bg(cx.theme().colors().editor_background) + .when(show_border, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .id(SharedString::from(format!( + "sandbox-authorization-path-name-{entry_ix}-{path_ix}" + ))) + .min_w_0() + .gap_0p5() + .child(path_icon) + .child( + Label::new(file_name) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + .when_some(parent_path, |this, parent_path| { + this.child( + Label::new(format!(" {parent_path}")) + .color(Color::Muted) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + }) + .tooltip(move |_window, cx| { + Tooltip::with_meta("Requested write path", None, display_path.clone(), cx) + }), + ) + } + fn render_permission_buttons( &self, session_id: acp::SessionId, @@ -7968,7 +8412,9 @@ impl ThreadView { Icon::new(IconName::CheckDouble) .size(IconSize::XSmall) .color(Color::Success), - if option.option_id.0.as_ref() == "allow_thread" { + if option.option_id.0.as_ref() + == acp_thread::SandboxPermission::AllowThread.as_id() + { None } else { Some(&AllowAlways as &dyn Action) @@ -8232,12 +8678,24 @@ impl ThreadView { .project .upgrade()? .read(cx) - .find_project_path(&tool_call_location.path, cx)?; + .find_project_path(&tool_call_location.path, cx); let open_task = self .workspace .update(cx, |workspace, cx| { - workspace.open_path(project_path, None, true, window, cx) + if let Some(project_path) = project_path { + workspace.open_path(project_path, None, true, window, cx) + } else { + workspace.open_abs_path( + tool_call_location.path.clone(), + OpenOptions { + focus: Some(true), + ..Default::default() + }, + window, + cx, + ) + } }) .log_err()?; window @@ -9065,11 +9523,14 @@ impl ThreadView { window: &mut Window, cx: &mut Context, ) -> Option
{ - let content = match self.thread_error.as_ref()? { + let callout = match self.thread_error.as_ref()? { ThreadError::Other { message, .. } => { self.render_any_thread_error(message.clone(), window, cx) } ThreadError::Refusal => self.render_refusal_error(cx), + ThreadError::DataRetentionConsentRequired => { + self.render_data_retention_consent_error(cx) + } ThreadError::AuthenticationRequired(error) => { self.render_authentication_required_error(error.clone(), cx) } @@ -9179,7 +9640,7 @@ impl ThreadView { ), }; - Some(div().child(content)) + Some(div().child(callout.border_position(self.callout_border_position()))) } fn render_refusal_error(&self, cx: &mut Context<'_, Self>) -> Callout { @@ -9443,7 +9904,7 @@ impl ThreadView { let description = "This agent does not support viewing previous messages. However, your session will still continue from where you last left off."; Callout::new() - .border_position(ui::BorderPosition::Bottom) + .border_position(CalloutBorderPosition::Bottom) .severity(Severity::Info) .icon(IconName::Info) .title("Resumed Session") @@ -9453,6 +9914,7 @@ impl ThreadView { fn render_codex_windows_warning(&self, cx: &mut Context) -> Callout { Callout::new() + .border_position(self.callout_border_position()) .icon(IconName::Warning) .severity(Severity::Warning) .title("Codex on Windows") @@ -9483,23 +9945,48 @@ impl ThreadView { ) } - fn render_skill_loading_errors(&self, cx: &mut Context) -> Vec { - self.skill_loading_errors + fn render_skill_loading_issues(&self, cx: &mut Context) -> Vec { + let border_position = self.callout_border_position(); + + let description_warnings = self + .skill_loading_issues + .iter() + .filter(|issue| issue.kind == SkillLoadingIssueKind::DescriptionTooLong) + .cloned() + .collect::>(); + + let long_description_warning = + self.render_skill_description_warnings(description_warnings, cx); + + let other_warnings = self + .skill_loading_issues .iter() + .filter(|issue| issue.kind != SkillLoadingIssueKind::DescriptionTooLong) .enumerate() - .map(|(index, error)| { - let abs_path = error.path.clone(); + .map(|(index, issue)| { + let abs_path = issue.path.clone(); let workspace = self.workspace.clone(); - let path_label = error.path.display().to_string(); - let target = error.clone(); + let path_label = issue.path.display().to_string(); + let target = issue.clone(); + + let title = match issue.kind { + SkillLoadingIssueKind::LoadFailed => "Skill Failed to Load", + SkillLoadingIssueKind::DescriptionTooLong => unreachable!(), + SkillLoadingIssueKind::CatalogBudgetExceeded => { + "Skill Omitted from Model Catalog" + } + }; + Callout::new() .icon(IconName::Warning) .severity(Severity::Warning) - .title("Skill failed to load") - .description(format!("{}\n{path_label}", error.message)) + .title(title) + .description(format!("{}\n{path_label}", issue.message)) .actions_slot( - Button::new(("open-skill-file", index), "Open File").on_click(cx.listener( - move |_, _, window, cx| { + Button::new(("open-skill-file", index), "Open Skill") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(move |_, _, window, cx| { let abs_path = abs_path.clone(); workspace .update(cx, |workspace, cx| { @@ -9513,34 +10000,134 @@ impl ThreadView { .detach_and_log_err(cx); }) .ok(); - }, - )), + })), ) .dismiss_action( - IconButton::new(("dismiss-skill-error", index), IconName::Close) + IconButton::new(("dismiss-skill-issue", index), IconName::Close) .icon_size(IconSize::Small) - .icon_color(Color::Muted) .tooltip(Tooltip::text("Dismiss")) .on_click(cx.listener(move |this, _, _, cx| { - this.skill_loading_errors.retain(|e| *e != target); - this.dismissed_skill_loading_errors.insert(target.clone()); + this.skill_loading_issues.retain(|issue| *issue != target); + this.dismissed_skill_loading_issues.insert(target.clone()); cx.notify(); })), ) }) + .collect::>(); + + long_description_warning + .into_iter() + .chain(other_warnings) + .map(|callout| callout.border_position(border_position)) .collect() } + fn render_skill_description_warnings( + &self, + description_warnings: Vec, + cx: &mut Context, + ) -> Option { + if description_warnings.is_empty() { + return None; + } + + let warning_count = description_warnings.len(); + let title = if warning_count == 1 { + "1 Skill Loaded with a Long Description".to_string() + } else { + format!("{warning_count} Skills Loaded with Long Descriptions") + }; + + let rows = description_warnings + .iter() + .enumerate() + .map(|(index, issue)| { + let abs_path = issue.path.clone(); + let workspace = self.workspace.clone(); + let full_path = issue.path.display().to_string(); + let file_label = skill_issue_file_label(&issue.path); + + ButtonLike::new(("skill-description-warning-file", index)) + .full_width() + .child( + h_flex() + .w_full() + .gap_1() + .child( + Icon::new(IconName::Dash) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(Label::new(file_label).size(LabelSize::Small)), + ) + .tooltip(move |_, cx| { + Tooltip::with_meta("Open Skill", None, full_path.clone(), cx) + }) + .on_click(cx.listener(move |_, _, window, cx| { + let abs_path = abs_path.clone(); + workspace + .update(cx, |workspace, cx| { + workspace + .open_abs_path( + abs_path, + workspace::OpenOptions::default(), + window, + cx, + ) + .detach_and_log_err(cx); + }) + .ok(); + })) + .into_any_element() + }) + .collect::>(); + + let callout = Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title(title) + .description_slot( + v_flex() + .gap_1() + .child( + Label::new(format!( + "Ensure skill descriptions are at most {MAX_SKILL_DESCRIPTION_LEN} bytes; longer ones may consume more model-context tokens." + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .children(rows), + ); + + let targets = description_warnings; + + Some( + callout.dismiss_action( + IconButton::new("dismiss-skill-description-warnings", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(cx.listener(move |this, _, _, cx| { + this.skill_loading_issues + .retain(|issue| !targets.contains(issue)); + for target in &targets { + this.dismissed_skill_loading_issues.insert(target.clone()); + } + cx.notify(); + })), + ), + ) + } + fn render_external_source_prompt_warning(&self, cx: &mut Context) -> Callout { Callout::new() + .border_position(self.callout_border_position()) .icon(IconName::Warning) .severity(Severity::Warning) - .title("Review before sending") - .description("This prompt was pre-filled by an external link. Read it carefully before you send it.") + .title("Review Before Sending") + .description("This prompt was pre-filled by an external link. Read it carefully before you submit it to the model.") .dismiss_action( IconButton::new("dismiss-external-source-prompt-warning", IconName::Close) .icon_size(IconSize::Small) - .icon_color(Color::Muted) .tooltip(Tooltip::text("Dismiss Warning")) .on_click(cx.listener({ move |this, _, _, cx| { @@ -9599,7 +10186,7 @@ impl ThreadView { "It currently only operates by default on \"{}\".", active_dir )) - .border_position(ui::BorderPosition::Bottom) + .border_position(self.callout_border_position()) .dismiss_action( IconButton::new("dismiss-multi-root-callout", IconName::Close) .icon_size(IconSize::Small) @@ -9616,9 +10203,9 @@ impl ThreadView { let server_view = self.server_view.clone(); let has_version = !version.is_empty(); let title = if has_version { - "New version available" + "New Version Available" } else { - "Agent update available" + "Agent Update Available" }; let button_label = if has_version { format!("Update to v{}", version) @@ -9632,7 +10219,7 @@ impl ThreadView { .pr_3() .w_full() .gap_1p5() - .border_t_1() + .border_b_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().element_background) .child( @@ -9666,14 +10253,12 @@ impl ThreadView { let token_usage = self.thread.read(cx).token_usage()?; - // When auto-compaction is available (the handoff feature flag is enabled - // and the model's context window is large enough), the thread is - // compacted automatically before it reaches the limit, so there's no - // need to warn the user. Models with a context window that's too small - // can't be auto-compacted, so we fall back to the normal warning. - if cx.has_flag::() - && token_usage.max_tokens >= agent::MIN_COMPACTION_CONTEXT_WINDOW - { + // When auto-compaction is available (the model's context window is large + // enough), the thread is compacted automatically before it reaches the + // limit, so there's no need to warn the user. Models with a context + // window that's too small can't be auto-compacted, so we fall back to + // the normal warning. + if token_usage.max_tokens >= agent::MIN_COMPACTION_CONTEXT_WINDOW { return None; } @@ -9697,6 +10282,7 @@ impl ThreadView { Some( Callout::new() + .border_position(self.callout_border_position()) .severity(severity) .icon(icon) .title(title) @@ -9721,6 +10307,118 @@ impl ThreadView { ) } + /// Returns the model to offer as a downgrade target when the current model + /// requires data retention consent (e.g. Opus 4.8 for Fable). + fn data_retention_fallback_model(&self, cx: &App) -> Option> { + let thread = self.as_native_thread(cx)?; + let model = thread.read(cx).model()?.clone(); + let fallback_id = model.refusal_fallback_model_id()?; + LanguageModelRegistry::read_global(cx) + .available_models(cx) + .find(|fallback| { + fallback.provider_id() == model.provider_id() + && fallback.id().0.as_ref() == fallback_id + }) + } + + fn render_data_retention_consent_error(&self, cx: &mut Context) -> Callout { + let fallback_model = self.data_retention_fallback_model(cx); + + Callout::new() + .severity(Severity::Warning) + .icon(IconName::Warning) + .title(format!( + "Note: {} cannot be offered with Zero Data Retention.", + self.current_model_name(cx) + )) + .description_slot( + h_flex() + .gap_1() + .child( + Label::new("Anthropic will retain inference logs.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Button::new("data-retention-learn-more", "Learn More") + .label_size(LabelSize::Small) + .on_click(|_, _, cx| { + cx.open_url(DATA_RETENTION_LEARN_MORE_URL); + }), + ), + ) + .actions_slot( + h_flex() + .gap_0p5() + .when_some(fallback_model, |this, fallback| { + this.child( + Button::new( + "switch-data-retention-fallback", + format!("Switch to {}", fallback.name().0), + ) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _, cx| { + this.switch_to_data_retention_fallback_and_resend(cx); + })), + ) + }) + .child( + Button::new("accept-data-retention", "Accept") + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(TintColor::Warning)) + .on_click(cx.listener(|this, _, _, cx| { + this.accept_data_retention_and_resend(cx); + })), + ), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn accept_data_retention_and_resend(&mut self, cx: &mut Context) { + let fs = self.thread.read(cx).project().read(cx).fs().clone(); + // Resume the failed turn only once the in-memory settings reflect + // consent, otherwise the resent request would be rejected again. + let completion = update_settings_file_with_completion(fs, cx, |settings, _| { + settings + .telemetry + .get_or_insert_default() + .anthropic_retention = Some(true); + }); + cx.spawn(async move |this, cx| { + completion.await??; + this.update(cx, |this, cx| this.retry_generation(cx))?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn switch_to_data_retention_fallback_and_resend(&mut self, cx: &mut Context) { + let Some(fallback) = self.data_retention_fallback_model(cx) else { + return; + }; + let model_id = acp_thread::AgentModelId::new(format!( + "{}/{}", + fallback.provider_id().0, + fallback.id().0 + )); + let session_id = self.thread.read(cx).session_id().clone(); + let Some(selector) = self + .thread + .read(cx) + .connection() + .model_selector(&session_id) + else { + return; + }; + let select = selector.select_model(model_id, cx); + cx.spawn(async move |this, cx| { + select.await?; + this.update(cx, |this, cx| this.retry_generation(cx))?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn open_permission_dropdown( &mut self, _: &crate::OpenPermissionDropdown, @@ -9915,7 +10613,12 @@ impl Render for ThreadView { } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, cx| { - thread.set_thinking_enabled(!thread.thinking_enabled(), cx); + let model_allows_disabling = thread + .model() + .is_none_or(|model| model.supports_disabling_thinking()); + if model_allows_disabling { + thread.set_thinking_enabled(!thread.thinking_enabled(), cx); + } }); } })) @@ -10072,7 +10775,6 @@ impl Render for ThreadView { .children(self.render_subagent_titlebar(cx)) .child(conversation) .children(self.render_multi_root_callout(cx)) - .children(self.render_skill_loading_errors(cx)) .children(self.render_activity_bar(window, cx)) .when(self.show_external_source_prompt_warning, |this| { this.child(self.render_external_source_prompt_warning(cx)) @@ -10080,7 +10782,8 @@ impl Render for ThreadView { .when(self.show_codex_windows_warning, |this| { this.child(self.render_codex_windows_warning(cx)) }) - .children(self.render_thread_retry_status_callout()) + .children(self.render_skill_loading_issues(cx)) + .children(self.render_thread_retry_status_callout(cx)) .children(self.render_thread_error(window, cx)) .when_some( match has_messages { @@ -10174,6 +10877,9 @@ pub(crate) fn open_link( MentionUri::TerminalSelection { .. } => {} MentionUri::GitDiff { .. } => {} MentionUri::MergeConflict { .. } => {} + MentionUri::Rule { name, .. } => { + crate::ui::open_migrated_rule(workspace, &name, window, cx); + } MentionUri::Skill { skill_file_path, .. } => { @@ -10191,7 +10897,156 @@ pub(crate) fn open_link( } }) } else { - cx.open_url(&url); + workspace.update(cx, |workspace, cx| { + workspace.open_url_or_file(&url, None, window, cx); + }); + } +} + +/// Returns the name of the leading built-in (native-category) slash command — +/// e.g. `compact` for `/compact` or `/compact summarize the API work` — whether +/// or not the user typed any trailing text after it. Built-in commands ignore +/// trailing arguments, so the caller sends the bare command and queues any +/// remainder rather than discarding it. Commands from MCP servers and ACP +/// agents are excluded: their trailing text is a real argument the agent +/// consumes. +/// +/// Native commands run a turn that produces its own thread entry, so the typed +/// command is never echoed as a user message (see `send_command_queueing_remainder`). +fn leading_native_command( + text: &str, + available_commands: &[acp::AvailableCommand], +) -> Option { + let rest = text.trim_start().strip_prefix('/')?; + let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let name = &rest[..name_end]; + let is_native = available_commands.iter().any(|command| { + command.name == name + && acp_thread::command_category_from_meta(&command.meta) + == Some(acp_thread::CommandCategory::Native) + }); + is_native.then(|| name.to_string()) +} + +/// Removes a leading `/command_name` token from `text`, returning the trimmed +/// remainder. Falls back to the trimmed input if the prefix isn't present. +fn strip_leading_command(text: &str, command_name: &str) -> String { + let trimmed = text.trim_start(); + trimmed + .strip_prefix('/') + .and_then(|rest| rest.strip_prefix(command_name)) + .map(|rest| rest.trim_start().to_string()) + .unwrap_or_else(|| trimmed.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use project::{FakeFs, Project}; + use serde_json::json; + use util::path; + use workspace::MultiWorkspace; + + fn native_command(name: &str) -> acp::AvailableCommand { + acp::AvailableCommand::new(name, "").meta(acp_thread::meta_with_command_category( + acp_thread::CommandCategory::Native, + )) + } + + fn mcp_command(name: &str) -> acp::AvailableCommand { + acp::AvailableCommand::new(name, "").meta(acp_thread::meta_with_command_category( + acp_thread::CommandCategory::Mcp, + )) + } + + #[test] + fn test_leading_native_command_matches_bare_and_with_remainder() { + let commands = [native_command("compact"), mcp_command("deploy")]; + + // Native command with trailing text. + assert_eq!( + leading_native_command("/compact summarize the API work", &commands), + Some("compact".to_string()) + ); + // Leading/trailing whitespace is tolerated. + assert_eq!( + leading_native_command(" /compact do x ", &commands), + Some("compact".to_string()) + ); + + // Bare native command (no remainder) is still recognized, so it runs as + // a command turn (without echoing a user message) rather than being sent + // to the model as a normal prompt. + assert_eq!( + leading_native_command("/compact", &commands), + Some("compact".to_string()) + ); + assert_eq!( + leading_native_command("/compact ", &commands), + Some("compact".to_string()) + ); + + // MCP/ACP commands are not native: their trailing text is a real + // argument the agent consumes, and they echo as normal user messages. + assert_eq!(leading_native_command("/deploy prod", &commands), None); + assert_eq!(leading_native_command("/deploy", &commands), None); + + // Unknown command, or not a slash command at all. + assert_eq!(leading_native_command("/unknown foo", &commands), None); + assert_eq!(leading_native_command("just a message", &commands), None); + } + + #[test] + fn test_strip_leading_command() { + assert_eq!(strip_leading_command("/compact do x", "compact"), "do x"); + assert_eq!( + strip_leading_command(" /compact do x ", "compact"), + "do x " + ); + // No matching prefix: returns the trimmed input unchanged. + assert_eq!(strip_leading_command("hello", "compact"), "hello"); + } + + #[gpui::test] + async fn test_open_link_bare_path(cx: &mut gpui::TestAppContext) { + crate::test_support::init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({"src": {"main.rs": ""}})) + .await; + + let project = Project::test(fs, [path!("/project").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, |mw, _| mw.workspace().clone()); + let workspace_weak = workspace.downgrade(); + + // Relative path — call from multi_workspace so the inner workspace entity is not locked + multi_workspace.update_in(cx, |_, window, cx| { + open_link("src/main.rs".into(), &workspace_weak, window, cx); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + let active = workspace + .active_item(cx) + .and_then(|item| item.project_path(cx)) + .expect("file should be open"); + assert!(*active.path == *"src/main.rs"); + }); + + // Absolute path + let abs_path: SharedString = path!("/project/src/main.rs").to_string().into(); + multi_workspace.update_in(cx, |_, window, cx| { + open_link(abs_path, &workspace_weak, window, cx); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + let active = workspace + .active_item(cx) + .and_then(|item| item.project_path(cx)) + .expect("file should be open"); + assert!(*active.path == *"src/main.rs"); + }); } } diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index 85db4e32e48004..68627d1ec871e4 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -232,7 +232,7 @@ impl EntryViewState { self.set_entry(index, Entry::CompletedPlan); } } - AgentThreadEntry::ContextCompaction => { + AgentThreadEntry::ContextCompaction(_) => { if !matches!(self.entries.get(index), Some(Entry::ContextCompaction)) { self.set_entry(index, Entry::ContextCompaction); } @@ -470,6 +470,7 @@ fn create_editor_diff( editor.set_show_code_actions(false, cx); editor.set_show_git_diff_gutter(false, cx); editor.set_expand_all_diff_hunks(cx); + editor.set_render_diff_hunks_as_unstaged(true, cx); editor.set_text_style_refinement(diff_editor_text_style_refinement(cx)); editor }) diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index b19a0f2ff4041f..6fddacc3279458 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -166,7 +166,8 @@ impl MentionSet { ))), MentionUri::PastedImage { .. } | MentionUri::TerminalSelection { .. } - | MentionUri::MergeConflict { .. } => { + | MentionUri::MergeConflict { .. } + | MentionUri::Rule { .. } => { Task::ready(Err(anyhow!("Unsupported mention URI type for paste"))) } } @@ -347,6 +348,10 @@ impl MentionSet { debug_panic!("unexpected merge conflict URI"); Task::ready(Err(anyhow!("unexpected merge conflict URI"))) } + MentionUri::Rule { .. } => { + debug_panic!("unexpected rule URI"); + Task::ready(Err(anyhow!("unexpected rule URI"))) + } }; let task = cx .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index c6e52939d603d3..d25f86c516c90c 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -13,6 +13,7 @@ use acp_thread::MentionUri; use agent::ThreadStore; use agent_client_protocol::schema as acp; use anyhow::{Result, anyhow}; +use base64::Engine as _; use editor::{ Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, @@ -24,8 +25,8 @@ use editor::{ use futures::{FutureExt as _, future::join_all}; use gpui::{ AppContext, ClipboardEntry, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, - Focusable, ImageFormat, KeyContext, SharedString, Subscription, Task, TaskExt, TextStyle, - WeakEntity, + Focusable, Image, ImageFormat, KeyContext, SharedString, Subscription, Task, TaskExt, + TextStyle, WeakEntity, }; use language::{Buffer, language_settings::InlayHintKind}; use parking_lot::RwLock; @@ -36,6 +37,7 @@ use project::{ use rope::Point; use settings::Settings; use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc}; +use text::LineEnding; use theme_settings::ThemeSettings; use ui::{ContextMenu, prelude::*}; use util::paths::PathStyle; @@ -114,6 +116,7 @@ impl SessionCapabilities { description: command.description.clone().into(), requires_argument: command.input.is_some(), source: None, + category: acp_thread::command_category_from_meta(&command.meta), }) .collect() } @@ -205,6 +208,7 @@ pub enum MessageEditorEvent { Cancel, Focus, LostFocus, + Edited, /// Emitted when the user opens slash-command autocomplete in this /// editor. Used by `ThreadView` to fire the global-skills scan /// trigger; see `NativeAgent::ensure_skills_scan_started`. @@ -556,6 +560,7 @@ impl MessageEditor { if let EditorEvent::Edited { .. } = event && !editor.read(cx).read_only(cx) { + cx.emit(MessageEditorEvent::Edited); editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); this.mention_set @@ -1718,11 +1723,15 @@ impl MessageEditor { let path_style = workspace.read(cx).project().read(cx).path_style(cx); let mut text = String::new(); let mut mentions = Vec::new(); + let append_normalized = |text: &mut String, mut segment: String| { + LineEnding::normalize(&mut segment); + text.push_str(&segment); + }; for chunk in message { match chunk { acp::ContentBlock::Text(text_content) => { - text.push_str(&text_content.text); + append_normalized(&mut text, text_content.text); } acp::ContentBlock::Resource(acp::EmbeddedResource { resource: acp::EmbeddedResourceResource::TextResourceContents(resource), @@ -1733,7 +1742,7 @@ impl MessageEditor { continue; }; let start = text.len(); - write!(&mut text, "{}", mention_uri.as_link()).ok(); + append_normalized(&mut text, mention_uri.as_link().to_string()); let end = text.len(); mentions.push(( start..end, @@ -1749,7 +1758,7 @@ impl MessageEditor { MentionUri::parse(&resource.uri, path_style).log_err() { let start = text.len(); - write!(&mut text, "{}", mention_uri.as_link()).ok(); + append_normalized(&mut text, mention_uri.as_link().to_string()); let end = text.len(); mentions.push((start..end, mention_uri, Mention::Link)); } @@ -1775,7 +1784,7 @@ impl MessageEditor { continue; }; let start = text.len(); - write!(&mut text, "{}", mention_uri.as_link()).ok(); + append_normalized(&mut text, mention_uri.as_link().to_string()); let end = text.len(); mentions.push(( start..end, @@ -1815,6 +1824,7 @@ impl MessageEditor { for (range, mention_uri, mention) in mentions { let adjusted_start = insertion_start + range.start; let anchor = snapshot.anchor_before(MultiBufferOffset(adjusted_start)); + let image_preview = image_preview_task_for_mention(&mention); let Some((crease_id, tx, crease_entity)) = insert_crease_for_mention( snapshot.anchor_to_buffer_anchor(anchor).unwrap().0, range.end - range.start, @@ -1823,7 +1833,7 @@ impl MessageEditor { mention_uri.tooltip_text(), Some(mention_uri.clone()), Some(self.workspace.clone()), - None, + image_preview, self.editor.clone(), window, cx, @@ -2094,6 +2104,31 @@ fn build_chunks_from_creases( (chunks, tracked_buffers) } +fn image_preview_task_for_mention( + mention: &Mention, +) -> Option, String>>>> { + let Mention::Image(mention_image) = mention else { + return None; + }; + + let bytes = + match base64::engine::general_purpose::STANDARD.decode(mention_image.data.as_bytes()) { + Ok(bytes) => bytes, + Err(error) => { + log::error!("failed to decode image mention: {error}"); + return None; + } + }; + + Some( + Task::ready(Ok::, String>(Arc::new(Image::from_bytes( + mention_image.format, + bytes, + )))) + .shared(), + ) +} + fn mention_to_content_block( uri: &MentionUri, mention: Option<&Mention>, @@ -2248,6 +2283,7 @@ mod tests { description: "Deploy the app".into(), source: "".into(), skill_file_path: skill_file_path.clone(), + warning: None, }; let session_capabilities = SessionCapabilities::new( acp::PromptCapabilities::default(), @@ -2262,6 +2298,40 @@ mod tests { assert_eq!(skills[0].skill_file_path, skill_file_path); } + #[test] + fn test_completion_commands_derive_category_from_meta() { + let session_capabilities = SessionCapabilities::new( + acp::PromptCapabilities::default(), + vec![ + acp::AvailableCommand::new("compact", "Built-in").meta( + acp_thread::meta_with_command_category(acp_thread::CommandCategory::Native), + ), + acp::AvailableCommand::new("deploy", "MCP").meta( + acp_thread::meta_with_command_category(acp_thread::CommandCategory::Mcp), + ), + // No category meta: this is how external ACP agents' commands + // arrive, and they should group on their own. + acp::AvailableCommand::new("help", "External"), + ], + Vec::new(), + ); + + let commands = session_capabilities.completion_commands(); + let category = |name: &str| { + commands + .iter() + .find(|command| command.name.as_ref() == name) + .unwrap() + .category + }; + assert_eq!( + category("compact"), + Some(acp_thread::CommandCategory::Native) + ); + assert_eq!(category("deploy"), Some(acp_thread::CommandCategory::Mcp)); + assert_eq!(category("help"), None); + } + #[test] fn test_validate_slash_commands_accepts_scope_qualified_skill() { let agent_id = AgentId::from("Zed"); @@ -2270,6 +2340,7 @@ mod tests { description: "desc".into(), source: source.into(), skill_file_path: PathBuf::from(format!("/tmp/{source}-{name}/SKILL.md")), + warning: None, }; // Global skills carry an empty scope (so the popup inserts @@ -5269,6 +5340,33 @@ mod tests { assert!(!message_editor.update(cx, |editor, cx| editor.is_empty(cx))); } + #[gpui::test] + async fn test_set_message_normalizes_crlf_before_mention(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, cx) = setup_message_editor(cx).await; + + message_editor.update_in(cx, |editor, window, cx| { + editor.set_message( + vec![ + acp::ContentBlock::Text(acp::TextContent::new("before\r\n".to_string())), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "file.txt", + "file:///project/file.txt", + )), + ], + window, + cx, + ); + }); + + let text = message_editor.update(cx, |editor, cx| editor.text(cx)); + assert_eq!(text, "before\n[@file.txt](file:///project/file.txt)"); + + let mention_uris = + message_editor.update(cx, |editor, cx| editor.mention_set.read(cx).mentions()); + assert_eq!(mention_uris.len(), 1); + } + #[gpui::test] async fn test_set_message_replaces_existing_content(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index c2f521641288de..c14ddd651de81c 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -1,15 +1,18 @@ +use std::time::Duration; + use acp_thread::AgentSessionListRequest; use agent::ThreadStore; use agent_client_protocol::schema as acp; use chrono::Utc; -use collections::HashSet; +use collections::{HashMap, HashSet}; use db::kvp::Dismissable; use db::sqlez; use fs::Fs; use futures::FutureExt as _; use gpui::{ - App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, - Render, SharedString, Task, TaskExt, WeakEntity, Window, + Animation, AnimationExt as _, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable, MouseDownEvent, Render, SharedString, Task, TaskExt, WeakEntity, Window, + pulsating_between, }; use itertools::Itertools as _; use notifications::status_toast::StatusToast; @@ -17,8 +20,8 @@ use project::{AgentId, AgentRegistryStore, AgentServerStore}; use release_channel::ReleaseChannel; use remote::RemoteConnectionOptions; use ui::{ - Checkbox, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, ModalHeader, Section, - prelude::*, + Checkbox, CommonAnimationExt, KeyBinding, ListItem, ListItemSpacing, Modal, ModalFooter, + ModalHeader, Section, Tooltip, prelude::*, }; use util::ResultExt; use workspace::{ModalView, MultiWorkspace, Workspace}; @@ -60,24 +63,37 @@ impl Dismissable for CrossChannelImportOnboarding { const KEY: &'static str = "dismissed-cross-channel-thread-import"; } -/// Returns the list of non-Dev, non-current release channels that have -/// at least one thread in their database. The result is suitable for -/// building a user-facing message ("from Zed Preview and Nightly"). -pub fn channels_with_threads(cx: &App) -> Vec { +pub fn channels_with_threads(cx: &App) -> Task> { let Some(current_channel) = ReleaseChannel::try_global(cx) else { - return Vec::new(); + return Task::ready(Vec::new()); }; let database_dir = paths::database_dir(); - ReleaseChannel::ALL - .iter() - .copied() - .filter(|channel| { - *channel != current_channel - && *channel != ReleaseChannel::Dev - && channel_has_threads(database_dir, *channel) - }) - .collect() + let channel_has_threads = |database_dir: &std::path::Path, channel: ReleaseChannel| { + let db_path = db::db_path(database_dir, channel); + if !db_path.exists() { + return false; + } + let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy()); + connection + .select_row::("SELECT 1 FROM sidebar_threads LIMIT 1") + .ok() + .and_then(|mut query| query().ok().flatten()) + .unwrap_or(false) + }; + + cx.background_spawn(async move { + ReleaseChannel::ALL + .iter() + .copied() + .filter(|channel| { + *channel != current_channel + && *channel != ReleaseChannel::Dev + && channel_has_threads(database_dir, *channel) + }) + .map(|channel| SharedString::new_static(channel.display_name())) + .collect() + }) } #[derive(Clone)] @@ -87,13 +103,39 @@ struct AgentEntry { icon_path: Option, } +#[derive(Clone)] +enum AgentImportStatus { + Loading, + Ready { importable_count: usize }, + Unsupported, + Error(SharedString), +} + +impl AgentImportStatus { + fn is_selectable(&self) -> bool { + matches!(self, Self::Ready { importable_count } if *importable_count > 0) + } + + fn tooltip_text(&self) -> Option { + match self { + Self::Loading => Some("Fetching Sessions…".into()), + Self::Ready { .. } => None, + Self::Unsupported => Some("Importing threads from this agent is not possible as it doesn't support ACP's session/list capability.".into()), + Self::Error(error) => Some(format!("Failed to fetch sessions: {error}").into()), + } + } +} + pub struct ThreadImportModal { focus_handle: FocusHandle, workspace: WeakEntity, multi_workspace: WeakEntity, agent_entries: Vec, unchecked_agents: HashSet, + agent_import_statuses: HashMap, + sessions_by_agent: Vec, selected_index: Option, + is_fetching_sessions: bool, is_importing: bool, last_error: Option, } @@ -142,16 +184,22 @@ impl ThreadImportModal { .sorted_unstable_by_key(|entry| entry.display_name.to_lowercase()) .collect::>(); - Self { + let this = Self { focus_handle: cx.focus_handle(), workspace, multi_workspace, agent_entries, unchecked_agents: HashSet::default(), + agent_import_statuses: HashMap::default(), + sessions_by_agent: Vec::new(), selected_index: None, + is_fetching_sessions: false, is_importing: false, last_error: None, - } + }; + cx.spawn(async move |this, cx| this.update(cx, |this, cx| this.fetch_sessions(cx))) + .detach_and_log_err(cx); + this } fn agent_ids(&self) -> Vec { @@ -161,7 +209,97 @@ impl ThreadImportModal { .collect() } + fn fetch_sessions(&mut self, cx: &mut Context) { + if self.agent_entries.is_empty() { + return; + } + + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + self.mark_all_agents_failed("Could not find workspace to import from."); + return; + }; + + let stores = resolve_agent_connection_stores(&multi_workspace, cx); + if stores.is_empty() { + log::error!("Did not find any workspaces to import from"); + self.mark_all_agents_failed("Did not find any workspaces to import from."); + return; + } + + self.is_fetching_sessions = true; + self.last_error = None; + self.sessions_by_agent.clear(); + self.agent_import_statuses = self + .agent_ids() + .into_iter() + .map(|agent_id| (agent_id, AgentImportStatus::Loading)) + .collect(); + + let existing_sessions: HashSet = ThreadMetadataStore::global(cx) + .read(cx) + .entries() + .filter_map(|metadata| metadata.session_id.clone()) + .collect(); + + for agent_id in self.agent_ids() { + let task = + fetch_sessions_for_agent(agent_id, existing_sessions.clone(), stores.clone(), cx); + cx.spawn(async move |this, cx| { + let result = task.await; + this.update(cx, |this, cx| { + let AgentSessionFetchResult { + agent_id, + sessions_by_agent, + status, + } = result; + this.sessions_by_agent + .retain(|sessions| sessions.agent_id != agent_id); + this.sessions_by_agent.extend(sessions_by_agent); + this.agent_import_statuses.insert(agent_id, status); + this.is_fetching_sessions = this.has_loading_agents(); + cx.notify(); + }) + }) + .detach_and_log_err(cx); + } + } + + fn mark_all_agents_failed(&mut self, message: impl Into) { + let message = message.into(); + self.is_fetching_sessions = false; + self.sessions_by_agent.clear(); + self.last_error = Some(message.clone()); + self.agent_import_statuses = self + .agent_ids() + .into_iter() + .map(|agent_id| (agent_id, AgentImportStatus::Error(message.clone()))) + .collect(); + } + + fn agent_is_selectable(&self, agent_id: &AgentId) -> bool { + self.agent_import_statuses + .get(agent_id) + .map_or(false, AgentImportStatus::is_selectable) + } + + fn has_checked_selectable_agent(&self) -> bool { + self.agent_entries.iter().any(|entry| { + self.agent_is_selectable(&entry.agent_id) + && !self.unchecked_agents.contains(&entry.agent_id) + }) + } + + fn has_loading_agents(&self) -> bool { + self.agent_import_statuses + .values() + .any(|status| matches!(status, AgentImportStatus::Loading)) + } + fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context) { + if self.is_importing || !self.agent_is_selectable(&agent_id) { + return; + } + if self.unchecked_agents.contains(&agent_id) { self.unchecked_agents.remove(&agent_id); } else { @@ -217,61 +355,36 @@ impl ThreadImportModal { _: &mut Window, cx: &mut Context, ) { - if self.is_importing { - return; - } - - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - self.is_importing = false; - cx.notify(); - return; - }; - - let stores = resolve_agent_connection_stores(&multi_workspace, cx); - if stores.is_empty() { - log::error!("Did not find any workspaces to import from"); - self.is_importing = false; - cx.notify(); + if self.is_importing || !self.has_checked_selectable_agent() { return; } self.is_importing = true; self.last_error = None; - cx.notify(); - - let agent_ids = self - .agent_ids() - .into_iter() - .filter(|agent_id| !self.unchecked_agents.contains(agent_id)) - .collect::>(); let existing_sessions: HashSet = ThreadMetadataStore::global(cx) .read(cx) .entries() - .filter_map(|m| m.session_id.clone()) + .filter_map(|metadata| metadata.session_id.clone()) .collect(); - let task = find_threads_to_import(agent_ids, existing_sessions, stores, cx); - cx.spawn(async move |this, cx| { - let result = task.await; - this.update(cx, |this, cx| match result { - Ok(threads) => { - let imported_count = threads.len(); - ThreadMetadataStore::global(cx) - .update(cx, |store, cx| store.save_all(threads, cx)); - this.is_importing = false; - this.last_error = None; - this.show_imported_threads_toast(imported_count, cx); - cx.emit(DismissEvent); - } - Err(error) => { - this.is_importing = false; - this.last_error = Some(error.to_string().into()); - cx.notify(); - } + let selected_sessions_by_agent = self + .sessions_by_agent + .iter() + .filter(|sessions| { + self.agent_is_selectable(&sessions.agent_id) + && !self.unchecked_agents.contains(&sessions.agent_id) }) - }) - .detach_and_log_err(cx); + .cloned() + .collect::>(); + let threads = collect_importable_threads(selected_sessions_by_agent, existing_sessions); + let imported_count = threads.len(); + + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_all(threads, cx)); + + self.is_importing = false; + self.show_imported_threads_toast(imported_count, cx); + cx.emit(DismissEvent); } fn show_imported_threads_toast(&self, imported_count: usize, cx: &mut App) { @@ -321,47 +434,114 @@ impl ModalView for ThreadImportModal {} impl Render for ThreadImportModal { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let has_agents = !self.agent_entries.is_empty(); - let disabled_import_thread = self.is_importing - || !has_agents - || self.unchecked_agents.len() == self.agent_entries.len(); + let disabled_import_thread = + self.is_importing || !has_agents || !self.has_checked_selectable_agent(); let agent_rows = self .agent_entries .iter() .enumerate() .map(|(ix, entry)| { - let is_checked = !self.unchecked_agents.contains(&entry.agent_id); + let status = self + .agent_import_statuses + .get(&entry.agent_id) + .cloned() + .unwrap_or(AgentImportStatus::Loading); + let is_selectable = status.is_selectable(); + let is_checked = is_selectable && !self.unchecked_agents.contains(&entry.agent_id); let is_focused = self.selected_index == Some(ix); + let row_disabled = self.is_importing || !is_selectable; + let checkbox_state = if is_checked { + ToggleState::Selected + } else { + ToggleState::Unselected + }; + let end_slot = match &status { + AgentImportStatus::Loading + | AgentImportStatus::Unsupported + | AgentImportStatus::Error(_) => { + Checkbox::new(("thread-import-agent-checkbox", ix), checkbox_state) + .disabled(true) + .into_any_element() + } + AgentImportStatus::Ready { .. } => { + Checkbox::new(("thread-import-agent-checkbox", ix), checkbox_state) + .disabled(row_disabled) + .into_any_element() + } + }; + + let is_loading = matches!(status, AgentImportStatus::Loading); + + let icon_color = if is_checked { + Color::Muted + } else { + Color::Disabled + }; + + let item = h_flex() + .w_full() + .gap_2() + .child(if let Some(icon_path) = entry.icon_path.clone() { + Icon::from_external_svg(icon_path) + .color(icon_color) + .size(IconSize::Small) + } else { + Icon::new(IconName::Sparkle) + .color(icon_color) + .size(IconSize::Small) + }) + .child( + Label::new(entry.display_name.clone()) + .when(!is_checked, |s| s.color(Color::Disabled)), + ) + .map(|this| match status { + AgentImportStatus::Loading => this, + AgentImportStatus::Ready { + importable_count: count, + } => { + let label: SharedString = if count == 0 { + "No threads".into() + } else { + format!("{} threads", count).into() + }; + this.child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) + } + AgentImportStatus::Unsupported => this.child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(IconSize::Small), + ), + AgentImportStatus::Error(_) => this.child( + Icon::new(IconName::XCircle) + .color(Color::Error) + .size(IconSize::Small), + ), + }); + + let item = if is_loading { + item.with_animation( + "pulsating-icon", + Animation::new(Duration::from_secs(1)) + .repeat() + .with_easing(pulsating_between(0.2, 0.6)), + |icon, delta| icon.opacity(delta), + ) + .into_any_element() + } else { + item.into_any_element() + }; ListItem::new(("thread-import-agent", ix)) .rounded() .spacing(ListItemSpacing::Sparse) .focused(is_focused) - .disabled(self.is_importing) - .child( - h_flex() - .w_full() - .gap_2() - .when(!is_checked, |this| this.opacity(0.6)) - .child(if let Some(icon_path) = entry.icon_path.clone() { - Icon::from_external_svg(icon_path) - .color(Color::Muted) - .size(IconSize::Small) - } else { - Icon::new(IconName::Sparkle) - .color(Color::Muted) - .size(IconSize::Small) - }) - .child(Label::new(entry.display_name.clone())), - ) - .end_slot(Checkbox::new( - ("thread-import-agent-checkbox", ix), - if is_checked { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - )) + .disabled(row_disabled) + .child(item) + .end_slot(end_slot) + .when_some(status.tooltip_text(), |this, tooltip| { + this.tooltip(Tooltip::text(tooltip)) + }) .on_click({ let agent_id = entry.agent_id.clone(); cx.listener(move |this, _event, _window, cx| { @@ -408,7 +588,7 @@ impl Render for ThreadImportModal { .when(has_agents, |this| this.children(agent_rows)) .when(!has_agents, |this| { this.child( - Label::new("No ACP agents available.") + Label::new("No external agents available.") .color(Color::Muted) .size(LabelSize::Small), ) @@ -417,6 +597,22 @@ impl Render for ThreadImportModal { ) .footer( ModalFooter::new() + .when(self.is_fetching_sessions, |this| { + this.start_slot( + h_flex() + .gap_1() + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(3), + ) + .child(Label::new("Fetching Agent Threads…") + .size(LabelSize::Small) + .color(Color::Muted)) + + ) + }) .when_some(self.last_error.clone(), |this, error| { this.start_slot( Label::new(error) @@ -476,12 +672,26 @@ fn resolve_agent_connection_stores( stores } -fn find_threads_to_import( - agent_ids: Vec, +struct AgentSessionFetchResult { + agent_id: AgentId, + sessions_by_agent: Vec, + status: AgentImportStatus, +} + +#[derive(Default)] +struct AgentSessionFetchStats { + supported_attempt_count: usize, + successful_attempt_count: usize, + unsupported_attempt_count: usize, + errors: Vec, +} + +fn fetch_sessions_for_agent( + agent_id: AgentId, existing_sessions: HashSet, stores: Vec>, cx: &mut App, -) -> Task>> { +) -> Task { let mut wait_for_connection_tasks = Vec::new(); for store in stores { @@ -490,46 +700,94 @@ fn find_threads_to_import( .project() .read(cx) .remote_connection_options(cx); - - for agent_id in agent_ids.clone() { - let agent = Agent::from(agent_id.clone()); - let server = agent.server(::global(cx), ThreadStore::global(cx)); - let entry = store.update(cx, |store, cx| store.request_connection(agent, server, cx)); - - wait_for_connection_tasks.push(entry.read(cx).wait_for_connection().map({ - let remote_connection = remote_connection.clone(); - move |state| (agent_id, remote_connection, state) - })); - } + let agent = Agent::from(agent_id.clone()); + let server = agent.server(::global(cx), ThreadStore::global(cx)); + let entry = store.update(cx, |store, cx| store.request_connection(agent, server, cx)); + + wait_for_connection_tasks.push(entry.read(cx).wait_for_connection().map({ + let agent_id = agent_id.clone(); + let remote_connection = remote_connection.clone(); + move |result| (agent_id, remote_connection, result) + })); } cx.spawn(async move |cx| { + let mut stats = AgentSessionFetchStats::default(); let results = futures::future::join_all(wait_for_connection_tasks).await; let mut page_tasks = Vec::new(); for (agent_id, remote_connection, result) in results { - let Some(state) = result.log_err() else { - continue; + let state = match result { + Ok(state) => state, + Err(error) => { + log::warn!("Failed to connect to {agent_id} to list sessions: {error}"); + stats.errors.push(error.to_string().into()); + continue; + } }; + let Some(list) = cx.update(|cx| state.connection.session_list(cx)) else { + stats.unsupported_attempt_count += 1; continue; }; + + stats.supported_attempt_count += 1; page_tasks.push(cx.spawn({ let list = list.clone(); - async move |cx| collect_all_sessions(agent_id, remote_connection, list, cx).await + let agent_id_for_error = agent_id.clone(); + async move |cx| { + ( + agent_id_for_error, + collect_all_sessions(agent_id, remote_connection, list, cx).await, + ) + } })); } - let sessions_by_agent = futures::future::join_all(page_tasks) - .await - .into_iter() - .filter_map(|result| result.log_err()) - .collect(); + let mut sessions_by_agent = Vec::new(); + for (agent_id, result) in futures::future::join_all(page_tasks).await { + match result { + Ok(sessions) => { + stats.successful_attempt_count += 1; + sessions_by_agent.push(sessions); + } + Err(error) => { + log::warn!("Failed to list sessions for {agent_id}: {error}"); + stats.errors.push(error.to_string().into()); + } + } + } + + let importable_counts_by_agent = + count_importable_threads_by_agent(&sessions_by_agent, &existing_sessions); + let status = if stats.successful_attempt_count > 0 { + AgentImportStatus::Ready { + importable_count: importable_counts_by_agent + .get(&agent_id) + .copied() + .unwrap_or(0), + } + } else if stats.supported_attempt_count > 0 { + AgentImportStatus::Error( + stats + .errors + .first() + .cloned() + .unwrap_or_else(|| "Failed to list sessions.".into()), + ) + } else if stats.unsupported_attempt_count > 0 { + AgentImportStatus::Unsupported + } else if let Some(error) = stats.errors.first().cloned() { + AgentImportStatus::Error(error) + } else { + AgentImportStatus::Unsupported + }; - Ok(collect_importable_threads( + AgentSessionFetchResult { + agent_id, sessions_by_agent, - existing_sessions, - )) + status, + } }) } @@ -561,12 +819,39 @@ async fn collect_all_sessions( }) } +#[derive(Clone)] struct SessionByAgent { agent_id: AgentId, remote_connection: Option, sessions: Vec, } +fn count_importable_threads_by_agent( + sessions_by_agent: &[SessionByAgent], + existing_sessions: &HashSet, +) -> HashMap { + let mut counts_by_agent = HashMap::default(); + let mut seen_sessions_by_agent = HashMap::>::default(); + + for sessions_for_agent in sessions_by_agent { + let seen_sessions = seen_sessions_by_agent + .entry(sessions_for_agent.agent_id.clone()) + .or_insert_with(|| existing_sessions.clone()); + for session in &sessions_for_agent.sessions { + if !seen_sessions.insert(session.session_id.clone()) { + continue; + } + if session.work_dirs.is_some() { + *counts_by_agent + .entry(sessions_for_agent.agent_id.clone()) + .or_insert(0) += 1; + } + } + } + + counts_by_agent +} + fn collect_importable_threads( sessions_by_agent: Vec, mut existing_sessions: HashSet, @@ -658,19 +943,6 @@ fn import_threads_from_other_channels_in( .detach(); } -fn channel_has_threads(database_dir: &std::path::Path, channel: ReleaseChannel) -> bool { - let db_path = db::db_path(database_dir, channel); - if !db_path.exists() { - return false; - } - let connection = sqlez::connection::Connection::open_file(&db_path.to_string_lossy()); - connection - .select_row::("SELECT 1 FROM sidebar_threads LIMIT 1") - .ok() - .and_then(|mut query| query().ok().flatten()) - .unwrap_or(false) -} - fn read_threads_from_channel( database_dir: &std::path::Path, channel: ReleaseChannel, @@ -910,6 +1182,45 @@ mod tests { assert!(result.is_empty()); } + #[test] + fn test_count_importable_threads_by_agent_counts_each_agent_independently() { + let existing = HashSet::from_iter(vec![acp::SessionId::new("existing")]); + let paths = PathList::new(&[Path::new("/project")]); + let sessions_by_agent = vec![ + SessionByAgent { + agent_id: AgentId::new("agent-a"), + remote_connection: None, + sessions: vec![ + make_session( + "existing", + Some("Existing"), + Some(paths.clone()), + None, + None, + ), + make_session("shared", Some("Shared A"), Some(paths.clone()), None, None), + make_session("no-dirs", Some("No Dirs"), None, None, None), + ], + }, + SessionByAgent { + agent_id: AgentId::new("agent-b"), + remote_connection: None, + sessions: vec![make_session( + "shared", + Some("Shared B"), + Some(paths), + None, + None, + )], + }, + ]; + + let counts = count_importable_threads_by_agent(&sessions_by_agent, &existing); + + assert_eq!(counts.get(&AgentId::new("agent-a")), Some(&1)); + assert_eq!(counts.get(&AgentId::new("agent-b")), Some(&1)); + } + fn create_channel_db( db_dir: &std::path::Path, channel: ReleaseChannel, diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 5b4b90e4b706a0..49bfbec9c0eade 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -718,6 +718,26 @@ impl ThreadMetadataStore { self.save(metadata, cx); } + pub fn set_generated_title( + &mut self, + thread_id: ThreadId, + title: SharedString, + cx: &mut Context, + ) { + let Some(existing) = self.entry(thread_id) else { + return; + }; + if existing.title.as_ref() == Some(&title) && existing.title_override.is_none() { + return; + } + let metadata = ThreadMetadata { + title: Some(title), + title_override: None, + ..existing.clone() + }; + self.save(metadata, cx); + } + fn save_internal(&mut self, metadata: ThreadMetadata) { if let Some(thread) = self.threads.get(&metadata.thread_id) { if thread.folder_paths() != metadata.folder_paths() { @@ -1985,6 +2005,39 @@ mod tests { }); } + #[gpui::test] + async fn test_store_set_generated_title_clears_title_override(cx: &mut TestAppContext) { + init_test(cx); + + let mut metadata = make_metadata( + "session-1", + "Old Generated Title", + Utc::now(), + PathList::default(), + ); + metadata.title_override = Some("User Title".into()); + let thread_id = metadata.thread_id; + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.save(metadata, cx); + store.set_generated_title(thread_id, "New Generated Title".into(), cx); + }); + }); + + cx.run_until_parked(); + + cx.update(|cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + let metadata = store.entry(thread_id).expect("metadata should be cached"); + assert_eq!(metadata.title.as_deref(), Some("New Generated Title")); + assert_eq!(metadata.title_override, None); + assert_eq!(metadata.display_title().as_ref(), "New Generated Title"); + }); + } + #[gpui::test] async fn test_store_initializes_cache_from_database(cx: &mut TestAppContext) { let first_paths = PathList::new(&[Path::new("/project-a")]); diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index a86c902443ce9a..75cf1bf9934c5a 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -133,6 +133,7 @@ pub enum ThreadsArchiveViewEvent { Activate { thread: ThreadMetadata }, CancelRestore { thread_id: ThreadId }, Import, + NewThread, } impl EventEmitter for ThreadsArchiveView {} @@ -824,7 +825,7 @@ impl ThreadsArchiveView { if let Some(list) = state .connection .session_list(cx) - .filter(|list| list.supports_delete(cx)) + .filter(|list| list.supports_delete()) { list.delete_session(session_id, cx) } else { @@ -961,6 +962,14 @@ impl ThreadsArchiveView { .child( h_flex() .gap_1() + .child( + IconButton::new("new-thread", IconName::Plus) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Start New Agent Thread")) + .on_click(cx.listener(|_this, _, _, cx| { + cx.emit(ThreadsArchiveViewEvent::NewThread); + })), + ) .child( IconButton::new("thread-import", IconName::Download) .icon_size(IconSize::Small) diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 77cfc5512edc1f..8b2fa0cbab6ec5 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -199,6 +199,9 @@ fn open_mention_uri( } => { open_skill_file(workspace, skill_file_path, window, cx); } + MentionUri::Rule { name, .. } => { + open_migrated_rule(workspace, &name, window, cx); + } MentionUri::Fetch { url } => { cx.open_url(url.as_str()); } @@ -211,49 +214,70 @@ fn open_mention_uri( }); } +/// Notify the user that rules became skills and open the skill the rule was +/// migrated into. Migrated skills live in the local global skills dir, so the +/// file is always resolved against the local filesystem (local, SSH, or +/// collab). Does nothing else when no matching skill exists. +pub(crate) fn open_migrated_rule( + workspace: &mut Workspace, + name: &str, + window: &mut Window, + cx: &mut Context, +) { + struct RulesMigratedToSkillsToast; + workspace.show_toast( + workspace::Toast::new( + workspace::notifications::NotificationId::unique::(), + "Rules have been migrated to Skills.", + ) + .on_click("View docs", |_, cx| { + cx.open_url("https://zed.dev/docs/ai/skills"); + }) + .autohide(), + cx, + ); + + let Some(slug) = agent_skills::slugify_skill_name(name) else { + return; + }; + let skill_file_path = agent_skills::global_skills_dir() + .join(slug) + .join(agent_skills::SKILL_FILE_NAME); + + if workspace.project().read(cx).is_local() { + // Local project: open the editable on-disk file if it exists. + if skill_file_path.exists() { + open_skill_file(workspace, skill_file_path, window, cx); + } + return; + } + + // Remote/collab: `open_abs_path` targets the remote project, where this + // local file doesn't exist, so read it locally and show it read-only. + let fs = workspace.app_state().fs.clone(); + cx.spawn_in(window, async move |workspace, cx| { + let Ok(content) = fs.load(&skill_file_path).await else { + return Ok(()); // No readable migrated skill: do nothing. + }; + let title = skill_content_buffer_title(&skill_file_path); + workspace.update_in(cx, |workspace, window, cx| { + open_skill_content_buffer(workspace, title, content, window, cx); + }) + }) + .detach_and_log_err(cx); +} + fn open_skill_file( workspace: &mut Workspace, skill_file_path: PathBuf, window: &mut Window, cx: &mut Context, ) { - // Built-in skills have synthetic paths that don't exist on disk. - // Open a read-only buffer with the embedded content instead. - // - // The buffer is intentionally not registered with the project's buffer - // store: it has no on-disk backing, isn't searchable, and `Project:: - // create_local_buffer` panics for remote projects (SSH/collab), which - // would crash Zed if a user clicked a built-in skill mention while - // connected to a remote project. + // Built-in skills have synthetic paths with no on-disk file, so show their + // embedded content in a local buffer instead. if let Some(content) = agent_skills::builtin_skill_content(&skill_file_path) { - let languages = workspace.project().read(cx).languages().clone(); - let buffer = cx.new(|cx| Buffer::local(content, cx)); - // Set markdown highlighting asynchronously — the buffer - // opens instantly and the highlighting appears once loaded. - cx.spawn({ - let buffer = buffer.clone(); - async move |_, cx| { - if let Ok(markdown) = languages.language_for_name("Markdown").await { - buffer.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx)); - } - } - }) - .detach(); - let editor = cx.new(|cx| { - let mut editor = Editor::for_buffer(buffer, None, window, cx); - editor.set_read_only(true); - let title = skill_file_path - .parent() - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "built-in skill".into()); - editor - .buffer() - .update(cx, |buffer, cx| buffer.set_title(title, cx)); - editor - }); - let pane = workspace.active_pane().clone(); - workspace.add_item(pane, Box::new(editor), None, true, true, window, cx); + let title = skill_content_buffer_title(&skill_file_path); + open_skill_content_buffer(workspace, title, content, window, cx); return; } @@ -270,6 +294,51 @@ fn open_skill_file( .detach_and_log_err(cx); } +fn skill_content_buffer_title(skill_file_path: &std::path::Path) -> String { + skill_file_path + .parent() + .and_then(|p| p.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "skill".into()) +} + +/// Open `content` as a local, read-only Markdown buffer, for skills with no +/// openable file in the active project (built-in skills, and migrated rules on +/// remote/collab projects). It's deliberately not registered with the project's +/// buffer store: that keeps it out of search and avoids +/// `Project::create_local_buffer` panicking on remote projects. +fn open_skill_content_buffer( + workspace: &mut Workspace, + title: String, + content: impl Into, + window: &mut Window, + cx: &mut Context, +) { + let languages = workspace.project().read(cx).languages().clone(); + let buffer = cx.new(|cx| Buffer::local(content, cx)); + // Set markdown highlighting asynchronously — the buffer + // opens instantly and the highlighting appears once loaded. + cx.spawn({ + let buffer = buffer.clone(); + async move |_, cx| { + if let Ok(markdown) = languages.language_for_name("Markdown").await { + buffer.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx)); + } + } + }) + .detach(); + let editor = cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer, None, window, cx); + editor.set_read_only(true); + editor + .buffer() + .update(cx, |buffer, cx| buffer.set_title(title, cx)); + editor + }); + let pane = workspace.active_pane().clone(); + workspace.add_item(pane, Box::new(editor), None, true, true, window, cx); +} + fn open_file( workspace: &mut Workspace, abs_path: PathBuf, diff --git a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs index 56b4e7ffaa686b..217bd635b24084 100644 --- a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs +++ b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs @@ -1,4 +1,4 @@ -use gpui::{Action, IntoElement, ParentElement, RenderOnce, point}; +use gpui::{Action, IntoElement, ParentElement, RenderOnce}; use language_model::{IconOrSvg, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; use ui::{Divider, List, ListBulletItem, prelude::*}; @@ -68,13 +68,9 @@ impl Render for ApiKeysWithProviders { .border_x_1() .border_color(cx.theme().colors().border.opacity(0.5)) .bg(cx.theme().colors().background.alpha(0.5)) - .shadow(vec![gpui::BoxShadow { - color: gpui::black().opacity(0.15), - offset: point(px(1.), px(-1.)), - blur_radius: px(3.), - spread_radius: px(0.), - inset: false, - }]) + .shadow(vec![ + gpui::BoxShadow::new(px(1.), px(-1.), gpui::black().opacity(0.15)).blur_radius(px(3.)), + ]) .child( h_flex() .px_2p5() diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index b8d4253d859fe2..33ad694509f197 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -20,6 +20,9 @@ pub mod completion; pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; +pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; +pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; + #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub enum AnthropicModelMode { @@ -954,73 +957,89 @@ impl From for Speed { impl From for language_model_core::LanguageModelCompletionError { fn from(error: AnthropicError) -> Self { - let provider = language_model_core::ANTHROPIC_PROVIDER_NAME; - match error { - AnthropicError::SerializeRequest(error) => Self::SerializeRequest { provider, error }, - AnthropicError::BuildRequestBody(error) => Self::BuildRequestBody { provider, error }, - AnthropicError::HttpSend(error) => Self::HttpSend { provider, error }, - AnthropicError::DeserializeResponse(error) => { - Self::DeserializeResponse { provider, error } - } - AnthropicError::ReadResponse(error) => Self::ApiReadResponseError { provider, error }, - AnthropicError::HttpResponseError { - status_code, - message, - } => Self::HttpResponseError { + completion_error_from_anthropic(error, language_model_core::ANTHROPIC_PROVIDER_NAME) + } +} + +impl From for language_model_core::LanguageModelCompletionError { + fn from(error: ApiError) -> Self { + completion_error_from_anthropic_api(error, language_model_core::ANTHROPIC_PROVIDER_NAME) + } +} + +pub fn completion_error_from_anthropic( + error: AnthropicError, + provider: language_model_core::LanguageModelProviderName, +) -> language_model_core::LanguageModelCompletionError { + use language_model_core::LanguageModelCompletionError as Error; + match error { + AnthropicError::SerializeRequest(error) => Error::SerializeRequest { provider, error }, + AnthropicError::BuildRequestBody(error) => Error::BuildRequestBody { provider, error }, + AnthropicError::HttpSend(error) => Error::HttpSend { provider, error }, + AnthropicError::DeserializeResponse(error) => { + Error::DeserializeResponse { provider, error } + } + AnthropicError::ReadResponse(error) => Error::ApiReadResponseError { provider, error }, + AnthropicError::HttpResponseError { + status_code, + message, + } => Error::HttpResponseError { + provider, + status_code, + message, + }, + AnthropicError::RateLimit { retry_after } => Error::RateLimitExceeded { + provider, + retry_after: Some(retry_after), + }, + AnthropicError::ServerOverloaded { retry_after } => Error::ServerOverloaded { + provider, + retry_after, + }, + AnthropicError::ApiError(api_error) => { + completion_error_from_anthropic_api(api_error, provider) + } + } +} + +pub fn completion_error_from_anthropic_api( + error: ApiError, + provider: language_model_core::LanguageModelProviderName, +) -> language_model_core::LanguageModelCompletionError { + use ApiErrorCode::*; + use language_model_core::LanguageModelCompletionError as Error; + match error.code() { + Some(code) => match code { + InvalidRequestError => Error::BadRequestFormat { provider, - status_code, - message, + message: error.message, }, - AnthropicError::RateLimit { retry_after } => Self::RateLimitExceeded { + AuthenticationError => Error::AuthenticationError { provider, - retry_after: Some(retry_after), + message: error.message, }, - AnthropicError::ServerOverloaded { retry_after } => Self::ServerOverloaded { + PermissionError => Error::PermissionError { provider, - retry_after, + message: error.message, }, - AnthropicError::ApiError(api_error) => api_error.into(), - } - } -} - -impl From for language_model_core::LanguageModelCompletionError { - fn from(error: ApiError) -> Self { - use ApiErrorCode::*; - let provider = language_model_core::ANTHROPIC_PROVIDER_NAME; - match error.code() { - Some(code) => match code { - InvalidRequestError => Self::BadRequestFormat { - provider, - message: error.message, - }, - AuthenticationError => Self::AuthenticationError { - provider, - message: error.message, - }, - PermissionError => Self::PermissionError { - provider, - message: error.message, - }, - NotFoundError => Self::ApiEndpointNotFound { provider }, - RequestTooLarge => Self::PromptTooLarge { - tokens: language_model_core::parse_prompt_too_long(&error.message), - }, - RateLimitError => Self::RateLimitExceeded { - provider, - retry_after: None, - }, - ApiError => Self::ApiInternalServerError { - provider, - message: error.message, - }, - OverloadedError => Self::ServerOverloaded { - provider, - retry_after: None, - }, + NotFoundError => Error::ApiEndpointNotFound { provider }, + RequestTooLarge => Error::PromptTooLarge { + tokens: language_model_core::parse_prompt_too_long(&error.message), }, - None => Self::Other(error.into()), - } + RateLimitError => Error::RateLimitExceeded { + provider, + retry_after: None, + }, + ApiError => Error::ApiInternalServerError { + provider, + message: error.message, + }, + OverloadedError => Error::ServerOverloaded { + provider, + retry_after: None, + }, + }, + None => Error::Other(error.into()), } } diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 2b42bcae88b946..76cbbec5809a8a 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -2,9 +2,9 @@ use anyhow::Result; use collections::HashMap; use futures::{Stream, StreamExt}; use language_model_core::{ - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - Role, StopReason, TokenUsage, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelProviderName, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, MessageContent, Role, StopReason, TokenUsage, util::{fix_streamed_json, parse_tool_arguments}, }; use std::pin::Pin; @@ -14,6 +14,7 @@ use crate::{ AdaptiveThinkingDisplay, AnthropicError, AnthropicModelMode, CacheControl, CacheControlType, CacheTtl, ContentDelta, Event, ImageSource, Message, RequestContent, ResponseContent, StringOrContents, Thinking, Tool, ToolChoice, ToolResultContent, ToolResultPart, Usage, + completion_error_from_anthropic, completion_error_from_anthropic_api, }; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -323,14 +324,16 @@ pub struct AnthropicEventMapper { tool_uses_by_index: HashMap, usage: Usage, stop_reason: StopReason, + provider_name: LanguageModelProviderName, } impl AnthropicEventMapper { - pub fn new() -> Self { + pub fn new(provider_name: LanguageModelProviderName) -> Self { Self { tool_uses_by_index: HashMap::default(), usage: Usage::default(), stop_reason: StopReason::EndTurn, + provider_name, } } @@ -342,7 +345,10 @@ impl AnthropicEventMapper { events.flat_map(move |event| { futures::stream::iter(match event { Ok(event) => self.map_event(event), - Err(error) => vec![Err(error.into())], + Err(error) => vec![Err(completion_error_from_anthropic( + error, + self.provider_name.clone(), + ))], }) }) } @@ -484,7 +490,10 @@ impl AnthropicEventMapper { vec![Ok(LanguageModelCompletionEvent::Stop(self.stop_reason))] } Event::Error { error } => { - vec![Err(error.into())] + vec![Err(completion_error_from_anthropic_api( + error, + self.provider_name.clone(), + ))] } _ => Vec::new(), } diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index c550d95979f7f8..e887841b584914 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -4,6 +4,7 @@ pub use encrypted_password::{EncryptedPassword, IKnowWhatIAmDoingAndIHaveReadThe use net::async_net::UnixListener; use smol::lock::Mutex; +#[cfg(not(target_os = "windows"))] use util::fs::make_file_executable; use std::ffi::OsStr; @@ -19,13 +20,20 @@ use futures::{ select_biased, }; use gpui::{AsyncApp, BackgroundExecutor, Task}; +#[cfg(not(target_os = "windows"))] use smol::fs; -use util::{ResultExt as _, debug_panic, maybe, paths::PathExt, shell::ShellKind}; +use util::{ResultExt as _, debug_panic, maybe}; + +#[cfg(not(target_os = "windows"))] +use util::{paths::PathExt, shell::ShellKind}; /// Path to the program used for askpass /// -/// On Unix and remote servers, this defaults to the current executable -/// On Windows, this is set to the CLI variant of zed +/// On Unix and remote servers, this defaults to the current executable. +/// On Windows, this must be set to the CLI variant of zed via set_askpass_program(), +/// because SSH_ASKPASS must point to a directly executable binary. The CLI binary +/// handles the ZED_ASKPASS_SOCKET env var to communicate with Zed over a Unix socket +/// without needing a wrapper script. static ASKPASS_PROGRAM: OnceLock = OnceLock::new(); #[derive(PartialEq, Eq)] @@ -80,11 +88,8 @@ pub struct AskPassSession { executor: BackgroundExecutor, } -const ASKPASS_SCRIPT_NAME: &str = if cfg!(target_os = "windows") { - "askpass.ps1" -} else { - "askpass.sh" -}; +#[cfg(not(target_os = "windows"))] +const ASKPASS_SCRIPT_NAME: &str = "askpass.sh"; impl AskPassSession { /// This will create a new AskPassSession. @@ -177,17 +182,34 @@ impl AskPassSession { self.secret.lock().ok()?.clone() } + /// Returns the value to set as SSH_ASKPASS. + /// On Unix this is the path to the generated shell script. + /// On Windows this is the path to cli.exe directly — no script needed. pub fn script_path(&self) -> impl AsRef { self.askpass_task.script_path() } + + /// Returns the socket path to set as ZED_ASKPASS_SOCKET. + /// + /// On Windows, SSH_ASKPASS points directly to cli.exe. SSH passes only + /// the prompt string as argv[1] with no mechanism for extra arguments, + /// so the socket path is communicated via this environment variable instead. + /// cli.exe must check ZED_ASKPASS_SOCKET before clap parses args. + #[cfg(target_os = "windows")] + pub fn socket_path(&self) -> impl AsRef { + self.askpass_task.socket_path() + } } pub struct PasswordProxy { _task: Task<()>, - #[cfg(not(target_os = "windows"))] + /// On Unix: path to the generated .sh askpass script (set as SSH_ASKPASS). + /// On Windows: path to cli.exe (set as SSH_ASKPASS directly — no script needed). askpass_script_path: std::path::PathBuf, + /// On Windows only: path to the Unix socket, passed as ZED_ASKPASS_SOCKET + /// so cli.exe can find it without --askpass argument parsing. #[cfg(target_os = "windows")] - askpass_helper: String, + askpass_socket_path: std::path::PathBuf, } impl PasswordProxy { @@ -202,19 +224,20 @@ impl PasswordProxy { ) -> Result { let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?; let askpass_socket = temp_dir.path().join("askpass.sock"); - let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME); let current_exec = std::env::current_exe().context("Failed to determine current zed executable path.")?; - // TODO: inferred from the use of powershell.exe in askpass_helper_script - let shell_kind = if cfg!(windows) { - ShellKind::PowerShell - } else { - ShellKind::Posix - }; let askpass_program = ASKPASS_PROGRAM.get_or_init(|| current_exec); - // Create an askpass script that communicates back to this process. - let askpass_script = generate_askpass_script(shell_kind, askpass_program, &askpass_socket)?; + + // Unix: SSH_ASKPASS = path to generated .sh script in temp dir. + // Windows: SSH_ASKPASS = path to cli.exe directly. No script is written. + #[cfg(not(target_os = "windows"))] + let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME); + #[cfg(target_os = "windows")] + let askpass_script_path = askpass_program.to_path_buf(); + + let askpass_socket_path = askpass_socket.clone(); + let _task = executor.spawn(async move { maybe!(async move { let listener = @@ -253,44 +276,60 @@ impl PasswordProxy { .log_err(); }); - fs::write(&askpass_script_path, askpass_script) - .await - .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?; - make_file_executable(&askpass_script_path) - .await - .with_context(|| { - format!("marking askpass script executable at {askpass_script_path:?}") - })?; - // todo(shell): There might be no powershell on the system - #[cfg(target_os = "windows")] - let askpass_helper = format!( - "powershell.exe -ExecutionPolicy Bypass -File \"{}\"", - askpass_script_path.display() - ); + // Unix only: write the shell script and mark it executable. + // On Windows cli.exe is invoked directly, so no script is needed. + #[cfg(not(target_os = "windows"))] + { + let askpass_script = generate_askpass_script(askpass_program, &askpass_socket_path)?; + fs::write(&askpass_script_path, askpass_script) + .await + .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?; + make_file_executable(&askpass_script_path) + .await + .with_context(|| { + format!("marking askpass script executable at {askpass_script_path:?}") + })?; + } Ok(Self { _task, - #[cfg(not(target_os = "windows"))] askpass_script_path, #[cfg(target_os = "windows")] - askpass_helper, + askpass_socket_path, }) } pub fn script_path(&self) -> impl AsRef { - #[cfg(not(target_os = "windows"))] - { - &self.askpass_script_path - } - #[cfg(target_os = "windows")] - { - &self.askpass_helper - } + &self.askpass_script_path + } + + #[cfg(target_os = "windows")] + pub fn socket_path(&self) -> impl AsRef { + &self.askpass_socket_path } } -/// The main function for when Zed is running in netcat mode for use in askpass. -/// Called from both the remote server binary and the zed binary in their respective main functions. + +/// Runs Zed in netcat mode for use in askpass. pub fn main(socket: &str) { + use std::io::{self, Read}; + use std::process::exit; + + let mut buffer = Vec::new(); + if let Err(err) = io::stdin().read_to_end(&mut buffer) { + eprintln!("Error reading from stdin: {}", err); + exit(1); + } + + connect_and_write_prompt(socket, buffer) +} + +/// Runs Zed in askpass mode using prompts passed as arguments. +pub fn main_from_args(socket: &str, args: impl IntoIterator) { + let prompt = args.into_iter().collect::>().join("\0"); + connect_and_write_prompt(socket, prompt.into_bytes()) +} + +fn connect_and_write_prompt(socket: &str, mut buffer: Vec) { use net::UnixStream; use std::io::{self, Read, Write}; use std::process::exit; @@ -303,12 +342,6 @@ pub fn main(socket: &str) { } }; - let mut buffer = Vec::new(); - if let Err(err) = io::stdin().read_to_end(&mut buffer) { - eprintln!("Error reading from stdin: {}", err); - exit(1); - } - #[cfg(target_os = "windows")] while buffer.last().is_some_and(|&b| b == b'\n' || b == b'\r') { buffer.pop(); @@ -340,13 +373,14 @@ pub fn set_askpass_program(path: std::path::PathBuf) { } } -#[inline] +/// Generates the Unix shell askpass script. +/// Not used on Windows — cli.exe is invoked directly as SSH_ASKPASS. #[cfg(not(target_os = "windows"))] fn generate_askpass_script( - shell_kind: ShellKind, askpass_program: &std::path::Path, askpass_socket: &std::path::Path, ) -> Result { + let shell_kind = ShellKind::Posix; let askpass_program = shell_kind.prepend_command_prefix( askpass_program .to_str() @@ -364,29 +398,3 @@ fn generate_askpass_script( "{shebang}\n{print_args} | {askpass_program} --askpass={askpass_socket} 2> /dev/null \n", )) } - -#[inline] -#[cfg(target_os = "windows")] -fn generate_askpass_script( - shell_kind: ShellKind, - askpass_program: &std::path::Path, - askpass_socket: &std::path::Path, -) -> Result { - let askpass_program = shell_kind.prepend_command_prefix( - askpass_program - .to_str() - .context("Askpass program is on a non-utf8 path")?, - ); - let askpass_program = shell_kind - .try_quote_prefix_aware(&askpass_program) - .context("Failed to shell-escape Askpass program path")?; - let askpass_socket = askpass_socket - .try_shell_safe(shell_kind) - .context("Failed to shell-escape Askpass socket path")?; - Ok(format!( - r#" - $ErrorActionPreference = 'Stop'; - ($args -join [char]0) | {askpass_program} --askpass={askpass_socket} 2> $null - "#, - )) -} diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index 24a4032d954c2e..e5222c5434d8f9 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -8,7 +8,6 @@ pub const CHANNEL_COUNT: ChannelCount = nz!(2); mod audio_settings; pub use audio_settings::AudioSettings; -pub use audio_settings::LIVE_SETTINGS; mod audio_pipeline; pub use audio_pipeline::Audio; diff --git a/crates/audio/src/audio_pipeline.rs b/crates/audio/src/audio_pipeline.rs index a2b353563bdf7b..e09f4474af6dca 100644 --- a/crates/audio/src/audio_pipeline.rs +++ b/crates/audio/src/audio_pipeline.rs @@ -19,17 +19,13 @@ 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); -} +pub fn init(_cx: &mut App) {} // TODO(jk): this is currently cached only once - we should observe and react instead pub fn ensure_devices_initialized(cx: &mut App) { diff --git a/crates/audio/src/audio_settings.rs b/crates/audio/src/audio_settings.rs index e200e232888e91..7f01fc9d980978 100644 --- a/crates/audio/src/audio_settings.rs +++ b/crates/audio/src/audio_settings.rs @@ -1,22 +1,10 @@ -use std::{ - str::FromStr, - sync::atomic::{AtomicBool, Ordering}, -}; +use std::str::FromStr; use cpal::DeviceId; -use gpui::App; -use settings::{RegisterSetting, Settings, SettingsStore}; +use settings::{RegisterSetting, Settings}; #[derive(Clone, Debug, RegisterSetting)] pub struct AudioSettings { - /// Automatically increase or decrease you microphone's volume. This affects how - /// loud you sound to others. - /// - /// Recommended: off (default) - /// Microphones are too quite in zed, until everyone is on experimental - /// audio and has auto speaker volume on this will make you very loud - /// compared to other speakers. - pub auto_microphone_volume: bool, /// Select specific output audio device. pub output_audio_device: Option, /// Select specific input audio device. @@ -28,7 +16,6 @@ impl Settings for AudioSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let audio = &content.audio.as_ref().unwrap(); AudioSettings { - auto_microphone_volume: audio.auto_microphone_volume.unwrap(), output_audio_device: audio .output_audio_device .as_ref() @@ -40,33 +27,3 @@ impl Settings for AudioSettings { } } } - -/// See docs on [LIVE_SETTINGS] -pub struct LiveSettings { - pub auto_microphone_volume: AtomicBool, -} - -impl LiveSettings { - pub(crate) fn initialize(&self, cx: &mut App) { - cx.observe_global::(move |cx| { - LIVE_SETTINGS.auto_microphone_volume.store( - AudioSettings::get_global(cx).auto_microphone_volume, - Ordering::Relaxed, - ); - }) - .detach(); - - let init_settings = AudioSettings::get_global(cx); - LIVE_SETTINGS - .auto_microphone_volume - .store(init_settings.auto_microphone_volume, Ordering::Relaxed); - } -} - -/// Allows access to settings from the audio thread. Updated by -/// observer of SettingsStore. Needed because audio playback and recording are -/// real time and must each run in a dedicated OS thread, therefore we can not -/// use the background executor. -pub static LIVE_SETTINGS: LiveSettings = LiveSettings { - auto_microphone_volume: AtomicBool::new(true), -}; diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 15afca8e8b6929..9786aa84d1622f 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -281,7 +281,7 @@ pub fn check(_: &Check, window: &mut Window, cx: &mut App) { gpui::PromptLevel::Info, "Zed was installed via a package manager.", Some(&message), - &["Ok"], + &["OK"], cx, )); return; @@ -301,7 +301,7 @@ pub fn check(_: &Check, window: &mut Window, cx: &mut App) { gpui::PromptLevel::Info, "Could not check for updates", Some("Auto-updates disabled for non-bundled app."), - &["Ok"], + &["OK"], cx, )); } @@ -719,8 +719,30 @@ impl AutoUpdater { cx.notify(); }); - let new_binary_path = Self::install_release(installer_dir, &target_path, cx) + #[cfg(test)] + let install_result = match cx + .try_read_global::(|g, _| g.0.clone()) + .map(|test_install| test_install(&target_path, cx)) + { + Some(result) => result, + None => return Ok(()), + }; + + #[cfg(not(test))] + let install_result = { + let running_app_path = cx.update(|cx| cx.app_path())?; + let background_executor = cx.background_executor().clone(); + let channel = cx.update(|cx| ReleaseChannel::global(cx).dev_name()); + cx.background_spawn(Self::install_release( + installer_dir, + target_path.clone(), + running_app_path, + channel, + background_executor, + )) .await + }; + let new_binary_path = install_result .with_context(|| format!("Failed to install update at: {}", target_path.display()))?; if let Some(new_binary_path) = new_binary_path { cx.update(|cx| cx.set_restart_path(new_binary_path)); @@ -819,21 +841,28 @@ impl AutoUpdater { Ok(installer_dir.path().join(filename)) } + #[cfg_attr(test, allow(dead_code))] async fn install_release( installer_dir: InstallerDir, - target_path: &Path, - cx: &AsyncApp, + target_path: PathBuf, + running_app_path: PathBuf, + channel: &str, + background_executor: BackgroundExecutor, ) -> Result> { - #[cfg(test)] - if let Some(test_install) = - cx.try_read_global::(|g, _| g.0.clone()) - { - return test_install(target_path, cx); - } match OS { - "macos" => install_release_macos(&installer_dir, target_path, cx).await, - "linux" => install_release_linux(&installer_dir, target_path, cx).await, - "windows" => install_release_windows(target_path).await, + "macos" => { + install_release_macos( + &installer_dir, + &target_path, + running_app_path, + &background_executor, + ) + .await + } + "linux" => { + install_release_linux(&installer_dir, &target_path, channel, running_app_path).await + } + "windows" => install_release_windows(&target_path).await, unsupported_os => anyhow::bail!("not supported: {unsupported_os}"), } } @@ -978,11 +1007,10 @@ async fn download_release( async fn install_release_linux( temp_dir: &InstallerDir, downloaded_tar_gz: &Path, - cx: &AsyncApp, + channel: &str, + running_app_path: PathBuf, ) -> Result> { - let channel = cx.update(|cx| ReleaseChannel::global(cx).dev_name()); let home_dir = PathBuf::from(env::var("HOME").context("no HOME env var set")?); - let running_app_path = cx.update(|cx| cx.app_path())?; let extracted = temp_dir.path().join("zed"); fs::create_dir_all(&extracted) @@ -1047,9 +1075,9 @@ async fn install_release_linux( async fn install_release_macos( temp_dir: &InstallerDir, downloaded_dmg: &Path, - cx: &AsyncApp, + running_app_path: PathBuf, + background_executor: &BackgroundExecutor, ) -> Result> { - let running_app_path = cx.update(|cx| cx.app_path())?; let running_app_filename = running_app_path .file_name() .with_context(|| format!("invalid running app path {running_app_path:?}"))?; @@ -1077,7 +1105,7 @@ async fn install_release_macos( // Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits let _unmounter = MacOsUnmounter { mount_path: mount_path.clone(), - background_executor: cx.background_executor(), + background_executor, }; let mut cmd = new_command("rsync"); diff --git a/crates/auto_update_ui/src/auto_update_ui.rs b/crates/auto_update_ui/src/auto_update_ui.rs index ca37bbb1b876c0..b6f4faec93f191 100644 --- a/crates/auto_update_ui/src/auto_update_ui.rs +++ b/crates/auto_update_ui/src/auto_update_ui.rs @@ -20,9 +20,10 @@ use util::{ResultExt as _, maybe}; use workspace::{ Workspace, notifications::{ - ErrorMessagePrompt, Notification, NotificationId, SuppressEvent, show_app_notification, + Notification, NotificationId, SuppressEvent, show_app_notification, simple_message_notification::MessageNotification, }, + workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError}, }; use zed_actions::ShowUpdateNotification; @@ -64,21 +65,28 @@ fn notify_release_notes_failed_to_show( _window: &mut Window, cx: &mut Context, ) { - struct ViewReleaseNotesError; - workspace.show_notification( - NotificationId::unique::(), - cx, - |cx| { - cx.new(move |cx| { - let url = release_notes_url(cx); - let mut prompt = ErrorMessagePrompt::new("Couldn't load release notes", cx); - if let Some(url) = url { - prompt = prompt.with_link_button("View in Browser".to_string(), url); - } - prompt - }) - }, - ); + let url = release_notes_url(cx); + + struct ReleaseNotesError { + url: Option, + } + + impl WorkspaceError for ReleaseNotesError { + fn primary_message(&self) -> SharedString { + "Couldn't load release notes".into() + } + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Error + } + fn primary_action(&self) -> ErrorAction { + self.url + .clone() + .map(|url| ErrorAction::link("View in Browser", url)) + .unwrap_or_else(ErrorAction::dismiss) + } + } + + workspace.show_error(ReleaseNotesError { url }, cx); } fn view_release_notes_locally( diff --git a/crates/benchmarks/Cargo.toml b/crates/benchmarks/Cargo.toml new file mode 100644 index 00000000000000..20f980a88110f2 --- /dev/null +++ b/crates/benchmarks/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "benchmarks" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lib] +path = "src/benchmarks.rs" +doctest = false + +[lints] +workspace = true + +[dev-dependencies] +action_log.workspace = true +agent = { workspace = true, features = ["test-support"] } +agent_settings.workspace = true +assets.workspace = true +criterion.workspace = true +editor = { workspace = true, features = ["test-support"] } +futures.workspace = true +gpui = { workspace = true, features = ["bench"] } +gpui_platform = { workspace = true, features = ["test-support"] } +itertools.workspace = true +language = { workspace = true, features = ["test-support"] } +language_model = { workspace = true, features = ["test-support"] } +lsp = { workspace = true, features = ["test-support"] } +multi_buffer.workspace = true +project = { workspace = true, features = ["test-support"] } +prompt_store.workspace = true +rand.workspace = true +serde_json.workspace = true +settings = { workspace = true, features = ["test-support"] } +text.workspace = true +theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true +ui.workspace = true +util = { workspace = true, features = ["test-support"] } +zed_actions.workspace = true + +[[bench]] +name = "editor_render" +harness = false + +[[bench]] +name = "display_map" +harness = false + +[[bench]] +name = "edit_file_tool" +harness = false diff --git a/crates/git_graph/LICENSE-GPL b/crates/benchmarks/LICENSE-GPL similarity index 100% rename from crates/git_graph/LICENSE-GPL rename to crates/benchmarks/LICENSE-GPL diff --git a/crates/editor/benches/display_map.rs b/crates/benchmarks/benches/display_map.rs similarity index 100% rename from crates/editor/benches/display_map.rs rename to crates/benchmarks/benches/display_map.rs diff --git a/crates/agent/benches/edit_file_tool.rs b/crates/benchmarks/benches/edit_file_tool.rs similarity index 100% rename from crates/agent/benches/edit_file_tool.rs rename to crates/benchmarks/benches/edit_file_tool.rs diff --git a/crates/editor/benches/editor_render.rs b/crates/benchmarks/benches/editor_render.rs similarity index 80% rename from crates/editor/benches/editor_render.rs rename to crates/benchmarks/benches/editor_render.rs index 2840d782bd594f..91885fa74a50c5 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/benchmarks/benches/editor_render.rs @@ -6,18 +6,18 @@ use editor::{ use gpui::{AppContext as _, BenchAppContext, Focusable as _, TestAppContext, TestDispatcher}; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; use settings::SettingsStore; -use ui::IntoElement; use util::RandomCharIter; +use zed_actions::editor::{MoveDown, MoveUp}; #[gpui::bench] -fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &mut BenchAppContext) { +fn editor_input_with_1000_cursors(cx: &mut BenchAppContext) { init_context(cx); let text = String::from_iter(["line:\n"; 1000]); let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); - let mut cx = cx.add_empty_window(); - let editor = cx.update(|window, cx| { + let mut window = cx.add_empty_window(); + let editor = window.update(|window, cx| { let editor = cx.new(|cx| { let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); editor.set_style(editor::EditorStyle::default(), window, cx); @@ -35,8 +35,8 @@ fn editor_input_with_1000_cursors(bencher: &mut Bencher<'_>, cx: &mut BenchAppCo editor }); - bencher.iter(|| { - cx.update(|window, cx| { + cx.bench_iter(|_| { + window.update(|window, cx| { editor.update(cx, |editor, cx| { editor.handle_input("hello world", window, cx); editor.delete_to_previous_word_start( @@ -80,8 +80,10 @@ fn open_editor_with_one_long_line(bencher: &mut Bencher<'_>, args: &(String, Tes }); } -fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { - let mut cx = cx.clone(); +#[gpui::bench] +fn editor_render(cx: &mut BenchAppContext) { + init_context(cx); + let buffer = cx.update(|cx| { let mut rng = StdRng::seed_from_u64(1); let text_len = rng.random_range(10000..90000); @@ -95,9 +97,9 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { } }); - let cx = cx.add_empty_window(); - let editor = cx.update(|window, cx| { - let editor = cx.new(|cx| { + let mut window = cx.add_empty_window(); + let editor = window.update(|window, cx| { + let editor = window.replace_root(cx, |window, cx| { let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx); editor.set_style(editor::EditorStyle::default(), window, cx); editor @@ -106,14 +108,15 @@ fn editor_render(bencher: &mut Bencher<'_>, cx: &TestAppContext) { editor }); - bencher.iter(|| { - cx.update(|window, cx| { - let mut view = editor.clone().into_any_element(); - let _ = view.request_layout(window, cx); - let _ = view.prepaint(window, cx); - view.paint(window, cx); - }); - }) + let mut move_down = true; + cx.bench_renderer(editor, move |editor, window, cx| { + if move_down { + editor.move_down(&MoveDown, window, cx); + } else { + editor.move_up(&MoveUp, window, cx); + } + move_down = !move_down; + }); } fn init_context(cx: &mut BenchAppContext) { @@ -141,16 +144,8 @@ fn criterion_benches(criterion: &mut criterion::Criterion) { let cx = gpui::TestAppContext::build(dispatcher, None); init_test_context(&cx); - let mut group = criterion.benchmark_group("Time to render"); - group.bench_with_input( - BenchmarkId::new("editor_render", "TestAppContext"), - &cx, - editor_render, - ); - group.finish(); - let text = String::from_iter(["char"; 1000]); - let input = (text, cx.clone()); + let input = (text, cx); let mut group = criterion.benchmark_group("Build buffer with one long line"); group.bench_with_input( BenchmarkId::new("editor_with_one_long_line", "(String, TestAppContext )"), @@ -160,5 +155,10 @@ fn criterion_benches(criterion: &mut criterion::Criterion) { group.finish(); } -gpui::bench_group!(benches, editor_input_with_1000_cursors, criterion_benches); +gpui::bench_group!( + benches, + editor_input_with_1000_cursors, + editor_render, + criterion_benches +); gpui::bench_main!(benches); diff --git a/crates/benchmarks/src/benchmarks.rs b/crates/benchmarks/src/benchmarks.rs new file mode 100644 index 00000000000000..1b606305052c8b --- /dev/null +++ b/crates/benchmarks/src/benchmarks.rs @@ -0,0 +1,6 @@ +//! Benchmark targets for Zed crates. +//! +//! Benchmarks live in their own crate so benchmark-only dependencies +//! (Criterion, `gpui_platform`, gpui's `bench` feature, ...) don't weigh down +//! the test builds of the crates being benchmarked. Each file in `benches/` +//! targets one area of the codebase. diff --git a/crates/buffer_diff/Cargo.toml b/crates/buffer_diff/Cargo.toml index d574b0dd3cfdb0..4bcdd3422ec99e 100644 --- a/crates/buffer_diff/Cargo.toml +++ b/crates/buffer_diff/Cargo.toml @@ -16,7 +16,6 @@ test-support = ["settings"] [dependencies] clock.workspace = true -futures.workspace = true imara-diff.workspace = true gpui.workspace = true language.workspace = true diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index cc89f8c314d0f3..c300ace11ae1d9 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -1,14 +1,12 @@ -use futures::channel::oneshot; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; use imara_diff::{Algorithm, Sink, intern::InternedInput, sources::lines_with_terminator}; use language::{ - Capability, Diff, DiffOptions, Language, LanguageName, LanguageRegistry, + Capability, DiffOptions, Language, LanguageName, LanguageRegistry, language_settings::LanguageSettings, word_diff_ranges, }; use rope::Rope; use std::{ cmp::Ordering, - future::Future, iter, ops::{Range, RangeInclusive}, sync::Arc, @@ -17,26 +15,33 @@ use sum_tree::SumTree; use text::{ Anchor, Bias, BufferId, Edit, OffsetRangeExt, Patch, Point, ToOffset as _, ToPoint as _, }; -use util::ResultExt; +use util::{ResultExt, debug_panic}; pub const MAX_WORD_DIFF_LINE_COUNT: usize = 5; pub struct BufferDiff { pub buffer_id: BufferId, - inner: BufferDiffInner>, + base_text_buffer: Entity, + diff_snapshot: Option, secondary_diff: Option>, + buffer_snapshot: text::BufferSnapshot, } #[derive(Clone)] pub struct BufferDiffSnapshot { - inner: BufferDiffInner, + hunks: SumTree, + pending_hunks: SumTree, + base_text: language::BufferSnapshot, + base_text_exists: bool, + buffer_snapshot: text::BufferSnapshot, secondary_diff: Option>, } impl std::fmt::Debug for BufferDiffSnapshot { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BufferDiffSnapshot") - .field("inner", &self.inner) + .field("hunks", &self.hunks) + .field("remote_id", &self.base_text.remote_id()) .field("secondary_diff", &self.secondary_diff) .finish() } @@ -44,24 +49,20 @@ impl std::fmt::Debug for BufferDiffSnapshot { #[derive(Clone)] pub struct BufferDiffUpdate { - inner: BufferDiffInner>, - buffer_snapshot: text::BufferSnapshot, - base_text_edits: Option, - base_text_changed: bool, -} - -#[derive(Clone)] -struct BufferDiffInner { hunks: SumTree, - pending_hunks: SumTree, - base_text: BaseText, + base_text: language::BufferSnapshot, base_text_exists: bool, buffer_snapshot: text::BufferSnapshot, } -impl BufferDiffInner { - fn buffer_version(&self) -> &clock::Global { - self.buffer_snapshot.version() +impl BufferDiffUpdate { + pub fn set_base_text_snapshot( + &mut self, + base_text: language::BufferSnapshot, + base_text_exists: bool, + ) { + self.base_text = base_text; + self.base_text_exists = base_text_exists; } } @@ -228,15 +229,6 @@ impl sum_tree::SeekTarget<'_, DiffHunkSummary, DiffHunkSummary> for usize { } } -impl std::fmt::Debug for BufferDiffInner { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("BufferDiffSnapshot") - .field("hunks", &self.hunks) - .field("remote_id", &self.base_text.remote_id()) - .finish() - } -} - impl BufferDiffSnapshot { #[cfg(test)] fn new_sync( @@ -249,26 +241,28 @@ impl BufferDiffSnapshot { } pub fn buffer_id(&self) -> BufferId { - self.inner.buffer_snapshot.remote_id() + self.buffer_snapshot.remote_id() + } + + pub fn buffer_snapshot(&self) -> &text::BufferSnapshot { + &self.buffer_snapshot } pub fn is_empty(&self) -> bool { - self.inner.hunks.is_empty() + self.hunks.is_empty() } pub fn changed_row_counts(&self) -> (u32, u32) { - let summary = self.inner.hunks.summary(); + let summary = self.hunks.summary(); (summary.added_rows, summary.removed_rows) } pub fn base_text_string(&self) -> Option { - self.inner - .base_text_exists - .then(|| self.inner.base_text.text()) + self.base_text_exists.then(|| self.base_text.text()) } pub fn base_text_exists(&self) -> bool { - self.inner.base_text_exists + self.base_text_exists } pub fn secondary_diff(&self) -> Option<&BufferDiffSnapshot> { @@ -276,11 +270,11 @@ impl BufferDiffSnapshot { } pub fn buffer_version(&self) -> &clock::Global { - self.inner.buffer_version() + self.buffer_snapshot.version() } fn original_buffer_snapshot(&self) -> &text::BufferSnapshot { - &self.inner.buffer_snapshot + &self.buffer_snapshot } #[ztracing::instrument(skip_all)] @@ -289,9 +283,15 @@ impl BufferDiffSnapshot { range: Range, buffer: &'a text::BufferSnapshot, ) -> impl 'a + Iterator { - let unstaged_counterpart = self.secondary_diff.as_ref().map(|diff| &diff.inner); - self.inner - .hunks_intersecting_range(range, buffer, unstaged_counterpart) + let unstaged_counterpart = self.secondary_diff.as_deref(); + let range = range.to_offset(buffer); + let filter = move |summary: &DiffHunkSummary| { + let summary_range = summary.buffer_range.to_offset(buffer); + let before_start = summary_range.end < range.start; + let after_end = summary_range.start > range.end; + !before_start && !after_end + }; + self.hunks_intersecting_range_impl(filter, buffer, unstaged_counterpart) } pub fn hunks_intersecting_range_rev<'a>( @@ -304,7 +304,7 @@ impl BufferDiffSnapshot { let after_end = summary.buffer_range.start.cmp(&range.end, buffer).is_gt(); !before_start && !after_end }; - self.inner.hunks_intersecting_range_rev_impl(filter, buffer) + self.hunks_intersecting_range_rev_impl(filter, buffer) } pub fn hunks_intersecting_base_text_range<'a>( @@ -312,14 +312,13 @@ impl BufferDiffSnapshot { range: Range, main_buffer: &'a text::BufferSnapshot, ) -> impl 'a + Iterator { - let unstaged_counterpart = self.secondary_diff.as_ref().map(|diff| &diff.inner); + let unstaged_counterpart = self.secondary_diff.as_deref(); let filter = move |summary: &DiffHunkSummary| { let before_start = summary.diff_base_byte_range.end < range.start; let after_end = summary.diff_base_byte_range.start > range.end; !before_start && !after_end }; - self.inner - .hunks_intersecting_range_impl(filter, main_buffer, unstaged_counterpart) + self.hunks_intersecting_range_impl(filter, main_buffer, unstaged_counterpart) } pub fn hunks_intersecting_base_text_range_rev<'a>( @@ -332,8 +331,7 @@ impl BufferDiffSnapshot { let after_end = summary.diff_base_byte_range.start.cmp(&range.end).is_gt(); !before_start && !after_end }; - self.inner - .hunks_intersecting_range_rev_impl(filter, main_buffer) + self.hunks_intersecting_range_rev_impl(filter, main_buffer) } pub fn hunks<'a>( @@ -374,7 +372,7 @@ impl BufferDiffSnapshot { } pub fn base_text(&self) -> &language::BufferSnapshot { - &self.inner.base_text + &self.base_text } /// If this function returns `true`, the base texts are equal. If this @@ -382,11 +380,11 @@ impl BufferDiffSnapshot { /// result is used to avoid recalculating diffs in situations where we know /// nothing has changed. pub fn base_texts_definitely_eq(&self, other: &Self) -> bool { - if self.inner.base_text_exists != other.inner.base_text_exists { + if self.base_text_exists != other.base_text_exists { return false; } - let left = &self.inner.base_text; - let right = &other.inner.base_text; + let left = &self.base_text; + let right = &other.base_text; let (old_id, old_version, old_empty) = (left.remote_id(), left.version(), left.is_empty()); let (new_id, new_version, new_empty) = (right.remote_id(), right.version(), right.is_empty()); @@ -438,7 +436,7 @@ impl BufferDiffSnapshot { range: RangeInclusive, buffer: &'a text::BufferSnapshot, ) -> Patch { - if !self.inner.base_text_exists { + if !self.base_text_exists { return Patch::new(vec![Edit { old: Point::zero()..buffer.max_point(), new: Point::zero()..Point::zero(), @@ -447,7 +445,7 @@ impl BufferDiffSnapshot { let mut edits_since_diff = Patch::new( buffer - .edits_since::(&self.inner.buffer_snapshot.version) + .edits_since::(&self.buffer_snapshot.version) .collect::>(), ); edits_since_diff.invert(); @@ -460,7 +458,7 @@ impl BufferDiffSnapshot { let original_snapshot = self.original_buffer_snapshot(); let base_text = self.base_text(); - let mut cursor = self.inner.hunks.cursor(original_snapshot); + let mut cursor = self.hunks.cursor(original_snapshot); self.hunk_before_buffer_anchor( original_snapshot.anchor_before(start_point), &mut cursor, @@ -519,8 +517,7 @@ impl BufferDiffSnapshot { inverted_edits_since.invert(); inverted_edits_since.compose( - self.inner - .hunks + self.hunks .iter() .map(|hunk| { let old_start = hunk.buffer_range.start.to_point(original_snapshot); @@ -536,16 +533,14 @@ impl BufferDiffSnapshot { new: new_start..new_end, } }) - .chain( - if !self.inner.base_text_exists && self.inner.hunks.is_empty() { - Some(Edit { - old: Point::zero()..original_snapshot.max_point(), - new: Point::zero()..Point::zero(), - }) - } else { - None - }, - ), + .chain(if !self.base_text_exists && self.hunks.is_empty() { + Some(Edit { + old: Point::zero()..original_snapshot.max_point(), + new: Point::zero()..Point::zero(), + }) + } else { + None + }), ) } @@ -558,7 +553,7 @@ impl BufferDiffSnapshot { range: RangeInclusive, buffer: &'a text::BufferSnapshot, ) -> Patch { - if !self.inner.base_text_exists { + if !self.base_text_exists { return Patch::new(vec![Edit { old: Point::zero()..Point::zero(), new: Point::zero()..buffer.max_point(), @@ -566,11 +561,11 @@ impl BufferDiffSnapshot { } let edits_since_diff = buffer - .edits_since::(&self.inner.buffer_snapshot.version) + .edits_since::(&self.buffer_snapshot.version) .collect::>(); let mut hunk_patch = Vec::new(); - let mut cursor = self.inner.hunks.cursor(self.original_buffer_snapshot()); + let mut cursor = self.hunks.cursor(self.original_buffer_snapshot()); let hunk_before = self .hunk_before_base_text_offset(range.start().to_offset(self.base_text()), &mut cursor); @@ -635,7 +630,7 @@ impl BufferDiffSnapshot { let original_snapshot = self.original_buffer_snapshot(); let mut hunk_edits: Vec> = Vec::new(); - for hunk in self.inner.hunks.iter() { + for hunk in self.hunks.iter() { let old_start = self .base_text() .offset_to_point(hunk.diff_base_byte_range.start); @@ -649,7 +644,7 @@ impl BufferDiffSnapshot { new: new_start..new_end, }); } - if !self.inner.base_text_exists && hunk_edits.is_empty() { + if !self.base_text_exists && hunk_edits.is_empty() { hunk_edits.push(Edit { old: Point::zero()..Point::zero(), new: Point::zero()..original_snapshot.max_point(), @@ -709,8 +704,7 @@ impl BufferDiffSnapshot { } } -impl BufferDiffInner> { - /// Returns the new index text and new pending hunks. +impl BufferDiffSnapshot { fn stage_or_unstage_hunks_impl( &mut self, unstaged_diff: &Self, @@ -718,14 +712,13 @@ impl BufferDiffInner> { hunks: &[DiffHunk], buffer: &text::BufferSnapshot, file_exists: bool, - cx: &mut Context, ) -> Option { let head_text = self .base_text_exists - .then(|| self.base_text.read(cx).as_rope().clone()); + .then(|| self.base_text.as_rope().clone()); let index_text = unstaged_diff .base_text_exists - .then(|| unstaged_diff.base_text.read(cx).as_rope().clone()); + .then(|| unstaged_diff.base_text.as_rope().clone()); // If the file doesn't exist in either HEAD or the index, then the // entire file must be either created or deleted in the index. @@ -932,23 +925,7 @@ impl BufferDiffInner> { } } -impl BufferDiffInner { - fn hunks_intersecting_range<'a>( - &'a self, - range: Range, - buffer: &'a text::BufferSnapshot, - secondary: Option<&'a Self>, - ) -> impl 'a + Iterator { - let range = range.to_offset(buffer); - let filter = move |summary: &DiffHunkSummary| { - let summary_range = summary.buffer_range.to_offset(buffer); - let before_start = summary_range.end < range.start; - let after_end = summary_range.start > range.end; - !before_start && !after_end - }; - self.hunks_intersecting_range_impl(filter, buffer, secondary) - } - +impl BufferDiffSnapshot { fn hunks_intersecting_range_impl<'a>( &'a self, filter: impl 'a + Fn(&DiffHunkSummary) -> bool, @@ -1473,6 +1450,7 @@ fn compare_hunks( changed_range, base_text_changed_range, extended_range, + base_text_changed: false, } } @@ -1489,61 +1467,91 @@ pub struct DiffChanged { pub changed_range: Option>, pub base_text_changed_range: Option>, pub extended_range: Option>, + pub base_text_changed: bool, } #[derive(Clone, Debug)] pub enum BufferDiffEvent { BaseTextChanged, DiffChanged(DiffChanged), - LanguageChanged, HunksStagedOrUnstaged(Option), } -struct SetSnapshotResult { - change: DiffChanged, - base_text_changed: bool, -} - impl EventEmitter for BufferDiff {} impl BufferDiff { - pub fn new(buffer: &text::BufferSnapshot, cx: &mut App) -> Self { + pub fn new( + buffer: &text::BufferSnapshot, + language: Option>, + language_registry: Option>, + cx: &mut App, + ) -> Self { let base_text = cx.new(|cx| { - let mut buffer = language::Buffer::local("", cx); - buffer.set_capability(Capability::ReadOnly, cx); - buffer + let mut base_buffer = language::Buffer::local("", cx); + base_buffer.set_capability(Capability::ReadOnly, cx); + if let Some(language_registry) = language_registry { + base_buffer.set_language_registry(language_registry); + } + base_buffer.set_language_async(language, cx); + base_buffer }); BufferDiff { buffer_id: buffer.remote_id(), - inner: BufferDiffInner { - base_text, - hunks: SumTree::new(buffer), - pending_hunks: SumTree::new(buffer), - base_text_exists: false, - buffer_snapshot: buffer.clone(), - }, + base_text_buffer: base_text, + diff_snapshot: None, + buffer_snapshot: buffer.clone(), + secondary_diff: None, + } + } + + pub fn new_with_base_text_buffer( + buffer: &text::BufferSnapshot, + base_text_buffer: Entity, + _cx: &mut App, + ) -> Self { + BufferDiff { + buffer_id: buffer.remote_id(), + base_text_buffer, + diff_snapshot: None, + buffer_snapshot: buffer.clone(), secondary_diff: None, } } - pub fn new_unchanged(buffer: &text::BufferSnapshot, cx: &mut Context) -> Self { + pub fn new_unchanged( + buffer: &text::BufferSnapshot, + language: Option>, + language_registry: Option>, + cx: &mut Context, + ) -> Self { let base_text = buffer.text(); let base_text = cx.new(|cx| { - let mut buffer = language::Buffer::local(base_text, cx); - buffer.set_capability(Capability::ReadOnly, cx); - buffer + let mut base_buffer = language::Buffer::local(base_text, cx); + base_buffer.set_capability(Capability::ReadOnly, cx); + if let Some(language_registry) = language_registry { + base_buffer.set_language_registry(language_registry); + } + base_buffer.set_language_async(language, cx); + base_buffer }); + let base_text_snapshot = base_text.read(cx).snapshot(); + + let diff_snapshot = BufferDiffSnapshot { + hunks: SumTree::new(buffer), + pending_hunks: SumTree::new(buffer), + base_text: base_text_snapshot, + base_text_exists: true, + buffer_snapshot: buffer.clone(), + secondary_diff: None, + }; + BufferDiff { buffer_id: buffer.remote_id(), - inner: BufferDiffInner { - base_text, - hunks: SumTree::new(buffer), - pending_hunks: SumTree::new(buffer), - base_text_exists: true, - buffer_snapshot: buffer.clone(), - }, + base_text_buffer: base_text, + diff_snapshot: Some(diff_snapshot), + buffer_snapshot: buffer.clone(), secondary_diff: None, } } @@ -1554,17 +1562,23 @@ impl BufferDiff { buffer: &text::BufferSnapshot, cx: &mut Context, ) -> Self { - let mut this = BufferDiff::new(&buffer, cx); + let mut this = BufferDiff::new(buffer, None, None, cx); let mut base_text = base_text.to_owned(); text::LineEnding::normalize(&mut base_text); - let inner = cx.foreground_executor().block_on(this.update_diff( + let base_text_buffer = cx.new(|cx| { + let mut buffer = language::Buffer::local(base_text, cx); + buffer.set_capability(Capability::ReadOnly, cx); + buffer + }); + let base_text = base_text_buffer.read(cx).snapshot(); + this.base_text_buffer = base_text_buffer; + let update = cx.foreground_executor().block_on(this.update_diff( buffer.clone(), - Some(Arc::from(base_text)), - Some(false), - None, + &base_text, + Some(Arc::from(base_text.text())), cx, )); - this.set_snapshot(inner, &buffer, cx).detach(); + this.set_snapshot(update, cx); this } @@ -1577,8 +1591,11 @@ impl BufferDiff { } pub fn clear_pending_hunks(&mut self, cx: &mut Context) { + let Some(diff_snapshot) = &mut self.diff_snapshot else { + return; + }; if self.secondary_diff.is_some() { - self.inner.pending_hunks = SumTree::from_summary(DiffHunkSummary { + diff_snapshot.pending_hunks = SumTree::from_summary(DiffHunkSummary { buffer_range: Anchor::min_min_range_for_buffer(self.buffer_id), diff_base_byte_range: 0..0, added_rows: 0, @@ -1590,6 +1607,7 @@ impl BufferDiff { changed_range: changed_range.clone(), base_text_changed_range: base_text_range, extended_range: changed_range, + base_text_changed: false, })); } } @@ -1602,19 +1620,18 @@ impl BufferDiff { file_exists: bool, cx: &mut Context, ) -> Option { - let new_index_text = self - .secondary_diff - .as_ref()? - .update(cx, |secondary_diff, cx| { - self.inner.stage_or_unstage_hunks_impl( - &secondary_diff.inner, - stage, - hunks, - buffer, - file_exists, - cx, - ) - }); + let secondary_diff = self.secondary_diff.clone()?; + let diff_snapshot = self.diff_snapshot.as_mut()?; + let unstaged_diff_snapshot = secondary_diff.read_with(cx, |secondary_diff, _cx| { + secondary_diff.diff_snapshot.clone() + })?; + let new_index_text = diff_snapshot.stage_or_unstage_hunks_impl( + &unstaged_diff_snapshot, + stage, + hunks, + buffer, + file_exists, + ); cx.emit(BufferDiffEvent::HunksStagedOrUnstaged( new_index_text.clone(), @@ -1627,6 +1644,7 @@ impl BufferDiff { changed_range: changed_range.clone(), base_text_changed_range, extended_range: changed_range, + base_text_changed: false, })); } new_index_text @@ -1643,12 +1661,23 @@ impl BufferDiff { .snapshot(cx) .hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer.remote_id()), buffer) .collect::>(); + let Some(diff_snapshot) = &mut self.diff_snapshot else { + return; + }; let Some(secondary) = self.secondary_diff.clone() else { return; }; - let secondary = secondary.read(cx).inner.clone(); - self.inner - .stage_or_unstage_hunks_impl(&secondary, stage, &hunks, buffer, file_exists, cx); + let secondary = secondary.read(cx); + let Some(secondary_snapshot) = &secondary.diff_snapshot else { + return; + }; + diff_snapshot.stage_or_unstage_hunks_impl( + &secondary_snapshot, + stage, + &hunks, + buffer, + file_exists, + ); if let Some((first, last)) = hunks.first().zip(hunks.last()) { let changed_range = Some(first.buffer_range.start..last.buffer_range.end); let base_text_changed_range = @@ -1657,6 +1686,7 @@ impl BufferDiff { changed_range: changed_range.clone(), base_text_changed_range, extended_range: changed_range, + base_text_changed: false, })); } } @@ -1664,172 +1694,139 @@ impl BufferDiff { pub fn update_diff( &self, buffer: text::BufferSnapshot, + base_text_snapshot: &language::BufferSnapshot, base_text: Option>, - base_text_change: Option, - language: Option>, cx: &App, ) -> Task { let base_text = base_text.map(|t| text::LineEnding::normalize_arc(t)); - let prev_base_text = self.base_text(cx).as_rope().clone(); - let base_text_changed = base_text_change.is_some(); - let compute_base_text_edits = base_text_change == Some(true); + debug_assert_eq!( + base_text.as_deref().unwrap_or_default(), + &base_text_snapshot.text() + ); + debug_assert_eq!( + base_text_snapshot.remote_id(), + self.base_text_buffer.read(cx).remote_id() + ); + + let language = base_text_snapshot.language(); let diff_options = build_diff_options( - language.as_ref().map(|l| l.name()), - language.as_ref().map(|l| l.default_scope()), + language.map(|l| l.name()), + language.map(|l| l.default_scope()), cx, ); let buffer_snapshot = buffer.clone(); - - let base_text_diff_task = if base_text_changed && compute_base_text_edits { - base_text - .as_ref() - .map(|new_text| self.inner.base_text.read(cx).diff(new_text.clone(), cx)) - } else { - None - }; - - let hunk_task = cx.background_executor().spawn({ - let buffer_snapshot = buffer_snapshot.clone(); - async move { - let base_text_rope = if let Some(base_text) = &base_text { - if base_text_changed { - Rope::from(base_text.as_ref()) - } else { - prev_base_text - } - } else { - Rope::new() - }; - let base_text_exists = base_text.is_some(); - let hunks = compute_hunks( - base_text - .clone() - .map(|base_text| (base_text, base_text_rope.clone())), - &buffer, - diff_options, - ); - let base_text = base_text.unwrap_or_default(); - BufferDiffInner { - base_text, - hunks, - base_text_exists, - pending_hunks: SumTree::new(&buffer), - buffer_snapshot, - } + let base_text_snapshot = base_text_snapshot.clone(); + let base_text_exists = base_text.is_some(); + let unchanged_hunks = self.diff_snapshot.as_ref().and_then(|diff_snapshot| { + if diff_snapshot.base_text_exists == base_text_exists + && diff_snapshot.base_text.version() == base_text_snapshot.version() + && diff_snapshot.buffer_snapshot.version() == buffer_snapshot.version() + { + Some(diff_snapshot.hunks.clone()) + } else { + None } }); cx.background_executor().spawn(async move { - let (inner, base_text_edits) = match base_text_diff_task { - Some(diff_task) => { - let (inner, diff) = futures::join!(hunk_task, diff_task); - (inner, Some(diff)) - } - None => (hunk_task.await, None), + let hunks = if let Some(unchanged_hunks) = unchanged_hunks { + unchanged_hunks + } else if let Some(base_text) = base_text { + compute_hunks( + Some((base_text, base_text_snapshot.as_rope().clone())), + &buffer, + diff_options, + ) + } else { + compute_hunks(None, &buffer, diff_options) }; BufferDiffUpdate { - inner, + hunks, + base_text: base_text_snapshot, + base_text_exists, buffer_snapshot, - base_text_edits, - base_text_changed, } }) } - #[ztracing::instrument(skip_all)] - pub fn language_changed( - &mut self, - language: Option>, - language_registry: Option>, - cx: &mut Context, - ) { - let fut = self.inner.base_text.update(cx, |base_text, cx| { - if let Some(language_registry) = language_registry { - base_text.set_language_registry(language_registry); - } - base_text.set_language_async(language, cx); - base_text.parsing_idle() - }); - cx.spawn(async move |this, cx| { - fut.await; - this.update(cx, |_, cx| { - cx.emit(BufferDiffEvent::LanguageChanged); - }) - .ok(); - }) - .detach(); - } - - fn set_snapshot_with_secondary_inner( + pub fn set_snapshot_with_secondary( &mut self, update: BufferDiffUpdate, - buffer: &text::BufferSnapshot, secondary_diff_change: Option>, clear_pending_hunks: bool, cx: &mut Context, - ) -> impl Future + use<> { + ) -> Option> { log::debug!("set snapshot with secondary {secondary_diff_change:?}"); - let old_snapshot = self.snapshot(cx); - let new_state = update.inner; - let base_text_changed = update.base_text_changed; - - let state = &mut self.inner; - state.base_text_exists = new_state.base_text_exists; - let should_compare_hunks = update.base_text_edits.is_some() || !base_text_changed; - let parsing_idle = if let Some(diff) = update.base_text_edits { - state.base_text.update(cx, |base_text, cx| { - base_text.set_sync_parse_timeout(None); - base_text.set_capability(Capability::ReadWrite, cx); - base_text.apply_diff(diff, cx); - base_text.set_capability(Capability::ReadOnly, cx); - Some(base_text.parsing_idle()) - }) - } else if update.base_text_changed { - state.base_text.update(cx, |base_text, cx| { - base_text.set_sync_parse_timeout(None); - base_text.set_capability(Capability::ReadWrite, cx); - base_text.set_text(new_state.base_text.clone(), cx); - base_text.set_capability(Capability::ReadOnly, cx); - Some(base_text.parsing_idle()) - }) - } else { - None + let BufferDiffUpdate { + hunks: new_hunks, + base_text: new_base_text, + base_text_exists: new_base_text_exists, + buffer_snapshot: new_buffer_snapshot, + } = update; + let buffer = &new_buffer_snapshot; + let old_snapshot = self + .diff_snapshot + .clone() + .unwrap_or_else(|| BufferDiffSnapshot { + hunks: SumTree::new(buffer), + pending_hunks: SumTree::new(buffer), + base_text: new_base_text.clone(), + base_text_exists: false, + buffer_snapshot: new_buffer_snapshot.clone(), + secondary_diff: None, + }); + let mut new_snapshot = BufferDiffSnapshot { + hunks: new_hunks.clone(), + base_text: new_base_text.clone(), + base_text_exists: new_base_text_exists, + buffer_snapshot: new_buffer_snapshot.clone(), + pending_hunks: old_snapshot.pending_hunks.clone(), + secondary_diff: None, }; - let old_buffer_snapshot = &old_snapshot.inner.buffer_snapshot; - let old_base_snapshot = &old_snapshot.inner.base_text; - let new_base_snapshot = state.base_text.read(cx).snapshot(); + let old_base_text_exists = old_snapshot.base_text_exists; + let old_buffer_snapshot = &old_snapshot.buffer_snapshot; + let old_base_text = &old_snapshot.base_text; + let base_text_changed = old_base_text_exists != new_base_text_exists + || (new_base_text_exists + && (old_base_text.remote_id() != new_base_text.remote_id() + || new_base_text + .version() + .changed_since(old_base_text.version()))); let DiffChanged { mut changed_range, mut base_text_changed_range, mut extended_range, - } = match (state.base_text_exists, new_state.base_text_exists) { - (false, false) => DiffChanged::default(), - (true, true) if should_compare_hunks => compare_hunks( - &new_state.hunks, - &old_snapshot.inner.hunks, + base_text_changed: _, + } = match (old_base_text_exists, new_base_text_exists) { + (false, false) if self.diff_snapshot.is_some() => DiffChanged::default(), + (true, true) => compare_hunks( + &new_hunks, + &old_snapshot.hunks, old_buffer_snapshot, buffer, - old_base_snapshot, - &new_base_snapshot, + old_base_text, + &new_base_text, ), _ => { let full_range = text::Anchor::min_max_range_for_buffer(self.buffer_id); - let full_base_range = 0..new_state.base_text.len(); + let full_base_range = 0..new_base_text.len(); DiffChanged { changed_range: Some(full_range.clone()), base_text_changed_range: Some(full_base_range), extended_range: Some(full_range), + base_text_changed: false, } } }; - state.hunks = new_state.hunks; - state.buffer_snapshot = update.buffer_snapshot; if base_text_changed || clear_pending_hunks { - if let Some((first, last)) = state.pending_hunks.first().zip(state.pending_hunks.last()) + if let Some((first, last)) = old_snapshot + .pending_hunks + .first() + .zip(old_snapshot.pending_hunks.last()) { let pending_range = first.buffer_range.start..last.buffer_range.end; if let Some(range) = &mut changed_range { @@ -1855,7 +1852,7 @@ impl BufferDiff { extended_range = Some(pending_range); } } - state.pending_hunks = SumTree::new(buffer); + new_snapshot.pending_hunks = SumTree::new(buffer); } if let Some(secondary_changed_range) = secondary_diff_change @@ -1884,140 +1881,164 @@ impl BufferDiff { } } - async move { - if let Some(parsing_idle) = parsing_idle { - parsing_idle.await; - } - SetSnapshotResult { - change: DiffChanged { - changed_range, - base_text_changed_range, - extended_range, - }, - base_text_changed, - } + self.diff_snapshot = Some(new_snapshot); + self.buffer_snapshot = new_buffer_snapshot; + + let result = DiffChanged { + changed_range, + base_text_changed_range, + extended_range, + base_text_changed, + }; + if result.base_text_changed { + cx.emit(BufferDiffEvent::BaseTextChanged); } + let changed_range = result.changed_range.clone(); + cx.emit(BufferDiffEvent::DiffChanged(result)); + changed_range } pub fn set_snapshot( &mut self, new_state: BufferDiffUpdate, - buffer: &text::BufferSnapshot, cx: &mut Context, - ) -> Task>> { - self.set_snapshot_with_secondary(new_state, buffer, None, false, cx) - } - - pub fn set_snapshot_with_secondary( - &mut self, - update: BufferDiffUpdate, - buffer: &text::BufferSnapshot, - secondary_diff_change: Option>, - clear_pending_hunks: bool, - cx: &mut Context, - ) -> Task>> { - let fut = self.set_snapshot_with_secondary_inner( - update, - buffer, - secondary_diff_change, - clear_pending_hunks, - cx, - ); - - cx.spawn(async move |this, cx| { - let result = fut.await; - this.update(cx, |_, cx| { - if result.base_text_changed { - cx.emit(BufferDiffEvent::BaseTextChanged); - } - cx.emit(BufferDiffEvent::DiffChanged(result.change.clone())); - }) - .ok(); - result.change.changed_range - }) + ) -> Option> { + self.set_snapshot_with_secondary(new_state, None, false, cx) } pub fn base_text(&self, cx: &App) -> language::BufferSnapshot { - self.inner.base_text.read(cx).snapshot() + self.base_text_buffer.read(cx).snapshot() } pub fn base_text_exists(&self) -> bool { - self.inner.base_text_exists + self.diff_snapshot + .as_ref() + .is_some_and(|diff_snapshot| diff_snapshot.base_text_exists) } pub fn snapshot(&self, cx: &App) -> BufferDiffSnapshot { - BufferDiffSnapshot { - inner: BufferDiffInner { - hunks: self.inner.hunks.clone(), - pending_hunks: self.inner.pending_hunks.clone(), - base_text: self.inner.base_text.read(cx).snapshot(), - base_text_exists: self.inner.base_text_exists, - buffer_snapshot: self.inner.buffer_snapshot.clone(), - }, - secondary_diff: self.secondary_diff.as_ref().map(|diff| { - debug_assert!(diff.read(cx).secondary_diff.is_none()); - Arc::new(diff.read(cx).snapshot(cx)) - }), - } + let mut snapshot = self.diff_snapshot.clone().unwrap_or_else(|| { + let base_text = self.base_text_buffer.read(cx).snapshot(); + BufferDiffSnapshot { + hunks: SumTree::new(&self.buffer_snapshot), + pending_hunks: SumTree::new(&self.buffer_snapshot), + base_text, + base_text_exists: false, + buffer_snapshot: self.buffer_snapshot.clone(), + secondary_diff: None, + } + }); + snapshot.secondary_diff = self.secondary_diff.as_ref().map(|diff| { + debug_assert!(diff.read(cx).secondary_diff.is_none()); + Arc::new(diff.read(cx).snapshot(cx)) + }); + snapshot } /// Used in cases where the change set isn't derived from git. + /// + /// Dropping the returned task cancels the update, leaving the diff + /// unchanged. Calls must not overlap; to re-run this when the buffer or + /// base text changes, store the task somewhere that the next call will + /// overwrite, so that the previous call is cancelled. pub fn set_base_text( &mut self, base_text: Option>, - language: Option>, buffer: text::BufferSnapshot, cx: &mut Context, - ) -> oneshot::Receiver<()> { - let (tx, rx) = oneshot::channel(); - let complete_on_drop = util::defer(|| { - tx.send(()).ok(); - }); + ) -> Task<()> { cx.spawn(async move |this, cx| { + let base_text_exists = base_text.is_some(); + let base_text = base_text.unwrap_or_default(); + let Some(base_text_diff) = this + .update(cx, |this, cx| { + this.base_text_buffer.update(cx, |base_text_buffer, cx| { + base_text_buffer.diff(base_text.clone(), cx) + }) + }) + .log_err() + else { + return; + }; + let base_text_diff = base_text_diff.await; + let Some(edited_base_text) = this + .update(cx, |this, cx| { + if this.base_text_buffer.read(cx).version() != base_text_diff.base_version { + log::warn!("dropping concurrent diff update"); + debug_panic!("incorrect concurrent call to set_base_text"); + return None; + } + let edited_base_text = + this.base_text_buffer.update(cx, |base_text_buffer, cx| { + base_text_buffer.set_line_ending(base_text_diff.line_ending, cx); + assert!(base_text_buffer.version() == base_text_diff.base_version); + base_text_buffer.snapshot_with_edits(base_text_diff.edits, cx) + }); + Some(edited_base_text) + }) + .log_err() + .flatten() + else { + return; + }; + let edited_base_text = edited_base_text.await; + let base_text_snapshot = edited_base_text.snapshot().clone(); let Some(state) = this .update(cx, |this, cx| { - this.update_diff(buffer.clone(), base_text, Some(false), language, cx) + this.update_diff( + buffer.clone(), + &base_text_snapshot, + base_text_exists.then(|| base_text.clone()), + cx, + ) }) .log_err() else { return; }; let state = state.await; - if let Some(task) = this - .update(cx, |this, cx| this.set_snapshot(state, &buffer, cx)) - .log_err() - { - task.await; - } - drop(complete_on_drop) + this.update(cx, |this, cx| { + if &this.base_text_buffer.read(cx).version() != edited_base_text.base_version() { + log::warn!("dropping concurrent diff update"); + debug_panic!("incorrect concurrent call to set_base_text"); + return; + } + + this.base_text_buffer.update(cx, |base_text_buffer, cx| { + base_text_buffer.fast_forward(edited_base_text, cx) + }); + this.set_snapshot(state, cx); + }) + .log_err(); }) - .detach(); - rx } - pub fn base_text_string(&self, cx: &App) -> Option { - self.inner - .base_text_exists - .then(|| self.inner.base_text.read(cx).text()) + pub fn base_text_string(&self, _cx: &App) -> Option { + self.diff_snapshot.as_ref().and_then(|diff_snapshot| { + if diff_snapshot.base_text_exists { + Some(diff_snapshot.base_text.text()) + } else { + None + } + }) } #[cfg(any(test, feature = "test-support"))] pub fn recalculate_diff_sync(&mut self, buffer: &text::BufferSnapshot, cx: &mut Context) { - let language = self.base_text(cx).language().cloned(); - let base_text = self.base_text_string(cx).map(|s| s.as_str().into()); - let fut = self.update_diff(buffer.clone(), base_text, None, language, cx); + let base_text = self.base_text(cx); + let fut = self.update_diff( + buffer.clone(), + &base_text, + self.base_text_exists().then(|| Arc::from(base_text.text())), + cx, + ); let fg_executor = cx.foreground_executor().clone(); let snapshot = fg_executor.block_on(fut); - let fut = self.set_snapshot_with_secondary_inner(snapshot, buffer, None, false, cx); - let result = fg_executor.block_on(fut); - if result.base_text_changed { - cx.emit(BufferDiffEvent::BaseTextChanged); - } - cx.emit(BufferDiffEvent::DiffChanged(result.change)); + let _changed_range = self.set_snapshot(snapshot, cx); } pub fn base_text_buffer(&self) -> &Entity { - &self.inner.base_text + &self.base_text_buffer } } @@ -2216,7 +2237,7 @@ mod tests { ], ); - diff = cx.update(|cx| BufferDiff::new(&buffer, cx).snapshot(cx)); + diff = cx.update(|cx| BufferDiff::new(&buffer, None, None, cx).snapshot(cx)); assert_hunks::<&str, _>( diff.hunks_intersecting_range( Anchor::min_max_range_for_buffer(buffer.remote_id()), @@ -2846,15 +2867,16 @@ mod tests { let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), buffer_text_1); - let empty_diff = cx.update(|cx| BufferDiff::new(&buffer, cx).snapshot(cx)); + let empty_diff = cx.update(|cx| BufferDiff::new(&buffer, None, None, cx).snapshot(cx)); let diff_1 = BufferDiffSnapshot::new_sync(&buffer, base_text.clone(), cx); let DiffChanged { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( - &diff_1.inner.hunks, - &empty_diff.inner.hunks, + &diff_1.hunks, + &empty_diff.hunks, &buffer, &buffer, &diff_1.base_text(), @@ -2887,9 +2909,10 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( - &diff_2.inner.hunks, - &diff_1.inner.hunks, + &diff_2.hunks, + &diff_1.hunks, &buffer, &buffer, diff_2.base_text(), @@ -2925,9 +2948,10 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( - &diff_3.inner.hunks, - &diff_2.inner.hunks, + &diff_3.hunks, + &diff_2.hunks, &buffer, &buffer, diff_3.base_text(), @@ -2959,9 +2983,10 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( - &diff_4.inner.hunks, - &diff_3.inner.hunks, + &diff_4.hunks, + &diff_3.hunks, &buffer, &buffer, diff_4.base_text(), @@ -2994,9 +3019,10 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( - &diff_5.inner.hunks, - &diff_4.inner.hunks, + &diff_5.hunks, + &diff_4.hunks, &buffer, &buffer, diff_5.base_text(), @@ -3029,9 +3055,10 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( - &diff_6.inner.hunks, - &diff_5.inner.hunks, + &diff_6.hunks, + &diff_5.hunks, &buffer, &buffer, diff_6.base_text(), @@ -3063,9 +3090,10 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( - &diff_7.inner.hunks, - &diff_6.inner.hunks, + &diff_7.hunks, + &diff_6.hunks, &buffer, &buffer, diff_7.base_text(), @@ -3096,9 +3124,10 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( - &diff_8.inner.hunks, - &diff_7.inner.hunks, + &diff_8.hunks, + &diff_7.hunks, &buffer, &buffer, diff_8.base_text(), @@ -3322,19 +3351,18 @@ mod tests { ); buffer.text_snapshot() }); + let base_text_snapshot = diff.read_with(cx, |diff, cx| diff.base_text(cx)); let update = diff .update(cx, |diff, cx| { diff.update_diff( snapshot.clone(), - Some(base_text.as_str().into()), - None, - None, + &base_text_snapshot, + Some(Arc::from(base_text_snapshot.text())), cx, ) }) .await; - diff.update(cx, |diff, cx| diff.set_snapshot(update, &snapshot, cx)) - .await; + diff.update(cx, |diff, cx| diff.set_snapshot(update, cx)); cx.run_until_parked(); drop(subscription); let events = rx.into_iter().collect::>(); @@ -3344,6 +3372,7 @@ mod tests { changed_range: _, base_text_changed_range, extended_range: _, + base_text_changed: _, }), ] => { // TODO(cole) this seems like it should pass but currently fails (see compare_hunks) @@ -3398,9 +3427,10 @@ mod tests { changed_range, base_text_changed_range: _, extended_range, + base_text_changed: _, } = compare_hunks( - &diff_b.inner.hunks, - &diff_a.inner.hunks, + &diff_b.hunks, + &diff_a.hunks, &old_buffer, &buffer, &diff_a.base_text(), @@ -3461,9 +3491,10 @@ mod tests { changed_range, base_text_changed_range: _, extended_range, + base_text_changed: _, } = compare_hunks( - &diff_2b.inner.hunks, - &diff_2a.inner.hunks, + &diff_2b.hunks, + &diff_2a.hunks, &old_buffer_2, &buffer_2, &diff_2a.base_text(), @@ -3531,6 +3562,7 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( &new_hunks_1, &old_hunks_1, @@ -3590,6 +3622,7 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( &new_hunks_2, &old_hunks_2, @@ -3658,6 +3691,7 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( &new_hunks_3, &old_hunks_3, @@ -3735,6 +3769,7 @@ mod tests { changed_range, base_text_changed_range, extended_range: _, + base_text_changed: _, } = compare_hunks( &new_hunks_4, &old_hunks_4, @@ -3990,17 +4025,11 @@ mod tests { ); let buffer_snapshot = buffer.snapshot(); - let diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, cx)); + let diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, None, None, cx)); diff.update(cx, |diff, cx| { - diff.set_base_text( - Some(Arc::from(base_text_crlf)), - None, - buffer_snapshot.clone(), - cx, - ) + diff.set_base_text(Some(Arc::from(base_text_crlf)), buffer_snapshot.clone(), cx) }) - .await - .ok(); + .await; cx.run_until_parked(); let snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx)); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 6f0c86f940b863..d9c976b688cc71 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -487,6 +487,14 @@ fn run() -> Result<()> { return mac_os::spawn_channel_cli(channel, std::env::args().skip(2).collect()); } } + + // Must happen before clap — SSH invokes cli.exe directly as SSH_ASKPASS + // and passes the socket path via env var to avoid argument parsing. + if let Ok(socket) = std::env::var("ZED_ASKPASS_SOCKET") { + askpass::main_from_args(&socket, std::env::args().skip(1)); + return Ok(()); + } + let args = Args::parse(); // `zed --askpass` Makes zed operate in nc/netcat mode for use with askpass diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 36c5a49602a916..2c8dfa8ba47ef7 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -540,13 +540,16 @@ impl Drop for PendingEntitySubscription { pub struct TelemetrySettings { pub diagnostics: bool, pub metrics: bool, + pub anthropic_retention: bool, } impl settings::Settings for TelemetrySettings { fn from_settings(content: &SettingsContent) -> Self { + let telemetry = content.telemetry.as_ref().unwrap(); Self { - diagnostics: content.telemetry.as_ref().unwrap().diagnostics.unwrap(), - metrics: content.telemetry.as_ref().unwrap().metrics.unwrap(), + diagnostics: telemetry.diagnostics.unwrap(), + metrics: telemetry.metrics.unwrap(), + anthropic_retention: telemetry.anthropic_retention.unwrap(), } } } @@ -968,7 +971,7 @@ impl Client { Ok(valid) => Ok(valid), Err(err) => { self.set_status(Status::AuthenticationError, cx); - Err(anyhow!("failed to validate credentials: {}", err)) + Err(err.context("failed to validate credentials")) } } } @@ -1603,7 +1606,7 @@ impl Client { pub async fn cached_llm_token( &self, llm_token: &LlmApiToken, - organization_id: Option, + organization_id: OrganizationId, ) -> Result { let system_id = self.telemetry().system_id().map(|x| x.to_string()); let cloud_client = self.cloud_client(); @@ -1627,7 +1630,7 @@ impl Client { pub async fn authenticated_llm_request( &self, llm_token: &LlmApiToken, - organization_id: Option, + organization_id: OrganizationId, build_request: impl Fn(&str) -> Result>, ) -> Result> { let http_client = self.http_client(); @@ -1648,7 +1651,7 @@ impl Client { pub async fn refresh_llm_token( &self, llm_token: &LlmApiToken, - organization_id: Option, + organization_id: OrganizationId, ) -> Result { let system_id = self.telemetry().system_id().map(|x| x.to_string()); let cloud_client = self.cloud_client(); @@ -1668,7 +1671,7 @@ impl Client { pub async fn clear_and_refresh_llm_token( &self, llm_token: &LlmApiToken, - organization_id: Option, + organization_id: OrganizationId, ) -> Result { let system_id = self.telemetry().system_id().map(|x| x.to_string()); let cloud_client = self.cloud_client(); @@ -2239,6 +2242,45 @@ mod tests { assert_eq!(credentials.access_token, "2"); } + #[gpui::test] + async fn test_sign_in_reports_connection_failure(cx: &mut TestAppContext) { + init_test(cx); + let http_client = FakeHttpClient::create(|_request| async move { + Ok(http_client::Response::builder() + .status(200) + .body("".into()) + .unwrap()) + }); + let client = + cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client.clone(), cx)); + client.override_authenticate(move |cx| { + cx.background_spawn(async move { + Ok(Credentials { + user_id: 1, + access_token: "token".into(), + }) + }) + }); + + // Sign in once so that the credentials are cached on the client. + client.sign_in(false, &cx.to_async()).await.unwrap(); + + // Simulate a transport-level failure (DNS/TCP/TLS/timeout) where the + // request never receives a response while validating cached credentials. + http_client + .as_fake() + .replace_handler(|_, _request| async move { + Err(anyhow!("connection reset by peer").context("boom")) + }); + + let error = client.sign_in(false, &cx.to_async()).await.unwrap_err(); + + assert_eq!( + format!("{error:#}"), + "failed to validate credentials: boom: connection reset by peer" + ); + } + #[gpui::test(iterations = 10)] async fn test_authenticating_more_than_once( cx: &mut TestAppContext, diff --git a/crates/client/src/llm_token.rs b/crates/client/src/llm_token.rs index 058be7905fa12d..94390fd1ad9754 100644 --- a/crates/client/src/llm_token.rs +++ b/crates/client/src/llm_token.rs @@ -1,10 +1,12 @@ use super::{Client, UserStore}; +use anyhow::anyhow; use cloud_api_client::LlmApiToken; use cloud_api_types::websocket_protocol::MessageToClient; use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME}; +use futures::StreamExt; use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _, Subscription, - TaskExt, + Task, TaskExt, }; use std::sync::Arc; @@ -42,6 +44,7 @@ pub struct RefreshLlmTokenListener { client: Arc, user_store: Entity, llm_api_token: LlmApiToken, + _clear_llm_token_on_sign_out: Task<()>, _subscription: Subscription, } @@ -73,10 +76,24 @@ impl RefreshLlmTokenListener { } }); + let llm_api_token = LlmApiToken::default(); + let mut status = client.status(); + let clear_llm_token_on_sign_out = cx.spawn({ + let llm_api_token = llm_api_token.clone(); + async move |_this, _cx| { + while let Some(status) = status.next().await { + if status.is_signed_out() { + llm_api_token.clear().await; + } + } + } + }); + Self { client, user_store, - llm_api_token: LlmApiToken::default(), + llm_api_token, + _clear_llm_token_on_sign_out: clear_llm_token_on_sign_out, _subscription: subscription, } } @@ -90,6 +107,9 @@ impl RefreshLlmTokenListener { .current_organization() .map(|organization| organization.id.clone()); cx.spawn(async move |this, cx| { + let organization_id = + organization_id.ok_or_else(|| anyhow!("No organization selected."))?; + match mode { TokenRefreshMode::Refresh => { client diff --git a/crates/cloud_api_client/Cargo.toml b/crates/cloud_api_client/Cargo.toml index 716276ccf44a37..baca3c1ce6f52d 100644 --- a/crates/cloud_api_client/Cargo.toml +++ b/crates/cloud_api_client/Cargo.toml @@ -19,6 +19,7 @@ gpui.workspace = true gpui_tokio.workspace = true http_client.workspace = true parking_lot.workspace = true +serde.workspace = true serde_json.workspace = true async-lock.workspace = true thiserror.workspace = true diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index dc00d001d3b949..e2f9d69002e236 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -11,9 +11,10 @@ use gpui::{App, Task}; use gpui_tokio::Tokio; use http_client::http::request; use http_client::{ - AsyncBody, HttpClientWithUrl, HttpRequestExt, Json, Method, Request, StatusCode, + AsyncBody, HttpClientWithUrl, HttpRequestExt, Json, Method, Request, Response, StatusCode, }; use parking_lot::RwLock; +use serde::de::DeserializeOwned; use thiserror::Error; use yawc::WebSocket; @@ -108,7 +109,6 @@ impl CloudApiClient { &self, system_id: Option, ) -> Result { - let host = self.cloud_host(); let request_builder = Request::builder() .method(Method::GET) .uri( @@ -122,37 +122,7 @@ impl CloudApiClient { }); let request = self.build_request(request_builder, AsyncBody::default())?; - - let mut response = self.http_client.send(request).await.map_err(|source| { - ClientApiError::ConnectionFailed { - host: host.clone(), - source, - } - })?; - - if !response.status().is_success() { - if response.status() == StatusCode::UNAUTHORIZED { - return Err(ClientApiError::Unauthorized); - } - - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await.ok(); - - return Err(ClientApiError::ServerError { - host, - status: response.status(), - body, - }); - } - - let mut body = String::new(); - response - .body_mut() - .read_to_string(&mut body) - .await - .map_err(|e| ClientApiError::InvalidResponse(e.into()))?; - - serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into())) + self.send_authenticated_json_request(request).await } pub fn connect(&self, cx: &App) -> Result>> { @@ -184,12 +154,11 @@ impl CloudApiClient { })) } - pub async fn create_llm_token( + async fn create_llm_token( &self, system_id: Option, - organization_id: Option, + organization_id: OrganizationId, ) -> Result { - let host = self.cloud_host(); let request_builder = Request::builder() .method(Method::POST) .uri( @@ -206,37 +175,7 @@ impl CloudApiClient { request_builder, Json(CreateLlmTokenBody { organization_id }), )?; - - let mut response = self.http_client.send(request).await.map_err(|source| { - ClientApiError::ConnectionFailed { - host: host.clone(), - source, - } - })?; - - if !response.status().is_success() { - if response.status() == StatusCode::UNAUTHORIZED { - return Err(ClientApiError::Unauthorized); - } - - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await.ok(); - - return Err(ClientApiError::ServerError { - host, - status: response.status(), - body, - }); - } - - let mut body = String::new(); - response - .body_mut() - .read_to_string(&mut body) - .await - .map_err(|e| ClientApiError::InvalidResponse(e.into()))?; - - serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into())) + self.send_authenticated_json_request(request).await } pub async fn update_system_settings( @@ -244,7 +183,6 @@ impl CloudApiClient { system_id: String, body: UpdateSystemSettingsBody, ) -> Result { - let host = self.cloud_host(); let request_builder = Request::builder() .method(Method::PATCH) .uri( @@ -256,7 +194,22 @@ impl CloudApiClient { .header(ZED_SYSTEM_ID_HEADER_NAME, system_id); let request = self.build_request(request_builder, Json(body))?; + self.send_authenticated_json_request(request).await + } + async fn send_authenticated_json_request( + &self, + request: Request, + ) -> Result { + let mut response = self.send_authenticated_request(request).await?; + Self::read_response_json(&mut response).await + } + + async fn send_authenticated_request( + &self, + request: Request, + ) -> Result, ClientApiError> { + let host = self.cloud_host(); let mut response = self.http_client.send(request).await.map_err(|source| { ClientApiError::ConnectionFailed { host: host.clone(), @@ -264,29 +217,39 @@ impl CloudApiClient { } })?; - if !response.status().is_success() { - if response.status() == StatusCode::UNAUTHORIZED { - return Err(ClientApiError::Unauthorized); - } - - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await.ok(); + let status = response.status(); + if status.is_success() { + return Ok(response); + } - return Err(ClientApiError::ServerError { - host, - status: response.status(), - body, - }); + if status == StatusCode::UNAUTHORIZED { + return Err(ClientApiError::Unauthorized); } + let body = match Self::read_response_body(&mut response).await { + Ok(body) => body, + Err(error) => format!("failed to read response body: {error}"), + }; + Err(ClientApiError::ServerError { host, status, body }) + } + + async fn read_response_json( + response: &mut Response, + ) -> Result { + let body = Self::read_response_body(response).await?; + serde_json::from_str(&body).map_err(|error| ClientApiError::InvalidResponse(error.into())) + } + + async fn read_response_body( + response: &mut Response, + ) -> Result { let mut body = String::new(); response .body_mut() .read_to_string(&mut body) .await - .map_err(|e| ClientApiError::InvalidResponse(e.into()))?; - - serde_json::from_str(&body).map_err(|e| ClientApiError::InvalidResponse(e.into())) + .map_err(|error| ClientApiError::InvalidResponse(error.into()))?; + Ok(body) } pub async fn validate_credentials(&self, user_id: u32, access_token: &str) -> Result { @@ -331,18 +294,7 @@ impl CloudApiClient { AsyncBody::from(serde_json::to_string(&body)?), )?; - let mut response = self.http_client.send(request).await?; - - if !response.status().is_success() { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - anyhow::bail!( - "Failed to submit agent feedback.\nStatus: {:?}\nBody: {body}", - response.status() - ) - } - + self.send_authenticated_request(request).await?; Ok(()) } @@ -359,18 +311,7 @@ impl CloudApiClient { AsyncBody::from(serde_json::to_string(&body)?), )?; - let mut response = self.http_client.send(request).await?; - - if !response.status().is_success() { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - anyhow::bail!( - "Failed to submit agent feedback comments.\nStatus: {:?}\nBody: {body}", - response.status() - ) - } - + self.send_authenticated_request(request).await?; Ok(()) } @@ -387,18 +328,7 @@ impl CloudApiClient { AsyncBody::from(serde_json::to_string(&body)?), )?; - let mut response = self.http_client.send(request).await?; - - if !response.status().is_success() { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - anyhow::bail!( - "Failed to submit edit prediction feedback.\nStatus: {:?}\nBody: {body}", - response.status() - ) - } - + self.send_authenticated_request(request).await?; Ok(()) } } diff --git a/crates/cloud_api_client/src/llm_token.rs b/crates/cloud_api_client/src/llm_token.rs index 7baafc545b5c48..fd9832d54fb484 100644 --- a/crates/cloud_api_client/src/llm_token.rs +++ b/crates/cloud_api_client/src/llm_token.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{fmt, sync::Arc}; use async_lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard}; use cloud_api_types::OrganizationId; @@ -6,21 +6,42 @@ use cloud_api_types::OrganizationId; use crate::{ClientApiError, CloudApiClient}; #[derive(Clone, Default)] -pub struct LlmApiToken(Arc>>); +pub struct LlmApiToken(Arc>>); + +struct CachedLlmApiToken { + /// The organization ID the token was minted for. + organization_id: OrganizationId, + token: String, +} + +impl fmt::Debug for CachedLlmApiToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CachedLlmApiToken") + .field("organization_id", &self.organization_id) + .field("token", &"") + .finish() + } +} impl LlmApiToken { - /// Returns the cached LLM token, fetching a fresh one only if none has - /// been cached yet. The returned token is not validated; callers must + /// Returns the cached LLM token, fetching a fresh one if none has been + /// cached yet or if the cached token was minted for a different + /// organization. The returned token is not validated; callers must /// be prepared to refresh it (via [`LlmApiToken::refresh`]) if the /// server rejects it. pub async fn cached( &self, client: &CloudApiClient, system_id: Option, - organization_id: Option, + organization_id: OrganizationId, ) -> Result { let lock = self.0.upgradable_read().await; - if let Some(token) = lock.as_ref() { + if let Some(CachedLlmApiToken { + organization_id: cached_organization_id, + token, + }) = lock.as_ref() + && *cached_organization_id == organization_id + { Ok(token.to_string()) } else { Self::fetch( @@ -37,11 +58,15 @@ impl LlmApiToken { &self, client: &CloudApiClient, system_id: Option, - organization_id: Option, + organization_id: OrganizationId, ) -> Result { Self::fetch(self.0.write().await, client, system_id, organization_id).await } + pub async fn clear(&self) { + *self.0.write().await = None; + } + /// Clears the existing token before attempting to fetch a new one. /// /// Used when switching organizations so that a failed refresh doesn't @@ -50,7 +75,7 @@ impl LlmApiToken { &self, client: &CloudApiClient, system_id: Option, - organization_id: Option, + organization_id: OrganizationId, ) -> Result { let mut lock = self.0.write().await; *lock = None; @@ -58,15 +83,21 @@ impl LlmApiToken { } async fn fetch( - mut lock: RwLockWriteGuard<'_, Option>, + mut lock: RwLockWriteGuard<'_, Option>, client: &CloudApiClient, system_id: Option, - organization_id: Option, + organization_id: OrganizationId, ) -> Result { - let result = client.create_llm_token(system_id, organization_id).await; + let result = client + .create_llm_token(system_id, organization_id.clone()) + .await; match result { Ok(response) => { - *lock = Some(response.token.0.clone()); + *lock = Some(CachedLlmApiToken { + organization_id, + token: response.token.0.clone(), + }); + Ok(response.token.0) } Err(err) => { diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index e5a67e68ef8251..27fd96b9660e7e 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -77,10 +77,9 @@ pub struct AcceptTermsOfServiceResponse { #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct LlmToken(pub String); -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct CreateLlmTokenBody { - #[serde(default)] - pub organization_id: Option, + pub organization_id: OrganizationId, } #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] @@ -155,6 +154,8 @@ pub struct SubmitEditPredictionJumpExampleBody { pub trigger: JumpExampleTrigger, pub repository_url: Option, pub revision: Option, + /// Note: this is only the uncommitted diff for files in `edit_history` + /// This is done to avoid excessive memory usage pub uncommitted_diff: Option, pub recently_opened_files: Vec, pub recently_viewed_files: Vec, diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index b9dd071bac30e2..eb1a1194d80285 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -122,7 +122,13 @@ pub struct PredictEditsBody { pub enum PredictEditsRequestTrigger { Testing, Diagnostics, + DiagnosticNavigation, Cli, + Explicit, + BufferEdit, + LSPCompletionAccepted, + PredictionAccepted, + PredictionPartiallyAccepted, #[default] Other, } @@ -292,6 +298,12 @@ pub struct LanguageModel { pub supports_tools: bool, pub supports_images: bool, pub supports_thinking: bool, + /// Whether thinking can be turned off entirely for this model, allowing + /// clients to offer an "off" choice alongside `supported_effort_levels`. + /// Some models (e.g. Claude Fable 5) always think and cannot honor an + /// "off" request. Only meaningful when `supports_thinking` is `true`. + #[serde(default)] + pub supports_disabling_thinking: bool, #[serde(default)] pub supports_fast_mode: bool, pub supported_effort_levels: Vec, diff --git a/crates/codestral/src/codestral.rs b/crates/codestral/src/codestral.rs index 7685fa8f5b1eae..64de772aec1a2b 100644 --- a/crates/codestral/src/codestral.rs +++ b/crates/codestral/src/codestral.rs @@ -2,6 +2,7 @@ use anyhow::Result; use edit_prediction::cursor_excerpt; use edit_prediction_types::{ EditPrediction, EditPredictionDelegate, EditPredictionDiscardReason, EditPredictionIconSet, + EditPredictionRequestTrigger, }; use futures::AsyncReadExt; use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task}; @@ -222,6 +223,7 @@ impl EditPredictionDelegate for CodestralEditPredictionDelegate { buffer: Entity, cursor_position: language::Anchor, debounce: bool, + _trigger: EditPredictionRequestTrigger, cx: &mut Context, ) { log::debug!("Codestral: Refresh called (debounce: {})", debounce); diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 67403f7b594d51..9ad0949e15580a 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -97,7 +97,6 @@ extension.workspace = true file_finder.workspace = true fs = { workspace = true, features = ["test-support"] } git = { workspace = true, features = ["test-support"] } -git_graph = { workspace = true, features = ["test-support"] } git_hosting_providers.workspace = true git_ui = { workspace = true, features = ["test-support"] } gpui = { 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 fe66b5749af310..730c3da9990393 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -1,24 +1,20 @@ +-- WARNING: If you are modifying this file you MUST open a PR to the Cloud repository prior to merging any changes. +-- If you are not Zed staff you MUST coordinate with a staff member to apply the schema migrations before this PR is merged. + CREATE TABLE "users" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "github_login" VARCHAR, "admin" BOOLEAN, "email_address" VARCHAR(255) DEFAULT NULL, "name" TEXT, "connected_once" BOOLEAN NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "metrics_id" TEXT, - "github_user_id" INTEGER NOT NULL, "accepted_tos_at" TIMESTAMP WITHOUT TIME ZONE, - "github_user_created_at" TIMESTAMP WITHOUT TIME ZONE, "custom_llm_monthly_allowance_in_cents" INTEGER ); -CREATE UNIQUE INDEX "index_users_github_login" ON "users" ("github_login"); - CREATE INDEX "index_users_on_email_address" ON "users" ("email_address"); -CREATE UNIQUE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"); - CREATE TABLE "contacts" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id_a" INTEGER NOT NULL, @@ -160,6 +156,7 @@ CREATE TABLE "worktree_diagnostic_summaries" ( "language_server_id" INTEGER NOT NULL, "error_count" INTEGER NOT NULL, "warning_count" INTEGER NOT NULL, + "info_count" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (project_id, worktree_id, path), FOREIGN KEY (project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE ); diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 3a3329af776686..8b136c347ae82d 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -1,5 +1,8 @@ -- 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. +-- +-- WARNING: If you are modifying this file you MUST open a PR to the Cloud repository prior to merging any changes. +-- If you are not Zed staff you MUST coordinate with a staff member to apply the schema migrations before this PR is merged. CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; @@ -224,7 +227,8 @@ CREATE TABLE public.language_servers ( id bigint NOT NULL, name character varying NOT NULL, capabilities text NOT NULL, - worktree_id bigint + worktree_id bigint, + language_name character varying ); CREATE TABLE public.notification_kinds ( @@ -417,15 +421,12 @@ CREATE TABLE public.shared_threads ( CREATE TABLE public.users ( id integer NOT NULL, - github_login character varying, admin boolean NOT NULL, email_address character varying(255) DEFAULT NULL::character varying, connected_once boolean DEFAULT false NOT NULL, created_at timestamp without time zone DEFAULT now() NOT NULL, - github_user_id integer NOT NULL, metrics_id uuid DEFAULT gen_random_uuid() NOT NULL, accepted_tos_at timestamp without time zone, - github_user_created_at timestamp without time zone, custom_llm_monthly_allowance_in_cents integer, name text ); @@ -446,7 +447,8 @@ CREATE TABLE public.worktree_diagnostic_summaries ( path character varying NOT NULL, language_server_id bigint NOT NULL, error_count integer NOT NULL, - warning_count integer NOT NULL + warning_count integer NOT NULL, + info_count integer DEFAULT 0 NOT NULL ); CREATE TABLE public.worktree_entries ( @@ -696,8 +698,6 @@ CREATE INDEX index_settings_files_on_project_id ON public.worktree_settings_file CREATE INDEX index_settings_files_on_project_id_and_wt_id ON public.worktree_settings_files USING btree (project_id, worktree_id); -CREATE UNIQUE INDEX index_users_github_login ON public.users USING btree (github_login); - CREATE INDEX index_users_on_email_address ON public.users USING btree (email_address); CREATE INDEX index_worktree_diagnostic_summaries_on_project_id ON public.worktree_diagnostic_summaries USING btree (project_id); @@ -712,14 +712,10 @@ CREATE INDEX index_worktrees_on_project_id ON public.worktrees USING btree (proj CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name public.gin_trgm_ops); -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); - ALTER TABLE ONLY public.breakpoints ADD CONSTRAINT breakpoints_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 10c4f7c961f152..60e49fc39a6091 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -365,13 +365,6 @@ pub struct WaitlistSummary { pub unknown_count: i64, } -/// The parameters to create a new user. -#[derive(Debug, Serialize, Deserialize)] -pub struct NewUserParams { - pub github_login: String, - pub github_user_id: i32, -} - /// The result of creating a new user. #[derive(Debug)] pub struct NewUserResult { diff --git a/crates/collab/src/db/queries/shared_threads.rs b/crates/collab/src/db/queries/shared_threads.rs index 2a02b247eb741e..9cb320d45d46bc 100644 --- a/crates/collab/src/db/queries/shared_threads.rs +++ b/crates/collab/src/db/queries/shared_threads.rs @@ -64,11 +64,10 @@ impl Database { return Ok(None); }; - let user = user::Entity::find_by_id(thread.user_id).one(&*tx).await?; - - let username = user - .map(|u| u.github_login) - .unwrap_or_else(|| "Unknown".to_string()); + // We can no longer read the `github_login` from Collab. + // + // Opting to just use "Unknown" here, as the feature is staff-only and infrequently used. + let username = "Unknown".to_string(); Ok(Some((thread, username))) }) diff --git a/crates/collab/src/db/queries/users.rs b/crates/collab/src/db/queries/users.rs index 6f84ae8ab5e255..8ec6ba8368d365 100644 --- a/crates/collab/src/db/queries/users.rs +++ b/crates/collab/src/db/queries/users.rs @@ -3,20 +3,13 @@ use super::*; impl Database { /// Creates a new user. #[cfg(feature = "test-support")] - pub async fn create_user(&self, admin: bool, params: NewUserParams) -> Result { + pub async fn create_user(&self, admin: bool) -> Result { self.transaction(|tx| async { let tx = tx; let user = user::Entity::insert(user::ActiveModel { - github_login: ActiveValue::set(params.github_login.clone()), - github_user_id: ActiveValue::set(params.github_user_id), admin: ActiveValue::set(admin), ..Default::default() }) - .on_conflict( - OnConflict::column(user::Column::GithubUserId) - .update_columns([user::Column::Admin, user::Column::GithubLogin]) - .to_owned(), - ) .exec_with_returning(&*tx) .await?; diff --git a/crates/collab/src/db/tables/user.rs b/crates/collab/src/db/tables/user.rs index 75dd72f1c122d9..e8bb135c1b8ca4 100644 --- a/crates/collab/src/db/tables/user.rs +++ b/crates/collab/src/db/tables/user.rs @@ -8,8 +8,6 @@ use serde::Serialize; pub struct Model { #[sea_orm(primary_key)] pub id: UserId, - pub github_login: String, - pub github_user_id: i32, pub admin: bool, pub connected_once: bool, } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index b5870daf3307ec..ef46033d128e13 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -30,7 +30,7 @@ use axum::{ response::IntoResponse, routing::get, }; -use collections::{HashMap, HashSet}; +use collections::{HashSet, TypeIdHashMap}; pub use connection_pool::{ConnectionPool, ZedVersion}; use core::fmt::{self, Debug, Formatter}; use futures::TryFutureExt as _; @@ -313,7 +313,7 @@ pub struct Server { peer: Arc, pub connection_pool: Arc>, app_state: Arc, - handlers: HashMap, + handlers: TypeIdHashMap, teardown: watch::Sender, } diff --git a/crates/collab/tests/integration/db_tests.rs b/crates/collab/tests/integration/db_tests.rs index 4142a4575b274b..bc2c99d2a21e9d 100644 --- a/crates/collab/tests/integration/db_tests.rs +++ b/crates/collab/tests/integration/db_tests.rs @@ -5,7 +5,6 @@ mod extension_tests; mod migrations; use std::sync::Arc; -use std::sync::atomic::{AtomicI32, Ordering::SeqCst}; use std::time::Duration; use collections::HashSet; @@ -205,17 +204,6 @@ fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str)]) -> Vec, email: &str) -> UserId { - db.create_user( - false, - NewUserParams { - github_login: email[0..email.find('@').unwrap()].to_string(), - github_user_id: GITHUB_USER_ID.fetch_add(1, SeqCst), - }, - ) - .await - .unwrap() - .user_id +async fn new_test_user(db: &Arc) -> UserId { + db.create_user(false).await.unwrap().user_id } diff --git a/crates/collab/tests/integration/db_tests/buffer_tests.rs b/crates/collab/tests/integration/db_tests/buffer_tests.rs index 35ce57cbf4d6fd..0a82fea800fe1a 100644 --- a/crates/collab/tests/integration/db_tests/buffer_tests.rs +++ b/crates/collab/tests/integration/db_tests/buffer_tests.rs @@ -11,41 +11,11 @@ test_both_dbs!( ); async fn test_channel_buffers(db: &Arc) { - let a_id = db - .create_user( - false, - NewUserParams { - github_login: "user_a".into(), - github_user_id: 101, - }, - ) - .await - .unwrap() - .user_id; - let b_id = db - .create_user( - false, - NewUserParams { - github_login: "user_b".into(), - github_user_id: 102, - }, - ) - .await - .unwrap() - .user_id; + let a_id = db.create_user(false).await.unwrap().user_id; + let b_id = db.create_user(false).await.unwrap().user_id; // This user will not be a part of the channel - let c_id = db - .create_user( - false, - NewUserParams { - github_login: "user_c".into(), - github_user_id: 103, - }, - ) - .await - .unwrap() - .user_id; + let c_id = db.create_user(false).await.unwrap().user_id; let owner_id = db.create_server("production").await.unwrap().0 as u32; @@ -180,28 +150,8 @@ test_both_dbs!( ); async fn test_channel_buffers_last_operations(db: &Database) { - let user_id = db - .create_user( - false, - NewUserParams { - github_login: "user_a".into(), - github_user_id: 101, - }, - ) - .await - .unwrap() - .user_id; - let observer_id = db - .create_user( - false, - NewUserParams { - github_login: "user_b".into(), - github_user_id: 102, - }, - ) - .await - .unwrap() - .user_id; + let user_id = db.create_user(false).await.unwrap().user_id; + let observer_id = db.create_user(false).await.unwrap().user_id; let owner_id = db.create_server("production").await.unwrap().0 as u32; let connection_id = ConnectionId { owner_id, diff --git a/crates/collab/tests/integration/db_tests/channel_tests.rs b/crates/collab/tests/integration/db_tests/channel_tests.rs index e7752c279546d8..2fd2217f3b4f4b 100644 --- a/crates/collab/tests/integration/db_tests/channel_tests.rs +++ b/crates/collab/tests/integration/db_tests/channel_tests.rs @@ -1,6 +1,6 @@ use super::{assert_channel_tree_matches, channel_tree, new_test_user}; use crate::test_both_dbs; -use collab::db::{Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId}; +use collab::db::{Channel, ChannelId, ChannelRole, Database, RoomId}; use rpc::{ ConnectionId, proto::{self, reorder_channel}, @@ -10,8 +10,8 @@ use std::{collections::HashSet, sync::Arc}; test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite); async fn test_channels(db: &Arc) { - let a_id = new_test_user(db, "user1@example.com").await; - let b_id = new_test_user(db, "user2@example.com").await; + let a_id = new_test_user(db).await; + let b_id = new_test_user(db).await; let zed_id = db.create_root_channel("zed", a_id).await.unwrap(); @@ -118,8 +118,8 @@ test_both_dbs!( async fn test_joining_channels(db: &Arc) { let owner_id = db.create_server("test").await.unwrap().0 as u32; - let user_1 = new_test_user(db, "user1@example.com").await; - let user_2 = new_test_user(db, "user2@example.com").await; + let user_1 = new_test_user(db).await; + let user_2 = new_test_user(db).await; let channel_1 = db.create_root_channel("channel_1", user_1).await.unwrap(); @@ -149,9 +149,9 @@ test_both_dbs!( async fn test_channel_invites(db: &Arc) { db.create_server("test").await.unwrap(); - let user_1 = new_test_user(db, "user1@example.com").await; - let user_2 = new_test_user(db, "user2@example.com").await; - let user_3 = new_test_user(db, "user3@example.com").await; + let user_1 = new_test_user(db).await; + let user_2 = new_test_user(db).await; + let user_3 = new_test_user(db).await; let channel_1_1_id = db.create_root_channel("channel_1", user_1).await.unwrap(); @@ -261,29 +261,9 @@ test_both_dbs!( async fn test_channel_renames(db: &Arc) { db.create_server("test").await.unwrap(); - let user_1 = db - .create_user( - false, - NewUserParams { - github_login: "user1".into(), - github_user_id: 5, - }, - ) - .await - .unwrap() - .user_id; - - let user_2 = db - .create_user( - false, - NewUserParams { - github_login: "user2".into(), - github_user_id: 6, - }, - ) - .await - .unwrap() - .user_id; + let user_1 = db.create_user(false).await.unwrap().user_id; + + let user_2 = db.create_user(false).await.unwrap().user_id; let zed_id = db.create_root_channel("zed", user_1).await.unwrap(); @@ -308,17 +288,7 @@ test_both_dbs!( ); async fn test_db_channel_moving(db: &Arc) { - let a_id = db - .create_user( - false, - NewUserParams { - github_login: "user1".into(), - github_user_id: 5, - }, - ) - .await - .unwrap() - .user_id; + let a_id = db.create_user(false).await.unwrap().user_id; let zed_id = db.create_root_channel("zed", a_id).await.unwrap(); @@ -396,29 +366,9 @@ test_both_dbs!( ); async fn test_channel_reordering(db: &Arc) { - let admin_id = db - .create_user( - false, - NewUserParams { - github_login: "admin".into(), - github_user_id: 1, - }, - ) - .await - .unwrap() - .user_id; - - let user_id = db - .create_user( - false, - NewUserParams { - github_login: "user".into(), - github_user_id: 2, - }, - ) - .await - .unwrap() - .user_id; + let admin_id = db.create_user(false).await.unwrap().user_id; + + let user_id = db.create_user(false).await.unwrap().user_id; // Create a root channel with some sub-channels let root_id = db.create_root_channel("root", admin_id).await.unwrap(); @@ -587,17 +537,7 @@ test_both_dbs!( ); async fn test_db_channel_moving_bugs(db: &Arc) { - let user_id = db - .create_user( - false, - NewUserParams { - github_login: "user1".into(), - github_user_id: 5, - }, - ) - .await - .unwrap() - .user_id; + let user_id = db.create_user(false).await.unwrap().user_id; let zed_id = db.create_root_channel("zed", user_id).await.unwrap(); @@ -657,9 +597,9 @@ test_both_dbs!( ); async fn test_user_is_channel_participant(db: &Arc) { - let admin = new_test_user(db, "admin@example.com").await; - let member = new_test_user(db, "member@example.com").await; - let guest = new_test_user(db, "guest@example.com").await; + let admin = new_test_user(db).await; + let member = new_test_user(db).await; + let guest = new_test_user(db).await; let zed_channel = db.create_root_channel("zed", admin).await.unwrap(); let internal_channel_id = db @@ -972,8 +912,8 @@ test_both_dbs!( async fn test_delete_channel_with_active_call(db: &Arc) { let owner_id = db.create_server("test").await.unwrap().0 as u32; - let user_1 = new_test_user(db, "user1@example.com").await; - let user_2 = new_test_user(db, "user2@example.com").await; + let user_1 = new_test_user(db).await; + let user_2 = new_test_user(db).await; let parent_channel_id = db .create_root_channel("parent_channel", user_1) diff --git a/crates/collab/tests/integration/db_tests/db_tests.rs b/crates/collab/tests/integration/db_tests/db_tests.rs index 17123db1b41035..15a90fcfedbc18 100644 --- a/crates/collab/tests/integration/db_tests/db_tests.rs +++ b/crates/collab/tests/integration/db_tests/db_tests.rs @@ -15,19 +15,8 @@ test_both_dbs!( async fn test_add_contacts(db: &Arc) { let mut user_ids = Vec::new(); - for i in 0..3 { - user_ids.push( - db.create_user( - false, - NewUserParams { - github_login: format!("user{i}"), - github_user_id: i, - }, - ) - .await - .unwrap() - .user_id, - ); + for _ in 0..3 { + user_ids.push(db.create_user(false).await.unwrap().user_id); } let user_1 = user_ids[0]; @@ -174,26 +163,8 @@ test_both_dbs!( async fn test_project_count(db: &Arc) { let owner_id = db.create_server("test").await.unwrap().0 as u32; - let user1 = db - .create_user( - true, - NewUserParams { - github_login: "admin".into(), - github_user_id: 0, - }, - ) - .await - .unwrap(); - let user2 = db - .create_user( - false, - NewUserParams { - github_login: "user".into(), - github_user_id: 1, - }, - ) - .await - .unwrap(); + let user1 = db.create_user(true).await.unwrap(); + let user2 = db.create_user(false).await.unwrap(); let room_id = RoomId::from_proto( db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "") @@ -268,7 +239,7 @@ async fn test_upsert_shared_thread(db: &Arc) { use collab::db::SharedThreadId; use uuid::Uuid; - let user_id = new_test_user(db, "user1@example.com").await; + let user_id = new_test_user(db).await; let thread_id = SharedThreadId(Uuid::new_v4()); let title = "My Test Thread"; @@ -285,7 +256,7 @@ async fn test_upsert_shared_thread(db: &Arc) { assert_eq!(thread.title, title); assert_eq!(thread.data, data); assert_eq!(thread.user_id, user_id); - assert_eq!(username, "user1"); + assert_eq!(username, "Unknown"); } test_both_dbs!( @@ -298,7 +269,7 @@ async fn test_upsert_shared_thread_updates_existing(db: &Arc) { use collab::db::SharedThreadId; use uuid::Uuid; - let user_id = new_test_user(db, "user1@example.com").await; + let user_id = new_test_user(db).await; let thread_id = SharedThreadId(Uuid::new_v4()); @@ -339,8 +310,8 @@ async fn test_cannot_update_another_users_shared_thread(db: &Arc) { use collab::db::SharedThreadId; use uuid::Uuid; - let user1_id = new_test_user(db, "user1@example.com").await; - let user2_id = new_test_user(db, "user2@example.com").await; + let user1_id = new_test_user(db).await; + let user2_id = new_test_user(db).await; let thread_id = SharedThreadId(Uuid::new_v4()); diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index 26faeb7c5f9cc4..d5f71085d5d661 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -11,7 +11,7 @@ use git::{ repository::{CommitData, InitialGraphCommitData, RepoPath, Worktree as GitWorktree}, status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; -use git_graph::GitGraph; +use git_ui::git_graph::GitGraph; use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff}; use gpui::{ AppContext as _, BackgroundExecutor, Entity, IntoElement as _, SharedString, TestAppContext, @@ -763,11 +763,9 @@ async fn test_remote_git_graph_data_and_search( .await; cx_a.update(|cx| { git_ui::init(cx); - git_graph::init(cx); }); cx_b.update(|cx| { git_ui::init(cx); - git_graph::init(cx); }); let active_call_a = cx_a.read(ActiveCall::global); @@ -781,7 +779,7 @@ async fn test_remote_git_graph_data_and_search( let search_query = "graph search match"; let mut rng = StdRng::seed_from_u64(7); - let commits = git_graph::generate_random_commit_dag(&mut rng, 12, true); + let commits = git_ui::git_graph::generate_random_commit_dag(&mut rng, 12, true); let dot_git = Path::new(path!("/project/.git")); client_a.fs().set_graph_commits(dot_git, commits.clone()); diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index 7fc56a3c86c6f5..dac33f9855b303 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -2886,6 +2886,107 @@ async fn test_git_diff_base_change( }); } +#[gpui::test(iterations = 10)] +async fn test_git_diff_index_matches_head( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &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; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + let committed_text = " + one + two + three + " + .unindent(); + let file_contents = " + one + TWO + three + " + .unindent(); + + client_a + .fs() + .insert_tree( + "/dir", + json!({ + ".git": {}, + "a.txt": file_contents, + }), + ) + .await; + client_a + .fs() + .set_head_and_index_for_repo(Path::new("/dir/.git"), &[("a.txt", committed_text.clone())]); + + let (project_local, worktree_id) = client_a.build_local_project("/dir", cx_a).await; + let project_id = active_call_a + .update(cx_a, |call, cx| { + call.share_project(project_local.clone(), cx) + }) + .await + .unwrap(); + let project_remote = client_b.join_remote_project(project_id, cx_b).await; + + // Open the uncommitted diff on the guest, without opening it on the host + // first, so that the host loads the diff bases in response to the guest's + // request. + let remote_buffer = project_remote + .update(cx_b, |p, cx| { + p.open_buffer((worktree_id, rel_path("a.txt")), cx) + }) + .await + .unwrap(); + let remote_uncommitted_diff = project_remote + .update(cx_b, |p, cx| { + p.open_uncommitted_diff(remote_buffer.clone(), cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + + // The guest's index and head texts share one allocation, which is only + // possible if the host detected that the index matches the head and sent + // `Mode::IndexMatchesHead`. + let buffer_id = remote_buffer.read_with(cx_b, |buffer, _| buffer.remote_id()); + project_remote.read_with(cx_b, |project, cx| { + assert!( + project + .git_store() + .read(cx) + .index_matches_head_for_buffer(buffer_id, cx), + "the host should send IndexMatchesHead when the index is clean" + ); + }); + + remote_uncommitted_diff.read_with(cx_b, |diff, cx| { + let buffer = remote_buffer.read(cx); + assert_eq!( + diff.base_text_string(cx).as_deref(), + Some(committed_text.as_str()) + ); + assert_hunks( + diff.snapshot(cx).hunks_in_row_range(0..3, buffer), + buffer, + &diff.base_text_string(cx).unwrap(), + &[( + 1..2, + "two\n", + "TWO\n", + DiffHunkStatus::modified(DiffHunkSecondaryStatus::HasSecondaryHunk), + )], + ); + }); +} + #[gpui::test(iterations = 10)] async fn test_git_branch_name( executor: BackgroundExecutor, diff --git a/crates/collab/tests/integration/randomized_test_helpers.rs b/crates/collab/tests/integration/randomized_test_helpers.rs index 98e64ced4149cd..7e1aa2677be312 100644 --- a/crates/collab/tests/integration/randomized_test_helpers.rs +++ b/crates/collab/tests/integration/randomized_test_helpers.rs @@ -1,7 +1,7 @@ use crate::{TestClient, TestServer}; use async_trait::async_trait; use collab::{ - db::{self, NewUserParams, UserId}, + db::{self, UserId}, rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, }; use futures::StreamExt; @@ -224,13 +224,7 @@ impl TestPlan { let user_id = server .app_state .db - .create_user( - false, - NewUserParams { - github_login: username.clone(), - github_user_id: ix as i32, - }, - ) + .create_user(false) .await .unwrap() .user_id; diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index d82971fe7a6489..87784b6328149d 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -934,7 +934,7 @@ async fn test_ssh_restarting_language_server_replaces_remote_status( }); project_a.update(cx_a, |project, cx| { - project.restart_language_servers_for_buffers(vec![buffer], HashSet::default(), cx); + project.restart_language_servers_for_buffers(vec![buffer], HashSet::default(), true, cx); }); let restarted_server = fake_language_servers.next().await.unwrap(); diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index 89bfc2dac9f342..de4c4a4e165b5c 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -174,7 +174,6 @@ impl TestServer { } let settings = SettingsStore::test(cx); cx.set_global(settings); - theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); }); @@ -357,9 +356,7 @@ impl TestServer { collab_ui::init(&app_state, cx); file_finder::init(cx); menu::init(); - cx.bind_keys( - settings::KeymapFile::load_asset_allow_partial_failure(os_keymap, cx).unwrap(), - ); + cx.bind_keys(settings::KeymapFile::load_asset_cached(os_keymap, cx).unwrap()); language_model::LanguageModelRegistry::test(cx); }); diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index f4aadb7433f143..bcc91ac0931645 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -324,7 +324,7 @@ impl ChannelView { return; }; - let link = channel.notes_link(closest_heading.map(|heading| heading.text), cx); + let link = channel.notes_link(closest_heading.map(|heading| heading.text.to_string()), cx); cx.write_to_clipboard(ClipboardItem::new_string(link)); self.workspace .update(cx, |workspace, cx| { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index b24c97e07802d0..907c462aa6366a 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -166,7 +166,7 @@ pub fn init(cx: &mut App) { }) .detach_and_notify_err(workspace_handle, window, cx); } else { - workspace.show_error(&"There’s no active call; join one first.", cx); + workspace.show_error("There’s no active call; join one first.", cx); } }); workspace.register_action(|workspace, _: &ShareProject, window, cx| { diff --git a/crates/collections/Cargo.toml b/crates/collections/Cargo.toml index aa3dd899a7222f..2a86e88d4f669e 100644 --- a/crates/collections/Cargo.toml +++ b/crates/collections/Cargo.toml @@ -19,3 +19,4 @@ test-support = [] [dependencies] indexmap.workspace = true rustc-hash.workspace = true +gpui_util.workspace = true diff --git a/crates/collections/src/collections.rs b/crates/collections/src/collections.rs index 8e6c334d2bd5d5..9a7f4942c8b66f 100644 --- a/crates/collections/src/collections.rs +++ b/crates/collections/src/collections.rs @@ -2,10 +2,12 @@ pub type HashMap = FxHashMap; pub type HashSet = FxHashSet; pub type IndexMap = indexmap::IndexMap; pub type IndexSet = indexmap::IndexSet; +pub type TypeIdHashMap = + std::collections::HashMap; +pub type TypeIdHashSet = std::collections::HashSet; pub use indexmap::Equivalent; -pub use rustc_hash::FxHasher; -pub use rustc_hash::{FxHashMap, FxHashSet}; +pub use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet, FxHasher}; pub use std::collections::*; pub mod vecmap; diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 3104fecf204deb..569d8407b6f7b4 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -488,7 +488,7 @@ impl PickerDelegate for CommandPaletteDelegate { CommandInterceptResult { results: vec![CommandInterceptItem { action: OpenZedUrl { - url: query_for_link.clone(), + url: query_for_link.clone().into(), } .boxed_clone(), string: query_for_link, diff --git a/crates/command_palette_hooks/src/command_palette_hooks.rs b/crates/command_palette_hooks/src/command_palette_hooks.rs index bd8f9375b77ec9..a137e5f1c89b95 100644 --- a/crates/command_palette_hooks/src/command_palette_hooks.rs +++ b/crates/command_palette_hooks/src/command_palette_hooks.rs @@ -4,7 +4,7 @@ use std::{any::TypeId, rc::Rc}; -use collections::HashSet; +use collections::{HashSet, TypeIdHashSet}; use derive_more::{Deref, DerefMut}; use gpui::{Action, App, BorrowAppContext, Global, Task, WeakEntity}; use workspace::Workspace; @@ -18,10 +18,10 @@ pub fn init(cx: &mut App) { #[derive(Default)] pub struct CommandPaletteFilter { hidden_namespaces: HashSet<&'static str>, - hidden_action_types: HashSet, + hidden_action_types: TypeIdHashSet, /// Actions that have explicitly been shown. These should be shown even if /// they are in a hidden namespace. - shown_action_types: HashSet, + shown_action_types: TypeIdHashSet, } #[derive(Deref, DerefMut, Default)] diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index 4b75feafe4b38b..d295d94198f5b7 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -8,7 +8,7 @@ use crate::{ use anyhow::Result; use edit_prediction_types::{ EditPrediction, EditPredictionDelegate, EditPredictionDiscardReason, EditPredictionIconSet, - interpolate_edits, + EditPredictionRequestTrigger, interpolate_edits, }; use gpui::{App, Context, Entity, Task, TaskExt}; use icons::IconName; @@ -78,6 +78,7 @@ impl EditPredictionDelegate for CopilotEditPredictionDelegate { buffer: Entity, cursor_position: language::Anchor, debounce: bool, + _trigger: EditPredictionRequestTrigger, cx: &mut Context, ) { let copilot = self.copilot.clone(); @@ -1041,7 +1042,13 @@ mod tests { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges([Point::new(0, 0)..Point::new(0, 0)]) }); - editor.refresh_edit_prediction(true, false, window, cx); + editor.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); @@ -1051,7 +1058,13 @@ mod tests { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([Point::new(5, 0)..Point::new(5, 0)]) }); - editor.refresh_edit_prediction(true, false, window, cx); + editor.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index fe9ac57bdac987..6d1f67607fc48f 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -37,7 +37,7 @@ pub fn initiate_sign_out(copilot: Entity, window: &mut Window, cx: &mut Err(err) => cx.update(|window, cx| { if let Some(workspace) = Workspace::for_window(window, cx) { workspace.update(cx, |workspace, cx| { - workspace.show_error(&err, cx); + workspace.show_error(format!("Error: {err}"), cx); }) } else { log::error!("{:?}", err); diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 910637343c0a70..fd1373ebf493e1 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -652,6 +652,7 @@ impl ConsoleQueryBarCompletionProvider { match_start: None, snippet_deduplication_key: None, icon_path: None, + icon_color: None, documentation: Some(CompletionDocumentation::MultiLineMarkdown( variable_value.into(), )), @@ -762,6 +763,7 @@ impl ConsoleQueryBarCompletionProvider { new_text, label: CodeLabel::plain(completion.label, None), icon_path: None, + icon_color: None, documentation: completion.detail.map(|detail| { CompletionDocumentation::MultiLineMarkdown(detail.into()) }), diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index b86b691546d737..f44e6a4ee51854 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -274,7 +274,8 @@ impl DiagnosticBlock { cx: &mut Context, ) { let Some(diagnostic_link) = link.strip_prefix("file://#diagnostic-") else { - editor::hover_popover::open_markdown_url(link, window, cx); + let workspace = editor.workspace(); + editor::hover_popover::open_markdown_url(workspace, link, window, cx); return; }; let Some((buffer_id, group_id, ix)) = maybe!({ diff --git a/crates/edit_prediction/src/capture_example.rs b/crates/edit_prediction/src/capture_example.rs index 1f5ccf8ed5d337..4f6e0f34b3018a 100644 --- a/crates/edit_prediction/src/capture_example.rs +++ b/crates/edit_prediction/src/capture_example.rs @@ -354,12 +354,17 @@ mod tests { ); let worktree_id = buffer.read_with(cx, |buffer, cx| buffer.file().unwrap().worktree_id(cx)); - let failed_capture = ep_store + let unfiltered_uncommitted_diffs = ep_store .update(cx, |_store, cx| { uncommitted_diffs_for_events(project.clone(), worktree_id, events.clone(), cx) }) - .await; - assert!(failed_capture.is_err()); + .await + .unwrap(); + assert!( + unfiltered_uncommitted_diffs + .iter() + .all(|(path, _, _)| path.as_ref() != Path::new("external.rs")) + ); let project_events = events .into_iter() diff --git a/crates/edit_prediction/src/data_collection.rs b/crates/edit_prediction/src/data_collection.rs index ae8a3bca7251b3..b56609a9d96a8b 100644 --- a/crates/edit_prediction/src/data_collection.rs +++ b/crates/edit_prediction/src/data_collection.rs @@ -1,13 +1,14 @@ use crate::{EditPredictionStore, StoredEvent}; -use anyhow::{Context as _, Result}; +use anyhow::Context as _; use buffer_diff::BufferDiffSnapshot; use collections::HashMap; use gpui::{Context, Entity, Task}; use language::BufferSnapshot; -use project::{Project, WorktreeId}; +use project::{Project, ProjectPath, WorktreeId}; use std::{fmt::Write as _, ops::Range, path::Path, sync::Arc}; use text::{OffsetRangeExt, Point}; +use util::rel_path::RelPath; pub type UncommittedDiffSnapshot = Vec<(Arc, BufferSnapshot, BufferDiffSnapshot)>; pub type UncommittedDiffResult = std::result::Result>; @@ -23,25 +24,34 @@ pub fn uncommitted_diffs_for_events( let git_store = project.read_with(cx, |project, _| project.git_store().clone()); cx.spawn(async move |_store, cx| { + let (worktree_root_name, worktree_abs_path, path_style) = project + .read_with(cx, |project, cx| { + let worktree = project.worktree_for_id(worktree_id, cx)?; + let worktree = worktree.read(cx); + let path_style = worktree.path_style(); + let root_name = RelPath::new(Path::new(worktree.root_name_str()), path_style) + .ok()? + .into_owned(); + Some((root_name, worktree.abs_path(), path_style)) + }) + .context("failed to find worktree for uncommitted diff capture") + .map_err(Arc::new)?; + let events_with_paths = events .into_iter() - .map(|stored_event| { + .filter_map(|stored_event| { let zeta_prompt::Event::BufferChange { path, .. } = stored_event.event.as_ref(); - project - .read_with(cx, |project, cx| { - let project_path = project - .find_project_path(path, cx) - .filter(|path| path.worktree_id == worktree_id)?; - let relative_path: Arc = project_path.path.as_std_path().into(); - Some((project_path, relative_path)) - }) - .map(|(project_path, relative_path)| { - (stored_event, project_path, relative_path) - }) - .context("failed to find project path for uncommitted diff capture") + let path = if let Ok(path) = RelPath::new(path, path_style) { + path.strip_prefix(&worktree_root_name).ok()?.into_arc() + } else { + let path = path.strip_prefix(worktree_abs_path.as_ref()).ok()?; + RelPath::new(path, path_style).ok()?.into_arc() + }; + let project_path = ProjectPath { worktree_id, path }; + let relative_path: Arc = project_path.path.as_std_path().into(); + Some((stored_event, project_path, relative_path)) }) - .collect::>>() - .map_err(Arc::new)?; + .collect::>(); let mut snapshots_by_path: HashMap, (BufferSnapshot, BufferDiffSnapshot)> = HashMap::default(); @@ -57,10 +67,20 @@ pub fn uncommitted_diffs_for_events( .await .context("failed to open buffer for uncommitted diff capture") .map_err(Arc::new)?; + let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); let file_context = stored_event.file_context.clone(); - let cached_diff = file_context.as_ref().and_then(|file_context| { - file_context.read_with(cx, |file_context, _| file_context.uncommitted_diff.clone()) - }); + let cached_diff = file_context + .as_ref() + .and_then(|file_context| { + file_context + .read_with(cx, |file_context, _| file_context.uncommitted_diff.clone()) + }) + // The cached diff is keyed by path, but its hunk anchors are pinned to a + // specific buffer. If that buffer was closed and reopened, `open_buffer` + // hands back a buffer with a new `BufferId`; reusing the stale diff against + // it would mix anchors from different buffers and panic. Drop the cache in + // that case so the diff is recomputed for the current buffer. + .filter(|diff| diff.read_with(cx, |diff, _| diff.buffer_id) == buffer_id); let diff = match cached_diff { Some(diff) => diff, None => { diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index ab88dbf2fa6a66..ac95aac0dfced4 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,4 +1,4 @@ -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, anyhow}; use buffer_diff::BufferDiff; use client::{Client, EditPredictionUsage, UserStore, global_llm_token}; use cloud_api_client::LlmApiToken; @@ -22,6 +22,7 @@ use copilot::{Copilot, Reinstall, SignIn, SignOut}; use credentials_provider::CredentialsProvider; use db::kvp::{Dismissable, KeyValueStore}; use edit_prediction_context::{RelatedExcerptStore, RelatedExcerptStoreEvent, RelatedFile}; +use edit_prediction_types::EditPredictionRequestTrigger; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag}; use futures::{ AsyncReadExt as _, FutureExt as _, StreamExt as _, @@ -55,6 +56,8 @@ use std::env; use std::rc::Rc; use text::{AnchorRangeExt, Edit}; use workspace::{AppState, Workspace}; +#[cfg(feature = "cli-support")] +use zeta_prompt::ContextSource; use zeta_prompt::{ZetaFormat, ZetaPromptInput}; use std::mem; @@ -129,6 +132,7 @@ const GIT_CHANGED_FILE_SETS_COMMIT_LIMIT: usize = 100; 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); +const REQUEST_TIMEOUT_BACKOFF: Duration = Duration::from_secs(10); const EDIT_PREDICTION_SETTLED_TTL: Duration = Duration::from_secs(60 * 5); const EDIT_PREDICTION_SETTLED_QUIESCENCE: Duration = Duration::from_secs(10); @@ -138,6 +142,10 @@ pub struct EditPredictionJumpsFeatureFlag; impl FeatureFlag for EditPredictionJumpsFeatureFlag { const NAME: &'static str = "edit_prediction_jumps"; type Value = PresenceFlag; + + fn enabled_for_staff() -> bool { + false + } } register_feature_flag!(EditPredictionJumpsFeatureFlag); @@ -165,6 +173,7 @@ pub struct EditPredictionStore { update_required: bool, edit_prediction_model: EditPredictionModel, zeta2_raw_config: Option, + request_backoff_until: Option, preferred_experiment: Option, available_experiments: Vec, pub mercury: Mercury, @@ -838,6 +847,28 @@ pub(crate) fn buffer_path_with_id_fallback( } } +fn predict_edits_request_trigger_from_editor_trigger( + trigger: EditPredictionRequestTrigger, +) -> PredictEditsRequestTrigger { + match trigger { + EditPredictionRequestTrigger::DiagnosticNavigation => { + PredictEditsRequestTrigger::DiagnosticNavigation + } + EditPredictionRequestTrigger::Explicit => PredictEditsRequestTrigger::Explicit, + EditPredictionRequestTrigger::BufferEdit => PredictEditsRequestTrigger::BufferEdit, + EditPredictionRequestTrigger::LSPCompletionAccepted => { + PredictEditsRequestTrigger::LSPCompletionAccepted + } + EditPredictionRequestTrigger::PredictionAccepted => { + PredictEditsRequestTrigger::PredictionAccepted + } + EditPredictionRequestTrigger::PredictionPartiallyAccepted => { + PredictEditsRequestTrigger::PredictionPartiallyAccepted + } + EditPredictionRequestTrigger::Other => PredictEditsRequestTrigger::Other, + } +} + impl EditPredictionStore { pub fn try_global(cx: &App) -> Option> { cx.try_global::() @@ -925,6 +956,7 @@ impl EditPredictionStore { update_required: false, edit_prediction_model: EditPredictionModel::Zeta, zeta2_raw_config: Self::zeta2_raw_config_from_env(), + request_backoff_until: None, preferred_experiment: None, available_experiments: Vec::new(), mercury: Mercury::new(cx), @@ -967,6 +999,27 @@ impl EditPredictionStore { self.zeta2_raw_config.as_ref() } + pub(crate) fn back_off_requests_after_timeout(&mut self, cx: &mut Context) { + self.request_backoff_until = Some(cx.background_executor().now() + REQUEST_TIMEOUT_BACKOFF); + log::info!( + "Backing off edit prediction requests for {:?} after Cloud timeout", + REQUEST_TIMEOUT_BACKOFF + ); + } + + fn request_backoff_active(&mut self, cx: &App) -> bool { + let Some(backoff_until) = self.request_backoff_until else { + return false; + }; + + if cx.background_executor().now() < backoff_until { + true + } else { + self.request_backoff_until = None; + false + } + } + pub fn preferred_experiment(&self) -> Option<&str> { self.preferred_experiment.as_deref() } @@ -1001,6 +1054,8 @@ impl EditPredictionStore { cx.spawn(async move |this, cx| { let experiments = cx .background_spawn(async move { + let organization_id = + organization_id.ok_or_else(|| anyhow!("No organization selected."))?; let url = client .http_client() .build_zed_llm_url("/edit_prediction_experiments", &[])?; @@ -1249,6 +1304,7 @@ impl EditPredictionStore { file_context.git_changed_file_sets = result .context("failed to receive git changed file sets") .flatten() + .log_with_level(log::Level::Trace) .map(|mut file_sets| file_sets.pop().unwrap_or_default()) .context("failed to load git changed file sets") .map(Arc::new) @@ -2188,21 +2244,25 @@ impl EditPredictionStore { project: Entity, buffer: Entity, position: language::Anchor, + trigger: EditPredictionRequestTrigger, cx: &mut Context, ) { + let trigger = predict_edits_request_trigger_from_editor_trigger(trigger); + self.queue_prediction_refresh( project.clone(), - PredictEditsRequestTrigger::Other, + trigger, buffer.entity_id(), cx, move |this, cx| { let Some(request_task) = this .update(cx, |this, cx| { - this.request_prediction( - &project, - &buffer, + this.request_prediction_internal( + project.clone(), + buffer.clone(), position, - PredictEditsRequestTrigger::Other, + trigger, + cx.has_flag::(), cx, ) }) @@ -2306,11 +2366,12 @@ impl EditPredictionStore { let Some(prediction_result) = this .update(cx, |this, cx| { - this.request_prediction( - &project, - &jump_buffer, + this.request_prediction_internal( + project.clone(), + jump_buffer.clone(), jump_position, PredictEditsRequestTrigger::Diagnostics, + cx.has_flag::(), cx, ) })? @@ -2331,6 +2392,7 @@ impl EditPredictionStore { EditPredictionResult { id: prediction_result.id, prediction: Err(EditPredictionRejectReason::CurrentPreferred), + display_prediction: None, model_version: prediction_result.model_version, e2e_latency: prediction_result.e2e_latency, } @@ -2427,7 +2489,8 @@ impl EditPredictionStore { request_trigger: PredictEditsRequestTrigger, ) -> &mut Option<(EntityId, Instant)> { match request_trigger { - PredictEditsRequestTrigger::Diagnostics => { + PredictEditsRequestTrigger::Diagnostics + | PredictEditsRequestTrigger::DiagnosticNavigation => { &mut project_state.last_jump_prediction_refresh } _ => &mut project_state.last_edit_prediction_refresh, @@ -2519,14 +2582,18 @@ impl EditPredictionStore { let new_current_prediction = if !is_cancelled && let Some((prediction_result, requested_by)) = new_prediction_result { - match prediction_result.prediction { - Ok(prediction) => { + match prediction_result { + EditPredictionResult { + prediction: Ok(prediction), + e2e_latency, + .. + } => { let new_prediction = CurrentEditPrediction { requested_by, prediction, was_shown: false, shown_with: None, - e2e_latency: prediction_result.e2e_latency, + e2e_latency, }; if let Some(current_prediction) = @@ -2556,15 +2623,39 @@ impl EditPredictionStore { Some(new_prediction) } } - Err(reject_reason) => { + EditPredictionResult { + id, + prediction: Err(reject_reason), + display_prediction, + model_version, + e2e_latency, + } => { + let should_show_rejected_prediction = matches!( + reject_reason, + EditPredictionRejectReason::Empty + | EditPredictionRejectReason::InterpolatedEmpty + ); + this.reject_prediction( - prediction_result.id, + id, reject_reason, false, - prediction_result.model_version, - Some(prediction_result.e2e_latency), + model_version, + Some(e2e_latency), cx, ); + + if should_show_rejected_prediction + && let Some(display_prediction) = display_prediction + { + this.shown_predictions.push_front(display_prediction); + if this.shown_predictions.len() > 50 + && let Some(completion) = this.shown_predictions.pop_back() + { + this.rated_predictions.remove(&completion.id); + } + } + None } } @@ -2655,6 +2746,13 @@ impl EditPredictionStore { return Task::ready(Ok(None)); } + if is_cloud_zeta && self.request_backoff_active(cx) { + log::debug!( + "Skipping Zeta edit prediction request while backing off after Cloud timeout" + ); + return Task::ready(Ok(None)); + } + self.get_or_init_project(&project, cx); let project_state = self.projects.get(&project.entity_id()).unwrap(); let stored_events = project_state.events(cx); @@ -2735,7 +2833,8 @@ impl EditPredictionStore { inputs.snapshot.clone(), inputs.position, match trigger { - PredictEditsRequestTrigger::Diagnostics => { + PredictEditsRequestTrigger::Diagnostics + | PredictEditsRequestTrigger::DiagnosticNavigation => { JumpExampleTrigger::Diagnostic } _ => JumpExampleTrigger::Prediction, @@ -2768,7 +2867,11 @@ impl EditPredictionStore { if prediction.is_none() && allow_jump && has_events - && !matches!(trigger, PredictEditsRequestTrigger::Diagnostics) + && !matches!( + trigger, + PredictEditsRequestTrigger::Diagnostics + | PredictEditsRequestTrigger::DiagnosticNavigation + ) { this.update(cx, |this, cx| { this.refresh_prediction_from_diagnostics( @@ -2985,6 +3088,9 @@ impl EditPredictionStore { where Res: DeserializeOwned, { + let organization_id = + organization_id.ok_or_else(|| anyhow!("No organization selected."))?; + let response = client .authenticated_llm_request(&llm_token, organization_id, |token| { build( @@ -3029,6 +3135,9 @@ impl EditPredictionStore { let status = response.status(); let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; + if status == http_client::http::StatusCode::REQUEST_TIMEOUT { + return Err(anyhow::Error::new(CloudRequestTimeoutError)); + } anyhow::bail!("Request failed with status: {status:?}\nBody: {body}"); } } @@ -3047,6 +3156,51 @@ impl EditPredictionStore { }); } + #[cfg(feature = "cli-support")] + pub fn collect_editable_context( + &mut self, + project: Entity, + buffer: Entity, + cursor_position: language::Anchor, + oracle_paths: Vec>, + context_sources: Vec, + cx: &mut Context, + ) -> Task>> { + use edit_prediction_context::{EditHistoryContextEntry, collect_editable_context}; + + let buffers_by_id = project.read(cx).opened_buffers(cx).into_iter().fold( + HashMap::default(), + |mut buffers_by_id, buffer| { + buffers_by_id.insert(buffer.read(cx).remote_id(), buffer.clone()); + buffers_by_id + }, + ); + let edit_history = self + .edit_history_for_project(&project, cx) + .into_iter() + .filter_map(|event| { + let buffer = buffers_by_id.get(&event.old_snapshot.remote_id())?.clone(); + Some(EditHistoryContextEntry { + buffer, + edited_range: event.total_edit_range, + }) + }) + .collect(); + + cx.spawn(async move |_, cx| { + collect_editable_context( + project, + buffer, + cursor_position, + edit_history, + oracle_paths, + context_sources, + cx, + ) + .await + }) + } + #[cfg(feature = "cli-support")] pub fn set_context_for_buffer( &mut self, @@ -3358,6 +3512,10 @@ pub struct ZedUpdateRequiredError { minimum_version: Version, } +#[derive(Error, Debug)] +#[error("Cloud request timed out")] +pub(crate) struct CloudRequestTimeoutError; + struct ZedPredictUpsell; fn is_upsell_dismissed(cx: &App) -> bool { diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 52c6948cdc5a21..5195393f2f6982 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1,5 +1,3 @@ -use super::*; -use crate::udiff::apply_diff_to_string; use client::{RefreshLlmTokenListener, UserStore, test::FakeServer}; use clock::FakeSystemClock; use clock::ReplicaId; @@ -9,12 +7,12 @@ use cloud_api_types::{ SubmitEditPredictionSettledResponse, }; use cloud_llm_client::{ - EditPredictionRejectReason, EditPredictionRejection, RejectEditPredictionsBody, + EditPredictionRejectReason, EditPredictionRejection, PredictEditsRequestTrigger, + RejectEditPredictionsBody, predict_edits_v3::{PredictEditsV3Request, PredictEditsV3Response}, }; use db::AppDatabase; -use settings::EditPredictionDataCollectionChoice; - +use edit_prediction_types::EditPredictionRequestTrigger; use futures::{ AsyncReadExt, FutureExt, StreamExt, channel::{mpsc, oneshot}, @@ -29,12 +27,12 @@ use language::{ Anchor, Buffer, BufferEditSource, Capability, CursorShape, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSeverity, Operation, Point, Selection, SelectionGoal, }; - use lsp::LanguageServerId; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_matches}; use project::{FakeFs, Project}; use serde_json::json; +use settings::EditPredictionDataCollectionChoice; use settings::SettingsStore; use std::{ops::Range, path::Path, sync::Arc, time::Duration}; use util::{ @@ -45,13 +43,18 @@ use uuid::Uuid; use workspace::{AppState, CollaboratorId, MultiWorkspace}; use zeta_prompt::ZetaPromptInput; +use crate::udiff::apply_diff_to_string; use crate::{ BufferEditPrediction, EDIT_PREDICTION_SETTLED_QUIESCENCE, EditPredictionId, EditPredictionJumpsFeatureFlag, EditPredictionStore, REJECT_REQUEST_DEBOUNCE, + REQUEST_TIMEOUT_BACKOFF, }; +use super::*; + #[gpui::test] async fn test_current_state(cx: &mut TestAppContext) { + enable_edit_prediction_jumps(cx); let (ep_store, mut requests) = init_test_with_fake_client(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( @@ -83,7 +86,13 @@ async fn test_current_state(cx: &mut TestAppContext) { // Prediction for current file ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer1.clone(), position, cx) + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer1.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ) }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -188,15 +197,9 @@ async fn test_current_state(cx: &mut TestAppContext) { #[gpui::test] async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppContext) { + enable_edit_prediction_jumps(cx); 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", @@ -238,7 +241,13 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon 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); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer1.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -354,15 +363,9 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon #[gpui::test] async fn test_diagnostics_refresh_suppressed_after_agent_edit(cx: &mut TestAppContext) { + enable_edit_prediction_jumps(cx); 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", @@ -440,7 +443,13 @@ async fn test_simple_request(cx: &mut TestAppContext) { let position = snapshot.anchor_before(language::Point::new(1, 3)); let prediction_task = ep_store.update(cx, |ep_store, cx| { - ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) + ep_store.request_prediction( + &project, + &buffer, + position, + PredictEditsRequestTrigger::Other, + cx, + ) }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -516,7 +525,13 @@ async fn test_request_events(cx: &mut TestAppContext) { let position = snapshot.anchor_before(language::Point::new(1, 3)); let prediction_task = ep_store.update(cx, |ep_store, cx| { - ep_store.request_prediction(&project, &buffer, position, Default::default(), cx) + ep_store.request_prediction( + &project, + &buffer, + position, + PredictEditsRequestTrigger::Other, + cx, + ) }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -1440,7 +1455,13 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { let position = snapshot.anchor_before(language::Point::new(1, 3)); ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Explicit, + cx, + ); }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -1457,6 +1478,15 @@ async fn test_empty_prediction(cx: &mut TestAppContext) { .prediction_at(&buffer, None, &project, cx) .is_none() ); + let shown_predictions = ep_store.shown_predictions().collect::>(); + assert_eq!(shown_predictions.len(), 1); + assert_eq!(shown_predictions[0].id.to_string(), id); + assert!(shown_predictions[0].edits.is_empty()); + assert!(shown_predictions[0].editable_range.is_some()); + assert!(matches!( + shown_predictions[0].trigger, + PredictEditsRequestTrigger::Explicit + )); }); // prediction is reported as rejected @@ -1498,7 +1528,13 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { let position = snapshot.anchor_before(language::Point::new(1, 3)); ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -1520,6 +1556,11 @@ async fn test_interpolated_empty(cx: &mut TestAppContext) { .prediction_at(&buffer, None, &project, cx) .is_none() ); + let shown_predictions = ep_store.shown_predictions().collect::>(); + assert_eq!(shown_predictions.len(), 1); + assert_eq!(shown_predictions[0].id.to_string(), id); + assert!(shown_predictions[0].edits.is_empty()); + assert!(shown_predictions[0].editable_range.is_some()); }); // prediction is reported as rejected @@ -1571,7 +1612,13 @@ async fn test_replace_current(cx: &mut TestAppContext) { let position = snapshot.anchor_before(language::Point::new(1, 3)); ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -1594,7 +1641,13 @@ async fn test_replace_current(cx: &mut TestAppContext) { // a second request is triggered ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -1655,7 +1708,13 @@ async fn test_current_preferred(cx: &mut TestAppContext) { let position = snapshot.anchor_before(language::Point::new(1, 3)); ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -1678,7 +1737,13 @@ async fn test_current_preferred(cx: &mut TestAppContext) { // a second request is triggered ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -1711,6 +1776,11 @@ async fn test_current_preferred(cx: &mut TestAppContext) { .0, first_id ); + let shown_prediction_ids = ep_store + .shown_predictions() + .map(|prediction| prediction.id.to_string()) + .collect::>(); + assert!(shown_prediction_ids.is_empty()); }); // second is reported as rejected @@ -1753,13 +1823,25 @@ async fn test_cancel_earlier_pending_requests(cx: &mut TestAppContext) { // start two refresh tasks ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request1, respond_first) = requests.predict.next().await.unwrap(); ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request, respond_second) = requests.predict.next().await.unwrap(); @@ -1847,13 +1929,25 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { // start two refresh tasks ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request1, respond_first) = requests.predict.next().await.unwrap(); ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request2, respond_second) = requests.predict.next().await.unwrap(); @@ -1863,7 +1957,13 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { ep_store.update(cx, |ep_store, cx| { // start a third request - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); // 2 are pending, so 2nd is cancelled assert_eq!( @@ -1967,6 +2067,7 @@ async fn test_cancel_second_on_third_request(cx: &mut TestAppContext) { #[gpui::test] async fn test_jump_and_edit_throttles_are_independent(cx: &mut TestAppContext) { + enable_edit_prediction_jumps(cx); let (ep_store, mut requests) = init_test_with_fake_client(cx); let fs = FakeFs::new(cx.executor()); @@ -1998,7 +2099,13 @@ async fn test_jump_and_edit_throttles_are_independent(cx: &mut TestAppContext) { // First edit request - no prior edit, so not throttled. ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (_edit_request, edit_response_tx) = requests.predict.next().await.unwrap(); edit_response_tx.send(empty_response()).unwrap(); @@ -2036,7 +2143,13 @@ async fn test_jump_and_edit_throttles_are_independent(cx: &mut TestAppContext) { // Second edit request - should be throttled by the first edit. ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); assert_no_predict_request_ready(&mut requests.predict); @@ -2066,6 +2179,81 @@ async fn test_jump_and_edit_throttles_are_independent(cx: &mut TestAppContext) { cx.run_until_parked(); } +#[gpui::test] +async fn test_cloud_timeout_backs_off_zeta_requests(cx: &mut TestAppContext) { + let (ep_store, mut requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\nHow\nBye\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.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(1, 3)); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_project(&project, cx); + ep_store.register_buffer(&buffer, &project, cx); + }); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); + }); + let (_request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx.send(request_timeout_response()).unwrap(); + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); + }); + cx.background_executor + .advance_clock(EditPredictionStore::THROTTLE_TIMEOUT); + cx.background_executor.run_until_parked(); + cx.run_until_parked(); + assert_no_predict_request_ready(&mut requests.predict); + + cx.background_executor + .advance_clock(REQUEST_TIMEOUT_BACKOFF); + cx.background_executor.run_until_parked(); + cx.run_until_parked(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); + }); + let (_request, respond_tx) = requests.predict.next().await.unwrap(); + respond_tx.send(empty_response()).unwrap(); + cx.run_until_parked(); +} + #[gpui::test] async fn test_same_frame_duplicate_requests_deduplicated(cx: &mut TestAppContext) { let (ep_store, mut requests) = init_test_with_fake_client(cx); @@ -2094,8 +2282,20 @@ async fn test_same_frame_duplicate_requests_deduplicated(cx: &mut TestAppContext // capture the same `proceed_count_at_enqueue`. Only the first task should // pass the deduplication gate; the second should be skipped. ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); // Let both spawned tasks run to completion (including any throttle waits). @@ -2546,6 +2746,18 @@ fn empty_response() -> PredictEditsV3Response { } } +const REQUEST_TIMEOUT_RESPONSE_ID: &str = "__request_timeout__"; + +fn request_timeout_response() -> PredictEditsV3Response { + PredictEditsV3Response { + request_id: REQUEST_TIMEOUT_RESPONSE_ID.to_string(), + editable_range: 0..0, + output: String::new(), + cursor_offset: None, + model_version: None, + } +} + 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") @@ -2562,6 +2774,12 @@ fn assert_no_predict_request_ready( } } +fn enable_edit_prediction_jumps(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.update_flags(true, vec![EditPredictionJumpsFeatureFlag::NAME.to_string()]); + }); +} + fn update_test_diagnostics( project: &Entity, path: &str, @@ -2614,7 +2832,7 @@ fn init_test_with_fake_client_and_legacy_data_collection( cx: &mut TestAppContext, legacy_data_collection_choice: Option<&str>, ) -> (Entity, RequestChannels) { - cx.update(move |cx| { + let result = cx.update(move |cx| { cx.set_global(AppDatabase::test_new()); let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); @@ -2659,9 +2877,20 @@ fn init_test_with_fake_client_and_legacy_data_collection( let decompressed = zstd::decode_all(&buf[..]).unwrap(); let req = serde_json::from_slice(&decompressed).unwrap(); - let (res_tx, res_rx) = oneshot::channel(); + let (res_tx, res_rx) = oneshot::channel::(); predict_req_tx.unbounded_send((req, res_tx)).unwrap(); - serde_json::to_string(&res_rx.await?).unwrap() + let response = res_rx.await?; + if response.request_id == REQUEST_TIMEOUT_RESPONSE_ID { + return Ok(Response::builder() + .status(http_client::http::StatusCode::REQUEST_TIMEOUT) + .body( + http_client::http::StatusCode::REQUEST_TIMEOUT + .as_str() + .into(), + ) + .unwrap()); + } + serde_json::to_string(&response).unwrap() } "/predict_edits/reject" => { let mut buf = Vec::new(); @@ -2704,13 +2933,49 @@ fn init_test_with_fake_client_and_legacy_data_collection( ( ep_store, + user_store, RequestChannels { predict: predict_req_rx, reject: reject_req_rx, settled: settled_req_rx, }, ) - }) + }); + + let (ep_store, user_store, channels) = result; + set_test_organization(&user_store, cx); + (ep_store, channels) +} + +/// Configures a current organization on the given `UserStore` for tests. +/// +/// The test client starts out signed out, which causes `UserStore` to clear the +/// current organization once that initial status is processed. This waits for +/// that to happen before configuring the organization, so it isn't subsequently +/// wiped out. +fn set_test_organization(user_store: &Entity, cx: &mut TestAppContext) { + cx.run_until_parked(); + cx.update(|cx| { + user_store.update(cx, |store, cx| { + store.set_current_organization_configuration_for_test( + Arc::new(Organization { + id: OrganizationId("org_1".into()), + name: "Organization 1".into(), + is_personal: false, + }), + OrganizationConfiguration { + is_zed_model_provider_enabled: true, + is_agent_thread_feedback_enabled: true, + is_collaboration_enabled: true, + edit_prediction: OrganizationEditPredictionConfiguration { + is_enabled: true, + is_feedback_enabled: true, + }, + }, + cx, + ) + }); + }); } #[gpui::test] @@ -2747,6 +3012,7 @@ async fn test_edit_prediction_basic_interpolation(cx: &mut TestAppContext) { repo_url: None, }, model_version: None, + trigger: PredictEditsRequestTrigger::Other, }; cx.update(|cx| { @@ -2932,7 +3198,13 @@ async fn test_edit_prediction_no_spurious_trailing_newline(cx: &mut TestAppConte let position = snapshot.anchor_before(language::Point::new(0, 5)); ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -2996,7 +3268,13 @@ async fn test_v3_prediction_strips_cursor_marker_from_edit_text(cx: &mut TestApp let position = snapshot.anchor_before(language::Point::new(0, 5)); ep_store.update(cx, |ep_store, cx| { - ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + ep_store.refresh_prediction_from_buffer( + project.clone(), + buffer.clone(), + position, + EditPredictionRequestTrigger::Other, + cx, + ); }); let (request, respond_tx) = requests.predict.next().await.unwrap(); @@ -3065,7 +3343,13 @@ async fn run_edit_prediction( }); cx.background_executor.run_until_parked(); let prediction_task = ep_store.update(cx, |ep_store, cx| { - ep_store.request_prediction(&project, buffer, cursor, Default::default(), cx) + ep_store.request_prediction( + &project, + buffer, + cursor, + PredictEditsRequestTrigger::Other, + cx, + ) }); prediction_task.await.unwrap().unwrap().prediction.unwrap() } @@ -3135,6 +3419,9 @@ async fn make_test_ep_store( }); let _server = FakeServer::for_client(42, &client, cx).await; + let project_user_store = cx.update(|cx| project.read(cx).user_store()); + set_test_organization(&project_user_store, cx); + let ep_store = cx.new(|cx| { let mut ep_store = EditPredictionStore::new(client, project.read(cx).user_store(), cx); ep_store.set_edit_prediction_model(EditPredictionModel::Zeta); @@ -3245,7 +3532,13 @@ async fn test_unauthenticated_without_custom_url_blocks_prediction_impl(cx: &mut let completion_task = ep_store.update(cx, |ep_store, cx| { ep_store.set_edit_prediction_model(EditPredictionModel::Zeta); - ep_store.request_prediction(&project, &buffer, cursor, Default::default(), cx) + ep_store.request_prediction( + &project, + &buffer, + cursor, + PredictEditsRequestTrigger::Other, + cx, + ) }); assert!(completion_task.await.unwrap().is_none()); diff --git a/crates/edit_prediction/src/fim.rs b/crates/edit_prediction/src/fim.rs index d0c47bfbe1729e..a28480a1141ee4 100644 --- a/crates/edit_prediction/src/fim.rs +++ b/crates/edit_prediction/src/fim.rs @@ -29,6 +29,7 @@ pub fn request_prediction( snapshot, position, events, + trigger, .. }: EditPredictionModelInput, prompt_format: EditPredictionPromptFormat, @@ -155,6 +156,7 @@ pub fn request_prediction( Some(output.editable_range), output.inputs, None, + trigger, cx.background_executor().now() - request_start, cx, ) diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index ddbe899313042c..db41d896fef044 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -50,6 +50,7 @@ impl Mercury { events, related_files, debug_tx, + trigger, .. }: EditPredictionModelInput, credentials_provider: Arc, @@ -255,6 +256,7 @@ impl Mercury { Some(editable_range), inputs, None, + trigger, cx.background_executor().now() - request_start, cx, ) diff --git a/crates/edit_prediction/src/prediction.rs b/crates/edit_prediction/src/prediction.rs index f9f7e548e76f19..c01c52e2bdae6c 100644 --- a/crates/edit_prediction/src/prediction.rs +++ b/crates/edit_prediction/src/prediction.rs @@ -1,6 +1,6 @@ use std::{ops::Range, sync::Arc}; -use cloud_llm_client::EditPredictionRejectReason; +use cloud_llm_client::{EditPredictionRejectReason, PredictEditsRequestTrigger}; use edit_prediction_types::{PredictedCursorPosition, interpolate_edits}; use gpui::{AsyncApp, Entity, SharedString}; use language::{Anchor, Buffer, BufferSnapshot, EditPreview, TextBufferSnapshot}; @@ -25,6 +25,7 @@ impl std::fmt::Display for EditPredictionId { pub struct EditPredictionResult { pub id: EditPredictionId, pub prediction: Result, + pub display_prediction: Option, pub model_version: Option, pub e2e_latency: std::time::Duration, } @@ -39,36 +40,65 @@ impl EditPredictionResult { editable_range: Option>, inputs: ZetaPromptInput, model_version: Option, + trigger: PredictEditsRequestTrigger, e2e_latency: std::time::Duration, cx: &mut AsyncApp, ) -> Self { if edits.is_empty() { + let empty_edits = Arc::new([]); return Self { - id, + id: id.clone(), prediction: Err(EditPredictionRejectReason::Empty), + display_prediction: Some(EditPrediction { + id, + edits: empty_edits, + cursor_position: None, + editable_range, + snapshot: edited_buffer_snapshot.clone(), + edit_preview: EditPreview::unchanged(edited_buffer_snapshot), + buffer: edited_buffer.clone(), + inputs, + model_version: model_version.clone(), + trigger, + }), model_version, e2e_latency, }; } - let Some((edits, snapshot, edit_preview_task)) = - edited_buffer.read_with(cx, |buffer, cx| { - let new_snapshot = buffer.snapshot(); - let edits: Arc<[_]> = - interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits)?.into(); + let (edits, snapshot) = edited_buffer.read_with(cx, |buffer, _cx| { + let new_snapshot = buffer.snapshot(); + let edits: Option, Arc)]>> = + interpolate_edits(&edited_buffer_snapshot, &new_snapshot, &edits).map(Arc::from); - Some((edits.clone(), new_snapshot, buffer.preview_edits(edits, cx))) - }) - else { + (edits, new_snapshot) + }); + + let Some(edits) = edits else { + let empty_edits: Arc<[(Range, Arc)]> = Vec::new().into(); return Self { - id, + id: id.clone(), prediction: Err(EditPredictionRejectReason::InterpolatedEmpty), + display_prediction: Some(EditPrediction { + id, + edits: empty_edits, + cursor_position: None, + editable_range, + snapshot: edited_buffer_snapshot.clone(), + edit_preview: EditPreview::unchanged(edited_buffer_snapshot), + inputs, + buffer: edited_buffer.clone(), + model_version: model_version.clone(), + trigger, + }), model_version, e2e_latency, }; }; - let edit_preview = edit_preview_task.await; + let edit_preview = edited_buffer + .read_with(cx, |buffer, cx| buffer.preview_edits(edits.clone(), cx)) + .await; Self { id: id.clone(), @@ -82,7 +112,9 @@ impl EditPredictionResult { inputs, buffer: edited_buffer.clone(), model_version: model_version.clone(), + trigger, }), + display_prediction: None, model_version, e2e_latency, } @@ -100,6 +132,7 @@ pub struct EditPrediction { pub buffer: Entity, pub inputs: zeta_prompt::ZetaPromptInput, pub model_version: Option, + pub trigger: PredictEditsRequestTrigger, } impl EditPrediction { @@ -153,6 +186,7 @@ mod tests { buffer: buffer.clone(), edit_preview, model_version: None, + trigger: PredictEditsRequestTrigger::Other, inputs: ZetaPromptInput { events: vec![], related_files: Some(vec![]), diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs index 072051a8de9c55..c3cb556c7b1b4b 100644 --- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs +++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs @@ -4,7 +4,7 @@ use client::{Client, UserStore}; use cloud_llm_client::EditPredictionRejectReason; use edit_prediction_types::{ DataCollectionState, EditPredictionDelegate, EditPredictionDiscardReason, - EditPredictionIconSet, SuggestionDisplayType, + EditPredictionIconSet, EditPredictionRequestTrigger, SuggestionDisplayType, }; use feature_flags::FeatureFlagAppExt; use fs::Fs; @@ -143,6 +143,7 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate { buffer: Entity, cursor_position: language::Anchor, _debounce: bool, + trigger: EditPredictionRequestTrigger, cx: &mut Context, ) { let store = self.store.read(cx); @@ -163,7 +164,13 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate { } store.refresh_context(&self.project, &buffer, cursor_position, cx); - store.refresh_prediction_from_buffer(self.project.clone(), buffer, cursor_position, cx) + store.refresh_prediction_from_buffer( + self.project.clone(), + buffer, + cursor_position, + trigger, + cx, + ) }); } diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index ee2bcd62f04aa6..172726a1d575e2 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -1,7 +1,7 @@ use crate::{ - CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, - EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, - ZedUpdateRequiredError, buffer_path_with_id_fallback, + CloudRequestTimeoutError, CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, + EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent, + EditPredictionStore, ZedUpdateRequiredError, buffer_path_with_id_fallback, cursor_excerpt::{self, compute_cursor_excerpt, compute_syntax_ranges}, data_collection::UncommittedDiffResult, prediction::EditPredictionResult, @@ -20,12 +20,15 @@ use language::{ use release_channel::AppVersion; use text::{Anchor, Bias, Point}; use ui::SharedString; -use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; +use workspace::notifications::simple_message_notification::MessageNotification; +use workspace::notifications::{NotificationId, show_app_notification}; +use workspace::workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError}; use zeta_prompt::{ParsedOutput, ZetaPromptInput}; use std::{ops::Range, path::Path, sync::Arc}; use zeta_prompt::{ - ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, stop_tokens_for_format, + ZetaFormat, excerpt_range_for_format, format_zeta_prompt, get_prefill, + parse_zeta2_model_output, stop_tokens_for_format, zeta1::{self, EDITABLE_REGION_END_MARKER}, }; @@ -307,7 +310,27 @@ pub fn request_prediction_with_zeta( cursor_offset_in_new_editable_region: cursor_offset_in_output, }) = output else { - return Ok((Some((request_id, None, model_version)), None)); + let editable_range_in_excerpt = + excerpt_range_for_format(zeta_format, &prompt_input.excerpt_ranges).0; + let editable_range_in_buffer = editable_range_in_excerpt.start + + full_context_offset_range.start + ..editable_range_in_excerpt.end + full_context_offset_range.start; + + return Ok(( + Some(( + request_id, + Some(Prediction { + prompt_input, + buffer, + snapshot: snapshot.clone(), + edits: Vec::new(), + cursor_position: None, + editable_range_in_buffer, + }), + model_version, + )), + usage, + )); }; let editable_range_in_buffer = editable_range_in_excerpt.start @@ -379,6 +402,7 @@ pub fn request_prediction_with_zeta( return Ok(Some(EditPredictionResult { id, prediction: Err(EditPredictionRejectReason::Empty), + display_prediction: None, model_version, e2e_latency: request_duration, })); @@ -393,6 +417,7 @@ pub fn request_prediction_with_zeta( Some(edited_buffer_snapshot.anchor_range_inside(editable_range_in_buffer.clone())), inputs, model_version, + trigger, request_duration, cx, ) @@ -491,6 +516,11 @@ fn handle_api_response( Ok(data) } Err(err) => { + if err.is::() { + this.update(cx, |this, cx| this.back_off_requests_after_timeout(cx)) + .ok(); + } + if err.is::() { cx.update(|cx| { this.update(cx, |this, _cx| { @@ -498,14 +528,33 @@ fn handle_api_response( }) .ok(); - let error_message: SharedString = err.to_string().into(); + let message: SharedString = err.to_string().into(); + + struct UpdateRequiredError { + message: SharedString, + } + impl WorkspaceError for UpdateRequiredError { + fn primary_message(&self) -> SharedString { + self.message.clone() + } + fn severity(&self) -> ErrorSeverity { + ErrorSeverity::Critical + } + fn primary_action(&self) -> ErrorAction { + ErrorAction::link("Update Zed", "https://zed.dev/releases") + } + } + show_app_notification( NotificationId::unique::(), cx, move |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new(error_message.clone(), cx) - .with_link_button("Update Zed", "https://zed.dev/releases") + cx.new({ + let message = message.clone(); + move |cx| { + let error = UpdateRequiredError { message }; + MessageNotification::from_workspace_error(error, cx) + } }) }, ); diff --git a/crates/edit_prediction_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml index 81e91fa262d0e1..733deb895c0bfd 100644 --- a/crates/edit_prediction_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -61,6 +61,7 @@ terminal_view.workspace = true util.workspace = true watch.workspace = true edit_prediction = { workspace = true, features = ["cli-support"] } +edit_prediction_context.workspace = true edit_prediction_metrics = { workspace = true, features = ["tree-sitter"] } telemetry_events.workspace = true wasmtime.workspace = true @@ -90,4 +91,3 @@ pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } tempfile.workspace = true workspace = { workspace = true, features = ["test-support"] } - diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index e0354a78aca427..8007499315a8e7 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -3,7 +3,7 @@ use crate::{ example::{ActualCursor, Example, ExamplePrompt}, headless::EpAppState, progress::{ExampleProgress, Step}, - retrieve_context::run_context_retrieval, + retrieve_context::{ContextRetrievalType, run_context_retrieval}, }; use anyhow::{Context as _, Result, anyhow}; use gpui::AsyncApp; @@ -30,7 +30,15 @@ pub async fn run_format_prompt( example_progress: &ExampleProgress, cx: AsyncApp, ) -> Result<()> { - run_context_retrieval(example, app_state.clone(), example_progress, cx.clone()).await?; + run_context_retrieval( + example, + app_state.clone(), + example_progress, + vec![ContextRetrievalType::Lsp], + false, + cx.clone(), + ) + .await?; let step_progress = example_progress.start(Step::FormatPrompt); diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 41e7b397745325..78f8324432d6a2 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -36,7 +36,7 @@ use gaoya::minhash::{ MinHashIndex, MinHasher, MinHasher32, calculate_minhash_params, compute_minhash_similarity, }; use gpui::{AppContext as _, BackgroundExecutor, Task}; -use zeta_prompt::ZetaFormat; +use zeta_prompt::{ContextSource, ZetaFormat}; use reqwest_client::ReqwestClient; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -59,7 +59,9 @@ use crate::paths::{FAILED_EXAMPLES_DIR, RUN_DIR}; use crate::predict::run_prediction; use crate::progress::Progress; use crate::pull_examples::{fetch_settled_examples_after, parse_settled_after_input}; -use crate::retrieve_context::run_context_retrieval; +use crate::retrieve_context::{ + ContextRetrievalType, context_sources_for_types, run_context_retrieval, +}; use crate::score::run_scoring; use crate::split_commit::SplitCommitArgs; use crate::split_dataset::SplitArgs; @@ -100,7 +102,7 @@ struct EpArgs { output: Option, #[arg(long, short, global = true)] in_place: bool, - #[arg(long, short, global = true)] + #[arg(long, global = true)] failfast: bool, /// How to handle failed examples in output: keep them or skip them. /// Failed examples are always logged to the run's failed directory. @@ -125,6 +127,27 @@ pub enum FailedHandling { SkipNoFiles, } +#[derive(Args, Debug, Clone)] +struct ContextArgs { + /// Which context collectors to run. + /// May be repeated or comma-delimited, e.g. `--type=all,oracle-file`. + #[arg(long = "type", value_enum, value_delimiter = ',')] + context_types: Vec, + /// Recompute context even if the example already has related files. + #[arg(long, short = 'f', default_value_t = false)] + force: bool, +} + +impl ContextArgs { + fn context_types(&self) -> Vec { + if self.context_types.is_empty() { + vec![ContextRetrievalType::Lsp] + } else { + self.context_types.clone() + } + } +} + const INPUTS_HELP: &str = r#" Inputs can be file paths or special specifiers: @@ -197,7 +220,7 @@ enum Command { /// Create git worktrees for each example and load file contents LoadProject, /// Retrieve context for input examples. - Context, + Context(ContextArgs), /// Generate a prompt string for a specific model FormatPrompt(FormatPromptArgs), /// Runs edit prediction @@ -239,7 +262,19 @@ impl Display for Command { match self { Command::Read(_) => write!(f, "read"), Command::LoadProject => write!(f, "load-project"), - Command::Context => write!(f, "context"), + Command::Context(args) => { + write!(f, "context --type=")?; + for (index, context_type) in args.context_types().iter().enumerate() { + if index > 0 { + write!(f, ",")?; + } + write!(f, "{}", context_type)?; + } + if args.force { + write!(f, " --force")?; + } + Ok(()) + } Command::FormatPrompt(args) => { write!(f, "format-prompt --provider={}", args.provider) } @@ -253,10 +288,28 @@ impl Display for Command { None => write!(f, "score"), }, Command::Distill => write!(f, "distill"), - Command::Eval(args) => match &args.predict.provider { - Some(provider) => write!(f, "eval --provider={}", provider), - None => write!(f, "eval"), - }, + Command::Eval(args) => { + write!(f, "eval")?; + if args.context_only { + write!(f, " --context-only")?; + } + if !args.context_types.is_empty() { + write!(f, " --type=")?; + for (index, context_type) in args.context_types.iter().enumerate() { + if index > 0 { + write!(f, ",")?; + } + write!(f, "{}", context_type)?; + } + } + if args.related_context_limit != score::EVAL_RELATED_CONTEXT_TOKENS_LIMIT { + write!(f, " --related-context-limit={}", args.related_context_limit)?; + } + if let Some(provider) = &args.predict.provider { + write!(f, " --provider={}", provider)?; + } + Ok(()) + } Command::Synthesize(args) => { write!(f, "synthesize --repos {}", args.repos.join(" ")) } @@ -309,6 +362,16 @@ struct PredictArgs { struct EvalArgs { #[clap(flatten)] predict: PredictArgs, + /// Only compute editable context coverage from expected patches and retrieved context. + #[clap(long)] + context_only: bool, + /// Only score persisted related context excerpts from these context types. + /// May be repeated or comma-delimited, e.g. `--type=current-file,edit-history`. + #[arg(long = "type", value_enum, value_delimiter = ',')] + context_types: Vec, + /// Maximum number of retrieved context tokens to include when scoring. + #[clap(long, default_value_t = score::EVAL_RELATED_CONTEXT_TOKENS_LIMIT)] + related_context_limit: usize, /// Path to write summary scores as JSON #[clap(long)] summary_json: Option, @@ -317,6 +380,16 @@ struct EvalArgs { verbose: bool, } +impl EvalArgs { + fn context_source_filter(&self) -> Option> { + if self.context_types.is_empty() { + None + } else { + Some(context_sources_for_types(&self.context_types)) + } + } +} + #[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Hash)] pub enum TeacherBackend { Sonnet46, @@ -588,6 +661,7 @@ impl EpArgs { /// This version introduced the current request schema with predicted edits in the edit /// history, and open source repos distinguished. const MIN_CAPTURE_VERSION: pull_examples::MinCaptureVersion = pull_examples::MinCaptureVersion { + major: 0, minor: 224, patch: 1, }; @@ -734,16 +808,21 @@ async fn load_examples( let mut settled_after_timestamps = Vec::new(); let mut rated_after_inputs: Vec<(String, Option)> = Vec::new(); + let mut accepted_after_timestamps = Vec::new(); let mut file_inputs = Vec::new(); for input in &args.inputs { let input_string = input.to_string_lossy(); if let Some(timestamp) = pull_examples::parse_captured_after_input(input_string.as_ref()) { captured_after_timestamps.push(timestamp.to_string()); - } else if let Some(timestamp) = + } else if let Some((explicit, timestamp)) = pull_examples::parse_rejected_after_input(input_string.as_ref()) { - rejected_after_timestamps.push(timestamp.to_string()); + rejected_after_timestamps.push((explicit, timestamp.to_string())); + } else if let Some(timestamp) = + pull_examples::parse_accepted_after_input(input_string.as_ref()) + { + accepted_after_timestamps.push(timestamp.to_string()); } else if let Some(timestamp) = pull_examples::parse_requested_after_input(input_string.as_ref()) { @@ -802,6 +881,21 @@ async fn load_examples( examples.append(&mut rejected_examples); } + if !accepted_after_timestamps.is_empty() { + accepted_after_timestamps.sort(); + + let mut accepted_examples = pull_examples::fetch_accepted_examples_after( + http_client.clone(), + &accepted_after_timestamps, + max_rows_per_timestamp, + remaining_offset, + background_executor.clone(), + Some(MIN_CAPTURE_VERSION), + ) + .await?; + examples.append(&mut accepted_examples); + } + if !requested_after_timestamps.is_empty() { requested_after_timestamps.sort(); @@ -1133,7 +1227,9 @@ fn main() { predict::sync_batches(args.provider.as_ref()).await?; } Command::Eval(args) => { - predict::sync_batches(args.predict.provider.as_ref()).await?; + if !args.context_only { + predict::sync_batches(args.predict.provider.as_ref()).await?; + } } Command::Qa(args) => { qa::sync_batches(args).await?; @@ -1222,11 +1318,13 @@ fn main() { ) .await?; } - Command::Context => { + Command::Context(args) => { run_context_retrieval( example, app_state.clone(), &example_progress, + args.context_types(), + args.force, cx.clone(), ) .await?; @@ -1264,18 +1362,35 @@ fn main() { app_state.clone(), &example_progress, cx.clone(), + false, + None, + None, ) .await?; } Command::Eval(args) => { - run_scoring( - example, - &args.predict, - app_state.clone(), - &example_progress, - cx.clone(), - ) - .await?; + let context_source_filter = + args.context_source_filter(); + if args.context_only { + score::run_context_coverage_scoring( + example, + &example_progress, + Some(args.related_context_limit * 3), + context_source_filter.as_deref(), + )?; + } else { + run_scoring( + example, + &args.predict, + app_state.clone(), + &example_progress, + cx.clone(), + true, + Some(args.related_context_limit * 3), + context_source_filter, + ) + .await?; + } } Command::Qa(args) => { qa::run_qa(example, args, &example_progress).await?; @@ -1394,15 +1509,17 @@ fn main() { } } Command::Eval(args) => { - predict::sync_batches(args.predict.provider.as_ref()).await?; - if args.predict.wait { - predict::wait_for_batches(args.predict.provider.as_ref()).await?; - let mut examples = - std::mem::take(&mut *finished_examples.lock().unwrap()); - predict::reprocess_after_batch_wait(&mut examples, &args.predict) - .await?; - rewrite_output(&examples, write_path, is_markdown)?; - *finished_examples.lock().unwrap() = examples; + if !args.context_only { + predict::sync_batches(args.predict.provider.as_ref()).await?; + if args.predict.wait { + predict::wait_for_batches(args.predict.provider.as_ref()).await?; + let mut examples = + std::mem::take(&mut *finished_examples.lock().unwrap()); + predict::reprocess_after_batch_wait(&mut examples, &args.predict) + .await?; + rewrite_output(&examples, write_path, is_markdown)?; + *finished_examples.lock().unwrap() = examples; + } } } Command::Qa(args) => { @@ -1425,9 +1542,21 @@ fn main() { match &command { Command::Eval(args) => { let examples = finished_examples.lock().unwrap(); - score::print_report(&examples, args.verbose); + let context_source_filter = args.context_source_filter(); + score::print_report( + &examples, + args.verbose, + args.context_only, + Some(args.related_context_limit * 3), + context_source_filter.as_deref(), + ); if let Some(summary_path) = &args.summary_json { - score::write_summary_json(&examples, summary_path)?; + score::write_summary_json( + &examples, + summary_path, + Some(args.related_context_limit * 3), + context_source_filter.as_deref(), + )?; } } Command::Repair(args) => { diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index a78d4d12d70a43..5ba7efb5bfa8b3 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -9,7 +9,7 @@ use crate::{ parse_output::parse_prediction_output, paths::{LATEST_EXAMPLE_RUN_DIR, RUN_DIR}, progress::{ExampleProgress, InfoStyle, Progress, Step, StepProgress}, - retrieve_context::run_context_retrieval, + retrieve_context::{ContextRetrievalType, run_context_retrieval}, }; use anyhow::Context as _; use cloud_llm_client::predict_edits_v3::{RawCompletionRequest, RawCompletionResponse}; @@ -68,7 +68,15 @@ pub async fn run_prediction( if let PredictionProvider::Teacher(backend, _) | PredictionProvider::TeacherNonBatching(backend, _) = provider { - run_context_retrieval(example, app_state.clone(), example_progress, cx.clone()).await?; + run_context_retrieval( + example, + app_state.clone(), + example_progress, + vec![ContextRetrievalType::Lsp], + false, + cx.clone(), + ) + .await?; run_format_prompt( example, &FormatPromptArgs { provider }, @@ -111,7 +119,15 @@ pub async fn run_prediction( } run_load_project(example, app_state.clone(), example_progress, cx.clone()).await?; - run_context_retrieval(example, app_state.clone(), example_progress, cx.clone()).await?; + run_context_retrieval( + example, + app_state.clone(), + example_progress, + vec![ContextRetrievalType::Lsp], + false, + cx.clone(), + ) + .await?; let step_progress = example_progress.start(Step::Predict); diff --git a/crates/edit_prediction_cli/src/pull_examples.rs b/crates/edit_prediction_cli/src/pull_examples.rs index 88de3053d850fb..73778ef548433b 100644 --- a/crates/edit_prediction_cli/src/pull_examples.rs +++ b/crates/edit_prediction_cli/src/pull_examples.rs @@ -15,7 +15,7 @@ use telemetry_events::EditPredictionRating; use zeta_prompt::{ZetaFormat, ZetaPromptInput, excerpt_range_for_format}; use crate::PredictionProvider; -use crate::example::{Example, ExamplePrompt}; +use crate::example::{Example, ExamplePrediction, ExamplePrompt}; use crate::progress::{InfoStyle, Progress, Step}; use edit_prediction::example_spec::{ExampleSpec, TelemetrySource}; @@ -24,10 +24,13 @@ pub(crate) const SNOWFLAKE_ASYNC_IN_PROGRESS_CODE: &str = "333334"; const SNOWFLAKE_TIMEOUT_CODE: &str = "000630"; /// Minimum Zed version for filtering captured examples. -/// For example, `MinCaptureVersion { minor: 224, patch: 1 }` means only pull examples -/// where `zed_version >= 0.224.1`. +/// For example, `MinCaptureVersion { major: 0, minor: 224, patch: 1 }` means only pull +/// examples where `zed_version >= 0.224.1`. The `major` component is required because Zed +/// moved from the `0..` scheme to `1..`; comparing on `minor` +/// alone would exclude all `1.*` versions (whose `minor` resets to small values). #[derive(Clone, Copy, Debug)] pub struct MinCaptureVersion { + pub major: u32, pub minor: u32, pub patch: u32, } @@ -45,9 +48,20 @@ pub fn parse_captured_after_input(input: &str) -> Option<&str> { input.strip_prefix("captured-after:") } +/// Parse an input token of the form `accepted-after:{timestamp}`. +pub fn parse_accepted_after_input(input: &str) -> Option<&str> { + input.strip_prefix("accepted-after:") +} + /// Parse an input token of the form `rejected-after:{timestamp}`. -pub fn parse_rejected_after_input(input: &str) -> Option<&str> { - input.strip_prefix("rejected-after:") +pub fn parse_rejected_after_input(input: &str) -> Option<(bool, &str)> { + if let Some(timestamp) = input.strip_prefix("rejected-after:") { + Some((false, timestamp)) + } else if let Some(timestamp) = input.strip_prefix("explicitly-rejected-after:") { + Some((true, timestamp)) + } else { + None + } } /// Parse an input token of the form `requested-after:{timestamp}`. @@ -543,7 +557,7 @@ pub(crate) async fn run_sql( pub async fn fetch_rejected_examples_after( http_client: Arc, - after_timestamps: &[String], + after_timestamps: &[(bool, String)], max_rows_per_timestamp: Option, offset: usize, background_executor: BackgroundExecutor, @@ -557,15 +571,16 @@ pub async fn fetch_rejected_examples_after( let mut all_examples = Vec::new(); - for after_date in after_timestamps.iter() { + for (explicit, after_date) in after_timestamps.iter() { let step_progress_name = format!("rejected>{after_date}"); let step_progress = progress.start(Step::PullExamples, &step_progress_name); step_progress.set_substatus("querying"); - let min_minor_str = min_capture_version.map(|version| version.minor.to_string()); - let min_patch_str = min_capture_version.map(|version| version.patch.to_string()); - let min_minor_str_ref = min_minor_str.as_deref(); - let min_patch_str_ref = min_patch_str.as_deref(); + let min_version_str = min_capture_version.map(|version| { + (version.major as u64 * 1_000_000 + version.minor as u64 * 1_000 + version.patch as u64) + .to_string() + }); + let min_version_ref = min_version_str.as_deref(); let statement = indoc! {r#" SELECT @@ -581,16 +596,14 @@ pub async fn fetch_rejected_examples_after( ep_rejected_reason AS reason, zed_version AS zed_version FROM ZED_DBT.DBT_PROD.fct_edit_prediction_examples - WHERE ep_outcome LIKE 'Rejected%' + WHERE ep_outcome LIKE ? AND is_ep_shown_before_rejected = true AND requested_at > TRY_TO_TIMESTAMP_NTZ(?) AND (? IS NULL OR ( - TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) > ? - OR ( - TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) = ? - AND TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER) >= ? - ) - )) + COALESCE(TRY_CAST(SPLIT_PART(zed_version, '.', 1) AS INTEGER), 0) * 1000000 + + COALESCE(TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER), 0) * 1000 + + COALESCE(TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER), 0) + ) >= ?) ORDER BY requested_at ASC LIMIT ? OFFSET ? @@ -608,13 +621,12 @@ pub async fn fetch_rejected_examples_after( }, |retry_state| { json!({ - "1": { "type": "TEXT", "value": retry_state.resume_after }, - "2": { "type": "FIXED", "value": min_minor_str_ref }, - "3": { "type": "FIXED", "value": min_minor_str_ref }, - "4": { "type": "FIXED", "value": min_minor_str_ref }, - "5": { "type": "FIXED", "value": min_patch_str_ref }, - "6": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, - "7": { "type": "FIXED", "value": retry_state.offset.to_string() } + "1": { "type": "TEXT", "value": if *explicit { "Rejected (Explicit)" } else { "Rejected%" } }, + "2": { "type": "TEXT", "value": retry_state.resume_after }, + "3": { "type": "FIXED", "value": min_version_ref }, + "4": { "type": "FIXED", "value": min_version_ref }, + "5": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, + "6": { "type": "FIXED", "value": retry_state.offset.to_string() } }) }, &[ @@ -639,6 +651,96 @@ pub async fn fetch_rejected_examples_after( Ok(all_examples) } +pub async fn fetch_accepted_examples_after( + http_client: Arc, + after_timestamps: &[String], + max_rows_per_timestamp: Option, + offset: usize, + background_executor: BackgroundExecutor, + min_capture_version: Option, +) -> Result> { + if after_timestamps.is_empty() { + return Ok(Vec::new()); + } + + let progress = Progress::global(); + + let mut all_examples = Vec::new(); + + for after_date in after_timestamps.iter() { + let step_progress_name = format!("accepted>{after_date}"); + let step_progress = progress.start(Step::PullExamples, &step_progress_name); + step_progress.set_substatus("querying"); + + let min_version_str = min_capture_version.map(|version| { + (version.major as u64 * 1_000_000 + version.minor as u64 * 1_000 + version.patch as u64) + .to_string() + }); + let min_version_ref = min_version_str.as_deref(); + + let statement = indoc! {r#" + SELECT + ep_request_id AS request_id, + device_id AS device_id, + requested_at::string AS continuation_time, + requested_at::string AS time, + input_payload AS input, + prompt AS prompt, + requested_output AS output, + settled_editable_region AS settled_editable_region, + zed_version AS zed_version + FROM ZED_DBT.DBT_PROD.fct_edit_prediction_examples + WHERE ep_outcome = 'Accepted' + AND requested_at > TRY_TO_TIMESTAMP_NTZ(?) + AND (? IS NULL OR ( + COALESCE(TRY_CAST(SPLIT_PART(zed_version, '.', 1) AS INTEGER), 0) * 1000000 + + COALESCE(TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER), 0) * 1000 + + COALESCE(TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER), 0) + ) >= ?) + ORDER BY requested_at ASC + LIMIT ? + OFFSET ? + "#}; + + let examples = fetch_examples_with_query( + http_client.clone(), + &step_progress, + background_executor.clone(), + statement, + QueryRetryState { + resume_after: after_date.clone(), + remaining_limit: max_rows_per_timestamp, + offset, + }, + |retry_state| { + json!({ + "1": { "type": "TEXT", "value": retry_state.resume_after }, + "2": { "type": "FIXED", "value": min_version_ref }, + "3": { "type": "FIXED", "value": min_version_ref }, + "4": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, + "5": { "type": "FIXED", "value": retry_state.offset.to_string() } + }) + }, + &[ + "request_id", + "device_id", + "time", + "input", + "prompt", + "output", + "settled_editable_region", + "zed_version", + ], + accepted_examples_from_response, + ) + .await?; + + all_examples.extend(examples); + } + + Ok(all_examples) +} + fn format_limit(limit: Option) -> String { return limit.map(|l| l.to_string()).unwrap_or("NULL".to_string()); } @@ -664,10 +766,11 @@ pub async fn fetch_requested_examples_after( let step_progress = progress.start(Step::PullExamples, &step_progress_name); step_progress.set_substatus("querying"); - let min_minor_str = min_capture_version.map(|version| version.minor.to_string()); - let min_patch_str = min_capture_version.map(|version| version.patch.to_string()); - let min_minor_str_ref = min_minor_str.as_deref(); - let min_patch_str_ref = min_patch_str.as_deref(); + let min_version_str = min_capture_version.map(|version| { + (version.major as u64 * 1_000_000 + version.minor as u64 * 1_000 + version.patch as u64) + .to_string() + }); + let min_version_ref = min_version_str.as_deref(); let statement = indoc! {r#" SELECT @@ -680,12 +783,10 @@ pub async fn fetch_requested_examples_after( FROM ZED_DBT.DBT_PROD.fct_edit_prediction_examples WHERE requested_at > TRY_TO_TIMESTAMP_NTZ(?) AND (? IS NULL OR ( - TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) > ? - OR ( - TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) = ? - AND TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER) >= ? - ) - )) + COALESCE(TRY_CAST(SPLIT_PART(zed_version, '.', 1) AS INTEGER), 0) * 1000000 + + COALESCE(TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER), 0) * 1000 + + COALESCE(TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER), 0) + ) >= ?) ORDER BY requested_at ASC LIMIT ? OFFSET ? @@ -704,12 +805,10 @@ pub async fn fetch_requested_examples_after( |retry_state| { json!({ "1": { "type": "TEXT", "value": retry_state.resume_after }, - "2": { "type": "FIXED", "value": min_minor_str_ref }, - "3": { "type": "FIXED", "value": min_minor_str_ref }, - "4": { "type": "FIXED", "value": min_minor_str_ref }, - "5": { "type": "FIXED", "value": min_patch_str_ref }, - "6": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, - "7": { "type": "FIXED", "value": retry_state.offset.to_string() } + "2": { "type": "FIXED", "value": min_version_ref }, + "3": { "type": "FIXED", "value": min_version_ref }, + "4": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, + "5": { "type": "FIXED", "value": retry_state.offset.to_string() } }) }, &["request_id", "device_id", "time", "input", "zed_version"], @@ -744,10 +843,11 @@ pub async fn fetch_captured_examples_after( let step_progress = progress.start(Step::PullExamples, &step_progress_name); step_progress.set_substatus("querying"); - let min_minor_str = min_capture_version.map(|version| version.minor.to_string()); - let min_patch_str = min_capture_version.map(|version| version.patch.to_string()); - let min_minor_str_ref = min_minor_str.as_deref(); - let min_patch_str_ref = min_patch_str.as_deref(); + let min_version_str = min_capture_version.map(|version| { + (version.major as u64 * 1_000_000 + version.minor as u64 * 1_000 + version.patch as u64) + .to_string() + }); + let min_version_ref = min_version_str.as_deref(); let statement = indoc! {r#" SELECT @@ -764,12 +864,10 @@ pub async fn fetch_captured_examples_after( AND example_payload IS NOT NULL AND requested_at > TRY_TO_TIMESTAMP_NTZ(?) AND (? IS NULL OR ( - TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) > ? - OR ( - TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER) = ? - AND TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER) >= ? - ) - )) + COALESCE(TRY_CAST(SPLIT_PART(zed_version, '.', 1) AS INTEGER), 0) * 1000000 + + COALESCE(TRY_CAST(SPLIT_PART(zed_version, '.', 2) AS INTEGER), 0) * 1000 + + COALESCE(TRY_CAST(SPLIT_PART(SPLIT_PART(zed_version, '.', 3), '+', 1) AS INTEGER), 0) + ) >= ?) ORDER BY requested_at ASC LIMIT ? OFFSET ? @@ -788,12 +886,10 @@ pub async fn fetch_captured_examples_after( |retry_state| { json!({ "1": { "type": "TEXT", "value": retry_state.resume_after }, - "2": { "type": "FIXED", "value": min_minor_str_ref }, - "3": { "type": "FIXED", "value": min_minor_str_ref }, - "4": { "type": "FIXED", "value": min_minor_str_ref }, - "5": { "type": "FIXED", "value": min_patch_str_ref }, - "6": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, - "7": { "type": "FIXED", "value": retry_state.offset.to_string() } + "2": { "type": "FIXED", "value": min_version_ref }, + "3": { "type": "FIXED", "value": min_version_ref }, + "4": { "type": "FIXED", "value": format_limit(retry_state.remaining_limit) }, + "5": { "type": "FIXED", "value": retry_state.offset.to_string() } }) }, &[ @@ -1724,6 +1820,143 @@ struct RejectionInfo { was_shown: bool, } +fn accepted_examples_from_response<'a>( + response: &'a SnowflakeStatementResponse, + column_indices: &'a std::collections::HashMap, +) -> Result + 'a>> { + if let Some(code) = &response.code { + if code != SNOWFLAKE_SUCCESS_CODE { + anyhow::bail!( + "snowflake sql api returned error code={code} message={}", + response.message.as_deref().unwrap_or("") + ); + } + } + + let iter = response + .data + .iter() + .enumerate() + .filter_map(move |(row_index, data_row)| { + let get_string = |name: &str| -> Option { + let index = column_indices.get(name).copied()?; + match data_row.get(index)? { + JsonValue::String(s) => Some(s.clone()), + JsonValue::Null => None, + other => Some(other.to_string()), + } + }; + + let get_json = |name: &str| -> Option { + let index = column_indices.get(name).copied()?; + let value = data_row.get(index)?; + if value.is_null() { + return None; + } + match value { + JsonValue::String(s) => serde_json::from_str(s).ok(), + other => Some(other.clone()), + } + }; + + let request_id_str = get_string("request_id"); + let device_id = get_string("device_id"); + let time = get_string("time"); + let input_json = get_json("input"); + let input: Option = + input_json.clone().and_then(|v| serde_json::from_value(v).ok()); + let prompt = get_string("prompt"); + let output = get_string("output"); + let settled_editable_region = get_string("settled_editable_region"); + let zed_version = get_string("zed_version"); + + match (request_id_str.clone(), device_id.clone(), time.clone(), input, output.clone()) { + (Some(request_id), Some(device_id), Some(time), Some(input), Some(output)) => { + Some(build_accepted_example( + request_id, + device_id, + time, + input, + prompt, + output, + settled_editable_region, + zed_version, + )) + } + _ => { + log::warn!( + "skipping row {row_index}: missing fields - request_id={:?} device_id={:?} time={:?} input={:?} output={:?}", + request_id_str.is_some(), + device_id.is_some(), + time.is_some(), + input_json.is_some(), + output.is_some(), + ); + None + } + } + }); + + Ok(Box::new(iter)) +} + +fn build_accepted_example( + request_id: String, + device_id: String, + time: String, + input: ZetaPromptInput, + prompt: Option, + output: String, + settled_editable_region: Option, + zed_version: Option, +) -> Example { + let accepted_patch = build_output_patch( + &input.cursor_path, + input.cursor_excerpt.as_ref(), + &input.excerpt_ranges.editable_350, + &output, + ); + let expected_patch = settled_editable_region + .as_ref() + .map(|settled_editable_region| { + build_output_patch( + &input.cursor_path, + input.cursor_excerpt.as_ref(), + &input.excerpt_ranges.editable_350, + settled_editable_region, + ) + }); + let mut example = build_example_from_snowflake( + request_id, + device_id, + time, + input, + vec!["accepted".to_string()], + None, + zed_version, + ); + if let Some(expected_patch) = expected_patch { + example.spec.expected_patches = vec![expected_patch]; + } + example.predictions.push(ExamplePrediction { + provider: PredictionProvider::default(), + actual_output: output.clone(), + actual_patch: Some(accepted_patch), + actual_cursor: None, // todo: why no cursor? + error: None, + cumulative_logprob: None, + avg_logprob: None, + }); + example.prompt = prompt.map(|prompt| ExamplePrompt { + input: prompt, + expected_output: Some(output), + rejected_output: None, + prefill: None, + provider: PredictionProvider::default(), + }); + example +} + fn build_example_from_snowflake( request_id: String, device_id: String, diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs index f02509ceb061db..93ce5ecb2c3eb8 100644 --- a/crates/edit_prediction_cli/src/retrieve_context.rs +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -5,25 +5,109 @@ use crate::{ progress::{ExampleProgress, InfoStyle, Step, StepProgress}, }; use anyhow::Context as _; +use clap::ValueEnum; use collections::HashSet; -use edit_prediction::{DebugEvent, EditPredictionStore}; +use edit_prediction::{DebugEvent, EditPredictionStore, udiff::refresh_worktree_entries}; use futures::{FutureExt as _, StreamExt as _, channel::mpsc}; use gpui::{AsyncApp, Entity}; use language::Buffer; use project::Project; -use std::sync::Arc; -use std::time::Duration; +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; +use zeta_prompt::{ContextSource, udiff::DiffLine}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum ContextRetrievalType { + Lsp, + Editable, + CurrentFile, + EditHistory, + EditHistoryFile, + GitLog, + Bm25, + OracleFile, + #[default] + All, + None, +} + +impl std::fmt::Display for ContextRetrievalType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ContextRetrievalType::Lsp => write!(f, "lsp"), + ContextRetrievalType::Editable => write!(f, "editable"), + ContextRetrievalType::CurrentFile => write!(f, "current-file"), + ContextRetrievalType::EditHistory => write!(f, "edit-history"), + ContextRetrievalType::EditHistoryFile => write!(f, "edit-history-file"), + ContextRetrievalType::GitLog => write!(f, "git-log"), + ContextRetrievalType::Bm25 => write!(f, "bm25"), + ContextRetrievalType::OracleFile => write!(f, "oracle-file"), + ContextRetrievalType::All => write!(f, "all"), + ContextRetrievalType::None => write!(f, "none"), + } + } +} + +impl ContextRetrievalType { + pub fn context_sources(self) -> Vec { + match self { + ContextRetrievalType::Lsp => vec![ContextSource::Lsp], + ContextRetrievalType::Editable => editable_context_sources(), + ContextRetrievalType::CurrentFile => vec![ContextSource::CurrentFile], + ContextRetrievalType::EditHistory => vec![ContextSource::EditHistory], + ContextRetrievalType::EditHistoryFile => vec![ContextSource::EditHistoryFile], + ContextRetrievalType::GitLog => vec![ContextSource::GitLog], + ContextRetrievalType::Bm25 => vec![ContextSource::Bm25], + ContextRetrievalType::OracleFile => vec![ContextSource::OracleFile], + ContextRetrievalType::All => { + let mut sources = vec![ContextSource::Lsp]; + sources.extend(editable_context_sources()); + sources + } + ContextRetrievalType::None => Vec::new(), + } + } +} + +pub fn context_sources_for_types(context_types: &[ContextRetrievalType]) -> Vec { + let mut context_sources = Vec::new(); + for context_type in context_types { + for context_source in context_type.context_sources() { + if !context_sources.contains(&context_source) { + context_sources.push(context_source); + } + } + } + context_sources +} + +fn editable_context_sources() -> Vec { + vec![ + ContextSource::CursorExcerpt, + ContextSource::CurrentFile, + ContextSource::EditHistory, + ContextSource::EditHistoryFile, + ContextSource::GitLog, + ContextSource::Bm25, + ] +} pub async fn run_context_retrieval( example: &mut Example, app_state: Arc, example_progress: &ExampleProgress, + context_types: Vec, + force: bool, mut cx: AsyncApp, ) -> anyhow::Result<()> { - if example - .prompt_inputs - .as_ref() - .is_some_and(|inputs| inputs.related_files.is_some()) + if (!force + && example + .prompt_inputs + .as_ref() + .is_some_and(|inputs| inputs.related_files.is_some())) || example.spec.repository_url.is_empty() { return Ok(()); @@ -36,32 +120,66 @@ pub async fn run_context_retrieval( let state = example.state.as_ref().unwrap(); let project = state.project.clone(); - let _lsp_handle = project.update(&mut cx, |project, cx| { - project.register_buffer_with_language_servers(&state.buffer, cx) - }); - wait_for_language_servers_to_start(&project, &state.buffer, &step_progress, &mut cx).await?; - let ep_store = cx .update(|cx| EditPredictionStore::try_global(cx)) .context("EditPredictionStore not initialized")?; - let mut events = ep_store.update(&mut cx, |store, cx| { - store.register_buffer(&state.buffer, &project, cx); - store.refresh_context(&project, &state.buffer, state.cursor_position, cx); - store.debug_info(&project, cx) - }); + let mut context_files = Vec::new(); + let context_sources = context_sources_for_types(&context_types); + + if context_sources.contains(&ContextSource::Lsp) { + let _lsp_handle = project.update(&mut cx, |project, cx| { + project.register_buffer_with_language_servers(&state.buffer, cx) + }); + wait_for_language_servers_to_start(&project, &state.buffer, &step_progress, &mut cx) + .await?; - while let Some(event) = events.next().await { - match event { - DebugEvent::ContextRetrievalFinished(_) => { - break; + let mut events = ep_store.update(&mut cx, |store, cx| { + store.register_buffer(&state.buffer, &project, cx); + store.refresh_context(&project, &state.buffer, state.cursor_position, cx); + store.debug_info(&project, cx) + }); + + while let Some(event) = events.next().await { + match event { + DebugEvent::ContextRetrievalFinished(_) => { + break; + } + _ => {} } - _ => {} } + + context_files + .extend(ep_store.update(&mut cx, |store, cx| store.context_for_project(&project, cx))); } - let context_files = - ep_store.update(&mut cx, |store, cx| store.context_for_project(&project, cx)); + let editable_context_sources = context_sources + .into_iter() + .filter(|context_source| *context_source != ContextSource::Lsp) + .collect::>(); + if !editable_context_sources.is_empty() { + let oracle_paths = if editable_context_sources.contains(&ContextSource::OracleFile) { + let oracle_paths = oracle_paths_from_expected_patches(example); + refresh_paths(&project, &oracle_paths, &mut cx).await?; + oracle_paths + } else { + Vec::new() + }; + + let editable_context = ep_store + .update(&mut cx, |store, cx| { + store.collect_editable_context( + project.clone(), + state.buffer.clone(), + state.cursor_position, + oracle_paths, + editable_context_sources, + cx, + ) + }) + .await?; + merge_context_files(&mut context_files, editable_context); + } let excerpt_count: usize = context_files.iter().map(|f| f.excerpts.len()).sum(); step_progress.set_info(format!("{} excerpts", excerpt_count), InfoStyle::Normal); @@ -72,6 +190,73 @@ pub async fn run_context_retrieval( Ok(()) } +fn merge_context_files( + context_files: &mut Vec, + new_files: Vec, +) { + for mut new_file in new_files { + if let Some(existing_file) = context_files + .iter_mut() + .find(|existing_file| existing_file.path == new_file.path) + { + existing_file.max_row = existing_file.max_row.max(new_file.max_row); + existing_file.excerpts.append(&mut new_file.excerpts); + existing_file + .excerpts + .sort_by_key(|excerpt| (excerpt.order, excerpt.row_range.start)); + existing_file.in_open_source_repo = + existing_file.in_open_source_repo && new_file.in_open_source_repo; + } else { + context_files.push(new_file); + } + } +} + +fn oracle_paths_from_expected_patches(example: &Example) -> Vec> { + let mut seen_paths = HashSet::default(); + let mut paths = Vec::new(); + + for patch in &example.spec.expected_patches { + for path in paths_from_diff(patch) { + if seen_paths.insert(path.clone()) { + paths.push(path.into()); + } + } + } + + paths +} + +fn paths_from_diff(diff: &str) -> Vec { + diff.lines() + .filter_map(|line| match DiffLine::parse(line) { + DiffLine::OldPath { path } | DiffLine::NewPath { path } + if path.as_ref() != "/dev/null" => + { + Some(Path::new(path.as_ref()).to_path_buf()) + } + _ => None, + }) + .collect() +} + +async fn refresh_paths( + project: &Entity, + paths: &[Arc], + cx: &mut AsyncApp, +) -> anyhow::Result<()> { + if paths.is_empty() { + return Ok(()); + } + + let Some(worktree) = project.read_with(cx, |project, cx| project.visible_worktrees(cx).next()) + else { + return Ok(()); + }; + + refresh_worktree_entries(&worktree, paths.iter().map(|path| path.as_ref()), cx).await +} + async fn wait_for_language_servers_to_start( project: &Entity, buffer: &Entity, diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs index cbaeb338650003..841d15541e4044 100644 --- a/crates/edit_prediction_cli/src/score.rs +++ b/crates/edit_prediction_cli/src/score.rs @@ -8,14 +8,18 @@ use crate::{ progress::{ExampleProgress, Step}, }; use anyhow::Context as _; +use edit_prediction_context::limit_retrieved_context_to_bytes; use edit_prediction_metrics::{ - ActualPredictionCursor, PredictionReversalContext, PredictionScoringInput, + ActualPredictionCursor, Excerpt, PredictionReversalContext, PredictionScoringInput, }; use gpui::{AppContext as _, AsyncApp}; use std::fs::File; use std::io::BufWriter; use std::path::Path; use std::sync::Arc; +use zeta_prompt::{ContextSource, RelatedFile}; + +pub const EVAL_RELATED_CONTEXT_TOKENS_LIMIT: usize = 4000; pub async fn run_scoring( example: &mut Example, @@ -23,8 +27,13 @@ pub async fn run_scoring( app_state: Arc, example_progress: &ExampleProgress, cx: AsyncApp, + allow_missing_predictions: bool, + retrieved_context_byte_limit: Option, + context_source_filter: Option>, ) -> anyhow::Result<()> { - run_prediction(example, args, app_state, example_progress, cx.clone()).await?; + if !(allow_missing_predictions && args.provider.is_none() && example.predictions.is_empty()) { + run_prediction(example, args, app_state, example_progress, cx.clone()).await?; + } let progress = example_progress.start(Step::Score); @@ -70,8 +79,33 @@ pub async fn run_scoring( })?; let cursor_path = example_for_scoring.spec.cursor_path.as_ref(); + let context = context_excerpts( + &example_for_scoring, + prompt_inputs, + retrieved_context_byte_limit, + context_source_filter.as_deref(), + ); let mut scores = vec![]; + if allow_missing_predictions && example_for_scoring.predictions.is_empty() { + scores.push(edit_prediction_metrics::score_prediction( + PredictionScoringInput { + original_text, + expected_patches: &prepared_expected_patches, + actual_patch: None, + actual_cursor: None, + reversal_context: Some(PredictionReversalContext { + edit_history: &prompt_inputs.events, + excerpt_start_row: prompt_inputs.excerpt_start_row, + cursor_path, + }), + cumulative_logprob: None, + avg_logprob: None, + context: Some(&context), + }, + )); + } + for prediction in &example_for_scoring.predictions { let actual_patch = prediction.actual_patch.clone().or_else(|| { parse_prediction_output( @@ -103,6 +137,7 @@ pub async fn run_scoring( }), cumulative_logprob: prediction.cumulative_logprob, avg_logprob: prediction.avg_logprob, + context: Some(&context), }, )); } @@ -113,11 +148,167 @@ pub async fn run_scoring( Ok(()) } -pub fn print_report(examples: &[Example], verbose: bool) { +pub fn run_context_coverage_scoring( + example: &mut Example, + example_progress: &ExampleProgress, + retrieved_context_byte_limit: Option, + context_source_filter: Option<&[ContextSource]>, +) -> anyhow::Result<()> { + let progress = example_progress.start(Step::Score); + + progress.set_substatus("computing context coverage"); + let prompt_inputs = example + .prompt_inputs + .as_ref() + .context("prompt_inputs is required for context coverage scoring")?; + let context = context_excerpts( + example, + prompt_inputs, + retrieved_context_byte_limit, + context_source_filter, + ); + + let editable_context_coverage = example + .spec + .expected_patches_with_cursor_positions() + .iter() + .map(|(expected_patch, _)| { + edit_prediction_metrics::editable_context_coverage(expected_patch, &context) + }) + .max_by(|left, right| { + left.lines_f1 + .total_cmp(&right.lines_f1) + .then_with(|| left.files_f1.total_cmp(&right.files_f1)) + }); + + let mut score = edit_prediction_metrics::PredictionScore::zero(); + score.editable_context_coverage = editable_context_coverage; + example.score = vec![score]; + + Ok(()) +} + +fn context_excerpts( + _example: &Example, + prompt_inputs: &zeta_prompt::ZetaPromptInput, + retrieved_context_byte_limit: Option, + context_source_filter: Option<&[ContextSource]>, +) -> Vec { + let mut context = Vec::new(); + + if let Some(excerpt_start_row) = prompt_inputs.excerpt_start_row { + let row_count = prompt_inputs.cursor_excerpt.lines().count() as u32; + + context.push(Excerpt { + path: prompt_inputs.cursor_path.to_string_lossy().to_string(), + row_range: excerpt_start_row..excerpt_start_row.saturating_add(row_count), + content: prompt_inputs.cursor_excerpt.to_string(), + }); + } + + if let Some(related_files) = &prompt_inputs.related_files { + let related_files = filtered_related_files(related_files, context_source_filter); + let related_files = if let Some(max_bytes) = retrieved_context_byte_limit { + limit_retrieved_context_to_bytes(&related_files, max_bytes) + } else { + related_files + }; + for related_file in &related_files { + for excerpt in &related_file.excerpts { + // First component is a project name which is not present in expected patch, skip it + let path = related_file + .path + .iter() + .skip(1) + .collect::() + .to_string_lossy() + .to_string(); + context.push(Excerpt { + path, + row_range: excerpt.row_range.clone(), + content: excerpt.text.to_string(), + }); + } + } + } + + context +} + +fn filtered_related_files( + related_files: &[RelatedFile], + context_source_filter: Option<&[ContextSource]>, +) -> Vec { + let Some(context_source_filter) = context_source_filter else { + return related_files.to_vec(); + }; + + related_files + .iter() + .filter_map(|related_file| { + let excerpts = related_file + .excerpts + .iter() + .filter(|excerpt| context_source_filter.contains(&excerpt.context_source)) + .cloned() + .collect::>(); + if excerpts.is_empty() { + None + } else { + Some(RelatedFile { + path: related_file.path.clone(), + max_row: related_file.max_row, + excerpts, + in_open_source_repo: related_file.in_open_source_repo, + }) + } + }) + .collect() +} + +fn retrieved_context_bytes( + example: &Example, + retrieved_context_byte_limit: Option, + context_source_filter: Option<&[ContextSource]>, +) -> Option { + let related_files = example.prompt_inputs.as_ref()?.related_files.as_ref()?; + let related_files = filtered_related_files(related_files, context_source_filter); + let related_files = if let Some(max_bytes) = retrieved_context_byte_limit { + limit_retrieved_context_to_bytes(&related_files, max_bytes) + } else { + related_files + }; + Some( + related_files + .iter() + .flat_map(|file| file.excerpts.iter()) + .map(|excerpt| excerpt.text.len()) + .sum::(), + ) +} + +pub fn print_report( + examples: &[Example], + verbose: bool, + context_only: bool, + retrieved_context_byte_limit: Option, + context_source_filter: Option<&[ContextSource]>, +) { const MAX_EXAMPLES_DEFAULT: usize = 20; use crate::metrics::ClassificationMetrics; const LINE_WIDTH: usize = 101; + + if context_only { + print_context_coverage_report( + examples, + verbose, + retrieved_context_byte_limit, + context_source_filter, + ); + return; + } + let separator = "─".repeat(LINE_WIDTH); println!("{}", separator); @@ -154,14 +345,32 @@ pub fn print_report(examples: &[Example], verbose: bool) { let mut discarded_chars_total: usize = 0; let mut recall_rate_sum: f64 = 0.0; let mut recall_rate_count: usize = 0; + let mut editable_context_coverage_count: usize = 0; + let mut editable_context_lines_precision_sum = 0.0; + let mut editable_context_lines_recall_sum = 0.0; + let mut editable_context_lines_f1_sum = 0.0; + let mut editable_context_files_precision_sum = 0.0; + let mut editable_context_files_recall_sum = 0.0; + let mut editable_context_files_f1_sum = 0.0; + let mut total_editable_context_lines = ClassificationMetrics::default(); + let mut total_editable_context_files = ClassificationMetrics::default(); let mut patch_inserted_tokens: Vec = Vec::new(); let mut patch_deleted_tokens: Vec = Vec::new(); let mut predictions_with_patch: usize = 0; + let mut retrieved_context_bytes_sum = 0.0; + let mut retrieved_context_bytes_count = 0; let mut printed_lines: usize = 0; let mut skipped_lines: usize = 0; for example in examples { + if let Some(bytes) = + retrieved_context_bytes(example, retrieved_context_byte_limit, context_source_filter) + { + retrieved_context_bytes_sum += bytes as f64; + retrieved_context_bytes_count += 1; + } + for (score_idx, score) in example.score.iter().enumerate() { let exact_lines = score.exact_lines_counts(); @@ -264,6 +473,25 @@ pub fn print_report(examples: &[Example], verbose: bool) { recall_rate_sum += rr; recall_rate_count += 1; } + if let Some(coverage) = &score.editable_context_coverage { + editable_context_coverage_count += 1; + editable_context_lines_precision_sum += coverage.lines_precision; + editable_context_lines_recall_sum += coverage.lines_recall; + editable_context_lines_f1_sum += coverage.lines_f1; + editable_context_files_precision_sum += coverage.files_precision; + editable_context_files_recall_sum += coverage.files_recall; + editable_context_files_f1_sum += coverage.files_f1; + total_editable_context_lines.accumulate(&ClassificationMetrics { + true_positives: coverage.lines_tp, + false_positives: coverage.lines_fp, + false_negatives: coverage.lines_fn, + }); + total_editable_context_files.accumulate(&ClassificationMetrics { + true_positives: coverage.files_tp, + false_positives: coverage.files_fp, + false_negatives: coverage.files_fn, + }); + } // Accumulate token change metrics (only for predictions that produced a patch) let has_patch = example @@ -414,6 +642,36 @@ pub fn print_report(examples: &[Example], verbose: bool) { recall_rate_count ); } + if retrieved_context_bytes_count > 0 { + println!( + "Retrieved context size: {:.0} bytes avg ({} examples)", + retrieved_context_bytes_sum / retrieved_context_bytes_count as f64, + retrieved_context_bytes_count + ); + } + if editable_context_coverage_count > 0 { + let count = editable_context_coverage_count as f64; + println!( + "Editable context lines: P={:.1}%, R={:.1}%, F1={:.1}% avg ({} evaluated, TP={}, FP={}, FN={})", + editable_context_lines_precision_sum / count * 100.0, + editable_context_lines_recall_sum / count * 100.0, + editable_context_lines_f1_sum / count * 100.0, + editable_context_coverage_count, + total_editable_context_lines.true_positives, + total_editable_context_lines.false_positives, + total_editable_context_lines.false_negatives + ); + println!( + "Editable context files: P={:.1}%, R={:.1}%, F1={:.1}% avg ({} evaluated, TP={}, FP={}, FN={})", + editable_context_files_precision_sum / count * 100.0, + editable_context_files_recall_sum / count * 100.0, + editable_context_files_f1_sum / count * 100.0, + editable_context_coverage_count, + total_editable_context_files.true_positives, + total_editable_context_files.false_positives, + total_editable_context_files.false_negatives + ); + } // Print token change percentile summary (only for predictions with a patch) if !patch_inserted_tokens.is_empty() { @@ -470,6 +728,151 @@ pub fn print_report(examples: &[Example], verbose: bool) { println!("\n"); } +fn print_context_coverage_report( + examples: &[Example], + verbose: bool, + retrieved_context_byte_limit: Option, + context_source_filter: Option<&[ContextSource]>, +) { + const MAX_EXAMPLES_DEFAULT: usize = 20; + const LINE_WIDTH: usize = 120; + + use crate::metrics::ClassificationMetrics; + + let separator = "─".repeat(LINE_WIDTH); + println!("{}", separator); + println!( + "{:<40} {:>6} {:>6} {:>6} {:>5} {:>5} {:>5} {:>6} {:>6} {:>6} {:>5} {:>5} {:>5}", + "Example", + "LineP", + "LineR", + "LineF1", + "LTP", + "LFP", + "LFN", + "FileP", + "FileR", + "FileF1", + "FTP", + "FFP", + "FFN" + ); + println!("{}", separator); + + let mut total_lines = ClassificationMetrics::default(); + let mut total_files = ClassificationMetrics::default(); + let mut line_precision_sum = 0.0; + let mut line_recall_sum = 0.0; + let mut line_f1_sum = 0.0; + let mut file_precision_sum = 0.0; + let mut file_recall_sum = 0.0; + let mut file_f1_sum = 0.0; + let mut total_scores = 0; + let mut retrieved_context_bytes_sum = 0.0; + let mut retrieved_context_bytes_count = 0; + let mut printed_lines = 0; + let mut skipped_lines = 0; + + for example in examples { + if let Some(bytes) = + retrieved_context_bytes(example, retrieved_context_byte_limit, context_source_filter) + { + retrieved_context_bytes_sum += bytes as f64; + retrieved_context_bytes_count += 1; + } + + for score in &example.score { + let Some(coverage) = &score.editable_context_coverage else { + continue; + }; + + if verbose || printed_lines < MAX_EXAMPLES_DEFAULT { + println!( + "{:<40} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5}", + truncate_name(&example.spec.name, 40), + coverage.lines_precision * 100.0, + coverage.lines_recall * 100.0, + coverage.lines_f1 * 100.0, + coverage.lines_tp, + coverage.lines_fp, + coverage.lines_fn, + coverage.files_precision * 100.0, + coverage.files_recall * 100.0, + coverage.files_f1 * 100.0, + coverage.files_tp, + coverage.files_fp, + coverage.files_fn + ); + printed_lines += 1; + } else { + skipped_lines += 1; + } + + total_scores += 1; + line_precision_sum += coverage.lines_precision; + line_recall_sum += coverage.lines_recall; + line_f1_sum += coverage.lines_f1; + file_precision_sum += coverage.files_precision; + file_recall_sum += coverage.files_recall; + file_f1_sum += coverage.files_f1; + total_lines.accumulate(&ClassificationMetrics { + true_positives: coverage.lines_tp, + false_positives: coverage.lines_fp, + false_negatives: coverage.lines_fn, + }); + total_files.accumulate(&ClassificationMetrics { + true_positives: coverage.files_tp, + false_positives: coverage.files_fp, + false_negatives: coverage.files_fn, + }); + } + } + + if skipped_lines > 0 { + println!( + "{:<40} (use --verbose to see all {} examples)", + format!("... and {} more", skipped_lines), + printed_lines + skipped_lines + ); + } + + println!("{}", separator); + + if total_scores > 0 { + let count = total_scores as f64; + println!( + "{:<40} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5} {:>5.1}% {:>5.1}% {:>5.1}% {:>5} {:>5} {:>5}", + "TOTAL / AVERAGE", + line_precision_sum / count * 100.0, + line_recall_sum / count * 100.0, + line_f1_sum / count * 100.0, + total_lines.true_positives, + total_lines.false_positives, + total_lines.false_negatives, + file_precision_sum / count * 100.0, + file_recall_sum / count * 100.0, + file_f1_sum / count * 100.0, + total_files.true_positives, + total_files.false_positives, + total_files.false_negatives + ); + println!("{}", separator); + println!( + "Evaluated editable context coverage for {} examples", + total_scores + ); + if retrieved_context_bytes_count > 0 { + println!( + "Retrieved context size: {:.0} bytes avg ({} examples)", + retrieved_context_bytes_sum / retrieved_context_bytes_count as f64, + retrieved_context_bytes_count + ); + } + } + + println!("\n"); +} + fn percentile(sorted_values: &[usize], p: usize) -> usize { if sorted_values.is_empty() { return 0; @@ -488,8 +891,14 @@ fn truncate_name(name: &str, max_len: usize) -> String { pub type SummaryJson = edit_prediction_metrics::SummaryJson; -pub fn compute_summary(examples: &[Example]) -> SummaryJson { +pub fn compute_summary( + examples: &[Example], + retrieved_context_byte_limit: Option, + context_source_filter: Option<&[ContextSource]>, +) -> SummaryJson { edit_prediction_metrics::compute_summary(examples.iter().flat_map(|example| { + let retrieved_context_bytes = + retrieved_context_bytes(example, retrieved_context_byte_limit, context_source_filter); example .score .iter() @@ -503,14 +912,30 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson { reverts_edits: qa.reverts_edits, confidence: qa.confidence, }); + let retrieved_context_bytes = (score_idx == 0) + .then_some(retrieved_context_bytes) + .flatten(); - edit_prediction_metrics::PredictionSummaryInput { score, qa } + edit_prediction_metrics::PredictionSummaryInput { + score, + qa, + retrieved_context_bytes, + } }) })) } -pub fn write_summary_json(examples: &[Example], path: &Path) -> anyhow::Result<()> { - let summary = compute_summary(examples); +pub fn write_summary_json( + examples: &[Example], + path: &Path, + retrieved_context_byte_limit: Option, + context_source_filter: Option<&[ContextSource]>, +) -> anyhow::Result<()> { + let summary = compute_summary( + examples, + retrieved_context_byte_limit, + context_source_filter, + ); let file = File::create(path) .with_context(|| format!("Failed to create summary JSON file: {}", path.display()))?; let writer = BufWriter::new(file); @@ -519,3 +944,100 @@ pub fn write_summary_json(examples: &[Example], path: &Path) -> anyhow::Result<( eprintln!("Wrote summary JSON to: {}", path.display()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use edit_prediction::example_spec::ExampleSpec; + use edit_prediction_metrics::PredictionScore; + use std::path::Path; + use zeta_prompt::{ExcerptRanges, RelatedExcerpt, ZetaPromptInput}; + + #[test] + fn summary_includes_limited_filtered_retrieved_context_bytes_once_per_example() { + let examples = vec![ + example_with_related_files( + Some(vec![RelatedFile { + path: Path::new("project/src/lib.rs").into(), + max_row: 10, + excerpts: vec![ + related_excerpt("abcd", 0..1, 0, ContextSource::CurrentFile), + related_excerpt("ignored by source filter", 1..2, 1, ContextSource::Lsp), + related_excerpt("efghij", 2..3, 2, ContextSource::CurrentFile), + ], + in_open_source_repo: false, + }]), + 2, + ), + example_with_related_files(None, 1), + ]; + + let summary = compute_summary(&examples, Some(10), Some(&[ContextSource::CurrentFile])); + + assert_eq!(summary.total_examples, 3); + assert_eq!(summary.avg_retrieved_context_bytes, Some(10.0)); + assert_eq!(summary.total_retrieved_context_bytes, Some(10)); + assert_eq!(summary.retrieved_context_examples, Some(1)); + } + + fn example_with_related_files( + related_files: Option>, + score_count: usize, + ) -> Example { + Example { + spec: ExampleSpec { + name: "example".to_string(), + repository_url: "https://github.com/zed-industries/zed.git".to_string(), + revision: "revision".to_string(), + tags: Vec::new(), + reasoning: None, + uncommitted_diff: String::new(), + recently_opened_files: Vec::new(), + recently_viewed_files: Vec::new(), + uncommitted_diff_contains_edit_history: false, + cursor_path: Path::new("project/src/main.rs").into(), + cursor_position: String::new(), + edit_history: String::new(), + expected_patches: Vec::new(), + rejected_patch: None, + telemetry: None, + human_feedback: Vec::new(), + rating: None, + }, + prompt_inputs: Some(ZetaPromptInput { + cursor_path: Path::new("project/src/main.rs").into(), + cursor_excerpt: "".into(), + cursor_offset_in_excerpt: 0, + excerpt_start_row: None, + events: Vec::new(), + related_files, + active_buffer_diagnostics: Vec::new(), + excerpt_ranges: ExcerptRanges::default(), + syntax_ranges: None, + in_open_source_repo: false, + can_collect_data: false, + repo_url: None, + }), + prompt: None, + predictions: Vec::new(), + score: vec![PredictionScore::zero(); score_count], + qa: Vec::new(), + zed_version: None, + state: None, + } + } + + fn related_excerpt( + text: &str, + row_range: std::ops::Range, + order: usize, + context_source: ContextSource, + ) -> RelatedExcerpt { + RelatedExcerpt { + row_range, + text: text.into(), + order, + context_source, + } + } +} diff --git a/crates/edit_prediction_cli/src/split_commit.rs b/crates/edit_prediction_cli/src/split_commit.rs index 844077593aab10..2868a441cfb6e2 100644 --- a/crates/edit_prediction_cli/src/split_commit.rs +++ b/crates/edit_prediction_cli/src/split_commit.rs @@ -5,7 +5,9 @@ //! //! TODO: Port Python code to generate chronologically-ordered commits use crate::FailedHandling; -use crate::reorder_patch::{Patch, PatchLine, edit_locations, extract_edits, locate_edited_line}; +use crate::reorder_patch::{ + EditLocation, Patch, PatchLine, edit_locations, extract_edits, locate_edited_line, +}; use crate::word_diff::tokenize; /// Find the largest valid UTF-8 char boundary at or before `index` in `s`. @@ -27,6 +29,7 @@ use clap::Args; use edit_prediction::example_spec::ExampleSpec; use rand::Rng; use rand::SeedableRng; +use rand::seq::SliceRandom; use serde::Deserialize; use similar::{DiffTag, TextDiff}; use std::collections::BTreeSet; @@ -36,11 +39,28 @@ use std::path::Path; use std::path::PathBuf; const MAX_SPLIT_POINT_SAMPLING_ATTEMPTS: usize = 10; +const SAME_FILE_NEAR_LINE_THRESHOLD: usize = 30; + +/// A commit has no split point matching the requested kind. This is an +/// expected outcome when filtering by kind, so such commits are skipped +/// rather than treated as failures. +#[derive(Debug)] +pub struct NoMatchingSplitPointError { + kind: SplitPointKind, +} + +impl std::fmt::Display for NoMatchingSplitPointError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "no split point found matching {}", self.kind) + } +} + +impl std::error::Error for NoMatchingSplitPointError {} /// `ep split-commit` CLI args. #[derive(Debug, Args, Clone)] pub struct SplitCommitArgs { - /// Split point (float 0.0-1.0 for fraction, or integer for index) + /// Split point (float 0.0-1.0 for fraction, integer for index, or one of: fim, same-file-near, same-file-far, cross-file; append : to validate a specific split) #[arg(long, short = 's')] pub split_point: Option, @@ -98,19 +118,96 @@ pub struct SplitCommit { } /// Split point specification for evaluation generation. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SplitPointKind { + Fim, + SameFileNear, + SameFileFar, + CrossFile, +} + +impl std::fmt::Display for SplitPointKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SplitPointKind::Fim => write!(f, "fim"), + SplitPointKind::SameFileNear => write!(f, "same-file-near"), + SplitPointKind::SameFileFar => write!(f, "same-file-far"), + SplitPointKind::CrossFile => write!(f, "cross-file"), + } + } +} + +impl std::str::FromStr for SplitPointKind { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "fim" => Ok(Self::Fim), + "same-file-near" => Ok(Self::SameFileNear), + "same-file-far" => Ok(Self::SameFileFar), + "cross-file" => Ok(Self::CrossFile), + _ => anyhow::bail!( + "invalid split point kind '{value}' (expected fim, same-file-near, same-file-far, or cross-file)" + ), + } + } +} + +#[derive(Debug, Clone, PartialEq)] pub enum SplitPoint { /// Fraction of total edits (0.0 to 1.0) Fraction(f64), /// Absolute index Index(usize), + /// Random split point matching the requested kind. + Kind(SplitPointKind), + /// Explicit split point that must match the requested kind. + KindWithSplit { + kind: SplitPointKind, + split_point: SplitPointValue, + }, } -fn parse_split_point(value: &str) -> Option { +#[derive(Debug, Clone, PartialEq)] +pub enum SplitPointValue { + Fraction(f64), + Index(usize), +} + +fn parse_split_point_value(value: &str) -> Result { if value.contains('.') { - value.parse::().ok().map(SplitPoint::Fraction) + value + .parse::() + .map(SplitPointValue::Fraction) + .with_context(|| format!("invalid split point fraction '{value}'")) } else { - value.parse::().ok().map(SplitPoint::Index) + value + .parse::() + .map(SplitPointValue::Index) + .with_context(|| format!("invalid split point index '{value}'")) + } +} + +fn parse_split_point(value: &str) -> Result { + if let Some((kind, split_point)) = value.split_once(':') { + let kind = kind.parse::()?; + anyhow::ensure!( + !split_point.is_empty(), + "missing split point after kind '{kind}:'" + ); + return Ok(SplitPoint::KindWithSplit { + kind, + split_point: parse_split_point_value(split_point)?, + }); + } + + if let Ok(kind) = value.parse::() { + return Ok(SplitPoint::Kind(kind)); + } + + match parse_split_point_value(value)? { + SplitPointValue::Fraction(value) => Ok(SplitPoint::Fraction(value)), + SplitPointValue::Index(value) => Ok(SplitPoint::Index(value)), } } @@ -179,6 +276,26 @@ fn edit_starts_on_service_file(patch: &Patch, split_pos: usize) -> bool { .is_some_and(|edit_location| is_service_file(&edit_location.filename)) } +fn has_submodule_gitlink_hunk(commit: &str) -> bool { + commit.lines().any(line_indicates_submodule_gitlink) +} + +fn line_indicates_submodule_gitlink(line: &str) -> bool { + let line = line.trim(); + + matches!( + line, + "new file mode 160000" | "deleted file mode 160000" | "old mode 160000" | "new mode 160000" + ) || line + .strip_prefix("index ") + .and_then(|line| line.split_whitespace().last()) + .is_some_and(|mode| mode == "160000") + || line + .strip_prefix('+') + .or_else(|| line.strip_prefix('-')) + .is_some_and(|line| line.starts_with("Subproject commit ")) +} + fn sample_split_point(patch: &Patch, rng: &mut dyn rand::RngCore) -> usize { let stats = patch.stats(); let num_edits = stats.added + stats.removed; @@ -197,6 +314,155 @@ fn sample_split_point(patch: &Patch, rng: &mut dyn rand::RngCore) -> usize { split } +fn resolve_split_point_value(split_point: SplitPointValue, num_edits: usize) -> usize { + match split_point { + SplitPointValue::Fraction(fraction) => { + let split = (fraction * num_edits as f64).floor() as usize; + split.min(num_edits) + } + SplitPointValue::Index(index) => index.min(num_edits), + } +} + +#[derive(Debug, Clone)] +struct GeneratedSplitCommit { + split: usize, + split_commit: SplitCommit, + cursor: CursorPosition, + cursor_from_human_edit: bool, +} + +fn generate_split_commit_at_split( + patch: &Patch, + split: usize, + rng: &mut dyn rand::RngCore, +) -> Result { + let (prefix, suffix) = split_ordered_patch(patch, split); + + let mut split_commit = SplitCommit { + source_patch: prefix, + target_patch: suffix, + }; + + let human_edit_seed = rng.random_range(1..=10000u64); + let (src_patch, tgt_patch, cursor_opt) = imitate_human_edits( + &split_commit.source_patch, + &split_commit.target_patch, + human_edit_seed, + ); + split_commit.source_patch = src_patch; + split_commit.target_patch = tgt_patch; + + let cursor_from_human_edit = cursor_opt.is_some(); + let cursor = match cursor_opt { + Some(cursor) => cursor, + None => sample_cursor_position(&split_commit, rng) + .context("failed to sample cursor position")?, + }; + + Ok(GeneratedSplitCommit { + split, + split_commit, + cursor, + cursor_from_human_edit, + }) +} + +fn classify_generated_split_commit( + generated_split_commit: &GeneratedSplitCommit, +) -> Option { + let target_patch = Patch::parse_unified_diff(&generated_split_commit.split_commit.target_patch); + let next_edit = locate_edited_line(&target_patch, 0)?; + + if next_edit.filename != generated_split_commit.cursor.file { + return Some(SplitPointKind::CrossFile); + } + + if generated_split_commit.cursor_from_human_edit + && next_edit.target_line_number == generated_split_commit.cursor.line + { + return Some(SplitPointKind::Fim); + } + + let line_distance = next_edit + .target_line_number + .abs_diff(generated_split_commit.cursor.line); + if line_distance <= SAME_FILE_NEAR_LINE_THRESHOLD { + Some(SplitPointKind::SameFileNear) + } else { + Some(SplitPointKind::SameFileFar) + } +} + +/// Cheap necessary condition for a split to be classifiable as `kind`, +/// computed from the full patch without generating the split. +/// +/// The cursor ends up either at the first target edit (or, via +/// `imitate_human_edits`, on its line), or at the last source edit. So the +/// edits adjacent to the split bound what classifications are reachable. +/// Line numbers here are in full-patch coordinates, which can drift slightly +/// from split-patch coordinates, so this is a heuristic pre-filter; the final +/// classification is always verified on the generated split. +fn split_can_match_kind( + edit_locations: &[EditLocation], + split: usize, + kind: SplitPointKind, +) -> bool { + let (Some(previous_edit), Some(next_edit)) = ( + split.checked_sub(1).and_then(|i| edit_locations.get(i)), + edit_locations.get(split), + ) else { + return false; + }; + + match kind { + SplitPointKind::Fim => matches!(next_edit.patch_line, PatchLine::Addition(_)), + SplitPointKind::SameFileNear => true, + SplitPointKind::SameFileFar => { + previous_edit.filename == next_edit.filename + && previous_edit + .target_line_number + .abs_diff(next_edit.target_line_number) + > SAME_FILE_NEAR_LINE_THRESHOLD + } + SplitPointKind::CrossFile => previous_edit.filename != next_edit.filename, + } +} + +fn sample_split_commit_of_kind( + patch: &Patch, + kind: SplitPointKind, + rng: &mut dyn rand::RngCore, +) -> Result { + let edit_locations = edit_locations(patch); + let num_edits = edit_locations.len(); + + let mut candidate_splits: Vec = (1..num_edits) + .filter(|&split| { + !edit_locations + .get(split) + .is_some_and(|next_edit| is_service_file(&next_edit.filename)) + && split_can_match_kind(&edit_locations, split, kind) + }) + .collect(); + candidate_splits.shuffle(rng); + + for split in candidate_splits { + for _ in 0..MAX_SPLIT_POINT_SAMPLING_ATTEMPTS { + let Ok(generated_split_commit) = generate_split_commit_at_split(patch, split, rng) + else { + continue; + }; + + if classify_generated_split_commit(&generated_split_commit) == Some(kind) { + return Ok(generated_split_commit); + } + } + } + + Err(NoMatchingSplitPointError { kind }.into()) +} + /// Entry point for the `ep split-commit` subcommand. /// /// This runs synchronously and outputs JSON Lines (one output per input line). @@ -216,7 +482,11 @@ pub fn run_split_commit( inputs }; - let split_point = args.split_point.as_deref().and_then(parse_split_point); + let split_point = args + .split_point + .as_deref() + .map(parse_split_point) + .transpose()?; let mut output_lines = Vec::new(); let mut processed_commits = 0usize; @@ -252,7 +522,7 @@ pub fn run_split_commit( &annotated.reordered_commit, &annotated.repo_url, &annotated.commit_sha, - None, // Use random split point for multi-sample mode + split_point.clone(), Some(sample_seed), Some(sample_idx), ) { @@ -265,6 +535,10 @@ pub fn run_split_commit( sample_idx, e ); + if e.is::() { + eprintln!("skipping: {}", err_msg); + continue; + } match failed { FailedHandling::Skip | FailedHandling::SkipNoFiles => { eprintln!("{}", err_msg); @@ -306,6 +580,10 @@ pub fn run_split_commit( line_num + 1, e ); + if e.is::() { + eprintln!("skipping: {}", err_msg); + continue; + } match failed { FailedHandling::Skip | FailedHandling::SkipNoFiles => { eprintln!("{}", err_msg); @@ -375,6 +653,11 @@ pub fn generate_evaluation_example_from_ordered_commit( seed: Option, sample_num: Option, ) -> Result { + anyhow::ensure!( + !has_submodule_gitlink_hunk(commit), + "commit contains submodule/gitlink hunk" + ); + let mut rng: Box = match seed { Some(seed) => Box::new(rand::rngs::StdRng::seed_from_u64(seed)), None => Box::new(rand::rngs::ThreadRng::default()), @@ -394,7 +677,6 @@ pub fn generate_evaluation_example_from_ordered_commit( } else { header_lines.join("\n") + "\n" }; - let commit_normalized = patch.to_string(); // Compute the split point let stats = patch.stats(); @@ -402,39 +684,39 @@ pub fn generate_evaluation_example_from_ordered_commit( anyhow::ensure!(num_edits != 0, "no edits found in commit"); - let split = match split_point { - None => sample_split_point(&patch, rng.as_mut()), - Some(SplitPoint::Fraction(f)) => { - let v = (f * num_edits as f64).floor() as usize; - v.min(num_edits) + let generated_split_commit = match split_point { + None => { + let split = sample_split_point(&patch, rng.as_mut()); + generate_split_commit_at_split(&patch, split, rng.as_mut())? + } + Some(SplitPoint::Fraction(fraction)) => { + let split = resolve_split_point_value(SplitPointValue::Fraction(fraction), num_edits); + generate_split_commit_at_split(&patch, split, rng.as_mut())? + } + Some(SplitPoint::Index(index)) => { + let split = resolve_split_point_value(SplitPointValue::Index(index), num_edits); + generate_split_commit_at_split(&patch, split, rng.as_mut())? + } + Some(SplitPoint::Kind(kind)) => sample_split_commit_of_kind(&patch, kind, rng.as_mut())?, + Some(SplitPoint::KindWithSplit { kind, split_point }) => { + let split = resolve_split_point_value(split_point, num_edits); + let generated_split_commit = + generate_split_commit_at_split(&patch, split, rng.as_mut())?; + let actual_kind = classify_generated_split_commit(&generated_split_commit); + anyhow::ensure!( + actual_kind == Some(kind), + "split point {split} classified as {}, expected {kind}", + actual_kind + .map(|kind| kind.to_string()) + .unwrap_or_else(|| "empty-target".to_string()) + ); + generated_split_commit } - Some(SplitPoint::Index(i)) => i.min(num_edits), - }; - - // Split the commit into source and target patches - let (prefix, suffix) = split_ordered_commit(&commit_normalized, split); - - let mut split_commit = SplitCommit { - source_patch: prefix, - target_patch: suffix, }; - // Imitate human edits - let human_edit_seed = rng.random_range(1..=10000u64); - let (src_patch, tgt_patch, cursor_opt) = imitate_human_edits( - &split_commit.source_patch, - &split_commit.target_patch, - human_edit_seed, - ); - split_commit.source_patch = src_patch; - split_commit.target_patch = tgt_patch; - - // Sample cursor position - let cursor = match cursor_opt { - Some(c) => c, - None => sample_cursor_position(&split_commit, rng.as_mut()) - .context("failed to sample cursor position")?, - }; + let split = generated_split_commit.split; + let cursor = generated_split_commit.cursor; + let mut split_commit = generated_split_commit.split_commit; // Get cursor excerpt let cursor_excerpt = get_cursor_excerpt( @@ -491,12 +773,11 @@ pub fn generate_evaluation_example_from_ordered_commit( /// /// # Returns /// A tuple of (source_diff, target_diff) -pub fn split_ordered_commit(commit: &str, split_pos: usize) -> (String, String) { - let patch = Patch::parse_unified_diff(commit); +pub fn split_ordered_patch(patch: &Patch, split_pos: usize) -> (String, String) { let source_edits: BTreeSet = (0..split_pos).collect(); - let (source, mut target) = extract_edits(&patch, &source_edits); + let (source, mut target) = extract_edits(patch, &source_edits); if !target.hunks.is_empty() { - if let Some(header) = header_for_edit(&patch, split_pos) { + if let Some(header) = header_for_edit(patch, split_pos) { target.header = header; } } @@ -1340,7 +1621,7 @@ mod tests { let stats = patch.stats(); assert_eq!(stats.added, 2); - let (source, target) = split_ordered_commit(commit, 1); + let (source, target) = split_ordered_patch(&patch, 1); // Source should have 1 addition let src_patch = Patch::parse_unified_diff(&source); @@ -1368,7 +1649,7 @@ mod tests { assert_eq!(stats.removed, 1); // Split at position 1 (after the deletion) - let (source, target) = split_ordered_commit(commit, 1); + let (source, target) = split_ordered_patch(&patch, 1); let src_patch = Patch::parse_unified_diff(&source); let tgt_patch = Patch::parse_unified_diff(&target); @@ -1415,7 +1696,7 @@ index 1827a70..d9b3ed1 100644 + return fmt.Errorf("failed: %w", err) "#; - let (_source, target) = split_ordered_commit(commit, 3); + let (_source, target) = split_ordered_patch(&Patch::parse_unified_diff(commit), 3); assert!( target.starts_with( @@ -1542,6 +1823,162 @@ Date: Mon Jan 1 00:00:00 2024 assert!(cursor.is_none()); } + #[test] + fn test_parse_typed_split_points() { + assert_eq!( + parse_split_point("fim").unwrap(), + SplitPoint::Kind(SplitPointKind::Fim) + ); + assert_eq!( + parse_split_point("same-file-near").unwrap(), + SplitPoint::Kind(SplitPointKind::SameFileNear) + ); + assert_eq!( + parse_split_point("same-file-far:2").unwrap(), + SplitPoint::KindWithSplit { + kind: SplitPointKind::SameFileFar, + split_point: SplitPointValue::Index(2), + } + ); + assert_eq!( + parse_split_point("cross-file:0.5").unwrap(), + SplitPoint::KindWithSplit { + kind: SplitPointKind::CrossFile, + split_point: SplitPointValue::Fraction(0.5), + } + ); + assert!(parse_split_point("local").is_err()); + } + + fn assert_generated_split_kind( + commit: &str, + kind: SplitPointKind, + seed: u64, + ) -> GeneratedSplitCommit { + let patch = Patch::parse_unified_diff(commit); + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + let generated_split_commit = sample_split_commit_of_kind(&patch, kind, &mut rng).unwrap(); + assert_eq!( + classify_generated_split_commit(&generated_split_commit), + Some(kind) + ); + generated_split_commit + } + + #[test] + fn test_classify_generated_split_commit() { + let target_patch = r#"--- a/src/main.rs ++++ b/src/main.rs +@@ -10,3 +10,3 @@ + fn main() { +-old(); ++new(); + } +"#; + let mut generated_split_commit = GeneratedSplitCommit { + split: 1, + split_commit: SplitCommit { + source_patch: String::new(), + target_patch: target_patch.to_string(), + }, + cursor: CursorPosition { + file: "src/main.rs".to_string(), + line: 11, + column: 5, + line_length: 10, + }, + cursor_from_human_edit: true, + }; + assert_eq!( + classify_generated_split_commit(&generated_split_commit), + Some(SplitPointKind::Fim) + ); + + generated_split_commit.cursor_from_human_edit = false; + assert_eq!( + classify_generated_split_commit(&generated_split_commit), + Some(SplitPointKind::SameFileNear) + ); + + generated_split_commit.cursor.line = 100; + assert_eq!( + classify_generated_split_commit(&generated_split_commit), + Some(SplitPointKind::SameFileFar) + ); + + generated_split_commit.cursor.file = "src/other.rs".to_string(); + assert_eq!( + classify_generated_split_commit(&generated_split_commit), + Some(SplitPointKind::CrossFile) + ); + } + + #[test] + fn test_sample_fim_split_point() { + let commit = r#"--- a/src/main.rs ++++ b/src/main.rs +@@ -1,3 +1,5 @@ + fn main() { ++ let first = 1; ++ let second = 2; + } +"#; + + assert_generated_split_kind(commit, SplitPointKind::Fim, 1); + } + + #[test] + fn test_sample_same_file_near_split_point() { + let commit = r#"--- a/src/main.rs ++++ b/src/main.rs +@@ -1,4 +1,5 @@ + fn main() { ++ let inserted = 0; +- old(); ++ new(); + } +"#; + + assert_generated_split_kind(commit, SplitPointKind::SameFileNear, 1); + } + + #[test] + fn test_sample_same_file_far_split_point() { + let commit = r#"--- a/src/main.rs ++++ b/src/main.rs +@@ -1,2 +1,3 @@ + start ++source_edit(); + context +@@ -100,2 +101,2 @@ +-far_old(); ++far_new(); + end +"#; + + assert_generated_split_kind(commit, SplitPointKind::SameFileFar, 1); + } + + #[test] + fn test_sample_cross_file_split_point() { + let commit = r#"--- a/src/main.rs ++++ b/src/main.rs +@@ -1,2 +1,3 @@ + fn main() { ++ source_edit(); + } +--- a/src/other.rs ++++ b/src/other.rs +@@ -1,3 +1,3 @@ + fn other() { +- old(); ++ new(); + } +"#; + + assert_generated_split_kind(commit, SplitPointKind::CrossFile, 1); + } + #[test] fn test_split_point_fraction() { let commit = r#"// Change @@ -1765,6 +2202,72 @@ index 123..456 789 assert!(!edit_starts_on_service_file(&patch, 2)); } + #[test] + fn test_submodule_gitlink_hunk_detection() { + assert!(has_submodule_gitlink_hunk( + r#"diff --git a/controllers/llguidance b/controllers/llguidance +index 21e68b9..cadabda 160000 +--- a/controllers/llguidance ++++ b/controllers/llguidance +@@ -1 +1 @@ +-Subproject commit 21e68b916d4705107e1c45ea7bc927e829136258 ++Subproject commit cadabdad21f3b81ff58b1918f8c23116b4ff7af3 +"# + )); + assert!(has_submodule_gitlink_hunk( + r#"--- a/controllers/derivre ++++ b/controllers/derivre +@@ -1 +1 @@ +-Subproject commit e83d8fb3cd92d2c6dd0437e98bfa9b64d8d8284b ++Subproject commit fb0ba7b6307782e0d43a0ca598b237836cb6d304 +"# + )); + assert!(has_submodule_gitlink_hunk( + r#"diff --git a/vendor/dependency b/vendor/dependency +new file mode 160000 +index 0000000..1234567 +--- /dev/null ++++ b/vendor/dependency +"# + )); + assert!(!has_submodule_gitlink_hunk( + r#"diff --git a/src/lib.rs b/src/lib.rs +index 1234567..89abcde 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1 +1,2 @@ + fn lib() {} ++fn helper() {} +"# + )); + } + + #[test] + fn test_generate_evaluation_example_rejects_submodule_gitlink_hunk() { + let commit = r#"diff --git a/controllers/llguidance b/controllers/llguidance +index 21e68b9..cadabda 160000 +--- a/controllers/llguidance ++++ b/controllers/llguidance +@@ -1 +1 @@ +-Subproject commit 21e68b916d4705107e1c45ea7bc927e829136258 ++Subproject commit cadabdad21f3b81ff58b1918f8c23116b4ff7af3 +"#; + + let result = generate_evaluation_example_from_ordered_commit( + commit, + "https://github.com/microsoft/aici", + "cadabdad21f3b81ff58b1918f8c23116b4ff7af3", + None, + Some(0), + None, + ); + + let Err(error) = result else { + panic!("expected submodule/gitlink commit to be rejected"); + }; + assert!(error.to_string().contains("submodule/gitlink")); + } + #[test] fn test_position_weight() { // High weight positions (natural pause points) diff --git a/crates/edit_prediction_context/Cargo.toml b/crates/edit_prediction_context/Cargo.toml index 3a63f16610a6b6..244b5f736e5862 100644 --- a/crates/edit_prediction_context/Cargo.toml +++ b/crates/edit_prediction_context/Cargo.toml @@ -24,6 +24,7 @@ parking_lot.workspace = true project.workspace = true serde.workspace = true smallvec.workspace = true +telemetry.workspace = true text.workspace = true tree-sitter.workspace = true util.workspace = true diff --git a/crates/edit_prediction_context/src/bm25_context.rs b/crates/edit_prediction_context/src/bm25_context.rs new file mode 100644 index 00000000000000..8bfb91ddf00b2e --- /dev/null +++ b/crates/edit_prediction_context/src/bm25_context.rs @@ -0,0 +1,682 @@ +use crate::editable_context::EditHistoryContextEntry; +use anyhow::{Context as _, Result, bail}; +use gpui::{AppContext as _, AsyncApp, Entity}; +use language::{Buffer, Point, ToPoint as _}; +use project::Project; +use std::{ + cmp::Ordering, + collections::{HashMap, HashSet}, + fs, + ops::Range, + path::{Path, PathBuf}, + time::Instant, +}; +use text::Anchor; +use util::command::new_command; + +const BM25_CONTEXT_QUERY_LINE_COUNT: u32 = 20; +const BM25_CONTEXT_EDIT_HISTORY_QUERY_ENTRY_COUNT: usize = 8; +const BM25_CONTEXT_CHUNK_LINE_COUNT: usize = 40; +const BM25_CONTEXT_CHUNK_OVERLAP_LINE_COUNT: usize = 10; +const BM25_CONTEXT_CHUNK_COUNT: usize = 12; +const BM25_CONTEXT_MAX_CHUNKS_PER_FILE: usize = 3; +const BM25_CONTEXT_MAX_FILE_BYTES: u64 = 1_000_000; +const BM25_K1: f64 = 1.2; +const BM25_B: f64 = 0.75; + +pub(super) struct Bm25ContextCandidate { + pub path: PathBuf, + pub row_range: Range, + pub order: usize, +} + +pub async fn collect_bm25_context( + project: Entity, + active_buffer: Entity, + cursor_position: Anchor, + edit_history: &[EditHistoryContextEntry], + next_order: usize, + cx: &mut AsyncApp, +) -> Vec { + let Some(query) = build_query(&project, &active_buffer, cursor_position, edit_history, cx) + else { + return Vec::new(); + }; + + let result = cx + .background_spawn(async move { collect_bm25_context_from_disk(query, next_order).await }) + .await; + + match result { + Ok(context) => context, + Err(error) => { + log::debug!("failed to collect BM25 context: {error:#}"); + Vec::new() + } + } +} + +struct Bm25ContextQuery { + worktree_abs_path: PathBuf, + worktree_root_name: String, + active_path: String, + cursor_excerpt: String, + edit_history_excerpts: Vec, +} + +fn build_query( + project: &Entity, + active_buffer: &Entity, + cursor_position: Anchor, + edit_history: &[EditHistoryContextEntry], + cx: &mut AsyncApp, +) -> Option { + let (worktree_abs_path, worktree_root_name, active_path, cursor_excerpt) = cx.update(|cx| { + let buffer = active_buffer.read(cx); + let file = buffer.file()?; + let project = project.read(cx); + if !project.is_local() { + return None; + } + let worktree = project.worktree_for_id(file.worktree_id(cx), cx)?; + let worktree = worktree.read(cx); + if !worktree.is_local() { + return None; + } + + let snapshot = buffer.snapshot(); + let range = expanded_anchor_range(&snapshot, cursor_position..cursor_position); + let cursor_excerpt = snapshot.text_for_range(range).collect::(); + + Some(( + worktree.abs_path(), + worktree.root_name().as_unix_str().to_string(), + file.path().as_unix_str().to_string(), + cursor_excerpt, + )) + })?; + + let edit_history_excerpts = edit_history + .iter() + .take(BM25_CONTEXT_EDIT_HISTORY_QUERY_ENTRY_COUNT) + .map(|entry| { + entry.buffer.read_with(cx, |buffer, _cx| { + let snapshot = buffer.snapshot(); + let range = expanded_anchor_range(&snapshot, entry.edited_range.clone()); + snapshot.text_for_range(range).collect::() + }) + }) + .collect(); + + Some(Bm25ContextQuery { + worktree_abs_path: worktree_abs_path.to_path_buf(), + worktree_root_name, + active_path, + cursor_excerpt, + edit_history_excerpts, + }) +} + +fn expanded_anchor_range( + snapshot: &language::BufferSnapshot, + range: Range, +) -> Range { + let start = range.start.to_point(snapshot); + let end = range.end.to_point(snapshot); + let start_row = start.row.saturating_sub(BM25_CONTEXT_QUERY_LINE_COUNT); + let end_row = end + .row + .saturating_add(BM25_CONTEXT_QUERY_LINE_COUNT) + .min(snapshot.max_point().row); + let start = snapshot.anchor_before(Point::new(start_row, 0)); + let end = snapshot.anchor_after(Point::new(end_row, snapshot.line_len(end_row))); + start..end +} + +async fn collect_bm25_context_from_disk( + query: Bm25ContextQuery, + next_order: usize, +) -> Result> { + let query_terms = query_terms(&query); + if query_terms.is_empty() { + return Ok(Vec::new()); + } + + let started_at = Instant::now(); + let index = Bm25Index::build(&query.worktree_abs_path).await?; + let elapsed = started_at.elapsed(); + log::debug!( + "built BM25 context index: candidate_files:{}, indexed_files:{}, indexed_bytes:{}, chunks:{}, terms:{}, latency:{elapsed:?}", + index.stats.candidate_file_count, + index.stats.indexed_file_count, + index.stats.indexed_bytes, + index.stats.document_count, + index.stats.term_count, + ); + + let candidates = index.search(&query_terms, &query.worktree_root_name, next_order); + log::debug!("selected {} BM25 context chunks", candidates.len()); + Ok(candidates) +} + +fn query_terms(query: &Bm25ContextQuery) -> HashMap { + let mut terms = HashMap::new(); + add_query_terms(&mut terms, &query.active_path, 3.0); + add_query_terms(&mut terms, &query.cursor_excerpt, 1.0); + for excerpt in &query.edit_history_excerpts { + add_query_terms(&mut terms, excerpt, 2.0); + } + terms +} + +fn add_query_terms(terms: &mut HashMap, text: &str, weight: f64) { + for token in tokenize(text) { + *terms.entry(token).or_default() += weight; + } +} + +struct Bm25Index { + documents: Vec, + document_frequencies: HashMap, + average_document_len: f64, + stats: Bm25IndexStats, +} + +#[derive(Default)] +struct Bm25IndexStats { + candidate_file_count: usize, + indexed_file_count: usize, + indexed_bytes: u64, + document_count: usize, + term_count: usize, +} + +struct Document { + relative_path: PathBuf, + row_range: Range, + term_frequencies: HashMap, + len: usize, +} + +struct ScoredDocument { + document_index: usize, + score: f64, +} + +struct DocumentsForFile { + documents: Vec, + byte_len: u64, +} + +impl Bm25Index { + async fn build(worktree_abs_path: &Path) -> Result { + let relative_paths = git_ls_files(worktree_abs_path).await?; + let mut stats = Bm25IndexStats { + candidate_file_count: relative_paths.len(), + ..Default::default() + }; + let mut documents = Vec::new(); + for relative_path in relative_paths { + let Some(documents_for_file) = documents_for_file(worktree_abs_path, relative_path) + else { + continue; + }; + + stats.indexed_file_count += 1; + stats.indexed_bytes += documents_for_file.byte_len; + documents.extend(documents_for_file.documents); + } + + let mut document_frequencies = HashMap::new(); + let mut total_document_len = 0; + for document in &documents { + total_document_len += document.len; + let mut seen_terms = HashSet::new(); + for term in document.term_frequencies.keys() { + if seen_terms.insert(term) { + *document_frequencies.entry(term.clone()).or_default() += 1; + } + } + } + + let average_document_len = if documents.is_empty() { + 0.0 + } else { + total_document_len as f64 / documents.len() as f64 + }; + stats.document_count = documents.len(); + stats.term_count = document_frequencies.len(); + + Ok(Self { + documents, + document_frequencies, + average_document_len, + stats, + }) + } + + fn search( + &self, + query_terms: &HashMap, + worktree_root_name: &str, + next_order: usize, + ) -> Vec { + if self.documents.is_empty() || self.average_document_len == 0.0 { + return Vec::new(); + } + + let mut scored_documents = self + .documents + .iter() + .enumerate() + .filter_map(|(document_index, document)| { + let score = self.score_document(document, query_terms); + (score > 0.0).then_some(ScoredDocument { + document_index, + score, + }) + }) + .collect::>(); + + scored_documents.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(Ordering::Equal) + .then_with(|| { + self.documents[left.document_index] + .relative_path + .cmp(&self.documents[right.document_index].relative_path) + }) + .then_with(|| { + self.documents[left.document_index] + .row_range + .start + .cmp(&self.documents[right.document_index].row_range.start) + }) + }); + + let mut selected_documents = Vec::new(); + let mut chunks_per_file = HashMap::::new(); + for scored_document in scored_documents { + let document = &self.documents[scored_document.document_index]; + let chunk_count = chunks_per_file + .entry(document.relative_path.clone()) + .or_default(); + if *chunk_count >= BM25_CONTEXT_MAX_CHUNKS_PER_FILE { + continue; + } + + *chunk_count += 1; + selected_documents.push(Bm25ContextCandidate { + path: Path::new(&format!( + "{}/{}", + worktree_root_name, + document.relative_path.to_string_lossy() + )) + .into(), + row_range: document.row_range.clone(), + order: next_order + selected_documents.len(), + }); + + if selected_documents.len() >= BM25_CONTEXT_CHUNK_COUNT { + break; + } + } + + selected_documents + } + + fn score_document(&self, document: &Document, query_terms: &HashMap) -> f64 { + let document_count = self.documents.len() as f64; + let document_len = document.len as f64; + let mut score = 0.0; + + for (term, query_weight) in query_terms { + let Some(term_frequency) = document.term_frequencies.get(term) else { + continue; + }; + let document_frequency = self + .document_frequencies + .get(term) + .copied() + .unwrap_or_default() as f64; + if document_frequency == 0.0 { + continue; + } + + let inverse_document_frequency = + ((document_count - document_frequency + 0.5) / (document_frequency + 0.5) + 1.0) + .ln(); + let term_frequency = *term_frequency as f64; + let denominator = term_frequency + + BM25_K1 + * (1.0 - BM25_B + BM25_B * document_len / self.average_document_len.max(1.0)); + score += query_weight * inverse_document_frequency * term_frequency * (BM25_K1 + 1.0) + / denominator; + } + + score + } +} + +async fn git_ls_files(worktree_abs_path: &Path) -> Result> { + let output = new_command("git") + .arg("ls-files") + .arg("-z") + .current_dir(worktree_abs_path) + .output() + .await + .with_context(|| { + format!( + "failed to run git ls-files in {}", + worktree_abs_path.display() + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "git ls-files failed in {} with status {}: {}", + worktree_abs_path.display(), + output.status, + stderr.trim() + ); + } + + let output = + String::from_utf8(output.stdout).context("git ls-files output was not valid UTF-8")?; + Ok(output + .split('\0') + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .collect()) +} + +fn documents_for_file( + worktree_abs_path: &Path, + relative_path: PathBuf, +) -> Option { + let absolute_path = worktree_abs_path.join(&relative_path); + let metadata = fs::symlink_metadata(&absolute_path).ok()?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > BM25_CONTEXT_MAX_FILE_BYTES + { + return None; + } + + let text = fs::read_to_string(&absolute_path).ok()?; + if text.is_empty() { + return None; + } + + let byte_len = metadata.len(); + let lines = lines(&text); + let path_tokens = tokenize(&relative_path.to_string_lossy()); + + let documents = chunk_line_ranges( + &lines, + BM25_CONTEXT_CHUNK_LINE_COUNT, + BM25_CONTEXT_CHUNK_OVERLAP_LINE_COUNT, + ) + .into_iter() + .filter_map(|row_range| { + let chunk_text = text_for_line_range(&text, row_range.clone()); + let mut term_frequencies = HashMap::new(); + add_term_frequencies(&mut term_frequencies, tokenize(&chunk_text), 1); + add_term_frequencies(&mut term_frequencies, path_tokens.clone(), 2); + let len = term_frequencies.values().sum(); + if len == 0 { + return None; + } + + Some(Document { + relative_path: relative_path.clone(), + row_range: row_range.start as u32..row_range.end as u32, + term_frequencies, + len, + }) + }) + .collect::>(); + + (!documents.is_empty()).then_some(DocumentsForFile { + documents, + byte_len, + }) +} + +fn add_term_frequencies( + term_frequencies: &mut HashMap, + tokens: Vec, + weight: usize, +) { + for token in tokens { + *term_frequencies.entry(token).or_default() += weight; + } +} + +fn chunk_line_ranges( + lines: &[&str], + target_line_count: usize, + overlap_line_count: usize, +) -> Vec> { + if lines.is_empty() || target_line_count == 0 { + return Vec::new(); + } + + let mut ranges = Vec::new(); + let mut start = 0; + while start < lines.len() { + let ideal_end = start.saturating_add(target_line_count).min(lines.len()); + let mut end = ideal_end; + if ideal_end < lines.len() + && let Some(boundary) = + empty_line_boundary_near(lines, start, ideal_end, overlap_line_count) + { + end = boundary; + } + if end <= start { + end = ideal_end; + } + if end <= start { + break; + } + + ranges.push(start..end); + if end == lines.len() { + break; + } + + let next_start = end.saturating_sub(overlap_line_count); + start = if next_start <= start { end } else { next_start }; + } + + ranges +} + +fn empty_line_boundary_near( + lines: &[&str], + start: usize, + ideal_end: usize, + overlap_line_count: usize, +) -> Option { + let search_start = ideal_end.saturating_sub(overlap_line_count).max(start + 1); + let search_end = ideal_end + .saturating_add(overlap_line_count) + .min(lines.len()); + + (search_start..search_end) + .filter(|row| lines[*row].trim().is_empty()) + .min_by_key(|row| row.abs_diff(ideal_end)) + .map(|row| row + 1) +} + +fn lines(text: &str) -> Vec<&str> { + text.split_inclusive('\n').collect() +} + +fn text_for_line_range(text: &str, range: Range) -> String { + lines(text) + .into_iter() + .skip(range.start) + .take(range.end.saturating_sub(range.start)) + .collect() +} + +fn tokenize(text: &str) -> Vec { + let mut tokens = Vec::new(); + let mut segment = String::new(); + + for character in text.chars() { + if character.is_alphanumeric() || character == '_' || character == '-' { + segment.push(character); + } else { + push_segment_tokens(&segment, &mut tokens); + segment.clear(); + } + } + push_segment_tokens(&segment, &mut tokens); + + tokens +} + +fn push_segment_tokens(segment: &str, tokens: &mut Vec) { + if segment.is_empty() { + return; + } + + let mut segment_tokens = Vec::new(); + push_token(segment, &mut segment_tokens); + for part in segment.split(['_', '-']).filter(|part| !part.is_empty()) { + push_token(part, &mut segment_tokens); + for camel_part in camel_case_parts(part) { + push_token(camel_part, &mut segment_tokens); + } + } + + let mut unique_segment_tokens = Vec::new(); + for token in segment_tokens { + if !unique_segment_tokens.contains(&token) { + unique_segment_tokens.push(token); + } + } + tokens.extend(unique_segment_tokens); +} + +fn camel_case_parts(text: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut previous = None; + + for (index, character) in text.char_indices() { + if index > 0 + && character.is_uppercase() + && previous + .is_some_and(|previous: char| previous.is_lowercase() || previous.is_numeric()) + { + parts.push(&text[start..index]); + start = index; + } + previous = Some(character); + } + + if start < text.len() { + parts.push(&text[start..]); + } + + parts +} + +fn push_token(token: &str, tokens: &mut Vec) { + let token = token.to_lowercase(); + if token.len() <= 1 + || token.len() > 128 + || !token.chars().any(|character| character.is_alphabetic()) + { + return; + } + tokens.push(token); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tokenize_splits_code_identifiers() { + let tokens = + tokenize("PrivateNetworkRequestPolicy foo_bar config/reg_default_16M_retrieval.json"); + + assert!(tokens.contains(&"privatenetworkrequestpolicy".to_string())); + assert!(tokens.contains(&"private".to_string())); + assert!(tokens.contains(&"network".to_string())); + assert!(tokens.contains(&"request".to_string())); + assert!(tokens.contains(&"policy".to_string())); + assert!(tokens.contains(&"foo_bar".to_string())); + assert!(tokens.contains(&"foo".to_string())); + assert!(tokens.contains(&"bar".to_string())); + assert!(tokens.contains(&"reg_default_16m_retrieval".to_string())); + assert!(tokens.contains(&"retrieval".to_string())); + } + + #[test] + fn test_chunk_line_ranges_prefers_empty_line_boundaries_with_overlap() { + let text = "a\nb\n\nc\nd\ne\nf\n\ng\nh\ni\nj\n"; + let lines = lines(text); + let ranges = chunk_line_ranges(&lines, 3, 1); + + assert_eq!(ranges[0], 0..3); + assert!(ranges[1].start < ranges[0].end); + } + + #[test] + fn test_bm25_ranks_matching_chunk() { + let documents = vec![ + Document { + relative_path: PathBuf::from("src/unrelated.rs"), + row_range: 0..1, + term_frequencies: { + let mut terms = HashMap::new(); + add_term_frequencies(&mut terms, tokenize("fn unrelated"), 1); + terms + }, + len: 2, + }, + Document { + relative_path: PathBuf::from("src/network.rs"), + row_range: 0..1, + term_frequencies: { + let mut terms = HashMap::new(); + add_term_frequencies( + &mut terms, + tokenize("fn update_private_network_request_policy"), + 1, + ); + terms + }, + len: 6, + }, + ]; + let mut document_frequencies = HashMap::new(); + for document in &documents { + for term in document.term_frequencies.keys() { + *document_frequencies.entry(term.clone()).or_default() += 1; + } + } + let index = Bm25Index { + documents, + document_frequencies, + average_document_len: 4.0, + stats: Bm25IndexStats::default(), + }; + let mut query = HashMap::new(); + add_query_terms(&mut query, "PrivateNetworkRequestPolicy", 1.0); + + let candidates = index.search(&query, "repo", 0); + + assert_eq!(candidates[0].path, Path::new("repo/src/network.rs")); + assert_eq!(candidates[0].row_range, 0..1); + assert_eq!(candidates[0].order, 0); + } +} diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 5671df6092eabd..44e295908af88c 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -20,14 +20,22 @@ use util::rel_path::RelPath; use util::{RangeExt as _, ResultExt}; mod assemble_excerpts; +mod bm25_context; #[cfg(test)] mod edit_prediction_context_tests; +mod editable_context; #[cfg(test)] mod fake_definition_lsp; +mod git_log_context; -pub use zeta_prompt::{RelatedExcerpt, RelatedFile}; +pub use editable_context::{ + EditHistoryContextEntry, collect_editable_context, limit_retrieved_context_to_bytes, +}; + +pub use zeta_prompt::{ContextSource, RelatedExcerpt, RelatedFile}; const IDENTIFIER_LINE_COUNT: u32 = 3; +const MAX_CONTEXT_IDENTIFIER_COUNT: usize = 32; pub struct RelatedExcerptStore { project: WeakEntity, @@ -220,9 +228,25 @@ impl RelatedExcerptStore { }; let file = snapshot.file().cloned(); + let file_extension = file + .as_ref() + .and_then(|file| file.path().extension()) + .unwrap_or("") + .to_string(); if let Some(file) = &file { log::debug!("retrieving_context buffer:{}", file.path().as_unix_str()); } + let (lsp_store, is_via_ssh) = project.read_with(cx, |project, _| { + (project.lsp_store(), project.is_via_remote_server()) + }); + let lsp_names = lsp_store.update(cx, |lsp_store, cx| { + buffer.update(cx, |buffer, cx| { + lsp_store + .running_language_servers_for_local_buffer(buffer, cx) + .map(|(_, server)| server.name().to_string()) + .collect::>() + }) + }); this.update(cx, |_, cx| { cx.emit(RelatedExcerptStoreEvent::StartedRefresh); @@ -252,7 +276,13 @@ impl RelatedExcerptStore { (id, distance) }) .collect(); - identifiers_with_distance.sort_by_key(|(_, distance)| *distance); + // Only the closest `MAX_CONTEXT_IDENTIFIER_COUNT` identifiers are + // used below, so select that prefix instead of fully sorting. + util::truncate_to_bottom_n_sorted_by( + &mut identifiers_with_distance, + MAX_CONTEXT_IDENTIFIER_COUNT, + &|(_, a), (_, b)| a.cmp(b), + ); let mut cursor_distances: HashMap = HashMap::default(); let mut current_rank = 0; @@ -386,10 +416,25 @@ impl RelatedExcerptStore { cache_hit_count += 1; } } + let lsp_fetch_latency_ms = start_time.elapsed().as_millis(); mean_definition_latency /= cache_miss_count.max(1) as u32; let (new_cache, related_buffers) = rebuild_related_files(&project, new_cache, &cursor_distances, cx).await?; + let latency_ms = start_time.elapsed().as_millis(); + let returned_excerpt_count = related_buffers + .iter() + .map(|related_buffer| related_buffer.anchor_ranges.len()) + .sum::(); + telemetry::event!( + "Edit Prediction LSP Context Retrieved", + lsp_names, + file_extension, + latency_ms, + lsp_fetch_latency_ms, + returned_excerpt_count, + is_via_ssh + ); if let Some(file) = &file { log::debug!( @@ -573,6 +618,7 @@ impl RelatedBuffer { row_range: start.row..end.row, text: buffer.text_for_range(start..end).collect::().into(), order, + context_source: ContextSource::Lsp, } }) .collect::>(); diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs index 32dc37b953207e..5b8e9c6aecb49c 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -308,6 +308,7 @@ async fn test_assemble_excerpts(cx: &mut TestAppContext) { row_range, text: buffer.text_for_range(start..end).collect::().into(), order, + context_source: ContextSource::Lsp, } }) .collect(); diff --git a/crates/edit_prediction_context/src/editable_context.rs b/crates/edit_prediction_context/src/editable_context.rs new file mode 100644 index 00000000000000..1aede0ce65a889 --- /dev/null +++ b/crates/edit_prediction_context/src/editable_context.rs @@ -0,0 +1,721 @@ +use collections::{HashMap, HashSet}; +use gpui::{App, AppContext as _, AsyncApp, Entity, EntityId}; +use language::{Buffer, BufferSnapshot, Point, ToPoint as _}; +use project::{Project, ProjectPath}; +use std::{ + ops::Range, + path::{Path, PathBuf}, + sync::Arc, +}; +use text::Anchor; +use util::{paths::PathStyle, rel_path::RelPath}; +use zeta_prompt::{ContextSource, RelatedExcerpt, RelatedFile}; + +use crate::{ + bm25_context::{Bm25ContextCandidate, collect_bm25_context}, + git_log_context::build_git_log_index, +}; + +/// This module contains collectors for editable context: +/// excerpts or full files that are likely to be edited. +const CURSOR_CONTEXT_LINE_COUNT: u32 = 20; +const EDIT_HISTORY_CONTEXT_LINE_COUNT: u32 = 20; +const GIT_LOG_CONTEXT_LINE_COUNT: u32 = 10000; +const GIT_LOG_CONTEXT_FILE_COUNT: usize = 10; + +type RangesByBuffer = HashMap, Vec)>; + +#[derive(Clone)] +pub struct EditHistoryContextEntry { + pub buffer: Entity, + pub edited_range: Range, +} + +struct EditableContextRange { + range: Range, + order: usize, + context_source: ContextSource, +} + +struct ResolvedEditableContextRange { + range: Range, + order: usize, + context_source: ContextSource, +} + +pub async fn collect_editable_context( + project: Entity, + active_buffer: Entity, + cursor_position: Anchor, + edit_history: Vec, + oracle_paths: Vec>, + context_sources: Vec, + cx: &mut AsyncApp, +) -> anyhow::Result> { + let mut ranges_by_buffer = RangesByBuffer::default(); + + if context_sources.contains(&ContextSource::CursorExcerpt) { + collect_cursor_excerpt_context( + &mut ranges_by_buffer, + active_buffer.clone(), + cursor_position, + cx, + ); + } + if context_sources.contains(&ContextSource::CurrentFile) { + collect_current_file_context(&mut ranges_by_buffer, active_buffer.clone(), cx); + } + if context_sources.contains(&ContextSource::EditHistory) { + collect_edit_history_context(&mut ranges_by_buffer, &edit_history, cx); + } + if context_sources.contains(&ContextSource::EditHistoryFile) { + collect_edit_history_file_context(&mut ranges_by_buffer, &edit_history, cx); + } + if context_sources.contains(&ContextSource::GitLog) { + collect_git_log_context( + &mut ranges_by_buffer, + project.clone(), + active_buffer.clone(), + cx, + ) + .await; + } + + if context_sources.contains(&ContextSource::Bm25) { + collect_bm25_context_ranges( + &mut ranges_by_buffer, + project.clone(), + active_buffer, + cursor_position, + &edit_history, + cx, + ) + .await; + } + + if context_sources.contains(&ContextSource::OracleFile) { + collect_oracle_file_context(&mut ranges_by_buffer, project.clone(), oracle_paths, cx).await; + } + + Ok(cx.update(|cx| { + let project = project.read(cx); + let mut related_files = ranges_by_buffer + .into_values() + .filter_map(|(buffer, ranges)| related_file_for_ranges(&project, &buffer, ranges, cx)) + .collect::>(); + related_files.sort_by_key(|file| { + file.excerpts + .iter() + .map(|excerpt| excerpt.order) + .min() + .unwrap_or(usize::MAX) + }); + related_files + })) +} + +pub fn limit_retrieved_context_to_bytes( + related_files: &[RelatedFile], + max_bytes: usize, +) -> Vec { + struct ExcerptCandidate { + file_index: usize, + excerpt_index: usize, + order: usize, + } + + let mut candidates = related_files + .iter() + .enumerate() + .flat_map(|(file_index, file)| { + file.excerpts + .iter() + .enumerate() + .map(move |(excerpt_index, excerpt)| ExcerptCandidate { + file_index, + excerpt_index, + order: excerpt.order, + }) + }) + .collect::>(); + candidates.sort_by_key(|candidate| { + ( + candidate.order, + candidate.file_index, + candidate.excerpt_index, + ) + }); + + let mut selected_excerpts = related_files + .iter() + .map(|file| vec![false; file.excerpts.len()]) + .collect::>(); + let mut covered_ranges_by_file = vec![Vec::>::new(); related_files.len()]; + let mut selected_bytes: usize = 0; + + for candidate in candidates { + let file = &related_files[candidate.file_index]; + let excerpt = &file.excerpts[candidate.excerpt_index]; + let added_bytes = + uncovered_excerpt_bytes(excerpt, &covered_ranges_by_file[candidate.file_index]); + if added_bytes == 0 || selected_bytes.saturating_add(added_bytes) > max_bytes { + continue; + } + + selected_bytes += added_bytes; + selected_excerpts[candidate.file_index][candidate.excerpt_index] = true; + push_covered_range( + &mut covered_ranges_by_file[candidate.file_index], + excerpt.row_range.clone(), + ); + } + + related_files + .iter() + .enumerate() + .filter_map(|(file_index, file)| { + let excerpts = file + .excerpts + .iter() + .enumerate() + .filter_map(|(excerpt_index, excerpt)| { + selected_excerpts[file_index][excerpt_index].then(|| excerpt.clone()) + }) + .collect::>(); + if excerpts.is_empty() { + return None; + } + + Some(RelatedFile { + path: file.path.clone(), + max_row: file.max_row, + excerpts, + in_open_source_repo: file.in_open_source_repo, + }) + }) + .collect() +} + +fn uncovered_excerpt_bytes(excerpt: &RelatedExcerpt, covered_ranges: &[Range]) -> usize { + let mut bytes = 0; + + for (row, line) in (excerpt.row_range.start..).zip(excerpt.text.split_inclusive('\n')) { + if row >= excerpt.row_range.end { + break; + } + if !covered_ranges + .iter() + .any(|covered_range| covered_range.contains(&row)) + { + bytes += line.len(); + } + } + + bytes +} + +fn push_covered_range(covered_ranges: &mut Vec>, range: Range) { + covered_ranges.push(range); + covered_ranges.sort_by_key(|range| (range.start, range.end)); + + let mut merged_ranges: Vec> = Vec::new(); + for range in covered_ranges.drain(..) { + if let Some(last_range) = merged_ranges.last_mut() + && range.start <= last_range.end + { + last_range.end = last_range.end.max(range.end); + continue; + } + + merged_ranges.push(range); + } + + *covered_ranges = merged_ranges; +} + +fn collect_cursor_excerpt_context( + ranges_by_buffer: &mut RangesByBuffer, + active_buffer: Entity, + cursor_position: Anchor, + cx: &mut AsyncApp, +) { + let cursor_range = active_buffer.read_with(cx, |buffer, _cx| { + let snapshot = buffer.snapshot(); + expanded_anchor_range( + &snapshot, + cursor_position..cursor_position, + CURSOR_CONTEXT_LINE_COUNT, + ) + }); + + push_context_range( + ranges_by_buffer, + active_buffer, + cursor_range, + 0, + ContextSource::CursorExcerpt, + ); +} + +fn collect_current_file_context( + ranges_by_buffer: &mut RangesByBuffer, + active_buffer: Entity, + cx: &mut AsyncApp, +) { + collect_full_buffer_context( + ranges_by_buffer, + active_buffer, + 0, + ContextSource::CurrentFile, + cx, + ); +} + +fn collect_edit_history_context( + ranges_by_buffer: &mut RangesByBuffer, + edit_history: &[EditHistoryContextEntry], + cx: &mut AsyncApp, +) { + for (index, entry) in edit_history.iter().enumerate() { + let edit_history_range = entry.buffer.read_with(cx, |buffer, _cx| { + expanded_anchor_range( + &buffer.snapshot(), + entry.edited_range.clone(), + EDIT_HISTORY_CONTEXT_LINE_COUNT, + ) + }); + + push_context_range( + ranges_by_buffer, + entry.buffer.clone(), + edit_history_range, + index + 1, + ContextSource::EditHistory, + ); + } +} + +fn collect_edit_history_file_context( + ranges_by_buffer: &mut RangesByBuffer, + edit_history: &[EditHistoryContextEntry], + cx: &mut AsyncApp, +) { + let next_order = next_context_order(ranges_by_buffer); + let mut seen_buffers = HashSet::default(); + let mut index = 0; + + for entry in edit_history { + if !seen_buffers.insert(entry.buffer.entity_id()) { + continue; + } + + collect_full_buffer_context( + ranges_by_buffer, + entry.buffer.clone(), + next_order + index, + ContextSource::EditHistoryFile, + cx, + ); + index += 1; + } +} + +async fn collect_bm25_context_ranges( + ranges_by_buffer: &mut RangesByBuffer, + project: Entity, + active_buffer: Entity, + cursor_position: Anchor, + edit_history: &[EditHistoryContextEntry], + cx: &mut AsyncApp, +) { + let next_order = next_context_order(ranges_by_buffer); + let candidates = collect_bm25_context( + project.clone(), + active_buffer, + cursor_position, + edit_history, + next_order, + cx, + ) + .await; + + for candidate in candidates { + collect_bm25_candidate_context(ranges_by_buffer, &project, candidate, cx).await; + } +} + +async fn collect_bm25_candidate_context( + ranges_by_buffer: &mut RangesByBuffer, + project: &Entity, + candidate: Bm25ContextCandidate, + cx: &mut AsyncApp, +) { + let buffer = match open_buffer_for_path(project, &candidate.path, cx).await { + Ok(Some(buffer)) => buffer, + Ok(None) => { + log::debug!( + "failed to find BM25 context path: {}", + candidate.path.display() + ); + return; + } + Err(error) => { + log::debug!( + "failed to open BM25 context path {}: {error:#}", + candidate.path.display() + ); + return; + } + }; + + let Some(range) = buffer.read_with(cx, |buffer, _cx| { + anchor_range_for_row_range(&buffer.snapshot(), candidate.row_range.clone()) + }) else { + return; + }; + + push_context_range( + ranges_by_buffer, + buffer, + range, + candidate.order, + ContextSource::Bm25, + ); +} + +fn anchor_range_for_row_range( + snapshot: &BufferSnapshot, + row_range: Range, +) -> Option> { + if row_range.start >= row_range.end || row_range.start > snapshot.max_point().row { + return None; + } + + let max_point = snapshot.max_point(); + let start = snapshot.anchor_before(Point::new(row_range.start, 0)); + let end_point = if row_range.end > max_point.row { + max_point + } else { + Point::new(row_range.end, 0) + }; + let end = snapshot.anchor_after(end_point); + Some(start..end) +} + +async fn collect_oracle_file_context( + ranges_by_buffer: &mut RangesByBuffer, + project: Entity, + oracle_paths: Vec>, + cx: &mut AsyncApp, +) { + let next_order = next_context_order(ranges_by_buffer); + let mut seen_buffers = HashSet::default(); + let mut index = 0; + + for path in oracle_paths { + let buffer = match open_buffer_for_path(&project, &path, cx).await { + Ok(Some(buffer)) => buffer, + Ok(None) => { + log::debug!("failed to find oracle file path: {}", path.display()); + continue; + } + Err(error) => { + log::debug!( + "failed to open oracle file path {}: {error:#}", + path.display() + ); + continue; + } + }; + + if !seen_buffers.insert(buffer.entity_id()) { + continue; + } + + collect_full_buffer_context( + ranges_by_buffer, + buffer, + next_order + index, + ContextSource::OracleFile, + cx, + ); + index += 1; + } +} + +async fn open_buffer_for_path( + project: &Entity, + path: &Path, + cx: &mut AsyncApp, +) -> anyhow::Result>> { + let path = path.to_path_buf(); + let path_without_prefix: PathBuf = path.components().skip(1).collect(); + let project_path = project.update(cx, |project, cx| { + project.find_project_path(&path, cx).or_else(|| { + if path_without_prefix.as_os_str().is_empty() { + None + } else { + project.find_project_path(&path_without_prefix, cx) + } + }) + }); + + let Some(project_path) = project_path else { + return Ok(None); + }; + + project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .map(Some) +} + +fn collect_full_buffer_context( + ranges_by_buffer: &mut RangesByBuffer, + buffer: Entity, + order: usize, + context_source: ContextSource, + cx: &mut AsyncApp, +) { + let range = buffer.read_with(cx, |buffer, _cx| full_file_anchor_range(&buffer.snapshot())); + push_context_range(ranges_by_buffer, buffer, range, order, context_source); +} + +fn full_file_anchor_range(snapshot: &BufferSnapshot) -> Range { + let start = snapshot.anchor_before(Point::new(0, 0)); + let max_point = snapshot.max_point(); + let end = snapshot.anchor_after(max_point); + start..end +} + +fn next_context_order(ranges_by_buffer: &RangesByBuffer) -> usize { + ranges_by_buffer + .values() + .flat_map(|(_, ranges)| ranges.iter().map(|range| range.order)) + .max() + .map_or(0, |order| order + 1) +} + +async fn collect_git_log_context( + ranges_by_buffer: &mut RangesByBuffer, + project: Entity, + active_buffer: Entity, + cx: &mut AsyncApp, +) { + let Some((worktree_id, active_path, worktree_abs_path)) = cx.update(|cx| { + let buffer = active_buffer.read(cx); + let file = buffer.file()?; + let project = project.read(cx); + if !project.is_local() { + return None; + } + let worktree = project.worktree_for_id(file.worktree_id(cx), cx)?; + let worktree = worktree.read(cx); + if !worktree.is_local() { + return None; + } + Some(( + file.worktree_id(cx), + file.path().clone(), + worktree.abs_path(), + )) + }) else { + return; + }; + + let index_result = cx + .background_spawn(async move { build_git_log_index(&worktree_abs_path).await }) + .await; + let index = match index_result { + Ok(index) => index, + Err(error) => { + log::debug!("failed to build git log context index: {error:#}"); + return; + } + }; + + let next_order = next_context_order(ranges_by_buffer); + + for (index, related_path) in index + .get_related(active_path.as_std_path(), GIT_LOG_CONTEXT_FILE_COUNT) + .into_iter() + .enumerate() + { + let Ok(related_path) = RelPath::new(&related_path, PathStyle::Posix) else { + continue; + }; + let project_path = ProjectPath { + worktree_id, + path: related_path.into_owned().into(), + }; + let buffer = match project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + { + Ok(buffer) => buffer, + Err(error) => { + log::debug!("failed to open git log related buffer: {error:#}"); + continue; + } + }; + + let range = buffer.read_with(cx, |buffer, _cx| { + let snapshot = buffer.snapshot(); + let max_row = GIT_LOG_CONTEXT_LINE_COUNT.min(snapshot.max_point().row); + let end = snapshot.anchor_after(Point::new(max_row, snapshot.line_len(max_row))); + snapshot.anchor_before(Point::new(0, 0))..end + }); + + push_context_range( + ranges_by_buffer, + buffer, + range, + next_order + index, + ContextSource::GitLog, + ); + } +} + +fn expanded_anchor_range( + snapshot: &BufferSnapshot, + range: Range, + context_line_count: u32, +) -> Range { + let start = range.start.to_point(snapshot); + let end = range.end.to_point(snapshot); + let start_row = start.row.saturating_sub(context_line_count); + let end_row = end + .row + .saturating_add(context_line_count) + .min(snapshot.max_point().row); + let start = snapshot.anchor_before(Point::new(start_row, 0)); + let end = snapshot.anchor_after(Point::new(end_row, snapshot.line_len(end_row))); + start..end +} + +fn push_context_range( + ranges_by_buffer: &mut RangesByBuffer, + buffer: Entity, + range: Range, + order: usize, + context_source: ContextSource, +) { + ranges_by_buffer + .entry(buffer.entity_id()) + .or_insert_with(|| (buffer.clone(), Vec::new())) + .1 + .push(EditableContextRange { + range, + order, + context_source, + }); +} + +fn related_file_for_ranges( + project: &Project, + buffer: &Entity, + ranges: Vec, + cx: &App, +) -> Option { + let buffer = buffer.read(cx); + let snapshot = buffer.snapshot(); + let file = snapshot.file()?; + let worktree = project.worktree_for_id(file.worktree_id(cx), cx)?; + let path: Arc = Path::new(&format!( + "{}/{}", + worktree.read(cx).root_name().as_unix_str(), + file.path().as_unix_str() + )) + .into(); + + let ranges = resolved_context_ranges(ranges, &snapshot); + + let mut excerpts = ranges + .into_iter() + .map(|range| RelatedExcerpt { + row_range: range.range.start.row..range.range.end.row, + text: snapshot + .text_for_range(range.range) + .collect::() + .into(), + order: range.order, + context_source: range.context_source, + }) + .collect::>(); + excerpts.sort_by_key(|excerpt| excerpt.order); + + Some(RelatedFile { + path, + max_row: snapshot.max_point().row, + excerpts, + in_open_source_repo: false, + }) +} + +fn resolved_context_ranges( + ranges: Vec, + snapshot: &BufferSnapshot, +) -> Vec { + ranges + .into_iter() + .filter_map(|range| { + let start = range.range.start.to_point(snapshot); + let end = range.range.end.to_point(snapshot); + if start >= end { + return None; + } + + Some(ResolvedEditableContextRange { + range: start..end, + order: range.order, + context_source: range.context_source, + }) + }) + .collect() +} + +#[allow(dead_code)] +fn merge_overlapping_ranges(ranges: &mut Vec) { + ranges.sort_by_key(|range| (range.range.start, range.range.end)); + let mut merged: Vec = Vec::new(); + + for range in ranges.drain(..) { + if let Some(last_range) = merged.last_mut() + && range.range.start <= last_range.range.end + { + if context_source_order(range.context_source) + < context_source_order(last_range.context_source) + { + last_range.context_source = range.context_source; + } + last_range.range.end = last_range.range.end.max(range.range.end); + last_range.order = last_range.order.min(range.order); + continue; + } + + merged.push(range); + } + + *ranges = merged; +} + +#[allow(dead_code)] +fn push_context_source(context_sources: &mut Vec, context_source: ContextSource) { + if !context_sources.contains(&context_source) { + context_sources.push(context_source); + context_sources.sort_by_key(|context_source| context_source_order(*context_source)); + } +} + +fn context_source_order(context_source: ContextSource) -> usize { + match context_source { + ContextSource::Lsp => 0, + ContextSource::CursorExcerpt => 1, + ContextSource::CurrentFile => 2, + ContextSource::EditHistory => 3, + ContextSource::EditHistoryFile => 4, + ContextSource::GitLog => 5, + ContextSource::Bm25 => 6, + ContextSource::OracleFile => 7, + } +} diff --git a/crates/edit_prediction_context/src/git_log_context.rs b/crates/edit_prediction_context/src/git_log_context.rs new file mode 100644 index 00000000000000..2ed358b03fb70d --- /dev/null +++ b/crates/edit_prediction_context/src/git_log_context.rs @@ -0,0 +1,255 @@ +// Goal: +// - Build an index that finds files that are frequently edited in the same git commit +// - Lookup by path and get a list of related files, sorted by most frequently edited together +// +// Path => Path => usize +// +// This is a symmetric relationship, so for a => (b, 1), also add b => (a, 1) + +use std::collections::HashMap; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::{Context as _, Result, anyhow, bail}; +use util::command::new_command; + +pub struct GitLogIndex { + index: HashMap>, +} + +impl GitLogIndex { + pub fn new() -> Self { + Self { + index: HashMap::new(), + } + } + + pub fn add_related(&mut self, path: PathBuf, related: PathBuf) { + let count = self + .index + .entry(path.clone()) + .or_default() + .entry(related.clone()) + .or_default(); + *count += 1; + + // add the reverse mapping + let reverse_count = self + .index + .entry(related) + .or_default() + .entry(path) + .or_default(); + *reverse_count += 1; + } + + pub fn get_related(&self, path: &Path, n: usize) -> Vec { + self.get_related_with_counts(path, n) + .into_iter() + .map(|(path, _)| path) + .collect() + } + + pub fn get_related_with_counts(&self, path: &Path, n: usize) -> Vec<(PathBuf, usize)> { + let Some(counts) = self.index.get(path) else { + return Vec::new(); + }; + + let mut related: Vec<_> = counts.iter().collect(); + related.sort_by(|(left_path, left_count), (right_path, right_count)| { + right_count + .cmp(left_count) + .then_with(|| left_path.cmp(right_path)) + }); + related + .into_iter() + .take(n) + .map(|(path, count)| (path.clone(), *count)) + .collect() + } +} + +impl Default for GitLogIndex { + fn default() -> Self { + Self::new() + } +} + +pub async fn build_git_log_index(worktree_dir: &Path) -> Result { + let mut index = GitLogIndex::new(); + + let output = new_command("git") + .arg("log") + .arg("-5000") + .arg("--pretty=tformat:@@COMMIT %H") + .arg("--name-only") + .current_dir(worktree_dir) + .output() + .await + .with_context(|| format!("failed to run git log in {}", worktree_dir.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "git log failed in {} with status {}: {}", + worktree_dir.display(), + output.status, + stderr.trim() + ); + } + + let log = String::from_utf8(output.stdout).context("git log output was not valid UTF-8")?; + let parsed = parse_git_log(&log); + for files in parsed { + for i in 0..files.len() { + for j in (i + 1)..files.len() { + index.add_related(files[i].clone(), files[j].clone()); + } + } + } + + Ok(index) +} + +#[allow(dead_code)] +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error:#}"); + ExitCode::FAILURE + } + } +} + +#[allow(dead_code)] +fn run() -> Result<()> { + let mut arguments = env::args_os(); + let program_name = arguments + .next() + .and_then(|path| PathBuf::from(path).file_name().map(|name| name.to_owned())) + .and_then(|name| name.into_string().ok()) + .unwrap_or_else(|| "git_log_context".to_string()); + + let worktree_dir = arguments.next().ok_or_else(|| { + print_usage(&program_name); + anyhow!("missing worktree path") + })?; + let query_path = arguments.next().ok_or_else(|| { + print_usage(&program_name); + anyhow!("missing query path") + })?; + if arguments.next().is_some() { + print_usage(&program_name); + bail!("too many arguments"); + } + + let worktree_dir = PathBuf::from(worktree_dir); + let query_path = normalize_query_path(&worktree_dir, &PathBuf::from(query_path)); + let index = futures::executor::block_on(build_git_log_index(&worktree_dir))?; + + for (path, count) in index.get_related_with_counts(&query_path, 10) { + println!("{count}\t{}", path.display()); + } + + Ok(()) +} + +#[allow(dead_code)] +fn print_usage(program_name: &str) { + eprintln!("Usage: {program_name} "); +} + +#[allow(dead_code)] +fn normalize_query_path(worktree_dir: &Path, query_path: &Path) -> PathBuf { + if query_path.is_absolute() { + query_path + .strip_prefix(worktree_dir) + .unwrap_or(query_path) + .components() + .collect() + } else { + query_path.components().collect() + } +} + +fn parse_git_log(log: &str) -> Vec> { + let mut lines = log.lines().peekable(); + let mut commits = Vec::new(); + + while let Some(line) = lines.next() { + if line.starts_with("@@COMMIT ") { + // skip blank line + lines.next(); + let mut files = Vec::new(); + while let Some(next) = lines.peek() + && !next.starts_with("@@COMMIT ") + { + let Some(next) = lines.next() else { + break; + }; + if !next.is_empty() { + files.push(next.into()); + } + } + commits.push(files); + } + } + + commits +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + + #[test] + fn test_git_log_index() { + let mut index = GitLogIndex::new(); + index.add_related(PathBuf::from("a"), PathBuf::from("b")); + index.add_related(PathBuf::from("a"), PathBuf::from("b")); + index.add_related(PathBuf::from("a"), PathBuf::from("c")); + index.add_related(PathBuf::from("b"), PathBuf::from("c")); + + let related = index.get_related(&PathBuf::from("a"), 100); + assert_eq!(related, vec![PathBuf::from("b"), PathBuf::from("c")]); + } + + #[test] + fn test_parse_git_log() { + let log = indoc! {" + @@COMMIT d2e451dd48be67ef8c943e90dabc02e80a6984c9 + + crates/edit_prediction/src/edit_prediction.rs + crates/edit_prediction_cli/src/format_prompt.rs + crates/edit_prediction_cli/src/main.rs + crates/edit_prediction_cli/src/predict.rs + @@COMMIT d666823f348bd151067464fa31676d28bdb96717 + + crates/edit_prediction_cli/src/main.rs + crates/edit_prediction_cli/src/predict.rs + "}; + let parsed = parse_git_log(log); + + assert_eq!(parsed.len(), 2); + assert_eq!( + parsed[0][0], + PathBuf::from("crates/edit_prediction/src/edit_prediction.rs") + ); + assert_eq!( + parsed[0][1], + PathBuf::from("crates/edit_prediction_cli/src/format_prompt.rs") + ); + + assert_eq!( + parsed[1][0], + PathBuf::from("crates/edit_prediction_cli/src/main.rs") + ); + assert_eq!( + parsed[1][1], + PathBuf::from("crates/edit_prediction_cli/src/predict.rs") + ); + } +} diff --git a/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs b/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs index 81a37148c79858..205075e2f5b36e 100644 --- a/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs +++ b/crates/edit_prediction_metrics/src/edit_prediction_metrics.rs @@ -1,4 +1,6 @@ +mod jumps; mod kept_rate; +mod patch; mod patch_metrics; mod prediction_score; mod reversal; @@ -7,6 +9,7 @@ mod tokenize; #[cfg(feature = "tree-sitter")] mod tree_sitter; +pub use jumps::{EditableContextCoverage, Excerpt, editable_context_coverage}; pub use kept_rate::AnnotatedToken; pub use kept_rate::KeptRateResult; pub use kept_rate::TokenAnnotation; diff --git a/crates/edit_prediction_metrics/src/jumps.rs b/crates/edit_prediction_metrics/src/jumps.rs new file mode 100644 index 00000000000000..8cfeafcc97aade --- /dev/null +++ b/crates/edit_prediction_metrics/src/jumps.rs @@ -0,0 +1,505 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + ops::Range, +}; + +use crate::patch::{Patch, PatchLine}; + +const LINE_RELEVANCE_WINDOW: u32 = 20; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Excerpt { + pub path: String, + pub row_range: Range, + pub content: String, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct EditableContextCoverage { + pub lines_tp: usize, + pub lines_fp: usize, + pub lines_fn: usize, + pub lines_precision: f64, + pub lines_recall: f64, + pub lines_f1: f64, + + pub files_tp: usize, + pub files_fp: usize, + pub files_fn: usize, + pub files_precision: f64, + pub files_recall: f64, + pub files_f1: f64, +} + +impl EditableContextCoverage { + pub fn new( + lines_tp: usize, + lines_fp: usize, + lines_fn: usize, + files_tp: usize, + files_fp: usize, + files_fn: usize, + ) -> Self { + Self { + lines_tp, + lines_fp, + lines_fn, + lines_precision: precision(lines_tp, lines_fp, lines_fn), + lines_recall: recall(lines_tp, lines_fp, lines_fn), + lines_f1: f1(lines_tp, lines_fp, lines_fn), + files_tp, + files_fp, + files_fn, + files_precision: precision(files_tp, files_fp, files_fn), + files_recall: recall(files_tp, files_fp, files_fn), + files_f1: f1(files_tp, files_fp, files_fn), + } + } +} + +/// Measures how much expected edit context was retrieved and how much unrelated context was retrieved. +pub fn editable_context_coverage( + expected_patch: &str, + context: &[Excerpt], +) -> EditableContextCoverage { + let patch = Patch::parse_unified_diff(expected_patch); + let (expected_files, expected_anchor_lines, relevant_lines) = expected_context(&patch); + let (retrieved_files, retrieved_lines) = retrieved_context(context); + + let (lines_tp, lines_fp) = classify_retrieved_rows(&relevant_lines, &retrieved_lines); + let (lines_fn, expected_anchor_line_count) = + count_missing_rows(&expected_anchor_lines, &retrieved_lines); + let (files_tp, files_fp, files_fn) = classify_values(&expected_files, &retrieved_files); + + let lines_precision = precision(lines_tp, lines_fp, lines_fn); + let lines_recall = line_recall(expected_anchor_line_count, lines_fn); + let lines_f1 = f1_from_precision_and_recall(lines_precision, lines_recall); + let files_precision = precision(files_tp, files_fp, files_fn); + let files_recall = recall(files_tp, files_fp, files_fn); + let files_f1 = f1_from_precision_and_recall(files_precision, files_recall); + + EditableContextCoverage { + lines_tp, + lines_fp, + lines_fn, + lines_precision, + lines_recall, + lines_f1, + files_tp, + files_fp, + files_fn, + files_precision, + files_recall, + files_f1, + } +} + +fn expected_context( + patch: &Patch, +) -> ( + BTreeSet, + BTreeMap>, + BTreeMap>, +) { + let mut expected_files = BTreeSet::new(); + let mut expected_anchor_lines = BTreeMap::new(); + let mut relevant_lines = BTreeMap::new(); + + for hunk in &patch.hunks { + if hunk + .lines + .iter() + .any(|line| matches!(line, PatchLine::Addition(_) | PatchLine::Deletion(_))) + { + expected_files.insert(hunk.filename.clone()); + } + + let mut old_row = hunk.old_start.saturating_sub(1).max(0) as u32; + let mut previous_context_row = None; + let mut index = 0; + + while index < hunk.lines.len() { + match &hunk.lines[index] { + PatchLine::Context(_) => { + previous_context_row = Some(old_row); + old_row = old_row.saturating_add(1); + index += 1; + } + PatchLine::Addition(_) | PatchLine::Deletion(_) => { + let mut deletion_rows = Vec::new(); + let mut has_addition = false; + + while index < hunk.lines.len() { + match &hunk.lines[index] { + PatchLine::Addition(_) => { + has_addition = true; + index += 1; + } + PatchLine::Deletion(_) => { + deletion_rows.push(old_row); + old_row = old_row.saturating_add(1); + index += 1; + } + _ => break, + } + } + + if deletion_rows.is_empty() { + if has_addition { + if let Some(row) = previous_context_row { + insert_anchor_row( + &mut expected_anchor_lines, + &mut relevant_lines, + &hunk.filename, + row, + ); + } + if matches!(hunk.lines.get(index), Some(PatchLine::Context(_))) { + insert_anchor_row( + &mut expected_anchor_lines, + &mut relevant_lines, + &hunk.filename, + old_row, + ); + } + } + } else { + for row in deletion_rows { + insert_anchor_row( + &mut expected_anchor_lines, + &mut relevant_lines, + &hunk.filename, + row, + ); + } + } + + previous_context_row = None; + } + PatchLine::Garbage(_) => { + index += 1; + } + } + } + } + + (expected_files, expected_anchor_lines, relevant_lines) +} + +fn retrieved_context(context: &[Excerpt]) -> (BTreeSet, BTreeMap>) { + let mut retrieved_files = BTreeSet::new(); + let mut retrieved_lines = BTreeMap::new(); + + for excerpt in context { + retrieved_files.insert(excerpt.path.clone()); + let rows = retrieved_lines + .entry(excerpt.path.clone()) + .or_insert_with(BTreeSet::new); + rows.extend(excerpt.row_range.clone()); + } + + (retrieved_files, retrieved_lines) +} + +fn insert_anchor_row( + anchor_lines_by_file: &mut BTreeMap>, + relevant_lines_by_file: &mut BTreeMap>, + path: &str, + row: u32, +) { + insert_row(anchor_lines_by_file, path, row); + + let start = row.saturating_sub(LINE_RELEVANCE_WINDOW); + let end = row.saturating_add(LINE_RELEVANCE_WINDOW); + for relevant_row in start..=end { + insert_row(relevant_lines_by_file, path, relevant_row); + } +} + +fn insert_row(lines_by_file: &mut BTreeMap>, path: &str, row: u32) { + lines_by_file + .entry(path.to_string()) + .or_insert_with(BTreeSet::new) + .insert(row); +} + +fn classify_retrieved_rows( + relevant: &BTreeMap>, + retrieved: &BTreeMap>, +) -> (usize, usize) { + let mut true_positives = 0; + let mut false_positives = 0; + + for (path, rows) in retrieved { + for row in rows { + if relevant + .get(path) + .is_some_and(|relevant_rows| relevant_rows.contains(row)) + { + true_positives += 1; + } else { + false_positives += 1; + } + } + } + + (true_positives, false_positives) +} + +fn count_missing_rows( + expected: &BTreeMap>, + retrieved: &BTreeMap>, +) -> (usize, usize) { + let mut false_negatives = 0; + let mut expected_count = 0; + + for (path, rows) in expected { + for row in rows { + expected_count += 1; + if !retrieved + .get(path) + .is_some_and(|retrieved_rows| retrieved_rows.contains(row)) + { + false_negatives += 1; + } + } + } + + (false_negatives, expected_count) +} + +fn classify_values( + expected: &BTreeSet, + retrieved: &BTreeSet, +) -> (usize, usize, usize) { + let true_positives = expected.intersection(retrieved).count(); + let false_positives = retrieved.difference(expected).count(); + let false_negatives = expected.difference(retrieved).count(); + (true_positives, false_positives, false_negatives) +} + +fn precision(true_positives: usize, false_positives: usize, false_negatives: usize) -> f64 { + if true_positives + false_positives + false_negatives == 0 { + return 1.0; + } + + let denominator = true_positives + false_positives; + if denominator == 0 { + 1.0 + } else { + true_positives as f64 / denominator as f64 + } +} + +fn recall(true_positives: usize, false_positives: usize, false_negatives: usize) -> f64 { + if true_positives + false_positives + false_negatives == 0 { + return 1.0; + } + + let denominator = true_positives + false_negatives; + if denominator == 0 { + 1.0 + } else { + true_positives as f64 / denominator as f64 + } +} + +fn line_recall(expected_anchor_line_count: usize, false_negatives: usize) -> f64 { + if expected_anchor_line_count == 0 { + 1.0 + } else { + (expected_anchor_line_count - false_negatives) as f64 / expected_anchor_line_count as f64 + } +} + +fn f1(true_positives: usize, false_positives: usize, false_negatives: usize) -> f64 { + let precision = precision(true_positives, false_positives, false_negatives); + let recall = recall(true_positives, false_positives, false_negatives); + + f1_from_precision_and_recall(precision, recall) +} + +fn f1_from_precision_and_recall(precision: f64, recall: f64) -> f64 { + if precision + recall == 0.0 { + 0.0 + } else { + 2.0 * precision * recall / (precision + recall) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + + fn excerpt(path: &str, row_range: Range) -> Excerpt { + Excerpt { + path: path.to_string(), + row_range, + content: String::new(), + } + } + + #[test] + fn deletion_counts_deleted_old_line_as_true_positive() { + let patch = indoc! {" + --- a/src/main.rs + +++ b/src/main.rs + @@ -2,1 +2,0 @@ + -let value = 1; + "}; + + let score = editable_context_coverage(patch, &[excerpt("src/main.rs", 1..2)]); + + assert_eq!(score, EditableContextCoverage::new(1, 0, 0, 1, 0, 0)); + } + + #[test] + fn retrieved_lines_inside_relevance_window_are_true_positives() { + let patch = indoc! {" + --- a/src/main.rs + +++ b/src/main.rs + @@ -4,1 +4,0 @@ + -let value = 4; + "}; + + let score = editable_context_coverage( + patch, + &[excerpt("src/main.rs", 0..1), excerpt("src/main.rs", 3..4)], + ); + + assert_eq!(score, EditableContextCoverage::new(2, 0, 0, 1, 0, 0)); + } + + #[test] + fn replacement_counts_deleted_old_line_without_addition_anchor() { + let patch = indoc! {" + --- a/src/main.rs + +++ b/src/main.rs + @@ -1,3 +1,3 @@ + fn main() { + - let value = 1; + + let value = 2; + } + "}; + + let score = editable_context_coverage(patch, &[excerpt("src/main.rs", 1..2)]); + + assert_eq!(score, EditableContextCoverage::new(1, 0, 0, 1, 0, 0)); + } + + #[test] + fn pure_insertion_counts_previous_and_next_old_lines_as_expected_context() { + let patch = indoc! {" + --- a/src/main.rs + +++ b/src/main.rs + @@ -1,2 +1,3 @@ + line 1 + +inserted + line 2 + "}; + + let score = editable_context_coverage(patch, &[excerpt("src/main.rs", 0..1)]); + + assert_eq!(score, EditableContextCoverage::new(1, 0, 1, 1, 0, 0)); + } + + #[test] + fn pure_insertion_at_file_boundary_uses_available_neighboring_context() { + let patch = indoc! {" + --- a/src/main.rs + +++ b/src/main.rs + @@ -1,1 +1,2 @@ + +inserted + line 1 + "}; + + let score = editable_context_coverage(patch, &[excerpt("src/main.rs", 0..1)]); + + assert_eq!(score, EditableContextCoverage::new(1, 0, 0, 1, 0, 0)); + } + + #[test] + fn counts_false_negatives_and_file_false_positives() { + let patch = indoc! {" + --- a/src/main.rs + +++ b/src/main.rs + @@ -1,3 +1,3 @@ + -let first = 1; + +let first = 2; + let middle = 3; + -let last = 4; + +let last = 5; + "}; + + let score = editable_context_coverage( + patch, + &[excerpt("src/main.rs", 0..1), excerpt("src/lib.rs", 0..1)], + ); + + assert_eq!(score, EditableContextCoverage::new(1, 1, 1, 1, 1, 0)); + } + + #[test] + fn overlapping_excerpts_are_counted_once() { + let patch = indoc! {" + --- a/src/main.rs + +++ b/src/main.rs + @@ -2,1 +2,0 @@ + -let value = 1; + "}; + + let score = editable_context_coverage( + patch, + &[excerpt("src/main.rs", 0..2), excerpt("src/main.rs", 1..3)], + ); + + assert_eq!(score, EditableContextCoverage::new(3, 0, 0, 1, 0, 0)); + } + + #[test] + fn nearby_lines_do_not_satisfy_line_recall_without_exact_anchor_lines() { + let patch = indoc! {" + --- a/src/main.rs + +++ b/src/main.rs + @@ -1,2 +1,3 @@ + line 1 + +inserted + line 2 + "}; + + let score = editable_context_coverage(patch, &[excerpt("src/main.rs", 2..3)]); + + assert_eq!(score.lines_tp, 1); + assert_eq!(score.lines_fp, 0); + assert_eq!(score.lines_fn, 2); + assert_eq!(score.lines_precision, 1.0); + assert_eq!(score.lines_recall, 0.0); + assert_eq!(score.lines_f1, 0.0); + } + + #[test] + fn retrieved_lines_outside_relevance_window_are_false_positives() { + let patch = indoc! {" + --- a/src/main.rs + +++ b/src/main.rs + @@ -1,1 +1,0 @@ + -line 1 + "}; + + let score = editable_context_coverage(patch, &[excerpt("src/main.rs", 21..22)]); + + assert_eq!(score, EditableContextCoverage::new(0, 1, 1, 1, 0, 0)); + } + + #[test] + fn empty_patch_with_no_context_has_perfect_f1() { + let score = editable_context_coverage( + indoc! {" + "}, + &[], + ); + + assert_eq!(score, EditableContextCoverage::new(0, 0, 0, 0, 0, 0)); + } +} diff --git a/crates/edit_prediction_metrics/src/main.rs b/crates/edit_prediction_metrics/src/main.rs index 0e557c35e7ff1f..c7c55ceff60f7b 100644 --- a/crates/edit_prediction_metrics/src/main.rs +++ b/crates/edit_prediction_metrics/src/main.rs @@ -5,10 +5,10 @@ use std::path::Path; use std::process; use edit_prediction_metrics::{ - ClassificationMetrics, DeltaChrFMetrics, KeptRateResult, TokenAnnotation, - annotate_kept_rate_tokens, braces_disbalance, compute_kept_rate, count_patch_token_changes, - delta_chr_f, exact_lines_match, extract_changed_lines_from_diff, - has_isolated_whitespace_changes, is_editable_region_correct, + ClassificationMetrics, DeltaChrFMetrics, EditableContextCoverage, Excerpt, KeptRateResult, + TokenAnnotation, annotate_kept_rate_tokens, braces_disbalance, compute_kept_rate, + count_patch_token_changes, delta_chr_f, editable_context_coverage, exact_lines_match, + extract_changed_lines_from_diff, has_isolated_whitespace_changes, is_editable_region_correct, }; use serde::Deserialize; @@ -41,10 +41,18 @@ fn run() -> Result<(), String> { let actual_patch = fs::read_to_string(&actual_patch_path) .map_err(|err| format!("failed to read {}: {err}", actual_patch_path.display()))?; - let expected = apply_patch_to_excerpt(&base, &expected_patch, 0)?; - let actual = apply_patch_to_excerpt(&base, &actual_patch, 0)?; - - EvaluationReport::new(base, expected_patch, actual_patch, expected, actual) + let expected = apply_patch_to_excerpt(&base, &expected_patch, 0, None)?; + let actual = apply_patch_to_excerpt(&base, &actual_patch, 0, None)?; + let context = []; + + EvaluationReport::new( + base, + expected_patch, + actual_patch, + expected, + actual, + &context, + ) } CliInput::Json { json_path, @@ -55,26 +63,7 @@ fn run() -> Result<(), String> { let example: JsonExample = serde_json::from_str(&json) .map_err(|err| format!("failed to parse {}: {err}", json_path.display()))?; - let base = example.prompt_inputs.cursor_excerpt; - let excerpt_start_row = example.prompt_inputs.excerpt_start_row; - let expected_patch = example - .expected_patches - .into_iter() - .next() - .ok_or_else(|| "JSON input is missing expected_patches[0]".to_string())?; - let actual_patch = example - .predictions - .into_iter() - .nth(prediction_index) - .ok_or_else(|| { - format!("JSON input does not contain predictions[{prediction_index}]") - })? - .actual_patch; - - let expected = apply_patch_to_excerpt(&base, &expected_patch, excerpt_start_row)?; - let actual = apply_patch_to_excerpt(&base, &actual_patch, excerpt_start_row)?; - - EvaluationReport::new(base, expected_patch, actual_patch, expected, actual) + report_from_json_example(example, prediction_index)? } }; @@ -82,6 +71,77 @@ fn run() -> Result<(), String> { Ok(()) } +fn get_context_excerpts(example: &JsonExample) -> Vec { + let mut context = vec![get_cursor_excerpt(example)]; + + if let Some(related) = &example.prompt_inputs.related_files { + context.extend(related.iter().flat_map(|file| { + file.excerpts.iter().map(|excerpt| Excerpt { + path: file.path.clone(), + row_range: excerpt.row_range.clone(), + content: excerpt.text.clone(), + }) + })); + } + + context +} + +fn get_cursor_excerpt(example: &JsonExample) -> Excerpt { + let content = example.prompt_inputs.cursor_excerpt.clone(); + let start_row = example.prompt_inputs.excerpt_start_row; + let rows = content.lines().count() as u32; + let row_range = start_row..start_row + rows; + Excerpt { + path: example.cursor_path.clone(), + row_range, + content, + } +} + +fn report_from_json_example( + example: JsonExample, + prediction_index: usize, +) -> Result { + let context = get_context_excerpts(&example); + let excerpt_start_row = example.prompt_inputs.excerpt_start_row; + let cursor_path = example.cursor_path; + let base = example.prompt_inputs.cursor_excerpt; + let expected_patch = example + .expected_patches + .into_iter() + .next() + .ok_or_else(|| "JSON input is missing expected_patches[0]".to_string())?; + let actual_patch = if example.predictions.is_empty() { + String::new() + } else { + example + .predictions + .into_iter() + .nth(prediction_index) + .ok_or_else(|| format!("JSON input does not contain predictions[{prediction_index}]"))? + .actual_patch + }; + + let expected = apply_patch_to_excerpt( + &base, + &expected_patch, + excerpt_start_row, + Some(&cursor_path), + )?; + let actual = + apply_patch_to_excerpt(&base, &actual_patch, excerpt_start_row, Some(&cursor_path))?; + + Ok(EvaluationReport::new( + base, + expected_patch, + actual_patch, + expected, + actual, + &context, + )) +} + fn print_usage() { eprintln!( "Usage:\n edit_prediction_metrics --base --expected-patch --actual-patch \n edit_prediction_metrics --json [--prediction-index ]" @@ -199,6 +259,7 @@ struct EvaluationReport { editable_region_correct: bool, expected_braces_disbalance: usize, actual_braces_disbalance: usize, + editable_context_coverage: EditableContextCoverage, } impl EvaluationReport { @@ -208,6 +269,7 @@ impl EvaluationReport { actual_patch: String, expected: String, actual: String, + context: &[Excerpt], ) -> Self { let kept_rate = compute_kept_rate(&base, &actual, &expected); let exact_lines = exact_lines_match(&expected_patch, &actual_patch); @@ -223,6 +285,7 @@ impl EvaluationReport { let editable_region_correct = is_editable_region_correct(&actual_patch); let expected_braces_disbalance = braces_disbalance(&expected); let actual_braces_disbalance = braces_disbalance(&actual); + let editable_context_coverage = editable_context_coverage(&expected_patch, context); Self { base, @@ -238,6 +301,7 @@ impl EvaluationReport { editable_region_correct, expected_braces_disbalance, actual_braces_disbalance, + editable_context_coverage, } } } @@ -319,6 +383,27 @@ fn print_report(report: &EvaluationReport) { println!(); print_kept_rate_explanation(&report.base, &report.actual, &report.expected); + + println!("Jumps metrics"); + println!("-------------"); + println!( + "Editable context lines: P={}%, R={}%, F1={}% (tp: {}, fp: {}, fn: {})", + (report.editable_context_coverage.lines_precision * 100.0).round(), + (report.editable_context_coverage.lines_recall * 100.0).round(), + (report.editable_context_coverage.lines_f1 * 100.0).round(), + report.editable_context_coverage.lines_tp, + report.editable_context_coverage.lines_fp, + report.editable_context_coverage.lines_fn + ); + println!( + "Editable context files: P={}%, R={}%, F1={}% (tp: {}, fp: {}, fn: {})", + (report.editable_context_coverage.files_precision * 100.0).round(), + (report.editable_context_coverage.files_recall * 100.0).round(), + (report.editable_context_coverage.files_f1 * 100.0).round(), + report.editable_context_coverage.files_tp, + report.editable_context_coverage.files_fp, + report.editable_context_coverage.files_fn + ); } fn print_kept_rate_explanation(base: &str, actual: &str, expected: &str) { @@ -373,7 +458,9 @@ fn visualize_whitespace(token: &str) -> String { #[derive(Debug, Deserialize)] struct JsonExample { prompt_inputs: PromptInputs, + cursor_path: String, expected_patches: Vec, + #[serde(default)] predictions: Vec, } @@ -381,6 +468,20 @@ struct JsonExample { struct PromptInputs { cursor_excerpt: String, excerpt_start_row: u32, + pub related_files: Option>, +} + +#[derive(Clone, Debug, PartialEq, Hash, Deserialize)] +pub struct RelatedFile { + pub path: String, + pub max_row: u32, + pub excerpts: Vec, +} + +#[derive(Clone, Debug, PartialEq, Hash, Deserialize)] +pub struct RelatedExcerpt { + pub row_range: std::ops::Range, + pub text: String, } #[derive(Debug, Deserialize)] @@ -391,6 +492,7 @@ struct Prediction { #[derive(Debug, Clone)] struct ParsedHunk { old_start: u32, + filename: Option, lines: Vec, } @@ -405,8 +507,20 @@ fn apply_patch_to_excerpt( base: &str, patch: &str, excerpt_start_row: u32, + target_path: Option<&str>, ) -> Result { let hunks = parse_diff_hunks(patch); + let hunks = if let Some(target_path) = target_path { + hunks + .into_iter() + .filter(|hunk| match hunk.filename.as_deref() { + Some(filename) => filename == target_path, + None => true, + }) + .collect::>() + } else { + hunks + }; let result = try_apply_hunks(base, &hunks, excerpt_start_row); @@ -598,6 +712,7 @@ fn filter_hunk_to_excerpt( Some(ParsedHunk { old_start: filtered_old_start.unwrap_or(excerpt_start_row), + filename: hunk.filename.clone(), lines: filtered_lines, }) } @@ -605,8 +720,14 @@ fn filter_hunk_to_excerpt( fn parse_diff_hunks(diff: &str) -> Vec { let mut hunks = Vec::new(); let mut current_hunk: Option = None; + let mut current_filename = None; for line in diff.lines() { + if let Some(filename) = parse_diff_filename(line) { + current_filename = Some(filename); + continue; + } + if let Some((old_start, old_count, _new_start, _new_count)) = parse_hunk_header(line) { if let Some(hunk) = current_hunk.take() { hunks.push(hunk); @@ -614,6 +735,7 @@ fn parse_diff_hunks(diff: &str) -> Vec { let _ = old_count; current_hunk = Some(ParsedHunk { old_start, + filename: current_filename.clone(), lines: Vec::new(), }); continue; @@ -664,6 +786,27 @@ fn parse_hunk_range(part: &str) -> Option<(u32, u32)> { } } +fn parse_diff_filename(line: &str) -> Option { + let path = line + .strip_prefix("--- ") + .or_else(|| line.strip_prefix("+++ "))?; + normalize_diff_path(path) +} + +fn normalize_diff_path(path: &str) -> Option { + let path = path.trim(); + let path = path + .strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path); + + if path == "/dev/null" { + None + } else { + Some(path.to_string()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -673,7 +816,7 @@ mod tests { let base = "fn main() {\n println!(\"hello\");\n}\n"; let patch = "@@ -1,3 +1,3 @@\n fn main() {\n- println!(\"hello\");\n+ println!(\"world\");\n }\n"; - let actual = apply_patch_to_excerpt(base, patch, 0).unwrap(); + let actual = apply_patch_to_excerpt(base, patch, 0, None).unwrap(); assert_eq!(actual, "fn main() {\n println!(\"world\");\n}\n"); } @@ -682,7 +825,7 @@ mod tests { let base = "b\nc\nd\n"; let patch = "@@ -2,2 +2,2 @@\n-b\n-c\n+x\n+y\n"; - let actual = apply_patch_to_excerpt(base, patch, 1).unwrap(); + let actual = apply_patch_to_excerpt(base, patch, 1, None).unwrap(); assert_eq!(actual, "x\ny\nd\n"); } @@ -693,7 +836,7 @@ mod tests { // even though the excerpt starts at file row 100. let patch = "@@ -2,2 +2,2 @@\n-b\n-c\n+x\n+y\n"; - let actual = apply_patch_to_excerpt(base, patch, 100).unwrap(); + let actual = apply_patch_to_excerpt(base, patch, 100, None).unwrap(); assert_eq!(actual, "a\nx\ny\nd\n"); } @@ -704,7 +847,87 @@ mod tests { // hunk targets line 6 (1-based) = row 5 (0-based) = first line. let patch = "@@ -6,2 +6,2 @@\n-a\n-b\n+x\n+y\n"; - let actual = apply_patch_to_excerpt(base, patch, 5).unwrap(); + let actual = apply_patch_to_excerpt(base, patch, 5, None).unwrap(); assert_eq!(actual, "x\ny\nc\n"); } + + #[test] + fn json_patch_application_ignores_unrelated_file_hunks() { + let base = "first\nsecond\nthird\n"; + let patch = "--- a/src/other.rs\n+++ b/src/other.rs\n@@ -2,1 +2,1 @@\n-second\n+changed\n"; + + let actual = apply_patch_to_excerpt(base, patch, 0, Some("src/main.rs")).unwrap(); + assert_eq!(actual, base); + } + + #[test] + fn json_patch_application_applies_matching_file_hunks() { + let base = "first\nsecond\nthird\n"; + let patch = "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -2,1 +2,1 @@\n-second\n+changed\n"; + + let actual = apply_patch_to_excerpt(base, patch, 0, Some("src/main.rs")).unwrap(); + assert_eq!(actual, "first\nchanged\nthird\n"); + } + + #[test] + fn json_patch_application_applies_headerless_hunks() { + let base = "first\nsecond\nthird\n"; + let patch = "@@ -2,1 +2,1 @@\n-second\n+changed\n"; + + let actual = apply_patch_to_excerpt(base, patch, 0, Some("src/main.rs")).unwrap(); + assert_eq!(actual, "first\nchanged\nthird\n"); + } + + fn json_example(predictions: Option<&str>) -> String { + let predictions = predictions + .map(|predictions| { + format!( + r#", + "predictions": {predictions}"# + ) + }) + .unwrap_or_default(); + + format!( + r#"{{ + "prompt_inputs": {{ + "cursor_excerpt": "first\nsecond\nthird\n", + "excerpt_start_row": 0 + }}, + "cursor_path": "src/main.rs", + "expected_patches": [ + "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -2,1 +2,1 @@\n-second\n+changed\n" + ]{predictions} +}}"# + ) + } + + fn report_from_json(predictions: Option<&str>) -> EvaluationReport { + let example = serde_json::from_str(&json_example(predictions)).unwrap(); + report_from_json_example(example, 0).unwrap() + } + + #[test] + fn json_report_with_missing_predictions_uses_expected_patch_for_context_coverage() { + let report = report_from_json(None); + + assert_eq!(report.actual, "first\nsecond\nthird\n"); + assert_eq!(report.actual_changed_lines, 0); + assert_eq!( + report.editable_context_coverage, + EditableContextCoverage::new(3, 0, 0, 1, 0, 0) + ); + } + + #[test] + fn json_report_with_empty_predictions_uses_expected_patch_for_context_coverage() { + let report = report_from_json(Some("[]")); + + assert_eq!(report.actual, "first\nsecond\nthird\n"); + assert_eq!(report.actual_changed_lines, 0); + assert_eq!( + report.editable_context_coverage, + EditableContextCoverage::new(3, 0, 0, 1, 0, 0) + ); + } } diff --git a/crates/edit_prediction_metrics/src/patch.rs b/crates/edit_prediction_metrics/src/patch.rs new file mode 100644 index 00000000000000..611544cec5810b --- /dev/null +++ b/crates/edit_prediction_metrics/src/patch.rs @@ -0,0 +1,127 @@ +#[derive(Debug, Default, Clone)] +pub struct Patch { + pub hunks: Vec, +} + +impl Patch { + pub fn parse_unified_diff(unified_diff: &str) -> Patch { + let mut current_file = String::new(); + let mut is_filename_inherited = false; + let mut hunk = Hunk::default(); + let mut patch = Patch::default(); + let mut in_header = true; + + for line in unified_diff.lines() { + if line.starts_with("--- ") || line.starts_with("+++") || line.starts_with("@@") { + in_header = false; + } + + if in_header { + continue; + } + + if line.starts_with("@@") { + if !hunk.lines.is_empty() { + patch.hunks.push(hunk); + } + hunk = Hunk::from_header(line, ¤t_file, is_filename_inherited); + is_filename_inherited = true; + } else if let Some(path) = line.strip_prefix("--- ") { + is_filename_inherited = false; + let path = path.trim().strip_prefix("a/").unwrap_or(path); + if path != "/dev/null" { + current_file = path.into(); + } + } else if let Some(path) = line.strip_prefix("+++ ") { + is_filename_inherited = false; + let path = path.trim().strip_prefix("b/").unwrap_or(path); + if path != "/dev/null" { + current_file = path.into(); + } + } else if let Some(line) = line.strip_prefix('+') { + hunk.lines.push(PatchLine::Addition(line.to_string())); + } else if let Some(line) = line.strip_prefix('-') { + hunk.lines.push(PatchLine::Deletion(line.to_string())); + } else if let Some(line) = line.strip_prefix(' ') { + hunk.lines.push(PatchLine::Context(line.to_string())); + } else { + hunk.lines.push(PatchLine::Garbage(line.to_string())); + } + } + + if !hunk.lines.is_empty() { + patch.hunks.push(hunk); + } + + patch + } +} + +#[derive(Debug, Default, Clone)] +pub struct Hunk { + pub old_start: isize, + pub new_start: isize, + pub lines: Vec, + pub filename: String, +} + +impl Hunk { + pub fn from_header(header: &str, filename: &str, _is_filename_inherited: bool) -> Self { + let (old_start, _, new_start, _, _) = Self::parse_hunk_header(header); + Self { + old_start, + new_start, + lines: Vec::new(), + filename: filename.to_string(), + } + } + + fn parse_hunk_header(line: &str) -> (isize, isize, isize, isize, String) { + let header_part = line.trim_start_matches("@@").trim(); + let parts: Vec<&str> = header_part.split_whitespace().collect(); + + if parts.len() < 2 { + return (0, 0, 0, 0, String::new()); + } + + let old_part = parts[0].trim_start_matches('-'); + let new_part = parts[1].trim_start_matches('+'); + + let (old_start, old_count) = Hunk::parse_hunk_header_range(old_part); + let (new_start, new_count) = Hunk::parse_hunk_header_range(new_part); + + let comment = if parts.len() > 2 { + parts[2..] + .join(" ") + .trim_start_matches("@@") + .trim() + .to_string() + } else { + String::new() + }; + + ( + old_start as isize, + old_count as isize, + new_start as isize, + new_count as isize, + comment, + ) + } + + fn parse_hunk_header_range(part: &str) -> (usize, usize) { + if let Some((start, count)) = part.split_once(',') { + (start.parse().unwrap_or(0), count.parse().unwrap_or(0)) + } else { + (part.parse().unwrap_or(0), 1) + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PatchLine { + Context(String), + Addition(String), + Deletion(String), + Garbage(String), +} diff --git a/crates/edit_prediction_metrics/src/patch_metrics.rs b/crates/edit_prediction_metrics/src/patch_metrics.rs index 85470da91c59d5..c913f9a7e4bb6c 100644 --- a/crates/edit_prediction_metrics/src/patch_metrics.rs +++ b/crates/edit_prediction_metrics/src/patch_metrics.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; -use crate::tokenize::tokenize; +use crate::{ + patch::{Patch, PatchLine}, + tokenize::tokenize, +}; use serde::Serialize; use similar::{DiffTag, TextDiff}; @@ -716,129 +719,6 @@ pub fn reconstruct_texts_from_diff(patch_str: &str) -> (String, String) { (old_lines.join("\n"), new_lines.join("\n")) } -#[derive(Debug, Default, Clone)] -struct Patch { - hunks: Vec, -} - -impl Patch { - fn parse_unified_diff(unified_diff: &str) -> Patch { - let mut current_file = String::new(); - let mut is_filename_inherited = false; - let mut hunk = Hunk::default(); - let mut patch = Patch::default(); - let mut in_header = true; - - for line in unified_diff.lines() { - if line.starts_with("--- ") || line.starts_with("+++") || line.starts_with("@@") { - in_header = false; - } - - if in_header { - continue; - } - - if line.starts_with("@@") { - if !hunk.lines.is_empty() { - patch.hunks.push(hunk); - } - hunk = Hunk::from_header(line, ¤t_file, is_filename_inherited); - is_filename_inherited = true; - } else if let Some(path) = line.strip_prefix("--- ") { - is_filename_inherited = false; - let path = path.trim().strip_prefix("a/").unwrap_or(path); - if path != "/dev/null" { - current_file = path.into(); - } - } else if let Some(path) = line.strip_prefix("+++ ") { - is_filename_inherited = false; - let path = path.trim().strip_prefix("b/").unwrap_or(path); - if path != "/dev/null" { - current_file = path.into(); - } - } else if let Some(line) = line.strip_prefix('+') { - hunk.lines.push(PatchLine::Addition(line.to_string())); - } else if let Some(line) = line.strip_prefix('-') { - hunk.lines.push(PatchLine::Deletion(line.to_string())); - } else if let Some(line) = line.strip_prefix(' ') { - hunk.lines.push(PatchLine::Context(line.to_string())); - } else { - hunk.lines.push(PatchLine::Garbage(line.to_string())); - } - } - - if !hunk.lines.is_empty() { - patch.hunks.push(hunk); - } - - patch - } -} - -#[derive(Debug, Default, Clone)] -struct Hunk { - new_start: isize, - lines: Vec, -} - -impl Hunk { - fn from_header(header: &str, _filename: &str, _is_filename_inherited: bool) -> Self { - let (_, _, new_start, _, _) = Self::parse_hunk_header(header); - Self { - new_start, - lines: Vec::new(), - } - } - - fn parse_hunk_header(line: &str) -> (isize, isize, isize, isize, String) { - let header_part = line.trim_start_matches("@@").trim(); - let parts: Vec<&str> = header_part.split_whitespace().collect(); - - if parts.len() < 2 { - return (0, 0, 0, 0, String::new()); - } - - let old_part = parts[0].trim_start_matches('-'); - let new_part = parts[1].trim_start_matches('+'); - - let (old_start, old_count) = Hunk::parse_hunk_header_range(old_part); - let (new_start, new_count) = Hunk::parse_hunk_header_range(new_part); - - let comment = if parts.len() > 2 { - parts[2..] - .join(" ") - .trim_start_matches("@@") - .trim() - .to_string() - } else { - String::new() - }; - - ( - old_start as isize, - old_count as isize, - new_start as isize, - new_count as isize, - comment, - ) - } - - fn parse_hunk_header_range(part: &str) -> (usize, usize) { - if let Some((start, count)) = part.split_once(',') { - (start.parse().unwrap_or(0), count.parse().unwrap_or(0)) - } else { - (part.parse().unwrap_or(0), 1) - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum PatchLine { - Context(String), - Addition(String), - Deletion(String), - Garbage(String), -} #[cfg(test)] mod test_optimization { diff --git a/crates/edit_prediction_metrics/src/prediction_score.rs b/crates/edit_prediction_metrics/src/prediction_score.rs index 942ce3c9d1a477..684a93ba9de631 100644 --- a/crates/edit_prediction_metrics/src/prediction_score.rs +++ b/crates/edit_prediction_metrics/src/prediction_score.rs @@ -5,12 +5,15 @@ use std::path::Path; use std::sync::Arc; use zeta_prompt::udiff::{apply_diff_to_string, apply_diff_to_string_with_hunk_offset}; -use crate::patch_metrics::{ - ClassificationMetrics, DeltaChrFMetrics, braces_disbalance, count_patch_token_changes, - delta_chr_f, delta_chr_f_beta, exact_lines_match, has_isolated_whitespace_changes, - is_editable_region_correct, -}; use crate::reversal::compute_prediction_reversal_ratio_from_history; +use crate::{ + jumps::{EditableContextCoverage, Excerpt, editable_context_coverage}, + patch_metrics::{ + ClassificationMetrics, DeltaChrFMetrics, braces_disbalance, count_patch_token_changes, + delta_chr_f, delta_chr_f_beta, exact_lines_match, has_isolated_whitespace_changes, + is_editable_region_correct, + }, +}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct PredictionScore { @@ -61,6 +64,8 @@ pub struct PredictionScore { pub cumulative_logprob: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub avg_logprob: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub editable_context_coverage: Option, } impl PredictionScore { @@ -91,6 +96,7 @@ impl PredictionScore { discarded_chars: None, cumulative_logprob: None, avg_logprob: None, + editable_context_coverage: None, } } @@ -193,11 +199,26 @@ pub struct PredictionScoringInput<'a> { pub reversal_context: Option>, pub cumulative_logprob: Option, pub avg_logprob: Option, + pub context: Option<&'a [Excerpt]>, } pub fn score_prediction(input: PredictionScoringInput<'_>) -> PredictionScore { + let editable_context_coverage = input.context.and_then(|context| { + input + .expected_patches + .iter() + .map(|expected| editable_context_coverage(&expected.patch, context)) + .max_by(|left, right| { + left.lines_f1 + .total_cmp(&right.lines_f1) + .then_with(|| left.files_f1.total_cmp(&right.files_f1)) + }) + }); + let Some(actual_patch) = input.actual_patch else { - return PredictionScore::zero(); + let mut score = PredictionScore::zero(); + score.editable_context_coverage = editable_context_coverage; + return score; }; let token_changes = count_patch_token_changes(actual_patch); @@ -208,6 +229,7 @@ pub fn score_prediction(input: PredictionScoringInput<'_>) -> PredictionScore { let mut score = PredictionScore::zero(); score.inserted_tokens = token_changes.inserted_tokens; score.deleted_tokens = token_changes.deleted_tokens; + score.editable_context_coverage = editable_context_coverage; return score; } }; @@ -302,6 +324,7 @@ pub fn score_prediction(input: PredictionScoringInput<'_>) -> PredictionScore { discarded_chars, cumulative_logprob: input.cumulative_logprob, avg_logprob: input.avg_logprob, + editable_context_coverage, } } @@ -343,6 +366,7 @@ mod tests { reversal_context: None, cumulative_logprob: None, avg_logprob: None, + context: None, }); assert_eq!(score.delta_chr_f, 0.0); diff --git a/crates/edit_prediction_metrics/src/summary.rs b/crates/edit_prediction_metrics/src/summary.rs index 249ae185755db5..f927a027d7662f 100644 --- a/crates/edit_prediction_metrics/src/summary.rs +++ b/crates/edit_prediction_metrics/src/summary.rs @@ -13,6 +13,7 @@ pub struct QaSummaryData { pub struct PredictionSummaryInput<'a> { pub score: &'a PredictionScore, pub qa: Option, + pub retrieved_context_bytes: Option, } #[derive(Clone, Debug, Serialize)] @@ -56,6 +57,36 @@ pub struct SummaryJson { pub total_correctly_deleted_chars: Option, #[serde(skip_serializing_if = "Option::is_none")] pub total_discarded_chars: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_editable_context_lines_precision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_editable_context_lines_recall: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_editable_context_lines_f1: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub editable_context_lines_tp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub editable_context_lines_fp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub editable_context_lines_fn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_editable_context_files_precision: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_editable_context_files_recall: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_editable_context_files_f1: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub editable_context_files_tp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub editable_context_files_fp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub editable_context_files_fn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_retrieved_context_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_retrieved_context_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retrieved_context_examples: Option, } pub fn compute_summary<'a>( @@ -91,6 +122,21 @@ pub fn compute_summary<'a>( let mut discarded_chars_count: usize = 0; let mut recall_rate_sum: f64 = 0.0; let mut recall_rate_count: usize = 0; + let mut editable_context_lines_precision_sum: f64 = 0.0; + let mut editable_context_lines_recall_sum: f64 = 0.0; + let mut editable_context_lines_f1_sum: f64 = 0.0; + let mut editable_context_files_precision_sum: f64 = 0.0; + let mut editable_context_files_recall_sum: f64 = 0.0; + let mut editable_context_files_f1_sum: f64 = 0.0; + let mut editable_context_coverage_count: usize = 0; + let mut editable_context_lines_tp: usize = 0; + let mut editable_context_lines_fp: usize = 0; + let mut editable_context_lines_fn: usize = 0; + let mut editable_context_files_tp: usize = 0; + let mut editable_context_files_fp: usize = 0; + let mut editable_context_files_fn: usize = 0; + let mut retrieved_context_bytes_total: usize = 0; + let mut retrieved_context_bytes_count: usize = 0; for prediction in predictions { let score = prediction.score; @@ -149,6 +195,26 @@ pub fn compute_summary<'a>( recall_rate_sum += recall_rate; recall_rate_count += 1; } + if let Some(retrieved_context_bytes) = prediction.retrieved_context_bytes { + retrieved_context_bytes_total += retrieved_context_bytes; + retrieved_context_bytes_count += 1; + } + + if let Some(coverage) = &score.editable_context_coverage { + editable_context_lines_precision_sum += coverage.lines_precision; + editable_context_lines_recall_sum += coverage.lines_recall; + editable_context_lines_f1_sum += coverage.lines_f1; + editable_context_files_precision_sum += coverage.files_precision; + editable_context_files_recall_sum += coverage.files_recall; + editable_context_files_f1_sum += coverage.files_f1; + editable_context_coverage_count += 1; + editable_context_lines_tp += coverage.lines_tp; + editable_context_lines_fp += coverage.lines_fp; + editable_context_lines_fn += coverage.lines_fn; + editable_context_files_tp += coverage.files_tp; + editable_context_files_fp += coverage.files_fp; + editable_context_files_fn += coverage.files_fn; + } if let Some(exact_match) = score.cursor_exact_match { cursor_total += 1; @@ -252,6 +318,82 @@ pub fn compute_summary<'a>( None }; + let avg_editable_context_lines_precision = if editable_context_coverage_count > 0 { + Some(editable_context_lines_precision_sum / editable_context_coverage_count as f64) + } else { + None + }; + let avg_editable_context_lines_recall = if editable_context_coverage_count > 0 { + Some(editable_context_lines_recall_sum / editable_context_coverage_count as f64) + } else { + None + }; + let avg_editable_context_lines_f1 = if editable_context_coverage_count > 0 { + Some(editable_context_lines_f1_sum / editable_context_coverage_count as f64) + } else { + None + }; + let editable_context_lines_tp = if editable_context_coverage_count > 0 { + Some(editable_context_lines_tp) + } else { + None + }; + let editable_context_lines_fp = if editable_context_coverage_count > 0 { + Some(editable_context_lines_fp) + } else { + None + }; + let editable_context_lines_fn = if editable_context_coverage_count > 0 { + Some(editable_context_lines_fn) + } else { + None + }; + let avg_editable_context_files_precision = if editable_context_coverage_count > 0 { + Some(editable_context_files_precision_sum / editable_context_coverage_count as f64) + } else { + None + }; + let avg_editable_context_files_recall = if editable_context_coverage_count > 0 { + Some(editable_context_files_recall_sum / editable_context_coverage_count as f64) + } else { + None + }; + let avg_editable_context_files_f1 = if editable_context_coverage_count > 0 { + Some(editable_context_files_f1_sum / editable_context_coverage_count as f64) + } else { + None + }; + let editable_context_files_tp = if editable_context_coverage_count > 0 { + Some(editable_context_files_tp) + } else { + None + }; + let editable_context_files_fp = if editable_context_coverage_count > 0 { + Some(editable_context_files_fp) + } else { + None + }; + let editable_context_files_fn = if editable_context_coverage_count > 0 { + Some(editable_context_files_fn) + } else { + None + }; + let avg_retrieved_context_bytes = if retrieved_context_bytes_count > 0 { + Some(retrieved_context_bytes_total as f64 / retrieved_context_bytes_count as f64) + } else { + None + }; + let total_retrieved_context_bytes = if retrieved_context_bytes_count > 0 { + Some(retrieved_context_bytes_total) + } else { + None + }; + let retrieved_context_examples = if retrieved_context_bytes_count > 0 { + Some(retrieved_context_bytes_count) + } else { + None + }; + SummaryJson { total_examples: total_scores, avg_delta_chr_f, @@ -289,5 +431,20 @@ pub fn compute_summary<'a>( total_kept_chars, total_correctly_deleted_chars, total_discarded_chars, + avg_editable_context_lines_precision, + avg_editable_context_lines_recall, + avg_editable_context_lines_f1, + editable_context_lines_tp, + editable_context_lines_fp, + editable_context_lines_fn, + avg_editable_context_files_precision, + avg_editable_context_files_recall, + avg_editable_context_files_f1, + editable_context_files_tp, + editable_context_files_fp, + editable_context_files_fn, + avg_retrieved_context_bytes, + total_retrieved_context_bytes, + retrieved_context_examples, } } diff --git a/crates/edit_prediction_types/src/edit_prediction_types.rs b/crates/edit_prediction_types/src/edit_prediction_types.rs index 31caf628544ade..a285e8aa70a72e 100644 --- a/crates/edit_prediction_types/src/edit_prediction_types.rs +++ b/crates/edit_prediction_types/src/edit_prediction_types.rs @@ -8,6 +8,19 @@ pub enum EditPredictionDiscardReason { Rejected, Ignored, } + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditPredictionRequestTrigger { + DiagnosticNavigation, + Explicit, + BufferEdit, + LSPCompletionAccepted, + PredictionAccepted, + PredictionPartiallyAccepted, + #[default] + Other, +} + use icons::IconName; use language::{Anchor, Buffer, OffsetRangeExt}; @@ -185,6 +198,7 @@ pub trait EditPredictionDelegate: 'static + Sized { buffer: Entity, cursor_position: language::Anchor, debounce: bool, + trigger: EditPredictionRequestTrigger, cx: &mut Context, ); fn accept(&mut self, cx: &mut Context); @@ -221,6 +235,7 @@ pub trait EditPredictionDelegateHandle { buffer: Entity, cursor_position: language::Anchor, debounce: bool, + trigger: EditPredictionRequestTrigger, cx: &mut App, ); fn did_show(&self, display_type: SuggestionDisplayType, cx: &mut App); @@ -296,10 +311,11 @@ where buffer: Entity, cursor_position: language::Anchor, debounce: bool, + trigger: EditPredictionRequestTrigger, cx: &mut App, ) { self.update(cx, |this, cx| { - this.refresh(buffer, cursor_position, debounce, cx) + this.refresh(buffer, cursor_position, debounce, trigger, cx) }) } diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 1d1a423cc828ad..62d6209f1f6e73 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -630,6 +630,7 @@ impl EditPredictionButton { window.dispatch_action( OpenSettingsAt { path: "edit_predictions.providers".to_string(), + target: None, } .boxed_clone(), cx, @@ -1117,7 +1118,7 @@ impl EditPredictionButton { .link_with_handler( "Learn More", OpenBrowser { - url: zed_urls::edit_prediction_docs(cx), + url: zed_urls::edit_prediction_docs(cx).into(), } .boxed_clone(), |_window, _cx| { @@ -1630,12 +1631,10 @@ fn emit_edit_prediction_menu_opened( ); } -fn copilot_settings_url(enterprise_uri: Option<&str>) -> String { +fn copilot_settings_url(enterprise_uri: Option<&str>) -> Arc { match enterprise_uri { - Some(uri) => { - format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH) - } - None => COPILOT_SETTINGS_URL.to_string(), + Some(uri) => format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH).into(), + None => COPILOT_SETTINGS_URL.into(), } } @@ -1671,7 +1670,7 @@ mod tests { ) }); - assert_eq!(url, "https://my-company.ghe.com/settings/copilot"); + assert_eq!(url.as_ref(), "https://my-company.ghe.com/settings/copilot"); } #[gpui::test] @@ -1701,7 +1700,7 @@ mod tests { ) }); - assert_eq!(url, "https://my-company.ghe.com/settings/copilot"); + assert_eq!(url.as_ref(), "https://my-company.ghe.com/settings/copilot"); } #[gpui::test] @@ -1722,6 +1721,6 @@ mod tests { ) }); - assert_eq!(url, "https://github.com/settings/copilot"); + assert_eq!(url.as_ref(), "https://github.com/settings/copilot"); } } diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 8299f86054f46a..deae41c21ef366 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -1,13 +1,14 @@ use buffer_diff::BufferDiff; +use cloud_llm_client::PredictEditsRequestTrigger; use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore}; use editor::{Editor, Inlay, MultiBuffer}; use feature_flags::{FeatureFlag, PresenceFlag, register_feature_flag}; use gpui::{ App, BorderStyle, DismissEvent, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable, - Length, StyleRefinement, TextStyleRefinement, Window, actions, prelude::*, + Length, StyleRefinement, Task, TextStyleRefinement, Window, actions, prelude::*, }; use language::{ - Anchor, Bias, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, Point, ToOffset, ToPoint, + Bias, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry, Point, ToOffset, ToPoint, language_settings::{self, InlayHintKind}, }; use markdown::{Markdown, MarkdownStyle}; @@ -67,11 +68,11 @@ struct ActivePrediction { prediction: EditPrediction, feedback_editor: Entity, expected_buffer: Entity, - expected_editable_range: Option>, expected_editor: Entity, - expected_diff_editor: Entity, - expected_patch_preview: bool, + _expected_buffer_subscription: gpui::Subscription, formatted_inputs: Entity, + _predicted_diff_task: Task<()>, + expected_diff_task: Task<()>, } #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] @@ -304,54 +305,19 @@ impl RatePredictionsModal { self.select_completion(completion, true, window, cx); } - fn update_diff_editor( - diff_editor: &Entity, - new_buffer: Entity, + fn update_buffer_diff( + diff: &Entity, + new_buffer_snapshot: BufferSnapshot, old_buffer_snapshot: BufferSnapshot, - visible_range: Range, - cx: &mut Context, - ) { - diff_editor.update(cx, |editor, cx| { - let new_buffer_snapshot = new_buffer.read(cx).snapshot(); - let new_buffer_id = new_buffer_snapshot.remote_id(); - let language = new_buffer_snapshot.language().cloned(); - let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx)); - diff.update(cx, |diff, cx| { - let update = diff.update_diff( - new_buffer_snapshot.text.clone(), - Some(old_buffer_snapshot.text().into()), - Some(true), - language, - cx, - ); - cx.spawn(async move |diff, cx| { - let update = update.await; - if let Some(task) = diff - .update(cx, |diff, cx| { - diff.set_snapshot(update, &new_buffer_snapshot.text, cx) - }) - .ok() - { - task.await; - } - }) - .detach(); - }); - - editor.disable_header_for_buffer(new_buffer_id, cx); - editor.buffer().update(cx, |multibuffer, cx| { - multibuffer.clear(cx); - multibuffer.set_excerpts_for_buffer(new_buffer, [visible_range], 0, cx); - multibuffer.add_diff(diff, cx); - }); - }); - } - - fn editable_range_for_prediction(prediction: &EditPrediction) -> Option> { - prediction - .editable_range - .clone() - .or_else(|| Some(prediction.edits.first()?.0.start..prediction.edits.last()?.0.end)) + cx: &mut App, + ) -> Task<()> { + diff.update(cx, |diff, cx| { + diff.set_base_text( + Some(old_buffer_snapshot.text().into()), + new_buffer_snapshot.text, + cx, + ) + }) } fn insert_editable_region_markers( @@ -471,23 +437,55 @@ impl RatePredictionsModal { return; } - let editable_range = Self::editable_range_for_prediction(&prediction); + let editable_range = prediction.editable_range.clone().or_else(|| { + Some(prediction.edits.first()?.0.start..prediction.edits.last()?.0.end) + }); let predicted_buffer = prediction.edit_preview.build_result_buffer(cx); let predicted_buffer_snapshot = predicted_buffer.read(cx).snapshot(); let visible_range = prediction .edit_preview .compute_visible_range(&prediction.edits) + .or_else(|| { + editable_range.as_ref().map(|range| { + range.start.to_point(&prediction.snapshot) + ..range.end.to_point(&prediction.snapshot) + }) + }) .unwrap_or(Point::zero()..Point::zero()); - let start = Point::new(visible_range.start.row.saturating_sub(5), 0); - let end = - Point::new(visible_range.end.row + 5, 0).min(predicted_buffer_snapshot.max_point()); - Self::update_diff_editor( - &self.diff_editor, - predicted_buffer.clone(), - prediction.snapshot.clone(), - start..end, - cx, - ); + let visible_range_with_context = + Point::new(visible_range.start.row.saturating_sub(5), 0) + ..Point::new(visible_range.end.row.saturating_add(5), 0) + .min(predicted_buffer_snapshot.max_point()); + let predicted_diff_task = self.diff_editor.update(cx, |editor, cx| { + let predicted_buffer_id = predicted_buffer_snapshot.remote_id(); + let diff = cx.new(|cx| { + BufferDiff::new( + &predicted_buffer_snapshot.text, + predicted_buffer_snapshot.language().cloned(), + predicted_buffer.read(cx).language_registry(), + cx, + ) + }); + let predicted_diff_task = Self::update_buffer_diff( + &diff, + predicted_buffer_snapshot.clone(), + prediction.snapshot.clone(), + cx, + ); + + editor.disable_header_for_buffer(predicted_buffer_id, cx); + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.clear(cx); + multibuffer.set_excerpts_for_buffer( + predicted_buffer.clone(), + [visible_range_with_context], + 0, + cx, + ); + multibuffer.add_diff(diff, cx); + }); + predicted_diff_task + }); if let Some(editable_range) = editable_range.as_ref() { Self::insert_editable_region_markers( @@ -621,7 +619,21 @@ impl RatePredictionsModal { range.start.to_point(&expected_buffer_snapshot) ..range.end.to_point(&expected_buffer_snapshot) }) - .unwrap_or_else(|| visible_range.clone()); + .unwrap_or(visible_range); + let expected_diff = cx.new(|cx| { + BufferDiff::new( + &expected_buffer_snapshot.text, + expected_buffer_snapshot.language().cloned(), + expected_buffer.read(cx).language_registry(), + cx, + ) + }); + let expected_diff_task = Self::update_buffer_diff( + &expected_diff, + expected_buffer_snapshot.clone(), + prediction.snapshot.clone(), + cx, + ); let expected_editor = cx.new(|cx| { let multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite); @@ -631,12 +643,14 @@ impl RatePredictionsModal { 0, cx, ); + multibuffer.add_diff(expected_diff.clone(), cx); multibuffer }); let mut editor = Editor::for_multibuffer(multibuffer, None, window, cx); let expected_buffer_id = expected_buffer.read(cx).remote_id(); editor.disable_header_for_buffer(expected_buffer_id, cx); editor.disable_inline_diagnostics(); + editor.set_expand_all_diff_hunks(cx); editor.set_show_git_diff_gutter(false, cx); editor.set_show_code_actions(false, cx); editor.set_show_runnables(false, cx); @@ -646,14 +660,6 @@ impl RatePredictionsModal { editor.set_show_edit_predictions(Some(false), window, cx); editor }); - let expected_diff_editor = cx.new(|cx| { - let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadOnly)); - let mut editor = Editor::for_multibuffer(multibuffer, None, window, cx); - editor.disable_inline_diagnostics(); - editor.set_expand_all_diff_hunks(cx); - editor.set_show_git_diff_gutter(false, cx); - editor - }); if let Some(expected_editable_range) = expected_editable_range.as_ref() { let expected_buffer_snapshot = expected_buffer.read(cx).snapshot(); Self::insert_editable_region_markers( @@ -669,6 +675,27 @@ impl RatePredictionsModal { ); } + let expected_buffer_subscription = cx.subscribe(&expected_buffer, { + let expected_diff = expected_diff.clone(); + let original_snapshot = prediction.snapshot.clone(); + move |this, buffer, event, cx| match event { + language::BufferEvent::Edited { .. } + | language::BufferEvent::LanguageChanged(_) + | language::BufferEvent::Reparsed => { + let task = Self::update_buffer_diff( + &expected_diff, + buffer.read(cx).snapshot(), + original_snapshot.clone(), + cx, + ); + if let Some(active_prediction) = this.active_prediction.as_mut() { + active_prediction.expected_diff_task = task; + } + } + _ => {} + } + }); + self.active_prediction = Some(ActivePrediction { prediction, feedback_editor: cx.new(|cx| { @@ -692,10 +719,10 @@ impl RatePredictionsModal { editor }), expected_buffer, - expected_editable_range, expected_editor, - expected_diff_editor, - expected_patch_preview: false, + _expected_buffer_subscription: expected_buffer_subscription, + _predicted_diff_task: predicted_diff_task, + expected_diff_task, formatted_inputs: cx.new(|cx| { Markdown::new( formatted_inputs.into(), @@ -746,57 +773,10 @@ impl RatePredictionsModal { ) } - fn toggle_expected_patch_preview(&mut self, cx: &mut Context) { - if let Some(active_prediction) = &mut self.active_prediction { - if active_prediction.expected_patch_preview { - active_prediction.expected_patch_preview = false; - } else { - let expected_buffer_snapshot = - active_prediction.expected_buffer.read(cx).snapshot(); - let visible_range = active_prediction - .prediction - .edit_preview - .compute_visible_range(&active_prediction.prediction.edits) - .unwrap_or(Point::zero()..Point::zero()); - let start = Point::new(visible_range.start.row.saturating_sub(5), 0); - let end = Point::new(visible_range.end.row + 5, 0) - .min(expected_buffer_snapshot.max_point()); - - Self::update_diff_editor( - &active_prediction.expected_diff_editor, - active_prediction.expected_buffer.clone(), - active_prediction.prediction.snapshot.clone(), - start..end, - cx, - ); - if let Some(expected_editable_range) = - active_prediction.expected_editable_range.as_ref() - { - let expected_buffer_snapshot = - active_prediction.expected_buffer.read(cx).snapshot(); - Self::insert_editable_region_markers( - &active_prediction.expected_diff_editor, - &active_prediction.expected_buffer, - expected_editable_range - .start - .to_offset(&expected_buffer_snapshot) - ..expected_editable_range - .end - .to_offset(&expected_buffer_snapshot), - cx, - ); - } - active_prediction.expected_patch_preview = true; - } - cx.notify(); - } - } - fn render_suggested_edits(&self, cx: &mut Context) -> Option> { let bg_color = cx.theme().colors().editor_background; let border_color = cx.theme().colors().border; let active_prediction = self.active_prediction.as_ref()?; - let expected_patch_preview = active_prediction.expected_patch_preview; Some( v_flex() @@ -840,22 +820,6 @@ impl RatePredictionsModal { .gap_2() .border_b_1() .border_color(border_color) - .child( - Button::new( - "expected-patch-preview", - if expected_patch_preview { - "Edit" - } else { - "Preview" - }, - ) - .label_size(LabelSize::Small) - .on_click(cx.listener( - |this, _, _window, cx| { - this.toggle_expected_patch_preview(cx); - }, - )), - ) .child(Label::new("Expected Patch").size(LabelSize::Small)), ) .child( @@ -866,14 +830,7 @@ impl RatePredictionsModal { .min_h_0() .overflow_scroll() .whitespace_nowrap() - .child(if expected_patch_preview { - active_prediction - .expected_diff_editor - .clone() - .into_any_element() - } else { - active_prediction.expected_editor.clone().into_any_element() - }), + .child(active_prediction.expected_editor.clone()), ), ), ) @@ -1186,6 +1143,28 @@ impl RatePredictionsModal { (false, true) => (IconName::File, Color::Muted, "No Edits Produced"), (false, false) => (IconName::FileDiff, Color::Accent, "Edits Available"), }; + let (trigger_icon, trigger_tooltip) = match completion.trigger { + PredictEditsRequestTrigger::Testing => (IconName::Debug, "Testing"), + PredictEditsRequestTrigger::Diagnostics => { + (IconName::ToolDiagnostics, "Diagnostics") + } + PredictEditsRequestTrigger::DiagnosticNavigation => { + (IconName::ArrowRight, "Diagnostic Navigation") + } + PredictEditsRequestTrigger::Cli => (IconName::Terminal, "CLI"), + PredictEditsRequestTrigger::Explicit => (IconName::Person, "Explicit"), + PredictEditsRequestTrigger::BufferEdit => (IconName::Pencil, "Buffer Edit"), + PredictEditsRequestTrigger::LSPCompletionAccepted => { + (IconName::Code, "LSP Completion Accepted") + } + PredictEditsRequestTrigger::PredictionAccepted => { + (IconName::ZedPredict, "Prediction Accepted") + } + PredictEditsRequestTrigger::PredictionPartiallyAccepted => { + (IconName::CheckDouble, "Prediction Partially Accepted") + } + PredictEditsRequestTrigger::Other => (IconName::CircleHelp, "Other"), + }; let file = completion.buffer.read(cx).file(); let file_name = file @@ -1205,6 +1184,11 @@ impl RatePredictionsModal { .id("completion-content") .gap_3() .child(Icon::new(icon_name).color(icon_color).size(IconSize::Small)) + .child( + Icon::new(trigger_icon) + .color(Color::Muted) + .size(IconSize::XSmall), + ) .child( v_flex().child( h_flex() @@ -1220,7 +1204,9 @@ impl RatePredictionsModal { ), ), ) - .tooltip(Tooltip::text(tooltip_text)) + .tooltip(Tooltip::text(format!( + "{tooltip_text} • Trigger: {trigger_tooltip}" + ))) .on_click(cx.listener(move |this, _, window, cx| { this.select_completion(Some(completion.clone()), true, window, cx); })) @@ -1396,6 +1382,7 @@ impl editor::CompletionProvider for FeedbackCompletionProvider { documentation: None, source: CompletionSource::Custom, icon_path: None, + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 813a8a9bc510f3..1ca500832e2807 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -106,7 +106,6 @@ zed_actions.workspace = true zlog.workspace = true [dev-dependencies] -criterion.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } @@ -138,12 +137,3 @@ util = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true - - -[[bench]] -name = "editor_render" -harness = false - -[[bench]] -name = "display_map" -harness = false diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 2ed935eb343e9f..906f4b00f26398 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -397,6 +397,15 @@ actions!( ] ); +actions!( + markdown, + [ + /// Toggles a block quote (`> `) prefix on the selected lines (or the + /// current line) while in Markdown files. + ToggleBlockQuote, + ] +); + actions!( editor, [ @@ -764,6 +773,8 @@ actions!( SelectDown, /// Selects the enclosing symbol. SelectEnclosingSymbol, + /// Selects inside the innermost enclosing bracket pair. + SelectInsideEnclosingBracket, /// Selects to the start of the next larger syntax node. SelectToStartOfLargerSyntaxNode, /// Selects to the end of the next larger syntax node. diff --git a/crates/editor/src/clipboard.rs b/crates/editor/src/clipboard.rs index d1380a732ce553..2d3afdac12b82a 100644 --- a/crates/editor/src/clipboard.rs +++ b/crates/editor/src/clipboard.rs @@ -139,7 +139,7 @@ impl Editor { &snapshot, range, to_insert, - url::Url::parse(to_insert).ok(), + is_standalone_url(to_insert), ) } else { (range, Cow::Borrowed(to_insert)) @@ -169,7 +169,7 @@ impl Editor { .all::(&this.display_snapshot(cx)); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); } else { - let url = url::Url::parse(&clipboard_text).ok(); + let clipboard_is_url = is_standalone_url(&clipboard_text); let auto_indent_mode = if !clipboard_text.is_empty() { Some(AutoindentMode::Block { @@ -213,7 +213,12 @@ impl Editor { let (edit_range, edit_text) = if let Some(language) = language && language.name() == "Markdown" { - edit_for_markdown_paste(&snapshot, range, text_for_cursor, url.clone()) + edit_for_markdown_paste( + &snapshot, + range, + text_for_cursor, + clipboard_is_url, + ) } else { (range, Cow::Borrowed(text_for_cursor)) }; @@ -538,18 +543,30 @@ fn edit_for_markdown_paste<'a>( buffer: &MultiBufferSnapshot, range: Range, to_insert: &'a str, - url: Option, + to_insert_is_url: bool, ) -> (Range, Cow<'a, str>) { - if url.is_none() { + if !to_insert_is_url { return (range, Cow::Borrowed(to_insert)); }; let old_text = buffer.text_for_range(range.clone()).collect::(); - let new_text = if range.is_empty() || url::Url::parse(&old_text).is_ok() { + let new_text = if range.is_empty() || is_standalone_url(&old_text) { Cow::Borrowed(to_insert) } else { Cow::Owned(format!("[{old_text}]({to_insert})")) }; (range, new_text) } + +/// Whether `text` consists solely of a single URL, as opposed to merely +/// starting with a scheme-like prefix (e.g. a commit message like +/// `editor: Fix ...`, which `url::Url::parse` would accept). +fn is_standalone_url(text: &str) -> bool { + let mut finder = linkify::LinkFinder::new(); + finder.kinds(&[linkify::LinkKind::Url]); + finder + .links(text) + .next() + .is_some_and(|link| link.start() == 0 && link.end() == text.len()) +} diff --git a/crates/editor/src/code_completion_tests.rs b/crates/editor/src/code_completion_tests.rs index cf8023cad8eed9..babcbede3bce3d 100644 --- a/crates/editor/src/code_completion_tests.rs +++ b/crates/editor/src/code_completion_tests.rs @@ -482,6 +482,7 @@ impl CompletionBuilder { resolved: false, }, icon_path: None, + icon_color: None, insert_text_mode: None, confirm: None, match_start: None, diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index d54cc667c3e285..25ac00a495c82b 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -392,6 +392,7 @@ impl CompletionsMenu { match_start: None, snippet_deduplication_key: None, icon_path: None, + icon_color: None, documentation: None, confirm: None, insert_text_mode: None, @@ -896,9 +897,18 @@ impl CompletionsMenu { let documentation = &completion.documentation; let mut len = completion.label.text.chars().count(); - if let Some(CompletionDocumentation::SingleLine(text)) = documentation { - if show_completion_documentation { - len += text.chars().count(); + if show_completion_documentation { + match documentation { + Some(CompletionDocumentation::SingleLine(text)) => { + len += text.chars().count(); + } + Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { + single_line, + .. + }) => { + len += single_line.chars().count(); + } + _ => {} } } @@ -1081,7 +1091,11 @@ impl CompletionsMenu { completion.icon_path.as_ref().map(|path| { Icon::from_path(path) .size(IconSize::XSmall) - .color(Color::Muted) + .color( + completion + .icon_color + .map_or(Color::Muted, Color::Custom), + ) .into_any_element() }) }); @@ -1256,6 +1270,7 @@ impl CompletionsMenu { window: &mut Window, cx: &mut Context, ) -> Div { + let editor = cx.weak_entity(); div().child( MarkdownElement::new(markdown, hover_markdown_style(window, cx)) .code_block_renderer(markdown::CodeBlockRenderer::Default { @@ -1263,7 +1278,17 @@ impl CompletionsMenu { wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }) - .on_url_click(open_markdown_url), + .on_url_click(move |link, window, cx| { + open_markdown_url( + editor + .read_with(cx, |editor, _| editor.workspace()) + .ok() + .flatten(), + link, + window, + cx, + ) + }), ) } diff --git a/crates/editor/src/completions.rs b/crates/editor/src/completions.rs index 0614f86a76a1ec..07ebf31457acd1 100644 --- a/crates/editor/src/completions.rs +++ b/crates/editor/src/completions.rs @@ -591,6 +591,7 @@ impl Editor { match_start: None, snippet_deduplication_key: None, icon_path: None, + icon_color: None, documentation: None, source: CompletionSource::BufferWord { word_range, @@ -910,7 +911,13 @@ impl Editor { }); } linked_edits.apply(cx); - editor.refresh_edit_prediction(true, false, window, cx); + editor.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::LSPCompletionAccepted, + window, + cx, + ); }); self.invalidate_autoclose_regions( &self.selections.disjoint_anchors_arc(), @@ -1338,6 +1345,7 @@ fn snippet_completions( filter_range: 0..matching_prefix.len(), }, icon_path: None, + icon_color: None, documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText { single_line: snippet.name.clone().into(), plain_text: snippet diff --git a/crates/editor/src/diagnostics.rs b/crates/editor/src/diagnostics.rs index cdbb341a41cd85..f3e3137b3fa9a6 100644 --- a/crates/editor/src/diagnostics.rs +++ b/crates/editor/src/diagnostics.rs @@ -191,7 +191,13 @@ impl Editor { s.select_ranges(vec![diagnostic.range.start..diagnostic.range.start]) }); self.activate_diagnostics(buffer_id, diagnostic, window, cx); - self.refresh_edit_prediction(false, true, window, cx); + self.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::DiagnosticNavigation, + window, + cx, + ); } pub fn go_to_diagnostic_in_direction( diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index ae28538cc935d6..8523bcb7b07cc5 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -284,7 +284,7 @@ fn highlights_from_buffer( .collect::(); let mut outline_text_highlights = Vec::new(); - match search_text.find(outline_text) { + match search_text.find(outline_text.as_str()) { Some(start_index) => { let multibuffer_start = search_start_offset + MultiBufferOffset(start_index); let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_text.len()); diff --git a/crates/editor/src/edit_prediction.rs b/crates/editor/src/edit_prediction.rs index bdaef3430eda18..c5d3bb1f16566d 100644 --- a/crates/editor/src/edit_prediction.rs +++ b/crates/editor/src/edit_prediction.rs @@ -166,7 +166,13 @@ impl Editor { provider: Arc::new(provider), }); self.update_edit_prediction_settings(cx); - self.refresh_edit_prediction(false, false, window, cx); + self.refresh_edit_prediction( + false, + false, + EditPredictionRequestTrigger::Other, + window, + cx, + ); } pub fn set_edit_predictions_hidden_for_vim_mode( @@ -180,7 +186,13 @@ impl Editor { if hidden { self.update_visible_edit_prediction(window, cx); } else { - self.refresh_edit_prediction(true, false, window, cx); + self.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::Other, + window, + cx, + ); } } } @@ -211,7 +223,13 @@ impl Editor { if let Some(false) = show_edit_predictions { self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx); } else { - self.refresh_edit_prediction(false, true, window, cx); + self.refresh_edit_prediction( + false, + true, + EditPredictionRequestTrigger::Explicit, + window, + cx, + ); } } @@ -219,6 +237,7 @@ impl Editor { &mut self, debounce: bool, user_requested: bool, + trigger: EditPredictionRequestTrigger, window: &mut Window, cx: &mut Context, ) -> Option<()> { @@ -251,8 +270,13 @@ impl Editor { return None; } - self.edit_prediction_provider()? - .refresh(buffer, cursor_buffer_position, debounce, cx); + self.edit_prediction_provider()?.refresh( + buffer, + cursor_buffer_position, + debounce, + trigger, + cx, + ); Some(()) } @@ -311,7 +335,13 @@ impl Editor { cx: &mut Context, ) { if !self.has_active_edit_prediction() { - self.refresh_edit_prediction(false, true, window, cx); + self.refresh_edit_prediction( + false, + true, + EditPredictionRequestTrigger::Explicit, + window, + cx, + ); return; } @@ -457,7 +487,13 @@ impl Editor { self.update_visible_edit_prediction(window, cx); if self.active_edit_prediction.is_none() { - self.refresh_edit_prediction(true, true, window, cx); + self.refresh_edit_prediction( + true, + true, + EditPredictionRequestTrigger::PredictionAccepted, + window, + cx, + ); } cx.notify(); } @@ -510,7 +546,13 @@ impl Editor { }); self.replace_selections(&text_to_insert, None, window, cx, false); - self.refresh_edit_prediction(true, true, window, cx); + self.refresh_edit_prediction( + true, + true, + EditPredictionRequestTrigger::PredictionPartiallyAccepted, + window, + cx, + ); cx.notify(); } else { self.accept_partial_edit_prediction( @@ -1975,13 +2017,10 @@ impl Editor { .gap_1() // Workaround: For some reason, there's a gap if we don't do this .ml(-BORDER_WIDTH) - .shadow(vec![gpui::BoxShadow { - color: gpui::black().opacity(0.05), - offset: point(px(1.), px(1.)), - blur_radius: px(2.), - spread_radius: px(0.), - inset: false, - }]) + .shadow(vec![ + gpui::BoxShadow::new(px(1.), px(1.), gpui::black().opacity(0.05)) + .blur_radius(px(2.)), + ]) .bg(Editor::edit_prediction_line_popover_bg_color(cx)) .border(BORDER_WIDTH) .border_color(cx.theme().colors().border) diff --git a/crates/editor/src/edit_prediction_tests.rs b/crates/editor/src/edit_prediction_tests.rs index 684fea4d54baf0..24ac960b916b81 100644 --- a/crates/editor/src/edit_prediction_tests.rs +++ b/crates/editor/src/edit_prediction_tests.rs @@ -1,5 +1,6 @@ use edit_prediction_types::{ - EditPredictionDelegate, EditPredictionIconSet, PredictedCursorPosition, + EditPredictionDelegate, EditPredictionIconSet, EditPredictionRequestTrigger, + PredictedCursorPosition, }; use futures::StreamExt; use gpui::{ @@ -532,7 +533,13 @@ async fn test_edit_prediction_refresh_suppressed_while_following(cx: &mut gpui:: propose_edits(&provider, vec![(8..8, "42")], &mut cx); cx.update_editor(|editor, window, cx| { - editor.refresh_edit_prediction(false, false, window, cx); + editor.refresh_edit_prediction( + false, + false, + EditPredictionRequestTrigger::Other, + window, + cx, + ); editor.update_visible_edit_prediction(window, cx); }); @@ -548,7 +555,13 @@ async fn test_edit_prediction_refresh_suppressed_while_following(cx: &mut gpui:: cx.update_editor(|editor, window, cx| { editor.leader_id = Some(CollaboratorId::PeerId(PeerId::default())); - editor.refresh_edit_prediction(false, false, window, cx); + editor.refresh_edit_prediction( + false, + false, + EditPredictionRequestTrigger::Other, + window, + cx, + ); }); assert_eq!( @@ -563,7 +576,13 @@ async fn test_edit_prediction_refresh_suppressed_while_following(cx: &mut gpui:: cx.update_editor(|editor, window, cx| { editor.leader_id = None; - editor.refresh_edit_prediction(false, false, window, cx); + editor.refresh_edit_prediction( + false, + false, + EditPredictionRequestTrigger::Other, + window, + cx, + ); }); assert_eq!( @@ -1678,6 +1697,7 @@ impl CompletionProvider for FakeCompletionMenuProvider { documentation: None, source: CompletionSource::Custom, icon_path: None, + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, @@ -1763,6 +1783,7 @@ impl EditPredictionDelegate for FakeEditPredictionDelegate { _buffer: gpui::Entity, _cursor_position: language::Anchor, _debounce: bool, + _trigger: edit_prediction_types::EditPredictionRequestTrigger, _cx: &mut gpui::Context, ) { self.refresh_count.fetch_add(1, atomic::Ordering::SeqCst); @@ -1841,6 +1862,7 @@ impl EditPredictionDelegate for FakeNonZedEditPredictionDelegate { _buffer: gpui::Entity, _cursor_position: language::Anchor, _debounce: bool, + _trigger: edit_prediction_types::EditPredictionRequestTrigger, _cx: &mut gpui::Context, ) { } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6718802d43a6a3..70568a66c4631c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -66,6 +66,7 @@ mod config; mod diagnostics; mod edit_prediction; mod input; +mod markdown_actions; mod navigation; mod rewrap; mod selection; @@ -73,6 +74,7 @@ mod selection; pub(crate) use actions::*; pub use clipboard::ClipboardSelection; pub use code_actions::CodeActionProvider; +use collections::TypeIdHashMap; pub use completions::CompletionProvider; #[cfg(test)] pub(crate) use completions::snippet_candidate_suffixes; @@ -93,6 +95,7 @@ pub(crate) use edit_prediction::{ EditPredictionKeybindAction, EditPredictionKeybindSurface, edit_prediction_edit_text, }; pub use edit_prediction_types::Direction; +pub use edit_prediction_types::EditPredictionRequestTrigger; pub use editor_settings::{ CompletionDetailAlignment, CompletionMenuItemKind, CurrentLineHighlight, DiffViewStyle, DocumentColorsRenderMode, EditorSettings, EditorSettingsScrollbarProxy, ScrollBeyondLastLine, @@ -991,10 +994,10 @@ pub struct Editor { show_indent_guides: Option, buffers_with_disabled_indent_guides: HashSet, highlight_order: usize, - highlighted_rows: HashMap>, + highlighted_rows: TypeIdHashMap>, background_highlights: HashMap, navigation_overlays: HashMap>, - gutter_highlights: HashMap, + gutter_highlights: TypeIdHashMap, scrollbar_marker_state: ScrollbarMarkerState, active_indent_guides_state: ActiveIndentGuidesState, nav_history: Option, @@ -1114,11 +1117,15 @@ pub struct Editor { breadcrumb_header: Option, focused_block: Option, next_scroll_position: NextScrollCursorCenterTopBottom, - addons: HashMap>, + addons: TypeIdHashMap>, registered_buffers: HashMap, load_diff_task: Option>>, /// Whether we are temporarily displaying a diff other than git's temporary_diff_override: bool, + /// Whether to render all diff hunks with the "unstaged" appearance, + /// regardless of whether they have a secondary hunk. Used by views whose + /// diffs aren't related to the git index (e.g. agent diffs). + render_diff_hunks_as_unstaged: bool, selection_mark_mode: bool, toggle_fold_multiple_buffers: Task<()>, _scroll_cursor_center_top_bottom_task: Task<()>, @@ -1793,8 +1800,10 @@ impl Editor { self.sticky_headers_task = cx.spawn(async move |this, cx| { let sticky_headers = background_task.await; this.update(cx, |this, cx| { - this.sticky_headers = Some(sticky_headers); - cx.notify(); + if this.sticky_headers.as_ref() != Some(&sticky_headers) { + this.sticky_headers = Some(sticky_headers); + cx.notify(); + } }) .ok(); }); @@ -2187,10 +2196,10 @@ impl Editor { show_indent_guides, buffers_with_disabled_indent_guides: HashSet::default(), highlight_order: 0, - highlighted_rows: HashMap::default(), + highlighted_rows: Default::default(), background_highlights: HashMap::default(), navigation_overlays: HashMap::default(), - gutter_highlights: HashMap::default(), + gutter_highlights: Default::default(), scrollbar_marker_state: ScrollbarMarkerState::default(), active_indent_guides_state: ActiveIndentGuidesState::default(), nav_history: None, @@ -2333,7 +2342,7 @@ impl Editor { breadcrumb_header: None, focused_block: None, next_scroll_position: NextScrollCursorCenterTopBottom::default(), - addons: HashMap::default(), + addons: Default::default(), registered_buffers: HashMap::default(), _scroll_cursor_center_top_bottom_task: Task::ready(()), selection_mark_mode: false, @@ -2343,6 +2352,7 @@ impl Editor { text_style_refinement: None, load_diff_task: load_uncommitted_diff, temporary_diff_override: false, + render_diff_hunks_as_unstaged: false, minimap: None, change_list: ChangeList::new(), mode, @@ -3079,6 +3089,8 @@ impl Editor { self.use_modal_editing } + /// Inserted text is normalized to LF line endings before being applied. + /// Normalize before measuring inserted text for post-edit offsets. pub fn edit(&mut self, edits: I, cx: &mut Context) where I: IntoIterator, T)>, @@ -4009,7 +4021,7 @@ impl Editor { anchor: Anchor, window: &mut Window, cx: &mut Context, - ) -> Entity { + ) -> Entity { let weak_editor = cx.weak_entity(); let focus_handle = self.focus_handle(cx); @@ -4064,37 +4076,50 @@ impl Editor { let run_to_cursor = window.is_action_available(&RunToCursor, cx); - let toggle_state_msg = breakpoint.as_ref().map_or(None, |bp| match bp.1.state { - BreakpointState::Enabled => Some("Disable"), - BreakpointState::Disabled => Some("Enable"), - }); + let toggle_state_entry: Option<(&str, Box)> = + breakpoint.as_ref().map(|bp| match bp.1.state { + BreakpointState::Enabled => { + ("Disable", crate::actions::DisableBreakpoint.boxed_clone()) + } + BreakpointState::Disabled => { + ("Enable", crate::actions::EnableBreakpoint.boxed_clone()) + } + }); let (anchor, breakpoint) = breakpoint.unwrap_or_else(|| (anchor, Arc::new(Breakpoint::new_standard()))); - ui::ContextMenu::build(window, cx, |menu, _, _cx| { + ContextMenu::build(window, cx, |menu, _, _cx| { menu.on_blur_subscription(Subscription::new(|| {})) .context(focus_handle) .when(run_to_cursor, |this| { let weak_editor = weak_editor.clone(); - this.entry("Run to Cursor", None, move |window, cx| { - weak_editor - .update(cx, |editor, cx| { - editor.change_selections( - SelectionEffects::no_scroll(), - window, - cx, - |s| s.select_ranges([Point::new(row, 0)..Point::new(row, 0)]), - ); - }) - .ok(); + this.entry( + "Run to Cursor", + Some(RunToCursor.boxed_clone()), + move |window, cx| { + weak_editor + .update(cx, |editor, cx| { + editor.change_selections( + SelectionEffects::no_scroll(), + window, + cx, + |s| { + s.select_ranges( + [Point::new(row, 0)..Point::new(row, 0)], + ) + }, + ); + }) + .ok(); - window.dispatch_action(Box::new(RunToCursor), cx); - }) + window.dispatch_action(Box::new(RunToCursor), cx); + }, + ) .separator() }) - .when_some(toggle_state_msg, |this, msg| { - this.entry(msg, None, { + .when_some(toggle_state_entry, |this, (msg, action)| { + this.entry(msg, Some(action), { let weak_editor = weak_editor.clone(); let breakpoint = breakpoint.clone(); move |_window, cx| { @@ -4111,39 +4136,47 @@ impl Editor { } }) }) - .entry(set_breakpoint_msg, None, { - let weak_editor = weak_editor.clone(); - let breakpoint = breakpoint.clone(); - move |_window, cx| { - weak_editor - .update(cx, |this, cx| { - this.edit_breakpoint_at_anchor( - anchor, - breakpoint.as_ref().clone(), - BreakpointEditAction::Toggle, - cx, - ); - }) - .log_err(); - } - }) - .entry(log_breakpoint_msg, None, { - let breakpoint = breakpoint.clone(); - let weak_editor = weak_editor.clone(); - move |window, cx| { - weak_editor - .update(cx, |this, cx| { - this.add_edit_breakpoint_block( - anchor, - breakpoint.as_ref(), - BreakpointPromptEditAction::Log, - window, - cx, - ); - }) - .log_err(); - } - }) + .entry( + set_breakpoint_msg, + Some(crate::actions::ToggleBreakpoint.boxed_clone()), + { + let weak_editor = weak_editor.clone(); + let breakpoint = breakpoint.clone(); + move |_window, cx| { + weak_editor + .update(cx, |this, cx| { + this.edit_breakpoint_at_anchor( + anchor, + breakpoint.as_ref().clone(), + BreakpointEditAction::Toggle, + cx, + ); + }) + .log_err(); + } + }, + ) + .entry( + log_breakpoint_msg, + Some(crate::actions::EditLogBreakpoint.boxed_clone()), + { + let breakpoint = breakpoint.clone(); + let weak_editor = weak_editor.clone(); + move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.add_edit_breakpoint_block( + anchor, + breakpoint.as_ref(), + BreakpointPromptEditAction::Log, + window, + cx, + ); + }) + .log_err(); + } + }, + ) .entry(condition_breakpoint_msg, None, { let breakpoint = breakpoint.clone(); let weak_editor = weak_editor.clone(); @@ -4179,13 +4212,17 @@ impl Editor { } }) .separator() - .entry(set_bookmark_msg, None, move |_window, cx| { - weak_editor - .update(cx, |this, cx| { - this.toggle_bookmark_at_anchor(anchor, cx); - }) - .log_err(); - }) + .entry( + set_bookmark_msg, + Some(ToggleBookmark.boxed_clone()), + move |_window, cx| { + weak_editor + .update(cx, |this, cx| { + this.toggle_bookmark_at_anchor(anchor, cx); + }) + .log_err(); + }, + ) }) } @@ -4803,7 +4840,13 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| s.select(selections)); this.insert("", window, cx); linked_edits.apply_with_left_expansion(cx); - this.refresh_edit_prediction(true, false, window, cx); + this.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); refresh_linked_ranges(this, window, cx); }); } @@ -4826,7 +4869,13 @@ impl Editor { let linked_edits = this.linked_edits_for_selections(Arc::from(""), cx); this.insert("", window, cx); linked_edits.apply(cx); - this.refresh_edit_prediction(true, false, window, cx); + this.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); refresh_linked_ranges(this, window, cx); }); } @@ -5011,7 +5060,13 @@ impl Editor { self.transact(window, cx, |this, window, cx| { this.buffer.update(cx, |b, cx| b.edit(edits, None, cx)); this.change_selections(Default::default(), window, cx, |s| s.select(selections)); - this.refresh_edit_prediction(true, false, window, cx); + this.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); }); } @@ -7322,7 +7377,13 @@ impl Editor { } self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(window, cx); - self.refresh_edit_prediction(true, false, window, cx); + self.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); cx.emit(EditorEvent::Edited { transaction_id }); cx.emit(EditorEvent::TransactionUndone { transaction_id }); } @@ -7350,7 +7411,13 @@ impl Editor { } self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(window, cx); - self.refresh_edit_prediction(true, false, window, cx); + self.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); cx.emit(EditorEvent::Edited { transaction_id }); } } @@ -7750,7 +7817,6 @@ impl Editor { self.selections .disjoint_anchor_ranges() - .filter(|range| range.start != range.end) .flat_map(|range| [range.start, range.end]) .filter_map(|anchor| snapshot.anchor_to_buffer_anchor(anchor)) .filter_map(|(_, buffer_snapshot)| multi_buffer.buffer(buffer_snapshot.remote_id())) @@ -7969,6 +8035,7 @@ impl Editor { project.restart_language_servers_for_buffers( multi_buffer.all_buffers().into_iter().collect(), HashSet::default(), + true, cx, ); }); @@ -8468,7 +8535,13 @@ impl Editor { (selection.range(), uuid.to_string()) }); this.edit(edits, cx); - this.refresh_edit_prediction(true, false, window, cx); + this.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); }); } @@ -9487,7 +9560,7 @@ impl Editor { } self.refresh_runnables(None, window, cx); self.update_edit_prediction_settings(cx); - self.refresh_edit_prediction(true, false, window, cx); + self.refresh_edit_prediction(true, false, EditPredictionRequestTrigger::Other, window, cx); self.refresh_inline_values(cx); let old_cursor_shape = self.cursor_shape; @@ -10646,7 +10719,7 @@ impl Editor { }; breadcrumbs.extend(symbols.iter().map(|symbol| HighlightedText { - text: symbol.text.clone().into(), + text: symbol.text.clone(), highlights: symbol.highlight_ranges.clone(), })); Some(breadcrumbs) @@ -11304,7 +11377,7 @@ impl EditorSnapshot { self.git_blame_gutter_max_author_length .map(|max_author_length| { let renderer = cx.global::().0.clone(); - const MAX_RELATIVE_TIMESTAMP: &str = "60 minutes ago"; + const MAX_RELATIVE_TIMESTAMP: &str = "2 years, 11 months ago"; /// The number of characters to dedicate to gaps and margins. const SPACING_WIDTH: usize = 4; @@ -11503,8 +11576,11 @@ pub enum EditorEvent { RestoreRequested { hunks: Vec, }, + /// Emitted when an underlying buffer changes, including edits made through another editor. BufferEdited, + /// Emitted when this editor creates, undoes, or redoes an edit transaction. Edited { + /// The transaction that changed the editor's buffer. transaction_id: clock::Lamport, }, Reparsed(BufferId), diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 10fb420017bc23..8c547eff6ffe38 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -4234,6 +4234,53 @@ fn test_newline_respects_read_only(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_newline_below_with_cursor_on_deleted_hunk(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("aaa\nbbb\ncˇcc"); + cx.set_head_text("aaa\nXXX\nbbb\nccc"); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.expand_all_diff_hunks(&Default::default(), window, cx); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, window, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(3), 3)..DisplayPoint::new(DisplayRow(3), 3), + ]); + }); + }); + + cx.update_editor(|editor, window, cx| { + editor.newline_below(&NewlineBelow, window, cx); + }); + cx.run_until_parked(); + + assert_eq!(cx.buffer(|buffer, _| buffer.text()), "aaa\nbbb\nccc\n"); + + let cursors = cx.update_editor(|editor, window, cx| { + let display_snapshot = editor.snapshot(window, cx).display_snapshot; + editor + .selections + .all_display(&display_snapshot) + .iter() + .map(|selection| selection.head()) + .collect::>() + }); + assert_eq!( + cursors, + vec![ + DisplayPoint::new(DisplayRow(1), 0), + DisplayPoint::new(DisplayRow(4), 0), + ], + ); +} + #[gpui::test] fn test_newline_below_multibuffer(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -7992,7 +8039,7 @@ async fn test_rewrap(cx: &mut TestAppContext) { &mut cx, ); - // Test that change in comment prefix (e.g., `//` to `///`) trigger seperate rewraps + // Test that change in comment prefix (e.g., `//` to `///`) trigger separate rewraps assert_rewrap( indoc! {" «// A regular long long comment to be wrapped. @@ -8008,7 +8055,7 @@ async fn test_rewrap(cx: &mut TestAppContext) { &mut cx, ); - // Test that change in indentation level trigger seperate rewraps + // Test that change in indentation level trigger separate rewraps assert_rewrap( indoc! {" fn foo() { @@ -14826,6 +14873,24 @@ async fn test_format_selections_action_available_when_range_formatting_is_suppor assert!(cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); } +#[gpui::test] +async fn test_format_selections_action_available_for_cursor_when_range_formatting_is_supported( + cx: &mut TestAppContext, +) { + let (_, editor, cx, _) = setup_range_format_test(cx).await; + + editor.update_in(cx, |editor, window, cx| { + editor.set_text("foo\nbar\n", window, cx); + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_ranges([Point::new(1, 1)..Point::new(1, 1)]); + }); + }); + + refresh_editor_actions(cx); + + assert!(cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) })); +} + #[gpui::test] async fn test_format_selections_action_hidden_without_range_formatting_support( cx: &mut TestAppContext, @@ -21201,6 +21266,88 @@ async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_select_inside_enclosing_bracket(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await; + + #[track_caller] + fn assert_after_runs(before: &str, after: &str, runs: usize, cx: &mut EditorLspTestContext) { + let _state_context = cx.set_state(before); + cx.run_until_parked(); + for _ in 0..runs { + cx.update_editor(|editor, window, cx| { + editor.select_inside_enclosing_bracket(&SelectInsideEnclosingBracket, window, cx) + }); + } + cx.run_until_parked(); + cx.assert_editor_state(after); + } + + #[track_caller] + fn assert(before: &str, after: &str, cx: &mut EditorLspTestContext) { + assert_after_runs(before, after, 1, cx); + } + + assert("console.log(ˇvar);", "console.log(«varˇ»);", &mut cx); + assert("console.logˇ(var);", "console.log(«varˇ»);", &mut cx); + assert("console.log(var)ˇ;", "console.log(«varˇ»);", &mut cx); + assert( + "let numbers = [1, ˇ2, 3];", + "let numbers = [«1, 2, 3ˇ»];", + &mut cx, + ); + assert( + "const object = { foo: ˇbar };", + "const object = {« foo: bar ˇ»};", + &mut cx, + ); + assert( + r#"const doubleQuoted = "foo ˇbar";"#, + r#"const doubleQuoted = "«foo barˇ»";"#, + &mut cx, + ); + assert( + "const singleQuoted = 'foo ˇbar';", + "const singleQuoted = '«foo barˇ»';", + &mut cx, + ); + assert( + "const template = `foo ˇbar`;", + "const template = `«foo barˇ»`;", + &mut cx, + ); + assert( + "let result = foo(bar(ˇbaz));", + "let result = foo(bar(«bazˇ»));", + &mut cx, + ); + assert( + "let result = foo(«barˇ»(baz));", + "let result = foo(«bar(baz)ˇ»);", + &mut cx, + ); + assert_after_runs( + "let result = foo(bar(ˇbaz));", + "let result = foo(«bar(baz)ˇ»);", + 2, + &mut cx, + ); + assert_after_runs( + r#"let result = (xx[xxx{xxˇx}] xx"xxx"xx);"#, + r#"let result = («xx[xxx{xxx}] xx"xxx"xxˇ»);"#, + 3, + &mut cx, + ); + assert("let plain = ˇvalue;", "let plain = ˇvalue;", &mut cx); + assert( + "foo(ˇone); bar(ˇtwo);", + "foo(«oneˇ»); bar(«twoˇ»);", + &mut cx, + ); +} + #[gpui::test] async fn test_move_to_enclosing_bracket_in_markdown_code_block(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -33203,6 +33350,36 @@ async fn test_paste_plain_text_from_other_app_replaces_selection_without_creatin cx.assert_editor_state(&format!("Hello, {text}ˇ.\nZed is {text}ˇ")); } +#[gpui::test] +async fn test_paste_text_with_scheme_like_prefix_replaces_selection_without_creating_markdown_link( + cx: &mut gpui::TestAppContext, +) { + init_test(cx, |_| {}); + + // `url::Url::parse` accepts this as a URL with the scheme `editor`, but it + // should not be treated as one when pasting. + let text = "editor: Fix double-click bracket selection for large spans"; + + let markdown_language = Arc::new(Language::new( + LanguageConfig { + name: "Markdown".into(), + ..LanguageConfig::default() + }, + None, + )); + + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + cx.set_state("«(feat on git-ui-add-info-exclude-to-context-menus) Fmtˇ»"); + + cx.update_editor(|editor, window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + editor.paste(&Paste, window, cx); + }); + + cx.assert_editor_state(&format!("{text}ˇ")); +} + #[gpui::test] async fn test_paste_url_from_other_app_without_creating_markdown_link_in_non_markdown_language( cx: &mut gpui::TestAppContext, @@ -37571,95 +37748,6 @@ async fn test_restore_and_next(cx: &mut TestAppContext) { ); } -#[gpui::test] -async fn test_restore_hunk_with_stale_base_text(cx: &mut TestAppContext) { - // Regression test: prepare_restore_change must read base_text from the same - // snapshot the hunk came from, not from the live BufferDiff entity. The live - // entity's base_text may have already been updated asynchronously (e.g. - // because git HEAD changed) while the MultiBufferSnapshot still holds the - // old hunk byte ranges — using both together causes Rope::slice to panic - // when the old range exceeds the new base text length. - init_test(cx, |_| {}); - let mut cx = EditorTestContext::new(cx).await; - - let long_base_text = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n"; - cx.set_state("ˇONE\ntwo\nTHREE\nfour\nFIVE\nsix\nseven\neight\nnine\nten\n"); - cx.set_head_text(long_base_text); - - let buffer_id = cx.update_buffer(|buffer, _| buffer.remote_id()); - - // Verify we have hunks from the initial diff. - let has_hunks = cx.update_editor(|editor, window, cx| { - let snapshot = editor.snapshot(window, cx); - let hunks = snapshot - .buffer_snapshot() - .diff_hunks_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len()); - hunks.count() > 0 - }); - assert!(has_hunks, "should have diff hunks before restoring"); - - // Now trigger a git HEAD change to a much shorter base text. - // After this, the live BufferDiff entity's base_text buffer will be - // updated synchronously (inside set_snapshot_with_secondary_inner), - // but DiffChanged is deferred until parsing_idle completes. - // We step the executor tick-by-tick to find the window where the - // live base_text is already short but the MultiBuffer snapshot is - // still stale (old hunks + old base_text). - let short_base_text = "short\n"; - let fs = cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).fs().as_fake()); - let path = cx.update_buffer(|buffer, _| buffer.file().unwrap().path().clone()); - fs.set_head_for_repo( - &Path::new(path!("/root")).join(".git"), - &[(path.as_unix_str(), short_base_text.to_string())], - "newcommit", - ); - - // Step the executor tick-by-tick. At each step, check whether the - // race condition exists: live BufferDiff has short base text but - // the MultiBuffer snapshot still has old (long) hunks. - let mut found_race = false; - for _ in 0..200 { - cx.executor().tick(); - - let race_exists = cx.update_editor(|editor, _window, cx| { - let multi_buffer = editor.buffer().read(cx); - let diff_entity = match multi_buffer.diff_for(buffer_id) { - Some(d) => d, - None => return false, - }; - let live_base_len = diff_entity.read(cx).base_text(cx).len(); - let snapshot = multi_buffer.snapshot(cx); - let snapshot_base_len = snapshot - .diff_for_buffer_id(buffer_id) - .map(|d| d.base_text().len()); - // Race: live base text is shorter than what the snapshot knows. - live_base_len < long_base_text.len() && snapshot_base_len == Some(long_base_text.len()) - }); - - if race_exists { - found_race = true; - // The race window is open: the live entity has new (short) base - // text but the MultiBuffer snapshot still has old hunks with byte - // ranges computed against the old long base text. Attempt restore. - // Without the fix, this panics with "cannot summarize past end of - // rope". With the fix, it reads base_text from the stale snapshot - // (consistent with the stale hunks) and succeeds. - cx.update_editor(|editor, window, cx| { - editor.select_all(&SelectAll, window, cx); - editor.git_restore(&Default::default(), window, cx); - }); - break; - } - } - - assert!( - found_race, - "failed to observe the race condition between \ - live BufferDiff base_text and stale MultiBuffer snapshot; \ - the test may need adjustment if the async diff pipeline changed" - ); -} - #[gpui::test] async fn test_align_selections(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -37980,3 +38068,257 @@ async fn test_toggle_diagnostics_persists_across_settings_change(cx: &mut TestAp ); }); } + +#[gpui::test] +async fn test_columnar_selection_with_multibyte_chars(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // The middle row contains a 2-byte char (ã) before the dragged column. A + // column selection that uses byte columns directly puts the ã row's + // selection at a different visual position than the ASCII rows; anchoring + // in x pixels keeps all rows at the same character offset. + cx.set_state(indoc! {" + ˇabcde + abcde + aãcde + abcde + abcde + "}); + + // Drag column-wise from (row 0, col 0) past the ã column on every row. + cx.update_editor(|editor, window, cx| { + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(0), 0), + goal_column: 0, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(DisplayRow(4), 4), + goal_column: 4, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + «abcdˇ»e + «abcdˇ»e + «aãcdˇ»e + «abcdˇ»e + «abcdˇ»e + "}); + + // Control: drag stops before the ã column, where byte columns and x + // positions agree. + cx.update_editor(|editor, window, cx| { + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(0), 0), + goal_column: 0, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(DisplayRow(4), 1), + goal_column: 1, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + «aˇ»bcde + «aˇ»bcde + «aˇ»ãcde + «aˇ»bcde + «aˇ»bcde + "}); +} + +#[gpui::test] +async fn test_columnar_selection_past_end_of_line(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + ˇaaaaaaaaaa + bb + cccccccccc + "}); + + // Drag from the start of the long first row to a point past the EOL of + // the short second row: the mouse handlers encode that as the nearest + // valid position (1, 2) plus an unclipped goal column of 8. The rectangle + // must keep tracking the mouse x on the long row instead of collapsing to + // the short row's width. + cx.update_editor(|editor, window, cx| { + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(0), 0), + goal_column: 0, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(DisplayRow(1), 2), + goal_column: 8, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + «aaaaaaaaˇ»aa + «bbˇ» + cccccccccc + "}); + + // Starting the drag past the EOL of the short row must anchor that edge + // of the rectangle at the click position, not at the short row's EOL. + cx.update_editor(|editor, window, cx| { + editor.select( + SelectPhase::BeginColumnar { + position: DisplayPoint::new(DisplayRow(1), 2), + goal_column: 8, + reset: true, + mode: ColumnarMode::FromMouse, + }, + window, + cx, + ); + editor.select( + SelectPhase::Update { + position: DisplayPoint::new(DisplayRow(2), 4), + goal_column: 4, + scroll_delta: gpui::Point::default(), + }, + window, + cx, + ); + }); + + cx.assert_editor_state(indoc! {" + aaaaaaaaaa + bb + cccc«ˇcccc»cc + "}); +} + +#[gpui::test] +async fn test_toggle_markdown_block_quote(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + // No-op with no language + cx.set_state(indoc! {" + «helloˇ» world + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «helloˇ» world + "}); + + // No-op in non-Markdown language (Rust) + cx.update_buffer(|buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + cx.set_state(indoc! {" + «helloˇ» world + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «helloˇ» world + "}); + + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_lang()), cx)); + + // Line is quoted with an empty selection + cx.set_state(indoc! {" + helˇlo world + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «> hello worldˇ» + "}); + + // Line is unquoted with an empty selection + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «hello worldˇ» + "}); + + // Multi-line selection is quoted, including blank lines + cx.set_state(indoc! {" + «first + + thirdˇ» + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «> first + > + > thirdˇ» + "}); + + // Multi-line selection is unquoted, including blank lines + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «first + + thirdˇ» + "}); + + // A multi-line selection, including a mixture of quoted and unquoted lines + // and a mixture of empty and non-empty lines, normalizes each line to a + // single quote. + cx.set_state(indoc! {" + «> first + second + > + + > third + >fourthˇ» + "}); + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «> first + > second + > + > + > third + > fourthˇ» + "}); + + // A multi-line selection is unquoted. + cx.update_editor(|e, window, cx| e.toggle_markdown_block_quote(&ToggleBlockQuote, window, cx)); + cx.assert_editor_state(indoc! {" + «first + second + + + third + fourthˇ» + "}); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 0ac3b2691d2d20..ec693e96f01689 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -334,6 +334,7 @@ impl EditorElement { register_action(editor, window, Editor::move_to_start_of_larger_syntax_node); register_action(editor, window, Editor::move_to_end_of_larger_syntax_node); register_action(editor, window, Editor::select_enclosing_symbol); + register_action(editor, window, Editor::select_inside_enclosing_bracket); register_action(editor, window, Editor::move_to_enclosing_bracket); register_action(editor, window, Editor::undo_selection); register_action(editor, window, Editor::redo_selection); @@ -426,6 +427,7 @@ impl EditorElement { register_action(editor, window, Editor::toggle_relative_line_numbers); register_action(editor, window, Editor::toggle_indent_guides); register_action(editor, window, Editor::toggle_inlay_hints); + register_action(editor, window, Editor::toggle_inline_values); register_action(editor, window, Editor::toggle_code_lens_action); register_action(editor, window, Editor::toggle_semantic_highlights); register_action(editor, window, Editor::toggle_edit_predictions); @@ -571,6 +573,7 @@ impl EditorElement { register_action(editor, window, Editor::redo); register_action(editor, window, Editor::toggle_comments); register_action(editor, window, Editor::toggle_block_comments); + register_action(editor, window, Editor::toggle_markdown_block_quote); register_action(editor, window, Editor::unwrap_syntax_node); register_action(editor, window, Editor::accept_next_word_edit_prediction); register_action(editor, window, Editor::accept_next_line_edit_prediction); @@ -4515,6 +4518,11 @@ impl EditorElement { ) { let colors = cx.theme().colors(); + let visible_start = + DisplayPoint::new(start_row, 0).to_offset(&snapshot.display_snapshot, Bias::Left); + let visible_end = DisplayPoint::new(DisplayRow(start_row.0 + row_infos.len() as u32), 0) + .to_offset(&snapshot.display_snapshot, Bias::Right); + let word_highlights = display_hunks .into_iter() .filter_map(|(hunk, _)| match hunk { @@ -4525,6 +4533,7 @@ impl EditorElement { }) .filter(|(_, status)| status.is_modified()) .flat_map(|(word_diffs, _)| word_diffs) + .filter(|word_diff| word_diff.start < visible_end && word_diff.end > visible_start) .flat_map(|word_diff| { let display_ranges = snapshot .display_snapshot @@ -5127,6 +5136,7 @@ impl EditorElement { } fn paint_gutter_diff_hunks( + &self, layout: &mut EditorLayout, split_side: Option, window: &mut Window, @@ -5202,7 +5212,7 @@ impl EditorElement { .editor_background .blend(background_color); - if !Self::diff_hunk_hollow(status, cx) { + if !self.diff_hunk_hollow(status, cx) { window.paint_quad(quad( hunk_bounds, corner_radii, @@ -5380,7 +5390,7 @@ impl EditorElement { ) }); if show_git_gutter { - Self::paint_gutter_diff_hunks(layout, self.split_side, window, cx) + self.paint_gutter_diff_hunks(layout, self.split_side, window, cx) } let highlight_width = 0.275 * layout.position_map.line_height; @@ -6493,8 +6503,9 @@ impl EditorElement { ) } - fn diff_hunk_hollow(status: DiffHunkStatus, cx: &mut App) -> bool { - let unstaged = status.has_secondary_hunk(); + fn diff_hunk_hollow(&self, status: DiffHunkStatus, cx: &mut App) -> bool { + let unstaged = + self.editor.read(cx).render_diff_hunks_as_unstaged || status.has_secondary_hunk(); let unstaged_hollow = matches!( ProjectSettings::get_global(cx).git.hunk_style, GitHunkStyleSetting::UnstagedHollow @@ -8186,7 +8197,7 @@ impl Element for EditorElement { type_id: None, }; - let background = if Self::diff_hunk_hollow(diff_status, cx) { + let background = if self.diff_hunk_hollow(diff_status, cx) { hollow_highlight } else { filled_highlight diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs index 02dfebdfc313f1..052afe1de6ae30 100644 --- a/crates/editor/src/element/header.rs +++ b/crates/editor/src/element/header.rs @@ -8,8 +8,8 @@ use gpui::{ Action, AnyElement, App, AvailableSpace, Bounds, ClickEvent, ClipboardItem, ContentMask, CursorStyle, DefiniteLength, Entity, Focusable as _, Hitbox, HitboxBehavior, Hsla, IntoElement, Length, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, - ShapedLine, SharedString, Styled, TextAlign, Window, div, fill, linear_color_stop, - linear_gradient, point, px, size, + ShapedLine, SharedString, Styled, TextAlign, Window, WindowBackgroundAppearance, div, fill, + linear_color_stop, linear_gradient, point, px, size, }; use language::language_settings::ShowWhitespaceSetting; use multi_buffer::{Anchor, ExcerptBoundaryInfo}; @@ -663,6 +663,11 @@ pub(crate) fn render_buffer_header( }; let focus_handle = editor_read.focus_handle(cx); let colors = cx.theme().colors(); + // On transparent windows `editor_subheader_background` stacks over the + // editor background into a darker bar (and the sticky shadow becomes a halo), + // so skip both unless the window is opaque. + let opaque_window = + cx.theme().window_background_appearance() == WindowBackgroundAppearance::Opaque; let header = div() .id(("buffer-header", buffer_id.to_proto())) @@ -678,7 +683,7 @@ pub(crate) fn render_buffer_header( .pr_2() .rounded_sm() .gap_1p5() - .when(is_sticky, |el| el.shadow_md()) + .when(is_sticky && opaque_window, |el| el.shadow_md()) .border_1() .map(|border| { let border_color = @@ -689,7 +694,9 @@ pub(crate) fn render_buffer_header( }; border.border_color(border_color) }) - .bg(colors.editor_subheader_background) + .when(opaque_window, |el| { + el.bg(colors.editor_subheader_background) + }) .hover(|style| style.bg(colors.element_hover)) .map(|header| { let editor = editor.clone(); diff --git a/crates/editor/src/element/mouse.rs b/crates/editor/src/element/mouse.rs index b3fc095dbbc00d..5c0709c8d2d1e2 100644 --- a/crates/editor/src/element/mouse.rs +++ b/crates/editor/src/element/mouse.rs @@ -569,11 +569,15 @@ impl EditorElement { if scroll_position != current_scroll_position { editor.scroll(scroll_position, axis, window, cx); cx.stop_propagation(); - } else if y < 0. { + } else if y < 0. && !forbid_vertical_scroll { // Due to clamping, we may fail to detect cases of overscroll to the top; // We want the scroll manager to get an update in such cases and detect the change of direction // on the next frame. - cx.notify(); + if editor.scroll_manager.should_notify_top_overscroll(axis) { + cx.notify(); + } + } else { + editor.scroll_manager.reset_top_overscroll_notification(); } }); } diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 16b1bc4daee2da..dbe4f073e50f23 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -175,6 +175,18 @@ impl Editor { cx.notify(); } + /// Make all diff hunks render with the "unstaged" appearance, regardless + /// of whether they have a secondary hunk. Intended for views whose diffs + /// aren't related to the git index (e.g. agent diffs). + pub fn set_render_diff_hunks_as_unstaged( + &mut self, + render_as_unstaged: bool, + cx: &mut Context, + ) { + self.render_diff_hunks_as_unstaged = render_as_unstaged; + cx.notify(); + } + pub fn git_blame_inline_enabled(&self) -> bool { self.git_blame_inline_enabled } @@ -230,6 +242,7 @@ impl Editor { pub fn end_temporary_diff_override(&mut self, cx: &mut Context) { self.temporary_diff_override = false; + self.render_diff_hunks_as_unstaged = false; self.set_render_diff_hunk_controls(Arc::new(render_diff_hunk_controls), cx); self.buffer.update(cx, |buffer, cx| { buffer.set_all_diff_hunks_collapsed(cx); diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 215389e30375bc..9e943d37daf43f 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -409,6 +409,11 @@ pub fn show_link_definition( let project = editor.project.clone(); let provider = editor.semantics_provider.clone(); + // Record the requested position so a mouse move on the same point short-circuits + // instead of re-querying, even when the server returns no `originSelectionRange` + // (which would otherwise leave `symbol_range` empty). + hovered_link_state.last_trigger_point = trigger_point.clone(); + hovered_link_state.task = Some(cx.spawn_in(window, async move |this, cx| { async move { // LSP document links take priority: the server explicitly @@ -568,6 +573,16 @@ pub fn show_link_definition( } }); + // When the server reports no `originSelectionRange`, fall back + // to the highlighted word as the symbol range so that hovering + // elsewhere within the same symbol reuses this result instead + // of issuing another request. + if let Some(hovered_link_state) = editor.hovered_link_state.as_mut() + && hovered_link_state.symbol_range.is_none() + { + hovered_link_state.symbol_range = Some(highlight_range.clone()); + } + match highlight_range { RangeInEditor::Text(text_range) => editor.highlight_text( HighlightKey::HoveredLinkState, @@ -600,7 +615,11 @@ pub fn show_link_definition( cx, ); } else { - editor.hide_hovered_link(cx); + // When no links are found, we don't want to completely + // throw away the `HoveredLinkState`, we'll want to at least + // keep the `trigger_point` around in order to avoid sending + // multiple requests for the same point. + hovered_link_state.links.clear(); } })?; @@ -1066,6 +1085,8 @@ mod tests { use multi_buffer::MultiBufferOffset; use settings::InlayHintSettingsContent; use std::str::FromStr; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; use util::{assert_set_eq, path}; use workspace::item::Item; @@ -1238,6 +1259,132 @@ mod tests { "}); } + #[gpui::test] + async fn test_go_to_definition_link_dedup(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + definition_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + fn ˇtest() { do_work(); } + fn do_work() { test(); } + "}); + + let request_count = Arc::new(AtomicUsize::new(0)); + let _requests = cx.set_request_handler::({ + let request_count = request_count.clone(); + move |url, _, _| { + request_count.fetch_add(1, Ordering::SeqCst); + async move { + // Return a bare `Location`, not an `originSelectionRange` + // so we can confirm that jiggling the mouse within the same + // symbol range does not trigger a second request, even + // though `originSelectionRange` was not returned. + Ok(Some(lsp::GotoDefinitionResponse::Scalar(lsp::Location { + uri: url, + range: lsp::Range::default(), + }))) + } + } + }); + + let symbol_start = cx.pixel_position(indoc! {" + fn test() { ˇdo_work(); } + fn do_work() { test(); } + "}); + let symbol_end = cx.pixel_position(indoc! {" + fn test() { do_worˇk(); } + fn do_work() { test(); } + "}); + let other_symbol = cx.pixel_position(indoc! {" + fn test() { do_work(); } + fn do_work() { teˇst(); } + "}); + + cx.simulate_mouse_move(symbol_start, None, Modifiers::secondary_key()); + cx.run_until_parked(); + + cx.simulate_mouse_move(symbol_end, None, Modifiers::secondary_key()); + cx.run_until_parked(); + + cx.simulate_mouse_move(other_symbol, None, Modifiers::secondary_key()); + cx.run_until_parked(); + + assert_eq!( + request_count.load(Ordering::SeqCst), + 2, + "expected one request per symbol, reused within a symbol" + ); + } + + #[gpui::test] + async fn test_go_to_definition_link_dedup_no_link(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + definition_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + fn ˇtest() { do_work(); } + fn do_work() { test(); } + "}); + + let request_count = Arc::new(AtomicUsize::new(0)); + let _requests = cx.set_request_handler::({ + let request_count = request_count.clone(); + + move |_, _, _| { + request_count.fetch_add(1, Ordering::SeqCst); + + // Simulate response from the language server, reporting + // that no link was found. + async move { Ok(None) } + } + }); + + let first_point = cx.pixel_position(indoc! {" + fn test() { do_wˇork(); } + fn do_work() { test(); } + "}); + let second_point = cx.pixel_position(indoc! {" + fn test() { do_woˇrk(); } + fn do_work() { test(); } + "}); + + cx.simulate_mouse_move(first_point, None, Modifiers::secondary_key()); + cx.run_until_parked(); + + cx.simulate_mouse_move(second_point, None, Modifiers::secondary_key()); + cx.run_until_parked(); + + // Jiggle within the same character should not produce a new request, + // even though the previous response was empty and produced no link to + // highlight. + cx.simulate_mouse_move(second_point, None, Modifiers::secondary_key()); + cx.run_until_parked(); + + assert_eq!( + request_count.load(Ordering::SeqCst), + 2, + "expected one definition request per distinct position" + ); + } + #[gpui::test] async fn test_hover_links(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 5c5eb651e350e2..c915232ba5f69b 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -253,7 +253,6 @@ pub fn hide_hover(editor: &mut Editor, cx: &mut Context) -> bool { let did_hide = info_popovers.count() > 0 || diagnostics_popover.is_some(); 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; @@ -309,15 +308,6 @@ fn show_hover( } } - // Don't request again if the location is the same as the previous request - if let Some(triggered_from) = &editor.hover_state.triggered_from - && triggered_from - .cmp(&anchor, &snapshot.buffer_snapshot()) - .is_eq() - { - return None; - } - let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; let all_diagnostics_active = editor.all_diagnostics_active(); let active_group_id = editor.active_diagnostic_group_id(); @@ -807,10 +797,15 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { } } -pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App) { +pub fn open_markdown_url( + workspace: Option>, + link: SharedString, + window: &mut Window, + cx: &mut App, +) { if let Ok(uri) = Url::parse(&link) && uri.scheme() == "file" - && let Some(workspace) = Workspace::for_window(window, cx) + && let Some(workspace) = workspace { workspace.update(cx, |workspace, cx| { let task = workspace.open_abs_path( @@ -857,14 +852,20 @@ pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App) }); return; } - cx.open_url(&link); + + if let Some(workspace) = workspace { + workspace.update(cx, |workspace, cx| { + workspace.open_url_or_file(&link, None, window, cx); + }); + } else { + cx.open_url(&link); + } } #[derive(Default)] pub struct HoverState { pub info_popovers: Vec, pub diagnostic_popover: Option, - pub triggered_from: Option, pub info_task: Option>>, pub closest_mouse_distance: Option, pub hiding_delay_task: Option>, @@ -1044,6 +1045,7 @@ impl InfoPopover { ) -> AnyElement { let keyboard_grace = Rc::clone(&self.keyboard_grace); let this = cx.entity().downgrade(); + let this2 = this.clone(); let bounds_cell = self.last_bounds.clone(); div() .id("info_popover") @@ -1094,7 +1096,17 @@ impl InfoPopover { wrap_button_visibility: markdown::WrapButtonVisibility::Hidden, border: false, }) - .on_url_click(open_markdown_url) + .on_url_click(move |link, window, cx| { + open_markdown_url( + this2 + .read_with(cx, |editor, _| editor.workspace()) + .ok() + .flatten(), + link, + window, + cx, + ) + }) .p_2(), ), ) @@ -2692,10 +2704,6 @@ mod tests { editor.hover_state.info_task.is_none(), "No hover info task should be scheduled when hover is disabled" ); - assert!( - editor.hover_state.triggered_from.is_none(), - "No hover trigger should be recorded when hover is disabled" - ); }); } } diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs index a894093c45afc4..04e546a6691851 100644 --- a/crates/editor/src/input.rs +++ b/crates/editor/src/input.rs @@ -523,7 +523,13 @@ impl Editor { } this.trigger_completion_on_input(&text, trigger_in_words, window, cx); refresh_linked_ranges(this, window, cx); - this.refresh_edit_prediction(true, false, window, cx); + this.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); jsx_tag_auto_close::handle_from(this, initial_buffer_versions, window, cx); }); } @@ -759,7 +765,13 @@ impl Editor { .collect(); this.change_selections(Default::default(), window, cx, |s| s.select(new_selections)); - this.refresh_edit_prediction(true, false, window, cx); + this.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::BufferEdit, + window, + cx, + ); if let Some(task) = this.trigger_on_type_formatting("\n".to_owned(), window, cx) { task.detach_and_log_err(cx); } @@ -841,7 +853,7 @@ impl Editor { } let mut buffer_edits: HashMap, Vec)> = HashMap::default(); - let mut rows = Vec::new(); + let mut rows: Vec> = Vec::new(); let mut rows_inserted = 0; for selection in self.selections.all_adjusted(&self.display_snapshot(cx)) { @@ -852,6 +864,7 @@ impl Editor { let Some((buffer_handle, buffer_point)) = self.buffer.read(cx).point_to_buffer_point(point, cx) else { + rows.push(None); continue; }; @@ -862,7 +875,7 @@ impl Editor { .push(buffer_point); rows_inserted += 1; - rows.push(row + rows_inserted); + rows.push(Some(row + rows_inserted)); } self.transact(window, cx, |editor, window, cx| { @@ -882,21 +895,21 @@ impl Editor { editor.change_selections(Default::default(), window, cx, |s| { let mut index = 0; - s.move_cursors_with(&mut |map, _, _| { - let row = rows[index]; + s.maybe_move_cursors_with(&mut |map, _, _| { + let row = rows.get(index).copied().flatten(); index += 1; - let point = Point::new(row, 0); + let point = Point::new(row?, 0); let boundary = map.next_line_boundary(point).1; let clipped = map.clip_point(boundary, Bias::Left); - (clipped, SelectionGoal::None) + Some((clipped, SelectionGoal::None)) }); }); let mut indent_edits = Vec::new(); let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx); - for row in rows { + for row in rows.into_iter().flatten() { let indents = multibuffer_snapshot.suggested_indents(row..row + 1, cx); for (row, indent) in indents { if indent.len == 0 { diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 884f5a8325191d..a5fc5b2b99149d 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -370,7 +370,7 @@ impl FollowableItem for Editor { }; drop(buffer); self.set_selections_from_remote(vec![selection], None, window, cx); - self.request_autoscroll_remotely(Autoscroll::fit(), cx); + self.request_autoscroll_remotely(Autoscroll::focused(), cx); } } @@ -767,7 +767,10 @@ impl Item for Editor { return None; } - Some(util::truncate_and_trailoff(description, MAX_TAB_TITLE_LEN)) + Some(util::truncate_and_trailoff( + description, + params.max_title_len.unwrap_or(MAX_TAB_TITLE_LEN), + )) }); // Whether the file was saved in the past but is now deleted. @@ -780,11 +783,20 @@ impl Item for Editor { h_flex() .gap_2() + .when(params.truncate_title_middle, |this| { + this.w_full().min_w_0().overflow_hidden() + }) .child( - Label::new(util::truncate_and_trailoff( - &self.title(cx), - MAX_TAB_TITLE_LEN, - )) + Label::new(if params.truncate_title_middle { + self.title(cx).to_string() + } else { + util::truncate_and_trailoff( + &self.title(cx), + params.max_title_len.unwrap_or(MAX_TAB_TITLE_LEN), + ) + }) + .when(params.truncate_title_middle, |this| this.truncate_middle()) + .when(params.truncate_title_middle, |this| this.flex_1()) .color(label_color) .when(params.preview, |this| this.italic()) .when(was_deleted, |this| this.strikethrough()), @@ -793,6 +805,9 @@ impl Item for Editor { this.child( Label::new(description) .size(LabelSize::XSmall) + .when(params.truncate_title_middle, |this| { + this.truncate_start().flex_shrink() + }) .color(Color::Muted), ) }) diff --git a/crates/editor/src/markdown_actions.rs b/crates/editor/src/markdown_actions.rs new file mode 100644 index 00000000000000..2b6af38d0a440c --- /dev/null +++ b/crates/editor/src/markdown_actions.rs @@ -0,0 +1,56 @@ +use super::*; + +impl Editor { + pub fn toggle_markdown_block_quote( + &mut self, + _: &ToggleBlockQuote, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_mutable_lines_in_markdown(window, cx, |lines| { + let all_lines_quoted = lines.iter().all(|line| line.starts_with('>')); + + for line in lines.iter_mut() { + let stripped_line = match line.strip_prefix("> ").or_else(|| line.strip_prefix('>')) + { + Some(rest) => rest.to_string(), + None => line.to_string(), + }; + + *line = if all_lines_quoted { + Cow::Owned(stripped_line) + } else if stripped_line.trim().is_empty() { + Cow::Borrowed(">") + } else { + Cow::Owned(format!("> {stripped_line}")) + }; + } + }); + } + + fn manipulate_mutable_lines_in_markdown( + &mut self, + window: &mut Window, + cx: &mut Context, + callback: Fn, + ) where + Fn: FnMut(&mut Vec>), + { + if !self.is_in_markdown_language(cx) { + return; + } + + self.manipulate_mutable_lines(window, cx, callback); + } + + fn is_in_markdown_language(&self, cx: &mut App) -> bool { + let snapshot = self.buffer.read(cx).snapshot(cx); + let head = self + .selections + .newest::(&self.display_snapshot(cx)) + .head(); + snapshot + .language_at(head) + .is_some_and(|language| language.name() == "Markdown") + } +} diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs index c117aa97a307e6..051b7f0ab4232d 100644 --- a/crates/editor/src/navigation.rs +++ b/crates/editor/src/navigation.rs @@ -1082,7 +1082,10 @@ impl Editor { if let Some(url) = url { cx.update(|window, cx| { if parse_zed_link(&url, cx).is_some() { - window.dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx); + window.dispatch_action( + Box::new(zed_actions::OpenZedUrl { url: url.into() }), + cx, + ); } else { cx.open_url(&url); } @@ -1310,12 +1313,12 @@ impl Editor { return anyhow::Ok(Navigated::No); } for ranges in locations.values_mut() { - ranges.sort_by_key(|range| (range.start, Reverse(range.end))); + ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end))); ranges.dedup(); } let mut num_locations = 0; for ranges in locations.values_mut() { - ranges.sort_by_key(|range| (range.start, Reverse(range.end))); + ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end))); ranges.dedup(); num_locations += ranges.len(); } @@ -1628,7 +1631,7 @@ impl Editor { })?; let mut num_locations = 0; for ranges in locations.values_mut() { - ranges.sort_by_key(|range| (range.start, Reverse(range.end))); + ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end))); ranges.dedup(); // Merge overlapping or contained ranges. After sorting by // (start, Reverse(end)), we can merge in a single pass: @@ -1727,8 +1730,10 @@ impl Editor { Some(Either::Left(url)) => { cx.update(|window, cx| { if parse_zed_link(&url, cx).is_some() { - window - .dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx); + window.dispatch_action( + Box::new(zed_actions::OpenZedUrl { url: url.into() }), + cx, + ); } else { cx.open_url(&url); } diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index f067519e7343f8..ec7f9036c4a2d4 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -86,8 +86,13 @@ impl SharedScrollAnchor { let snapshot = if let Some(display_map_id) = self.display_map_id && display_map_id != snapshot.display_map_id { - let companion_snapshot = snapshot.companion_snapshot().unwrap(); - assert_eq!(companion_snapshot.display_map_id, display_map_id); + let companion_snapshot = snapshot + .companion_snapshot() + .expect("shared scroll anchor references a non native display map, but snapshot has no companion"); + assert_eq!( + companion_snapshot.display_map_id, display_map_id, + "shared scroll anchor display map should match the snapshot's split companion" + ); companion_snapshot } else { snapshot @@ -100,8 +105,13 @@ impl SharedScrollAnchor { let snapshot = if let Some(display_map_id) = self.display_map_id && display_map_id != snapshot.display_map_id { - let companion_snapshot = snapshot.companion_snapshot().unwrap(); - assert_eq!(companion_snapshot.display_map_id, display_map_id); + let companion_snapshot = snapshot + .companion_snapshot() + .expect("shared scroll anchor references a non native display map, but snapshot has no companion"); + assert_eq!( + companion_snapshot.display_map_id, display_map_id, + "shared scroll anchor display map should match the snapshot's split companion" + ); companion_snapshot } else { snapshot @@ -217,6 +227,7 @@ pub struct ScrollManager { visible_line_count: Option, visible_column_count: Option, forbid_vertical_scroll: bool, + notified_top_overscroll: bool, minimap_thumb_state: Option, _save_scroll_position_task: Task<()>, } @@ -240,6 +251,7 @@ impl ScrollManager { visible_line_count: None, visible_column_count: None, forbid_vertical_scroll: false, + notified_top_overscroll: false, minimap_thumb_state: None, _save_scroll_position_task: Task::ready(()), } @@ -292,8 +304,14 @@ impl ScrollManager { let mut result = if let Some(display_map_id) = shared.display_map_id && display_map_id != snapshot.display_map_id { - let companion_snapshot = snapshot.companion_snapshot().unwrap(); - assert_eq!(companion_snapshot.display_map_id, display_map_id); + let companion_snapshot = snapshot + .companion_snapshot() + .expect("shared scroll anchor references a non native display map, but the snapshot has no companion"); + assert_eq!( + companion_snapshot.display_map_id, display_map_id, + "shared scroll anchor display map should match the companion used for native anchor conversion" + ); + let mut display_point = shared .scroll_anchor .anchor @@ -335,6 +353,14 @@ impl ScrollManager { self.anchor = entity; } + pub fn unshare_scroll_anchor(&mut self, snapshot: &DisplaySnapshot, cx: &mut Context) { + let scroll_anchor = self.native_anchor(snapshot, cx); + self.anchor = cx.new(|_| SharedScrollAnchor { + scroll_anchor, + display_map_id: Some(snapshot.display_map_id), + }); + } + pub fn ongoing_scroll(&self) -> OngoingScroll { self.ongoing } @@ -344,6 +370,21 @@ impl ScrollManager { self.ongoing.axis = axis; } + pub fn should_notify_top_overscroll(&mut self, axis: Option) -> bool { + let now = Instant::now(); + let new_scroll = now.duration_since(self.ongoing.last_event) > SCROLL_EVENT_SEPARATION; + let axis_changed = self.ongoing.axis != axis; + let should_notify = !self.notified_top_overscroll || new_scroll || axis_changed; + self.ongoing.last_event = now; + self.ongoing.axis = axis; + self.notified_top_overscroll = true; + should_notify + } + + pub fn reset_top_overscroll_notification(&mut self) { + self.notified_top_overscroll = false; + } + pub fn scroll_position( &self, snapshot: &DisplaySnapshot, @@ -445,6 +486,7 @@ impl ScrollManager { return WasScrolled(false); } + self.notified_top_overscroll = false; self.anchor.update(cx, |shared, _| { shared.scroll_anchor = adjusted_anchor; shared.display_map_id = Some(display_map.display_map_id); diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs index e6c5a8bb1fbe56..11892a39a7906b 100644 --- a/crates/editor/src/selection.rs +++ b/crates/editor/src/selection.rs @@ -966,6 +966,43 @@ impl Editor { self.select_to_syntax_nodes(window, cx, true); } + pub fn select_inside_enclosing_bracket( + &mut self, + _: &SelectInsideEnclosingBracket, + window: &mut Window, + cx: &mut Context, + ) { + self.change_selections(Default::default(), window, cx, |s| { + s.move_offsets_with(&mut |snapshot, selection| { + let Some(enclosing_bracket_ranges) = + snapshot.enclosing_bracket_ranges(selection.start..selection.end) + else { + return; + }; + + let mut best = None; + let mut best_length = usize::MAX; + + for (open, close) in enclosing_bracket_ranges { + let inside = open.end..close.start; + if inside == (selection.start..selection.end) { + continue; + } + + let length = close.end - open.start; + if length < best_length { + best_length = length; + best = Some(inside); + } + } + + if let Some(inside) = best { + selection.set_head_tail(inside.end, inside.start, SelectionGoal::None); + } + }) + }); + } + pub fn move_to_enclosing_bracket( &mut self, _: &MoveToEnclosingBracket, @@ -1700,30 +1737,73 @@ impl Editor { let start_row = cmp::min(tail.row(), head.row()); let end_row = cmp::max(tail.row(), head.row()); - let start_column = cmp::min(tail.column(), goal_column); - let end_column = cmp::max(tail.column(), goal_column); - let reversed = start_column < tail.column(); + + // Anchor the columnar rectangle in x pixels rather than byte columns so + // rows with multi-byte characters (e.g. diacritics) stay visually aligned. + let text_layout_details = self.text_layout_details(window, cx); + + // The mouse handlers encode drags past a line's end as extra columns + // beyond the line length, in em layout widths (see + // `PositionMap::point_for_position`). `x_for_display_point` clamps at + // the line's width, so convert that overshoot back to pixels with the + // same unit to keep the rectangle tracking the mouse past short lines. + let font_id = text_layout_details + .text_system + .resolve_font(&text_layout_details.editor_style.text.font()); + let font_size = text_layout_details + .editor_style + .text + .font_size + .to_pixels(text_layout_details.rem_size); + let em_layout_width = text_layout_details + .text_system + .em_layout_width(font_id, font_size); + let x_for_unclipped_point = |point: DisplayPoint| { + let line_len = display_map.line_len(point.row()); + if point.column() > line_len { + let eol_x = display_map.x_for_display_point( + DisplayPoint::new(point.row(), line_len), + &text_layout_details, + ); + eol_x + em_layout_width * (point.column() - line_len) as f32 + } else { + display_map.x_for_display_point(point, &text_layout_details) + } + }; + + let tail_x = x_for_unclipped_point(tail); + let head_x = x_for_unclipped_point(DisplayPoint::new(head.row(), goal_column)); + let start_x = tail_x.min(head_x); + let end_x = tail_x.max(head_x); + let reversed = head_x < tail_x; let selection_ranges = (start_row.0..=end_row.0) .map(DisplayRow) .filter_map(|row| { - if (matches!(columnar_state, ColumnarSelectionState::FromMouse { .. }) - || start_column <= display_map.line_len(row)) - && !display_map.is_block_line(row) + if display_map.is_block_line(row) { + return None; + } + + let layout = display_map.layout_row(row, &text_layout_details); + if matches!(columnar_state, ColumnarSelectionState::FromSelection { .. }) + && start_x > layout.width { - let start = display_map - .clip_point(DisplayPoint::new(row, start_column), Bias::Left) - .to_point(display_map); - let end = display_map - .clip_point(DisplayPoint::new(row, end_column), Bias::Right) - .to_point(display_map); - if reversed { - Some(end..start) - } else { - Some(start..end) - } + return None; + } + + let start_column = layout.closest_index_for_x(start_x) as u32; + let end_column = layout.closest_index_for_x(end_x) as u32; + + let start = display_map + .clip_point(DisplayPoint::new(row, start_column), Bias::Left) + .to_point(display_map); + let end = display_map + .clip_point(DisplayPoint::new(row, end_column), Bias::Right) + .to_point(display_map); + if reversed { + Some(end..start) } else { - None + Some(start..end) } }) .collect::>(); diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index 6ec2fe6152c13e..99d2ed710b6617 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -382,6 +382,7 @@ impl SignatureHelpPopover { return div().into_any_element(); }; + let editor = cx.weak_entity(); let main_content = div() .occlude() .p_2() @@ -413,7 +414,20 @@ impl SignatureHelpPopover { markdown::WrapButtonVisibility::Hidden, border: false, }) - .on_url_click(open_markdown_url), + .on_url_click({ + let editor = editor.clone(); + move |link, window, cx| { + open_markdown_url( + editor + .read_with(cx, |editor, _| editor.workspace()) + .ok() + .flatten(), + link, + window, + cx, + ) + } + }), ) }, ) @@ -427,7 +441,17 @@ impl SignatureHelpPopover { markdown::WrapButtonVisibility::Hidden, border: false, }) - .on_url_click(open_markdown_url), + .on_url_click(move |link, window, cx| { + open_markdown_url( + editor + .read_with(cx, |editor, _| editor.workspace()) + .ok() + .flatten(), + link, + window, + cx, + ) + }), ) }), ) diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 11710744aa407b..2c3bd5f8dba18c 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -471,6 +471,12 @@ impl SplittableEditor { }); } + pub fn set_render_diff_hunks_as_unstaged(&self, cx: &mut Context) { + self.update_editors(cx, |editor, cx| { + editor.set_render_diff_hunks_as_unstaged(true, cx); + }); + } + fn focused_side(&self) -> SplitSide { if let Some(lhs) = &self.lhs && lhs.was_last_focused @@ -607,9 +613,11 @@ impl SplittableEditor { }); let render_diff_hunk_controls = self.rhs_editor.read(cx).render_diff_hunk_controls.clone(); + let render_diff_hunks_as_unstaged = self.rhs_editor.read(cx).render_diff_hunks_as_unstaged; let lhs_editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(lhs_multibuffer.clone(), Some(project.clone()), window, cx); + editor.set_render_diff_hunks_as_unstaged(render_diff_hunks_as_unstaged, cx); editor.set_number_deleted_lines(true, cx); editor.set_delegate_expand_excerpts(true); editor.set_delegate_stage_and_restore(true); @@ -1043,6 +1051,17 @@ impl SplittableEditor { let Some(lhs) = self.lhs.take() else { return; }; + + // Detach the stale lhs editor from the shared scroll anchor while the split companion still exists, + // so its anchor can be converted to lhs native before rhs tears down split specific state. + lhs.editor.update(cx, |editor, cx| { + let lhs_snapshot = editor.display_map.update(cx, |dm, cx| dm.snapshot(cx)); + editor + .scroll_manager + .unshare_scroll_anchor(&lhs_snapshot, cx); + editor.set_on_local_selections_changed(None); + }); + self.rhs_editor.update(cx, |rhs, cx| { let rhs_snapshot = rhs.display_map.update(cx, |dm, cx| dm.snapshot(cx)); let native_anchor = rhs.scroll_manager.native_anchor(&rhs_snapshot, cx); @@ -1064,9 +1083,6 @@ impl SplittableEditor { dm.set_companion(None, cx); }); }); - lhs.editor.update(cx, |editor, _cx| { - editor.set_on_local_selections_changed(None); - }); cx.notify(); } @@ -1319,19 +1335,108 @@ impl SplittableEditor { use crate::display_map::Block; use crate::display_map::DisplayRow; + let rhs_snapshot = self + .rhs_editor + .update(cx, |editor, cx| editor.display_snapshot(cx)); + + let Some(lhs) = self.lhs.as_ref() else { + assert!( + rhs_snapshot.companion_snapshot().is_none(), + "rhs display snapshot should not have a companion when unsplit" + ); + + let shared_scroll_anchor = self + .rhs_editor + .read(cx) + .scroll_manager + .shared_scroll_anchor(cx); + if let Some(display_map_id) = shared_scroll_anchor.display_map_id { + assert_eq!( + display_map_id, rhs_snapshot.display_map_id, + "unsplit editor should not retain a scroll anchor native to a torn-down split companion" + ); + } + + let _ = self + .rhs_editor + .read(cx) + .scroll_manager + .native_anchor(&rhs_snapshot, cx); + return; + }; + self.debug_print(cx); self.check_excerpt_invariants(quiesced, cx); - let lhs = self.lhs.as_ref().unwrap(); + let lhs_snapshot = lhs + .editor + .update(cx, |editor, cx| editor.display_snapshot(cx)); - if quiesced { - let lhs_snapshot = lhs - .editor - .update(cx, |editor, cx| editor.display_snapshot(cx)); - let rhs_snapshot = self - .rhs_editor - .update(cx, |editor, cx| editor.display_snapshot(cx)); + let lhs_companion = lhs_snapshot + .companion_snapshot() + .expect("lhs display snapshot should have rhs companion while split"); + assert_eq!( + lhs_companion.display_map_id, rhs_snapshot.display_map_id, + "lhs display snapshot companion should point to rhs display map" + ); + assert!( + lhs_companion.companion_snapshot().is_none(), + "embedded companion snapshot should not recursively contain another companion" + ); + let rhs_companion = rhs_snapshot + .companion_snapshot() + .expect("rhs display snapshot should have lhs companion while split"); + assert_eq!( + rhs_companion.display_map_id, lhs_snapshot.display_map_id, + "rhs display snapshot companion should point to lhs display map" + ); + assert!( + rhs_companion.companion_snapshot().is_none(), + "embedded companion snapshot should not recursively contain another companion" + ); + + let lhs_scroll_anchor_entity_id = lhs + .editor + .read(cx) + .scroll_manager + .scroll_anchor_entity() + .entity_id(); + let rhs_scroll_anchor_entity_id = self + .rhs_editor + .read(cx) + .scroll_manager + .scroll_anchor_entity() + .entity_id(); + assert_eq!( + lhs_scroll_anchor_entity_id, rhs_scroll_anchor_entity_id, + "split editors should share a scroll anchor entity" + ); + + let shared_scroll_anchor = self + .rhs_editor + .read(cx) + .scroll_manager + .shared_scroll_anchor(cx); + if let Some(display_map_id) = shared_scroll_anchor.display_map_id { + assert!( + display_map_id == lhs_snapshot.display_map_id + || display_map_id == rhs_snapshot.display_map_id, + "shared scroll anchor should be native to one side of the split" + ); + } + let _ = lhs + .editor + .read(cx) + .scroll_manager + .native_anchor(&lhs_snapshot, cx); + let _ = self + .rhs_editor + .read(cx) + .scroll_manager + .native_anchor(&rhs_snapshot, cx); + + if quiesced { let lhs_max_row = lhs_snapshot.max_point().row(); let rhs_max_row = rhs_snapshot.max_point().row(); assert_eq!(lhs_max_row, rhs_max_row, "mismatch in display row count"); @@ -2406,11 +2511,29 @@ mod tests { } } 75..=79 => { - log::info!("unsplit and resplit"); + log::info!("unsplit, scroll stale lhs, and resplit"); + let Some(lhs_editor) = editor.update(cx, |editor, _cx| { + editor.lhs.as_ref().map(|lhs| lhs.editor.clone()) + }) else { + continue; + }; + let lhs_max_row = lhs_editor.update(cx, |editor, cx| { + editor.display_snapshot(cx).max_point().row().0 + }); editor.update_in(cx, |editor, window, cx| { editor.unsplit(window, cx); }); cx.run_until_parked(); + + if lhs_max_row > 0 { + lhs_editor.update_in(cx, |editor, window, cx| { + editor.set_scroll_position(gpui::Point::new(0., 1.), window, cx); + }); + editor.update(cx, |editor, cx| { + editor.check_invariants(false, cx); + }); + } + editor.update_in(cx, |editor, window, cx| { editor.split(window, cx); }); @@ -2497,7 +2620,7 @@ mod tests { .collect::>(); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path(path, buffer.clone(), ranges, 0, diff.clone(), cx); }); cx.run_until_parked(); @@ -2554,7 +2677,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -2683,7 +2806,7 @@ mod tests { let (buffer2, diff2) = buffer_with_diff(&base_text2, &base_text2, &mut cx); editor.update(cx, |editor, cx| { - let path1 = PathKey::for_buffer(&buffer1, cx); + let path1 = PathKey::sorted(0); editor.update_excerpts_for_path( path1, buffer1.clone(), @@ -2692,7 +2815,7 @@ mod tests { diff1.clone(), cx, ); - let path2 = PathKey::for_buffer(&buffer2, cx); + let path2 = PathKey::sorted(1); editor.update_excerpts_for_path( path2, buffer2.clone(), @@ -2841,7 +2964,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -2968,7 +3091,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -3087,7 +3210,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -3217,7 +3340,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -3314,7 +3437,7 @@ mod tests { editor.update(cx, |editor, cx| { let end = Point::new(0, text.len() as u32); - let path1 = PathKey::for_buffer(&buffer1, cx); + let path1 = PathKey::sorted(0); editor.update_excerpts_for_path( path1, buffer1.clone(), @@ -3323,7 +3446,7 @@ mod tests { diff1.clone(), cx, ); - let path2 = PathKey::for_buffer(&buffer2, cx); + let path2 = PathKey::sorted(1); editor.update_excerpts_for_path( path2, buffer2.clone(), @@ -3391,7 +3514,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -3454,7 +3577,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -3515,7 +3638,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&text, &text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -3628,10 +3751,10 @@ mod tests { .unindent(); let buffer2 = cx.new(|cx| Buffer::local(current_text.to_string(), cx)); - let diff2 = cx.new(|cx| BufferDiff::new(&buffer2.read(cx).text_snapshot(), cx)); + let diff2 = cx.new(|cx| BufferDiff::new(&buffer2.read(cx).text_snapshot(), None, None, cx)); editor.update(cx, |editor, cx| { - let path1 = PathKey::for_buffer(&buffer1, cx); + let path1 = PathKey::sorted(0); editor.update_excerpts_for_path( path1, buffer1.clone(), @@ -3641,7 +3764,7 @@ mod tests { cx, ); - let path2 = PathKey::for_buffer(&buffer2, cx); + let path2 = PathKey::sorted(1); editor.update_excerpts_for_path( path2, buffer2.clone(), @@ -3739,7 +3862,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -3815,7 +3938,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -3902,7 +4025,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -4016,7 +4139,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -4100,7 +4223,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -4184,7 +4307,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&content, &content, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -4276,7 +4399,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -4404,7 +4527,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -4551,7 +4674,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -4773,7 +4896,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -5112,18 +5235,16 @@ mod tests { let buffer2_id = buffer2.read_with(cx, |buffer, _| buffer.remote_id()); editor.update(cx, |editor, cx| { - let path1 = PathKey::for_buffer(&buffer1, cx); editor.update_excerpts_for_path( - path1, + PathKey::sorted(0), buffer1.clone(), vec![Point::new(0, 0)..buffer1.read(cx).max_point()], 0, diff1.clone(), cx, ); - let path2 = PathKey::for_buffer(&buffer2, cx); editor.update_excerpts_for_path( - path2, + PathKey::sorted(1), buffer2.clone(), vec![Point::new(0, 0)..buffer2.read(cx).max_point()], 1, @@ -5277,7 +5398,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -5438,7 +5559,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -5597,7 +5718,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -5728,7 +5849,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -5789,7 +5910,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -5872,7 +5993,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), @@ -5981,8 +6102,8 @@ mod tests { let (buffer_a, diff_a) = buffer_with_diff(&base_text_a, ¤t_text_a, &mut cx); let (buffer_b, diff_b) = buffer_with_diff(&base_text_b, ¤t_text_b, &mut cx); - let path_a = cx.read(|cx| PathKey::for_buffer(&buffer_a, cx)); - let path_b = cx.read(|cx| PathKey::for_buffer(&buffer_b, cx)); + let path_a = PathKey::sorted(0); + let path_b = PathKey::sorted(1); editor.update(cx, |editor, cx| { editor.update_excerpts_for_path( @@ -6123,7 +6244,7 @@ mod tests { let (buffer, diff) = buffer_with_diff(&base_text, ¤t_text, &mut cx); editor.update(cx, |editor, cx| { - let path = PathKey::for_buffer(&buffer, cx); + let path = PathKey::sorted(0); editor.update_excerpts_for_path( path, buffer.clone(), diff --git a/crates/editor_benchmarks/src/main.rs b/crates/editor_benchmarks/src/main.rs index 81df55334014da..09e100bc08dfd5 100644 --- a/crates/editor_benchmarks/src/main.rs +++ b/crates/editor_benchmarks/src/main.rs @@ -15,6 +15,7 @@ struct Args { regex: bool, whole_word: bool, case_sensitive: bool, + single: bool, } fn parse_args() -> Args { @@ -26,6 +27,7 @@ fn parse_args() -> Args { regex: false, whole_word: false, case_sensitive: false, + single: false, }; let mut positional = Vec::new(); @@ -34,6 +36,7 @@ fn parse_args() -> Args { "--regex" => parsed.regex = true, "--whole-word" => parsed.whole_word = true, "--case-sensitive" => parsed.case_sensitive = true, + "--single" => parsed.single = true, "-r" | "--replace" => { parsed.replace = args_iter.next(); } @@ -105,6 +108,7 @@ fn main() { let query = Arc::new(query); let has_replacement = args.replace.is_some(); + let single = args.single; gpui_platform::headless().run(move |cx| { release_channel::init_test( @@ -152,8 +156,9 @@ fn main() { if has_replacement && !matches.is_empty() { window_handle.update(cx, |editor: &mut Editor, window, cx| { - let mut match_iter = matches.iter(); - println!("Replacing all matches..."); + let to_replace = if single { 1 } else { matches.len() }; + let mut match_iter = matches.iter().take(to_replace); + println!("Replacing {to_replace} matches..."); let timer = std::time::Instant::now(); editor.replace_all( &mut match_iter, @@ -163,10 +168,7 @@ fn main() { cx, ); let replace_elapsed = timer.elapsed(); - println!( - "Replaced {} matches in {replace_elapsed:?}", - matches.len() - ); + println!("Replaced {to_replace} matches in {replace_elapsed:?}"); })?; } diff --git a/crates/eval_cli/zed_eval/pyproject.toml b/crates/eval_cli/zed_eval/pyproject.toml index 07a61d7a13b7cb..e25ec9825c025f 100644 --- a/crates/eval_cli/zed_eval/pyproject.toml +++ b/crates/eval_cli/zed_eval/pyproject.toml @@ -3,7 +3,7 @@ name = "zed-eval" version = "0.1.0" description = "Harbor agent wrapper for Zed's eval-cli" requires-python = ">=3.12" -dependencies = ["harbor==0.7.0"] +dependencies = ["harbor==0.13.0"] [build-system] requires = ["setuptools"] diff --git a/crates/extension/src/extension_builder.rs b/crates/extension/src/extension_builder.rs index 2fc50434603578..6229da6b8dc231 100644 --- a/crates/extension/src/extension_builder.rs +++ b/crates/extension/src/extension_builder.rs @@ -1,21 +1,26 @@ use crate::{ - ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, build_debug_adapter_schema_path, - parse_wasm_extension_version, + ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, parse_wasm_extension_version, }; use ::fs::Fs; use anyhow::{Context as _, Result, bail}; -use futures::{StreamExt, io}; +use futures::{ + FutureExt, StreamExt, + channel::oneshot::{self, Sender}, + io, +}; use heck::ToSnakeCase; use http_client::{self, AsyncBody, HttpClient}; use language::LanguageConfig; +use semver::Version; use serde::Deserialize; use std::{ env, fs, mem, + num::NonZeroUsize, + ops::Not, path::{Path, PathBuf}, - str::FromStr, sync::Arc, }; -use util::{command::Stdio, rel_path::PathExt}; +use util::{ResultExt, command::Stdio, rel_path::PathExt}; use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _}; use wasmparser::Parser; @@ -44,13 +49,24 @@ pub struct ExtensionBuilder { pub http: Arc, } +pub enum CompilationConcurrency { + Unbounded, + Bounded(NonZeroUsize), +} + +const DEFAULT_COMPILATION_CONCURRENCY: NonZeroUsize = NonZeroUsize::new(3).unwrap(); + pub struct CompileExtensionOptions { pub release: bool, + pub max_concurrency: CompilationConcurrency, } impl CompileExtensionOptions { pub const fn dev() -> Self { - Self { release: false } + Self { + release: false, + max_concurrency: CompilationConcurrency::Bounded(DEFAULT_COMPILATION_CONCURRENCY), + } } } @@ -79,7 +95,9 @@ impl ExtensionBuilder { options: CompileExtensionOptions, fs: Arc, ) -> Result<()> { - populate_defaults(extension_manifest, extension_dir, fs).await?; + let start = std::time::Instant::now(); + + populate_defaults(extension_manifest, extension_dir, fs.clone()).await?; if extension_dir.is_relative() { bail!( @@ -88,58 +106,116 @@ impl ExtensionBuilder { ); } - fs::create_dir_all(&self.cache_dir).context("failed to create cache dir")?; + fs.create_dir(&self.cache_dir) + .await + .context("failed to create cache dir")?; - if extension_manifest.lib.kind == Some(ExtensionLibraryKind::Rust) { - log::info!("compiling Rust extension {}", extension_dir.display()); - self.compile_rust_extension(extension_dir, extension_manifest, options) - .await - .context("failed to compile Rust extension")?; - log::info!("compiled Rust extension {}", extension_dir.display()); - } + let (tx, mut rx) = oneshot::channel(); - for (debug_adapter_name, meta) in &mut extension_manifest.debug_adapters { - let debug_adapter_schema_path = - extension_dir.join(build_debug_adapter_schema_path(debug_adapter_name, meta)?); + let clang_path = extension_manifest.grammars.is_empty().not().then(|| { + std::iter::repeat_n( + async { + self.install_wasi_sdk_if_needed() + .await + .log_err() + .map(Arc::new) + } + .shared(), + extension_manifest.grammars.len(), + ) + }); + + let rust_compilation_task = + (extension_manifest.lib.kind == Some(ExtensionLibraryKind::Rust)).then(|| { + async { + log::info!("compiling Rust extension {}", extension_dir.display()); + self.compile_rust_extension(extension_dir, extension_manifest, tx, &options) + .await + .context("failed to compile Rust extension")?; + + log::info!("compiled Rust extension {}", extension_dir.display()); + Ok(()) + } + .boxed() + }); + + let grammar_compilation_tasks = extension_manifest + .grammars + .iter() + .zip(clang_path.into_iter().flatten()) + .map(|((grammar_name, grammar_metadata), clang_path_task)| { + async move { + let snake_cased_grammar_name = grammar_name.to_snake_case(); + if grammar_name.as_ref() != snake_cased_grammar_name.as_str() { + bail!( + "grammar name '{grammar_name}' must be \ + written in snake_case: {snake_cased_grammar_name}" + ); + } - let debug_adapter_schema = fs::read_to_string(&debug_adapter_schema_path) - .with_context(|| { - format!("failed to read debug adapter schema for `{debug_adapter_name}` from `{debug_adapter_schema_path:?}`") - })?; - _ = serde_json::Value::from_str(&debug_adapter_schema).with_context(|| { - format!("Debug adapter schema for `{debug_adapter_name}` (path: `{debug_adapter_schema_path:?}`) is not a valid JSON") - })?; - } - for (grammar_name, grammar_metadata) in &extension_manifest.grammars { - let snake_cased_grammar_name = grammar_name.to_snake_case(); - if grammar_name.as_ref() != snake_cased_grammar_name.as_str() { - bail!( - "grammar name '{grammar_name}' must be written in snake_case: {snake_cased_grammar_name}" - ); + log::info!( + "compiling grammar {grammar_name} for extension {}", + extension_dir.display() + ); + + let clang_path = clang_path_task + .await + .context("Failed to resolve clang path")?; + + self.compile_grammar( + extension_dir, + grammar_name.as_ref(), + grammar_metadata, + &clang_path, + ) + .await + .with_context(|| format!("failed to compile grammar '{grammar_name}'"))?; + log::info!( + "compiled grammar {grammar_name} for extension {}", + extension_dir.display() + ); + + Ok(()) + } + .boxed() + }); + + let tasks = rust_compilation_task + .into_iter() + .chain(grammar_compilation_tasks) + .collect::>(); + + match options.max_concurrency { + CompilationConcurrency::Unbounded => { + futures::future::try_join_all(tasks).await?; } + CompilationConcurrency::Bounded(max_concurrency) => { + let mut stream = futures::stream::iter(tasks).buffered(max_concurrency.get()); - log::info!( - "compiling grammar {grammar_name} for extension {}", - extension_dir.display() - ); - self.compile_grammar(extension_dir, grammar_name.as_ref(), grammar_metadata) - .await - .with_context(|| format!("failed to compile grammar '{grammar_name}'"))?; - log::info!( - "compiled grammar {grammar_name} for extension {}", - extension_dir.display() - ); + while let Some(result) = stream.next().await { + result?; + } + } } - log::info!("finished compiling extension {}", extension_dir.display()); + if let Ok(version) = rx.try_recv() { + extension_manifest.lib.version = version; + } + + log::info!( + "finished compiling extension {} in {time:.2}s", + extension_dir.display(), + time = start.elapsed().as_secs_f64(), + ); Ok(()) } async fn compile_rust_extension( &self, extension_dir: &Path, - manifest: &mut ExtensionManifest, - options: CompileExtensionOptions, + manifest: &ExtensionManifest, + wasm_extension_api_version_tx: Sender, + options: &CompileExtensionOptions, ) -> anyhow::Result<()> { self.install_rust_wasm_target_if_needed().await?; @@ -201,7 +277,9 @@ impl ExtensionBuilder { let wasm_extension_api_version = parse_wasm_extension_version(&manifest.id, &component_bytes) .context("compiled wasm did not contain a valid zed extension api version")?; - manifest.lib.version = Some(wasm_extension_api_version); + wasm_extension_api_version_tx + .send(wasm_extension_api_version) + .map_err(|_| anyhow::anyhow!("Failed to send API version"))?; let extension_file = extension_dir.join("extension.wasm"); fs::write(extension_file.clone(), &component_bytes) @@ -221,9 +299,8 @@ impl ExtensionBuilder { extension_dir: &Path, grammar_name: &str, grammar_metadata: &GrammarManifestEntry, + clang_path: &Path, ) -> Result<()> { - let clang_path = self.install_wasi_sdk_if_needed().await?; - let mut grammar_repo_dir = extension_dir.to_path_buf(); grammar_repo_dir.extend(["grammars", grammar_name]); diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index c4f7ecd07e7477..f99b1cad9100fe 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -409,14 +409,14 @@ fn manifest_from_old_manifest( lib: Default::default(), themes: { let mut themes = manifest_json.themes.into_values().collect::>(); - themes.sort(); + themes.sort_unstable(); themes.dedup(); themes }, icon_themes: Vec::new(), languages: { let mut languages = manifest_json.languages.into_values().collect::>(); - languages.sort(); + languages.sort_unstable(); languages.dedup(); languages }, diff --git a/crates/extension_api/wit/since_v0.8.0/platform.wit b/crates/extension_api/wit/since_v0.8.0/platform.wit index 48472a99bc175f..6bdd0a45fb0e79 100644 --- a/crates/extension_api/wit/since_v0.8.0/platform.wit +++ b/crates/extension_api/wit/since_v0.8.0/platform.wit @@ -13,8 +13,6 @@ interface platform { enum architecture { /// AArch64 (e.g., Apple Silicon). aarch64, - /// x86. - x86, /// x86-64. x8664, } diff --git a/crates/extension_cli/Cargo.toml b/crates/extension_cli/Cargo.toml index 2170647845b291..623187faaa3fec 100644 --- a/crates/extension_cli/Cargo.toml +++ b/crates/extension_cli/Cargo.toml @@ -18,6 +18,7 @@ clap = { workspace = true, features = ["derive"] } cloud_api_types.workspace = true env_logger.workspace = true extension.workspace = true +futures.workspace = true fs.workspace = true gpui_platform.workspace = true language.workspace = true diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 0b7c491ec11a22..921990d284c37b 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -3,12 +3,15 @@ use std::collections::HashMap; use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::str::FromStr as _; use std::sync::Arc; -use ::fs::{CopyOptions, Fs, RealFs, copy_recursive}; +use ::fs::{CopyOptions, Fs, RealFs, RemoveOptions, copy_recursive}; use anyhow::{Context as _, Result, anyhow, bail}; use clap::Parser; use cloud_api_types::ExtensionProvides; +use extension::build_debug_adapter_schema_path; +use extension::extension_builder::CompilationConcurrency; use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use extension::{ExtensionManifest, ExtensionSnippets}; use language::LanguageConfig; @@ -47,6 +50,11 @@ async fn main() -> Result<()> { .source_dir .canonicalize() .context("failed to canonicalize source_dir")?; + + fs.create_dir(&args.scratch_dir) + .await + .context("failed to create scratch dir")?; + let scratch_dir = args .scratch_dir .canonicalize() @@ -75,7 +83,10 @@ async fn main() -> Result<()> { .compile_extension( &extension_path, &mut manifest, - CompileExtensionOptions { release: true }, + CompileExtensionOptions { + release: true, + max_concurrency: CompilationConcurrency::Unbounded, + }, fs.clone(), ) .await @@ -88,9 +99,18 @@ async fn main() -> Result<()> { test_languages(&manifest, &extension_path, &grammars)?; test_themes(&manifest, &extension_path, fs.clone()).await?; test_snippets(&manifest, &extension_path, fs.clone()).await?; + test_debug_adapter_schemas(&manifest, &extension_path, fs.clone()).await?; let archive_dir = output_dir.join("archive"); - fs::remove_dir_all(&archive_dir).ok(); + fs.remove_dir( + &archive_dir, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + .ok(); copy_extension_resources(&manifest, &extension_path, &archive_dir, fs.clone()) .await .context("failed to copy extension resources")?; @@ -120,8 +140,16 @@ async fn main() -> Result<()> { wasm_api_version: manifest.lib.version.map(|version| version.to_string()), provides: extension_provides, })?; - fs::remove_dir_all(&archive_dir)?; - fs::write(output_dir.join("manifest.json"), manifest_json.as_bytes())?; + fs.remove_dir( + &archive_dir, + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await?; + fs.write(&output_dir.join("manifest.json"), manifest_json.as_bytes()) + .await?; Ok(()) } @@ -132,68 +160,107 @@ async fn copy_extension_resources( output_dir: &Path, fs: Arc, ) -> Result<()> { - fs::create_dir_all(output_dir).context("failed to create output dir")?; + fs.create_dir(output_dir) + .await + .context("failed to create output dir")?; let manifest_toml = toml::to_string(&manifest).context("failed to serialize manifest")?; - fs::write(output_dir.join("extension.toml"), &manifest_toml) + fs.write(&output_dir.join("extension.toml"), manifest_toml.as_bytes()) + .await .context("failed to write extension.toml")?; if manifest.lib.kind.is_some() { - fs::copy( - extension_path.join("extension.wasm"), - output_dir.join("extension.wasm"), + fs.copy_file( + &extension_path.join("extension.wasm"), + &output_dir.join("extension.wasm"), + CopyOptions { + overwrite: true, + ignore_if_exists: false, + }, ) + .await .context("failed to copy extension.wasm")?; } if !manifest.grammars.is_empty() { let source_grammars_dir = extension_path.join("grammars"); let output_grammars_dir = output_dir.join("grammars"); - fs::create_dir_all(&output_grammars_dir)?; - for grammar_name in manifest.grammars.keys() { - let mut grammar_filename = PathBuf::from(grammar_name.as_ref()); - grammar_filename.set_extension("wasm"); - fs::copy( - source_grammars_dir.join(&grammar_filename), - output_grammars_dir.join(&grammar_filename), - ) - .with_context(|| format!("failed to copy grammar '{}'", grammar_filename.display()))?; - } + fs.create_dir(&output_grammars_dir).await?; + futures::future::try_join_all(manifest.grammars.keys().map(|grammar_name| { + let fs = fs.clone(); + let source_grammars_dir = source_grammars_dir.as_path(); + let output_grammars_dir = output_grammars_dir.as_path(); + async move { + let mut grammar_filename = PathBuf::from(grammar_name.as_ref()); + grammar_filename.set_extension("wasm"); + fs.copy_file( + &source_grammars_dir.join(&grammar_filename), + &output_grammars_dir.join(&grammar_filename), + CopyOptions { + overwrite: true, + ignore_if_exists: false, + }, + ) + .await + .with_context(|| format!("failed to copy grammar '{}'", grammar_filename.display())) + } + })) + .await?; } if !manifest.themes.is_empty() { let output_themes_dir = output_dir.join("themes"); - fs::create_dir_all(&output_themes_dir)?; - for theme_path in &manifest.themes { - let theme_path = theme_path.as_std_path(); - fs::copy( - extension_path.join(theme_path), - output_themes_dir.join(theme_path.file_name().context("invalid theme path")?), - ) - .with_context(|| format!("failed to copy theme '{}'", theme_path.display()))?; - } + fs.create_dir(&output_themes_dir).await?; + futures::future::try_join_all(manifest.themes.iter().map(|theme_path| { + let fs = fs.clone(); + let output_themes_dir = output_themes_dir.as_path(); + async move { + let theme_path = theme_path.as_std_path(); + fs.copy_file( + &extension_path.join(theme_path), + &output_themes_dir.join(theme_path.file_name().context("invalid theme path")?), + CopyOptions { + overwrite: true, + ignore_if_exists: false, + }, + ) + .await + .with_context(|| format!("failed to copy theme '{}'", theme_path.display())) + } + })) + .await?; } if !manifest.icon_themes.is_empty() { let output_icon_themes_dir = output_dir.join("icon_themes"); - fs::create_dir_all(&output_icon_themes_dir)?; - for icon_theme_path in &manifest.icon_themes { - let icon_theme_path = icon_theme_path.as_std_path(); - fs::copy( - extension_path.join(icon_theme_path), - output_icon_themes_dir.join( - icon_theme_path - .file_name() - .context("invalid icon theme path")?, - ), - ) - .with_context(|| { - format!("failed to copy icon theme '{}'", icon_theme_path.display()) - })?; - } + fs.create_dir(&output_icon_themes_dir).await?; + futures::future::try_join_all(manifest.icon_themes.iter().map(|icon_theme_path| { + let fs = fs.clone(); + let output_icon_themes_dir = output_icon_themes_dir.as_path(); + async move { + let icon_theme_path = icon_theme_path.as_std_path(); + fs.copy_file( + &extension_path.join(icon_theme_path), + &output_icon_themes_dir.join( + icon_theme_path + .file_name() + .context("invalid icon theme path")?, + ), + CopyOptions { + overwrite: true, + ignore_if_exists: false, + }, + ) + .await + .with_context(|| { + format!("failed to copy icon theme '{}'", icon_theme_path.display()) + }) + } + })) + .await?; let output_icons_dir = output_dir.join("icons"); - fs::create_dir_all(&output_icons_dir)?; + fs.create_dir(&output_icons_dir).await?; copy_recursive( fs.as_ref(), &extension_path.join("icons"), @@ -209,73 +276,90 @@ async fn copy_extension_resources( if !manifest.languages.is_empty() { let output_languages_dir = output_dir.join("languages"); - fs::create_dir_all(&output_languages_dir)?; - for language_path in &manifest.languages { - let language_path = language_path.as_std_path(); - copy_recursive( - fs.as_ref(), - &extension_path.join(language_path), - &output_languages_dir - .join(language_path.file_name().context("invalid language path")?), - CopyOptions { - overwrite: true, - ignore_if_exists: false, - }, - ) - .await - .with_context(|| { - format!("failed to copy language dir '{}'", language_path.display()) - })?; - } + fs.create_dir(&output_languages_dir).await?; + futures::future::try_join_all(manifest.languages.iter().map(|language_path| { + let fs = fs.clone(); + let output_languages_dir = output_languages_dir.clone(); + async move { + let language_path = language_path.as_std_path(); + copy_recursive( + fs.as_ref(), + &extension_path.join(language_path), + &output_languages_dir + .join(language_path.file_name().context("invalid language path")?), + CopyOptions { + overwrite: true, + ignore_if_exists: false, + }, + ) + .await + .with_context(|| { + format!("failed to copy language dir '{}'", language_path.display()) + }) + } + })) + .await?; } if !manifest.debug_adapters.is_empty() { - for (debug_adapter, entry) in &manifest.debug_adapters { - let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, entry)?; - let parent = schema_path - .parent() - .with_context(|| format!("invalid empty schema path for {debug_adapter}"))?; - let schema_path = schema_path.as_std_path(); - fs::create_dir_all(output_dir.join(parent))?; - copy_recursive( - fs.as_ref(), - &extension_path.join(&schema_path), - &output_dir.join(&schema_path), - CopyOptions { - overwrite: true, - ignore_if_exists: false, - }, - ) - .await - .with_context(|| { - format!( - "failed to copy debug adapter schema '{}'", - schema_path.display(), - ) - })?; - } + futures::future::try_join_all(manifest.debug_adapters.iter().map( + |(debug_adapter, entry)| { + let fs = fs.clone(); + let debug_adapter = debug_adapter.clone(); + async move { + let schema_path = + extension::build_debug_adapter_schema_path(&debug_adapter, &entry)?; + let parent = schema_path.parent().with_context(|| { + format!("invalid empty schema path for {debug_adapter}") + })?; + let schema_path = schema_path.as_std_path(); + fs.create_dir(&output_dir.join(parent)).await?; + copy_recursive( + fs.as_ref(), + &extension_path.join(schema_path), + &output_dir.join(schema_path), + CopyOptions { + overwrite: true, + ignore_if_exists: false, + }, + ) + .await + .with_context(|| { + format!( + "failed to copy debug adapter schema '{}'", + schema_path.display(), + ) + }) + } + }, + )) + .await?; } if let Some(snippets) = manifest.snippets.as_ref() { - for snippets_path in snippets.paths() { - let parent = snippets_path.parent(); - if let Some(parent) = parent.filter(|p| p.components().next().is_some()) { - fs::create_dir_all(output_dir.join(parent))?; + futures::future::try_join_all(snippets.paths().map(|snippets_path| { + let fs = fs.clone(); + async move { + let parent = snippets_path.parent(); + if let Some(parent) = parent.filter(|p| p.components().next().is_some()) { + fs.create_dir(&output_dir.join(parent)).await?; + } + copy_recursive( + fs.as_ref(), + &extension_path.join(&snippets_path), + &output_dir.join(&snippets_path), + CopyOptions { + overwrite: true, + ignore_if_exists: false, + }, + ) + .await + .with_context(|| { + format!("failed to copy snippets from '{}'", snippets_path.display()) + }) } - copy_recursive( - fs.as_ref(), - &extension_path.join(&snippets_path), - &output_dir.join(&snippets_path), - CopyOptions { - overwrite: true, - ignore_if_exists: false, - }, - ) - .await - .with_context(|| { - format!("failed to copy snippets from '{}'", snippets_path.display()) - })?; - } + })) + .await?; } Ok(()) @@ -486,6 +570,40 @@ async fn test_snippets( Ok(()) } +async fn test_debug_adapter_schemas( + manifest: &ExtensionManifest, + extension_path: &Path, + fs: Arc, +) -> Result<()> { + futures::future::try_join_all(manifest.debug_adapters.iter().map( + |(debug_adapter_name, meta)| { + let fs = fs.clone(); + async move { + let debug_adapter_schema_path = + extension_path.join(build_debug_adapter_schema_path(debug_adapter_name, meta)?); + + let debug_adapter_schema = + fs.load(&debug_adapter_schema_path).await.with_context(|| { + anyhow::anyhow!( + "failed to read debug adapter schema for \ + `{debug_adapter_name}` from `{debug_adapter_schema_path:?}`" + ) + })?; + _ = serde_json::Value::from_str(&debug_adapter_schema).with_context(|| { + anyhow::anyhow!( + "Debug adapter schema for `{debug_adapter_name}`\ + (path: `{debug_adapter_schema_path:?}`) is not a valid JSON" + ) + })?; + + Ok(()) + } + }, + )) + .await + .map(|_| ()) +} + #[cfg(test)] mod tests { use cloud_api_types::ExtensionProvides; diff --git a/crates/extension_host/benches/extension_compilation_benchmark.rs b/crates/extension_host/benches/extension_compilation_benchmark.rs index b6dade97183ca5..e8abdffbd86309 100644 --- a/crates/extension_host/benches/extension_compilation_benchmark.rs +++ b/crates/extension_host/benches/extension_compilation_benchmark.rs @@ -4,7 +4,7 @@ use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_ma use extension::{ ExtensionCapability, ExtensionHostProxy, ExtensionLibraryKind, ExtensionManifest, LanguageServerManifestEntry, LibManifestEntry, SchemaVersion, - extension_builder::{CompileExtensionOptions, ExtensionBuilder}, + extension_builder::{CompilationConcurrency, CompileExtensionOptions, ExtensionBuilder}, }; use extension_host::wasm_host::WasmHost; use fs::{Fs, RealFs}; @@ -76,7 +76,10 @@ fn wasm_bytes(cx: &TestAppContext, manifest: &mut ExtensionManifest, fs: Arc Result<(Os, Architecture)> { - latest::zed::extension::platform::Host::current_platform(self).await + since_v0_6_0::zed::extension::platform::Host::current_platform(self).await } async fn set_language_server_installation_status( diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs index 41d652cec3087e..b967af4ebf2837 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs @@ -18,9 +18,9 @@ wasmtime::component::bindgen!({ }, path: "../extension_api/wit/since_v0.0.4", with: { - "worktree": ExtensionWorktree, - "zed:extension/github": since_v0_6_0::zed::extension::github, - "zed:extension/platform": latest::zed::extension::platform, + "worktree": ExtensionWorktree, + "zed:extension/github": since_v0_6_0::zed::extension::github, + "zed:extension/platform": since_v0_6_0::zed::extension::platform, }, }); @@ -141,7 +141,7 @@ impl ExtensionImports for WasmState { } async fn current_platform(&mut self) -> Result<(Os, Architecture)> { - latest::zed::extension::platform::Host::current_platform(self).await + since_v0_6_0::zed::extension::platform::Host::current_platform(self).await } async fn set_language_server_installation_status( diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs index e1dfdf8248b41d..cd731372276372 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs @@ -18,11 +18,11 @@ wasmtime::component::bindgen!({ }, path: "../extension_api/wit/since_v0.0.6", with: { - "worktree": ExtensionWorktree, - "zed:extension/github": since_v0_6_0::zed::extension::github, - "zed:extension/lsp": since_v0_1_0::zed::extension::lsp, - "zed:extension/nodejs": latest::zed::extension::nodejs, - "zed:extension/platform": latest::zed::extension::platform, + "worktree": ExtensionWorktree, + "zed:extension/github": since_v0_6_0::zed::extension::github, + "zed:extension/lsp": since_v0_1_0::zed::extension::lsp, + "zed:extension/nodejs": latest::zed::extension::nodejs, + "zed:extension/platform": since_v0_6_0::zed::extension::platform, }, }); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs index fca4dca2459e33..288b31b2202c46 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs @@ -34,13 +34,13 @@ wasmtime::component::bindgen!({ }, path: "../extension_api/wit/since_v0.1.0", with: { - "worktree": ExtensionWorktree, - "key-value-store": ExtensionKeyValueStore, - "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream, - "zed:extension/github": since_v0_6_0::zed::extension::github, - "zed:extension/nodejs": latest::zed::extension::nodejs, - "zed:extension/platform": latest::zed::extension::platform, - "zed:extension/slash-command": latest::zed::extension::slash_command, + "worktree": ExtensionWorktree, + "key-value-store": ExtensionKeyValueStore, + "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream, + "zed:extension/github": since_v0_6_0::zed::extension::github, + "zed:extension/nodejs": latest::zed::extension::nodejs, + "zed:extension/platform": since_v0_6_0::zed::extension::platform, + "zed:extension/slash-command": latest::zed::extension::slash_command, }, }); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs index 691e6d2dd549b6..1575c8a1d87c0f 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs @@ -19,15 +19,15 @@ wasmtime::component::bindgen!({ }, path: "../extension_api/wit/since_v0.2.0", with: { - "worktree": ExtensionWorktree, - "project": ExtensionProject, - "key-value-store": ExtensionKeyValueStore, - "zed:extension/github": since_v0_6_0::zed::extension::github, - "zed:extension/http-client": latest::zed::extension::http_client, - "zed:extension/lsp": since_v0_6_0::zed::extension::lsp, - "zed:extension/nodejs": latest::zed::extension::nodejs, - "zed:extension/platform": latest::zed::extension::platform, - "zed:extension/slash-command": latest::zed::extension::slash_command, + "worktree": ExtensionWorktree, + "project": ExtensionProject, + "key-value-store": ExtensionKeyValueStore, + "zed:extension/github": since_v0_6_0::zed::extension::github, + "zed:extension/http-client": latest::zed::extension::http_client, + "zed:extension/lsp": since_v0_6_0::zed::extension::lsp, + "zed:extension/nodejs": latest::zed::extension::nodejs, + "zed:extension/platform": since_v0_6_0::zed::extension::platform, + "zed:extension/slash-command": latest::zed::extension::slash_command, }, }); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs index 53aa65d5187663..36c48779509e5d 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs @@ -19,17 +19,17 @@ wasmtime::component::bindgen!({ }, path: "../extension_api/wit/since_v0.3.0", with: { - "worktree": ExtensionWorktree, - "project": ExtensionProject, - "key-value-store": ExtensionKeyValueStore, - "zed:extension/common": latest::zed::extension::common, - "zed:extension/github": since_v0_6_0::zed::extension::github, - "zed:extension/http-client": latest::zed::extension::http_client, - "zed:extension/lsp": since_v0_6_0::zed::extension::lsp, - "zed:extension/nodejs": latest::zed::extension::nodejs, - "zed:extension/platform": latest::zed::extension::platform, - "zed:extension/process": latest::zed::extension::process, - "zed:extension/slash-command": latest::zed::extension::slash_command, + "worktree": ExtensionWorktree, + "project": ExtensionProject, + "key-value-store": ExtensionKeyValueStore, + "zed:extension/common": latest::zed::extension::common, + "zed:extension/github": since_v0_6_0::zed::extension::github, + "zed:extension/http-client": latest::zed::extension::http_client, + "zed:extension/lsp": since_v0_6_0::zed::extension::lsp, + "zed:extension/nodejs": latest::zed::extension::nodejs, + "zed:extension/platform": since_v0_6_0::zed::extension::platform, + "zed:extension/process": latest::zed::extension::process, + "zed:extension/slash-command": latest::zed::extension::slash_command, }, }); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs index 44b7d7ba1ad4e3..75ab8c2a019783 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs @@ -27,7 +27,7 @@ wasmtime::component::bindgen!({ "zed:extension/http-client": latest::zed::extension::http_client, "zed:extension/lsp": since_v0_6_0::zed::extension::lsp, "zed:extension/nodejs": latest::zed::extension::nodejs, - "zed:extension/platform": latest::zed::extension::platform, + "zed:extension/platform": since_v0_6_0::zed::extension::platform, "zed:extension/process": latest::zed::extension::process, "zed:extension/slash-command": latest::zed::extension::slash_command, }, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs index 4dff0d90a94fe1..bfb75ee205dcb8 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs @@ -27,7 +27,7 @@ wasmtime::component::bindgen!({ "zed:extension/http-client": latest::zed::extension::http_client, "zed:extension/lsp": since_v0_6_0::zed::extension::lsp, "zed:extension/nodejs": latest::zed::extension::nodejs, - "zed:extension/platform": latest::zed::extension::platform, + "zed:extension/platform": since_v0_6_0::zed::extension::platform, "zed:extension/process": latest::zed::extension::process, "zed:extension/slash-command": latest::zed::extension::slash_command, "zed:extension/context-server": latest::zed::extension::context_server, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index 91d446e1637bac..76f93020be60b0 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -26,7 +26,6 @@ wasmtime::component::bindgen!({ "zed:extension/common": latest::zed::extension::common, "zed:extension/http-client": latest::zed::extension::http_client, "zed:extension/nodejs": latest::zed::extension::nodejs, - "zed:extension/platform": latest::zed::extension::platform, "zed:extension/process": latest::zed::extension::process, "zed:extension/slash-command": latest::zed::extension::slash_command, "zed:extension/context-server": latest::zed::extension::context_server, @@ -384,6 +383,33 @@ impl ExtensionImports for WasmState { } } +impl From for platform::Architecture { + fn from(value: latest::platform::Architecture) -> Self { + match value { + latest::platform::Architecture::Aarch64 => Self::Aarch64, + latest::platform::Architecture::X8664 => Self::X8664, + } + } +} + +impl From for platform::Os { + fn from(value: latest::platform::Os) -> Self { + match value { + latest::platform::Os::Linux => Self::Linux, + latest::platform::Os::Mac => Self::Mac, + latest::platform::Os::Windows => Self::Windows, + } + } +} + +impl platform::Host for WasmState { + async fn current_platform(&mut self) -> Result<(platform::Os, platform::Architecture)> { + latest::platform::Host::current_platform(self) + .await + .map(|(os, arch)| (os.into(), arch.into())) + } +} + impl From for latest::dap::TcpArguments { fn from(value: dap::TcpArguments) -> Self { let [a, b, c, d] = std::net::Ipv4Addr::from_bits(value.host).octets(); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs index 8da53ca638c004..15e5ff94062094 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -865,13 +865,12 @@ impl platform::Host for WasmState { "macos" => platform::Os::Mac, "linux" => platform::Os::Linux, "windows" => platform::Os::Windows, - _ => panic!("unsupported os"), + _ => bail!("unsupported os"), }, match env::consts::ARCH { "aarch64" => platform::Architecture::Aarch64, - "x86" => platform::Architecture::X86, "x86_64" => platform::Architecture::X8664, - _ => panic!("unsupported architecture"), + _ => bail!("unsupported architecture"), }, )) } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 0202756d1c5a16..daa470c0ae4ce9 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -132,7 +132,12 @@ pub fn init(cx: &mut App) { Err(err) => { workspace_handle .update(cx, |workspace, cx| { - workspace.show_portal_error(err.to_string(), cx); + workspace.show_error( + workspace::workspace_error::PortalError::new( + err.to_string(), + ), + cx, + ); }) .ok(); return None; @@ -149,10 +154,10 @@ pub fn init(cx: &mut App) { log::error!("Failed to install dev extension: {:?}", err); workspace_handle .update(cx, |workspace, cx| { + // NOTE: using `anyhow::context` here ends up not printing + // the error workspace.show_error( - // NOTE: using `anyhow::context` here ends up not printing - // the error - &format!("Failed to install dev extension: {}", err), + format!("Failed to install dev extension: {}", err), cx, ); }) diff --git a/crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.mod b/crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.mod index 0042a4d65c7a39..913596269cc222 100644 --- a/crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.mod +++ b/crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.mod @@ -70,8 +70,6 @@ require ( github.com/getkin/kin-openapi v0.133.0 // indirect github.com/getsentry/sentry-go v0.25.0 // indirect github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab // indirect - github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-co-op/gocron/v2 v2.11.0 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -114,6 +112,7 @@ require ( github.com/gomlx/onnx-gomlx v0.4.2-0.20260327164137-4e2832549fc1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-github/v57 v57.0.0 // indirect + github.com/google/go-github/v61 v61.0.0 // indirect github.com/google/go-github/v62 v62.0.0 // indirect github.com/google/go-github/v75 v75.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -206,7 +205,6 @@ require ( github.com/pkoukk/tiktoken-go v0.1.6 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/redis/go-redis/v9 v9.18.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.2-0.20210106135023-bc59245fe10e // indirect github.com/rs/zerolog v1.35.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -235,7 +233,6 @@ require ( github.com/tiktoken-go/tokenizer v0.6.2 // indirect github.com/tmc/langchaingo v0.1.12 // indirect github.com/tsawler/tabula v1.6.6 // indirect - github.com/tylermmorton/tmpl v1.1.0 // indirect github.com/unknwon/com v1.0.1 // indirect github.com/viant/afs v1.30.0 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect @@ -306,10 +303,6 @@ require ( gorm.io/driver/sqlite v1.6.0 // indirect gorm.io/gorm v1.31.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect - modernc.org/libc v1.22.5 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/sqlite v1.23.1 // indirect ) // During development, use local helix source (assumes zed and helix are sibling directories). diff --git a/crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.sum b/crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.sum index 6271a36fc9e97c..f2706c7085bf01 100644 --- a/crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.sum +++ b/crates/external_websocket_sync/e2e-test/helix-ws-test-server/go.sum @@ -310,10 +310,6 @@ github.com/getzep/zep-go v1.0.4 h1:09o26bPP2RAPKFjWuVWwUWLbtFDF/S8bfbilxzeZAAg= github.com/getzep/zep-go v1.0.4/go.mod h1:HC1Gz7oiyrzOTvzeKC4dQKUiUy87zpIJl0ZFXXdHuss= github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab h1:+7KwW/yy/ThnRXW9khailFFncxJiiFpxyk5BI9GK9pI= github.com/gfleury/go-bitbucket-v1 v0.0.0-20230825095122-9bc1711434ab/go.mod h1:IqOZzks2wlWCIai0esXnZPdPwxF2yOz0HcCYw5I4pCg= -github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= -github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= @@ -470,6 +466,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs= github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85PimQh4oygmLIxHw= +github.com/google/go-github/v61 v61.0.0 h1:VwQCBwhyE9JclCI+22/7mLB1PuU9eowCXKY5pNlu1go= +github.com/google/go-github/v61 v61.0.0/go.mod h1:0WR+KmsWX75G2EbpyGsGmradjo3IiciuI4BmdVCobQY= github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= github.com/google/go-github/v75 v75.0.0 h1:k7q8Bvg+W5KxRl9Tjq16a9XEgVY1pwuiG5sIL7435Ic= @@ -778,9 +776,6 @@ github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDa github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -891,8 +886,6 @@ github.com/tmc/langchaingo v0.1.12 h1:yXwSu54f3b1IKw0jJ5/DWu+qFVH1NBblwC0xddBzGJ github.com/tmc/langchaingo v0.1.12/go.mod h1:cd62xD6h+ouk8k/QQFhOsjRYBSA1JJ5UVKXSIgm7Ni4= github.com/tsawler/tabula v1.6.6 h1:B2W1Iindrg58/VTLp30LPA2NCzxgxAE6Ne6RG693GHY= github.com/tsawler/tabula v1.6.6/go.mod h1:CzvlQnJQLM2C6Cq0gRhP5z9kq9u9iM6s494QEHCmCbw= -github.com/tylermmorton/tmpl v1.1.0 h1:IRdkWtoHnGDx/l69u9YbJoW3vt9/myAnDgS5qTe3yW0= -github.com/tylermmorton/tmpl v1.1.0/go.mod h1:7E7f4TC2F+OCe7KG33X/MXRmfWG8tZm8wOBKmEmu2HE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs= @@ -1260,14 +1253,6 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 1d01e0d7d28f98..a38139dec174bf 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -41,18 +41,6 @@ impl FeatureFlag for AgentSharingFeatureFlag { } register_feature_flag!(AgentSharingFeatureFlag); -pub struct HandoffFeatureFlag; - -impl FeatureFlag for HandoffFeatureFlag { - const NAME: &'static str = "handoff"; - type Value = PresenceFlag; - - fn enabled_for_staff() -> bool { - false - } -} -register_feature_flag!(HandoffFeatureFlag); - pub struct DiffReviewFeatureFlag; impl FeatureFlag for DiffReviewFeatureFlag { @@ -65,18 +53,6 @@ impl FeatureFlag for DiffReviewFeatureFlag { } register_feature_flag!(DiffReviewFeatureFlag); -pub struct UpdatePlanToolFeatureFlag; - -impl FeatureFlag for UpdatePlanToolFeatureFlag { - const NAME: &'static str = "update-plan-tool"; - type Value = PresenceFlag; - - fn enabled_for_staff() -> bool { - false - } -} -register_feature_flag!(UpdatePlanToolFeatureFlag); - /// Gates the `create_thread` and `list_agents_and_models` tools, which let /// the agent spawn independent sibling threads that show up in the agent /// panel sidebar. @@ -92,18 +68,6 @@ impl FeatureFlag for CreateThreadToolFeatureFlag { } register_feature_flag!(CreateThreadToolFeatureFlag); -pub struct UpdateTitleToolFeatureFlag; - -impl FeatureFlag for UpdateTitleToolFeatureFlag { - const NAME: &'static str = "update-title-tool"; - type Value = PresenceFlag; - - fn enabled_for_staff() -> bool { - false - } -} -register_feature_flag!(UpdateTitleToolFeatureFlag); - pub struct LspToolFeatureFlag; impl FeatureFlag for LspToolFeatureFlag { diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index f45f81a6edb3d2..a4feaf29523298 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1876,10 +1876,13 @@ impl PickerDelegate for FileFinderDelegate { .toggle_state(selected) .child( h_flex() + .w_full() + .min_w_0() + .overflow_hidden() .gap_2() .py_px() - .child(file_name_label) - .child(full_path_label), + .child(file_name_label.truncate_middle().flex_1()) + .child(full_path_label.truncate_start().flex_shrink()), ), ) } diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 3e5f5dcad2f5ae..01526ae07a3316 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -43,7 +43,7 @@ text.workspace = true time.workspace = true util.workspace = true is_executable = "1.0.5" -notify = "8.2.0" +notify = "9.0.0-rc.4" trash = { git = "https://github.com/zed-industries/trash-rs", rev = "3bf27effd4eb8699f2e484d3326b852fe3e53af7" } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index ce1798a73ccfe5..68c57c655d59eb 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -1335,6 +1335,39 @@ struct FakeFsState { moves: std::collections::HashMap, job_event_subscribers: Arc>>, trash: Vec<(TrashedEntry, FakeFsEntry)>, + file_to_create_before_watch_add: Option<(PathBuf, PathBuf)>, +} + +#[cfg(feature = "test-support")] +impl FakeFsState { + fn create_file_before_watch_add(&mut self, watch_path: &Path) -> Result<()> { + let Some((pending_watch_path, file_path)) = self.file_to_create_before_watch_add.take() + else { + return Ok(()); + }; + if pending_watch_path != watch_path { + self.file_to_create_before_watch_add = Some((pending_watch_path, file_path)); + return Ok(()); + } + + let inode = self.get_and_increment_inode(); + let mtime = self.get_and_increment_mtime(); + self.write_path(&file_path, |entry| { + let btree_map::Entry::Vacant(entry) = entry else { + anyhow::bail!("file already exists: {}", file_path.display()); + }; + entry.insert(FakeFsEntry::File { + inode, + mtime, + len: 0, + content: Vec::new(), + git_dir_path: None, + }); + Ok(()) + })?; + self.emit_event([(file_path, Some(PathEventKind::Created))]); + Ok(()) + } } #[cfg(feature = "test-support")] @@ -1621,6 +1654,7 @@ impl FakeFs { moves: Default::default(), job_event_subscribers: Arc::new(Mutex::new(Vec::new())), trash: Vec::new(), + file_to_create_before_watch_add: None, })), }); @@ -1796,6 +1830,17 @@ impl FakeFs { self.state.lock().buffered_events.clear(); } + pub fn create_file_before_next_watch_add( + &self, + watch_path: impl AsRef, + path: impl AsRef, + ) { + self.state.lock().file_to_create_before_watch_add = Some(( + normalize_path(watch_path.as_ref()), + normalize_path(path.as_ref()), + )); + } + pub fn flush_events(&self, count: usize) { self.state.lock().flush_events(count); } @@ -2591,7 +2636,6 @@ impl FakeFsEntry { #[cfg(feature = "test-support")] struct FakeWatcher { tx: async_channel::Sender>, - original_path: PathBuf, fs_state: Arc>, prefixes: Mutex>, } @@ -2599,19 +2643,34 @@ struct FakeWatcher { #[cfg(feature = "test-support")] impl Watcher for FakeWatcher { fn add(&self, path: &Path) -> Result<()> { - if path.starts_with(&self.original_path) { + let path = normalize_path(path); + self.fs_state + .try_lock() + .unwrap() + .create_file_before_watch_add(&path)?; + + let mut prefixes = self.prefixes.lock(); + if prefixes.iter().any(|prefix| path.starts_with(prefix)) { return Ok(()); } + self.fs_state .try_lock() .unwrap() .event_txs - .push((path.to_owned(), self.tx.clone())); - self.prefixes.lock().push(path.to_owned()); + .push((path.clone(), self.tx.clone())); + prefixes.push(path); Ok(()) } - fn remove(&self, _: &Path) -> Result<()> { + fn remove(&self, path: &Path) -> Result<()> { + let path = normalize_path(path); + self.prefixes.lock().retain(|prefix| prefix != &path); + self.fs_state + .try_lock() + .unwrap() + .event_txs + .retain(|(watched_path, _)| watched_path != &path); Ok(()) } } @@ -3065,7 +3124,6 @@ impl Fs for FakeFs { let executor = self.executor.clone(); let watcher = Arc::new(FakeWatcher { tx, - original_path: path.to_owned(), fs_state: self.state.clone(), prefixes: Mutex::new(vec![path]), }); diff --git a/crates/fs/src/fs_watcher.rs b/crates/fs/src/fs_watcher.rs index c4138a084d353d..82aeaf2a73e47f 100644 --- a/crates/fs/src/fs_watcher.rs +++ b/crates/fs/src/fs_watcher.rs @@ -726,7 +726,14 @@ impl GlobalWatcher { return Ok(()); } - let watcher = notify::recommended_watcher(handle_native_event)?; + // CORE excludes Access events, which Zed discards anyway. Without this, + // the default mask subscribes to inotify OPEN/CLOSE_* on Linux, so every + // file read in a watched directory would queue events, increasing the + // risk of queue overflows (and thus full rescans) under read-heavy + // workloads like grep or language server indexing. + let config = notify::Config::default().with_event_kinds(notify::EventKindMask::CORE); + let watcher = + ::new(handle_native_event, config)?; *self.native_watcher.lock() = Some(Box::new(watcher)); Ok(()) } diff --git a/crates/fuzzy_nucleo/src/strings.rs b/crates/fuzzy_nucleo/src/strings.rs index b72c7da205da96..81b377b598bdb1 100644 --- a/crates/fuzzy_nucleo/src/strings.rs +++ b/crates/fuzzy_nucleo/src/strings.rs @@ -24,8 +24,8 @@ pub struct StringMatchCandidate { } impl StringMatchCandidate { - pub fn new(id: usize, string: impl ToString) -> Self { - Self::from_shared(id, SharedString::new(string.to_string())) + pub fn new(id: usize, string: impl Into) -> Self { + Self::from_shared(id, string.into()) } pub fn from_shared(id: usize, string: SharedString) -> Self { @@ -298,7 +298,7 @@ mod tests { strings .iter() .enumerate() - .map(|(id, s)| StringMatchCandidate::new(id, s)) + .map(|(id, s)| StringMatchCandidate::new(id, *s)) .collect() } diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index b10a50942b826a..dd905f521fe9b7 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -3,8 +3,9 @@ use crate::commit::get_messages; use crate::repository::{GitBinary, RepoPath}; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; -use futures::AsyncWriteExt; +use futures::{AsyncWriteExt, try_join}; use serde::{Deserialize, Serialize}; +use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use std::ops::Range; use text::{LineEnding, Rope}; use time::OffsetDateTime; @@ -25,9 +26,7 @@ impl Blame { content: &Rope, line_ending: LineEnding, ) -> Result { - let output = run_git_blame(git, path, content, line_ending).await?; - let mut entries = parse_git_blame(&output)?; - entries.sort_unstable_by_key(|entry| entry.range.start); + let mut entries = run_git_blame(git, path, content, line_ending).await?; let mut unique_shas = HashSet::default(); @@ -40,19 +39,21 @@ impl Blame { .await .context("failed to get commit messages")?; + entries.sort_unstable_by_key(|entry| entry.range.start); Ok(Self { entries, messages }) } } const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD"; const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; +const BLAME_PARSE_YIELD_INTERVAL: usize = 512; async fn run_git_blame( git: &GitBinary, path: &RepoPath, contents: &Rope, line_ending: LineEnding, -) -> Result { +) -> Result> { let mut child = { let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str()); let _enter = span.enter(); @@ -66,28 +67,82 @@ async fn run_git_blame( .context("starting git blame process")? }; - let stdin = child + let mut stdin = child .stdin - .as_mut() + .take() .context("failed to get pipe to stdin of git blame command")?; + let stdout = child + .stdout + .take() + .context("failed to get stdout from git blame command")?; + let stderr = child + .stderr + .take() + .context("failed to get stderr from git blame command")?; + + let write_stdin = async move { + for chunk in text::chunks_with_line_ending(contents, line_ending) { + stdin.write_all(chunk.as_bytes()).await?; + } + stdin.flush().await.map_err(Into::into) + }; - for chunk in text::chunks_with_line_ending(contents, line_ending) { - stdin.write_all(chunk.as_bytes()).await?; - } - stdin.flush().await?; + let read_stdout = async move { + let mut parser = GitBlameParser::new(); + let mut reader = BufReader::new(stdout); + let mut line_buffer = String::new(); + let mut lines_read = 0; + + loop { + line_buffer.clear(); + let bytes_read = reader + .read_line(&mut line_buffer) + .await + .context("reading git blame stdout")?; + if bytes_read == 0 { + break; + } - let output = child.output().await.context("reading git blame output")?; + let line = line_buffer.trim_end_matches(&['\r', '\n'][..]); + parser.push_line(line)?; + lines_read += 1; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + if lines_read % BLAME_PARSE_YIELD_INTERVAL == 0 { + smol::future::yield_now().await; + } + } + + Ok(parser.entries) + }; + + let read_stderr = async move { + let mut stderr_output = String::new(); + BufReader::new(stderr) + .read_to_string(&mut stderr_output) + .await + .context("reading git blame stderr")?; + Result::::Ok(stderr_output) + }; + + let wait_for_status = async { + child + .status() + .await + .context("waiting for git blame process") + }; + + let ((), entries, stderr, status) = + try_join!(write_stdin, read_stdout, read_stderr, wait_for_status)?; + + if !status.success() { let trimmed = stderr.trim(); if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) { - return Ok(String::new()); + return Ok(Vec::new()); } anyhow::bail!("git blame process failed: {stderr}"); } - Ok(String::from_utf8(output.stdout)?) + Ok(entries) } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -177,7 +232,7 @@ impl BlameEntry { } } -// parse_git_blame parses the output of `git blame --incremental`, which returns +// GitBlameParser parses the output of `git blame --incremental`, which returns // all the blame-entries for a given path incrementally, as it finds them. // // Each entry *always* starts with: @@ -213,22 +268,32 @@ impl BlameEntry { // filename index.js // // More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html -fn parse_git_blame(output: &str) -> Result> { - let mut entries: Vec = Vec::new(); - let mut index: HashMap = HashMap::default(); +struct GitBlameParser { + entries: Vec, + index: HashMap, + current_entry: Option, +} - let mut current_entry: Option = None; +impl GitBlameParser { + fn new() -> Self { + Self { + entries: Vec::new(), + index: HashMap::default(), + current_entry: None, + } + } - for line in output.lines() { + fn push_line(&mut self, line: &str) -> Result<()> { let mut done = false; - match &mut current_entry { + match &mut self.current_entry { None => { let mut new_entry = BlameEntry::new_from_blame_line(line)?; - if let Some(existing_entry) = index + if let Some(existing_entry) = self + .index .get(&new_entry.sha) - .and_then(|slot| entries.get(*slot)) + .and_then(|slot| self.entries.get(*slot)) { new_entry.author.clone_from(&existing_entry.author); new_entry @@ -249,11 +314,11 @@ fn parse_git_blame(output: &str) -> Result> { new_entry.summary.clone_from(&existing_entry.summary); } - current_entry.replace(new_entry); + self.current_entry.replace(new_entry); } Some(entry) => { let Some((key, value)) = line.split_once(' ') else { - continue; + return Ok(()); }; let is_committed = !entry.sha.is_zero(); match key { @@ -282,25 +347,44 @@ fn parse_git_blame(output: &str) -> Result> { } }; - if done && let Some(entry) = current_entry.take() { - index.insert(entry.sha, entries.len()); - - // We only want annotations that have a commit. - if !entry.sha.is_zero() { - entries.push(entry); - } + if done { + self.push_current_entry(); } + + Ok(()) } - Ok(entries) + fn push_current_entry(&mut self) { + let Some(entry) = self.current_entry.take() else { + return; + }; + + self.index.insert(entry.sha, self.entries.len()); + + // We only want annotations that have a commit. + if !entry.sha.is_zero() { + self.entries.push(entry); + } + } } #[cfg(test)] mod tests { use std::path::PathBuf; + use crate::blame::GitBlameParser; + use super::BlameEntry; - use super::parse_git_blame; + + fn parse_git_blame(output: &str) -> anyhow::Result> { + let mut parser = GitBlameParser::new(); + + for line in output.lines() { + parser.push_line(line)?; + } + + Ok(parser.entries) + } fn read_test_data(filename: &str) -> String { let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index e826e5c0c3c5ad..c61f819ea27cdc 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -107,6 +107,8 @@ actions!( ViewCommit, /// Adds a file to .gitignore. AddToGitignore, + /// Adds a file to the repository's .git/info/exclude. + AddToGitInfoExclude, /// Copies the current branch name to the clipboard. CopyBranchName, ] diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 6746133a5bf80e..c4849f9f0c5119 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -2029,11 +2029,48 @@ impl GitRepository for RealGitRepository { } fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { - let git_binary = self.git_binary(); + let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - git_binary.run(&["checkout", &name]).await?; - anyhow::Ok(()) + let git_binary = git_binary?; + let local_ref = format!("refs/heads/{name}"); + if git_binary + .run(&["show-ref", "--verify", "--quiet", &local_ref]) + .await + .is_ok() + { + git_binary.run(&["checkout", &name]).await?; + return anyhow::Ok(()); + } + + let remote_ref = format!("refs/remotes/{name}"); + if git_binary + .run(&["show-ref", "--verify", "--quiet", &remote_ref]) + .await + .is_ok() + { + let (_, branch_name) = + name.split_once('/').context("Unexpected branch format")?; + let local_branch_ref = format!("refs/heads/{branch_name}"); + if git_binary + .run(&["show-ref", "--verify", "--quiet", &local_branch_ref]) + .await + .is_ok() + { + git_binary + .run(&["branch", "--set-upstream-to", &name, branch_name]) + .await?; + } else { + git_binary + .run(&["branch", "--track", branch_name, &name]) + .await?; + } + + git_binary.run(&["checkout", branch_name]).await?; + return anyhow::Ok(()); + } + + anyhow::bail!("Branch '{}' not found", name); }) .boxed() } @@ -3553,6 +3590,8 @@ async fn run_git_command( .env("GIT_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS", ask_pass.script_path()) .env("SSH_ASKPASS_REQUIRE", "force"); + #[cfg(target_os = "windows")] + command.env("ZED_ASKPASS_SOCKET", ask_pass.socket_path()); let git_process = command.spawn()?; run_askpass_command(ask_pass, git_process).await @@ -3934,6 +3973,118 @@ mod tests { ); } + #[gpui::test] + async fn test_change_branch_creates_local_tracking_branch_from_remote(cx: &mut TestAppContext) { + disable_git_global_config(); + cx.executor().allow_parking(); + + let temp_dir = tempfile::tempdir().unwrap(); + let remote_dir = temp_dir.path().join("remote.git"); + let seed_dir = temp_dir.path().join("seed"); + let clone_dir = temp_dir.path().join("clone"); + + git_command( + temp_dir.path(), + [ + OsString::from("init"), + OsString::from("--bare"), + OsString::from("-b"), + OsString::from("main"), + remote_dir.as_os_str().into(), + ], + ); + git_init_repo(&seed_dir); + fs::write(seed_dir.join("file.txt"), "main").unwrap(); + git_command(&seed_dir, ["add", "file.txt"]); + git_command(&seed_dir, ["commit", "-m", "initial"]); + git_command(&seed_dir, ["switch", "-c", "feature"]); + fs::write(seed_dir.join("feature.txt"), "feature").unwrap(); + git_command(&seed_dir, ["add", "feature.txt"]); + git_command(&seed_dir, ["commit", "-m", "feature"]); + git_command( + &seed_dir, + [ + OsString::from("remote"), + OsString::from("add"), + OsString::from("origin"), + remote_dir.as_os_str().into(), + ], + ); + git_command(&seed_dir, ["push", "-u", "origin", "main"]); + git_command(&seed_dir, ["push", "-u", "origin", "feature"]); + git_command( + temp_dir.path(), + [ + OsString::from("clone"), + remote_dir.as_os_str().into(), + clone_dir.as_os_str().into(), + ], + ); + + let repository = RealGitRepository::new( + &clone_dir.join(".git"), + None, + Some("git".into()), + cx.executor(), + ) + .unwrap(); + let git = repository.git_binary_in_worktree().unwrap(); + assert!( + git.run(&[ + "show-ref", + "--verify", + "--quiet", + "refs/remotes/origin/feature" + ]) + .await + .is_ok() + ); + assert!( + git.run(&["show-ref", "--verify", "--quiet", "refs/heads/feature"]) + .await + .is_err() + ); + + repository + .change_branch("origin/feature".to_string()) + .await + .unwrap(); + + let git = repository.git_binary_in_worktree().unwrap(); + assert_eq!( + git.run(&["branch", "--show-current"]).await.unwrap(), + "feature" + ); + assert_eq!( + git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",]) + .await + .unwrap(), + "origin/feature" + ); + + git.run(&["checkout", "main"]).await.unwrap(); + git.run(&["branch", "--unset-upstream", "feature"]) + .await + .unwrap(); + + repository + .change_branch("origin/feature".to_string()) + .await + .unwrap(); + + let git = repository.git_binary_in_worktree().unwrap(); + assert_eq!( + git.run(&["branch", "--show-current"]).await.unwrap(), + "feature" + ); + assert_eq!( + git.run(&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}",]) + .await + .unwrap(), + "origin/feature" + ); + } + #[gpui::test] fn test_real_git_repository_new_rejects_malformed_git_file(cx: &mut TestAppContext) { disable_git_global_config(); diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml deleted file mode 100644 index 2922453c46891d..00000000000000 --- a/crates/git_graph/Cargo.toml +++ /dev/null @@ -1,60 +0,0 @@ -[package] -name = "git_graph" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/git_graph.rs" - -[features] -default = [] -test-support = [ - "dep:rand", - "project/test-support", - "gpui/test-support", - "remote_connection/test-support", -] - -[dependencies] -anyhow.workspace = true -async-channel.workspace = true -collections.workspace = true -db.workspace = true -editor.workspace = true -git.workspace = true -git_ui.workspace = true -gpui.workspace = true -language.workspace = true -menu.workspace = true -picker.workspace = true -project.workspace = true -project_panel.workspace = true -rand = { workspace = true, optional = true } -release_channel.workspace = true -search.workspace = true -settings.workspace = true -smallvec.workspace = true -task.workspace = true -theme.workspace = true -theme_settings.workspace = true -time.workspace = true -ui.workspace = true -workspace.workspace = true - -[dev-dependencies] -db = { workspace = true, features = ["test-support"] } -fs = { workspace = true, features = ["test-support"] } -git = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -language_model.workspace = true -project = { workspace = true, features = ["test-support"] } -rand.workspace = true -remote_connection = { 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_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 3273277c794d64..e65265264b82cf 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -14,13 +14,15 @@ path = "src/git_ui.rs" [features] test-support = ["multi_buffer/test-support", "remote_connection/test-support"] +call = ["dep:call"] [dependencies] agent_settings.workspace = true anyhow.workspace = true askpass.workspace = true +async-channel.workspace = true buffer_diff.workspace = true -call.workspace = true +call = { workspace = true, optional = true } collections.workspace = true component.workspace = true db.workspace = true @@ -48,15 +50,18 @@ project.workspace = true prompt_store.workspace = true proto.workspace = true rand.workspace = true +release_channel.workspace = true remote_connection.workspace = true remote.workspace = true schemars.workspace = true +search.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true smallvec.workspace = true strum.workspace = true sysinfo.workspace = true +task.workspace = true telemetry.workspace = true terminal.workspace = true theme.workspace = true @@ -78,7 +83,10 @@ windows.workspace = true [dev-dependencies] ctor.workspace = true +db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +fs = { workspace = true, features = ["test-support"] } +git = { workspace = true, features = ["test-support"] } 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 34bf205b57af2d..080217e5c53a57 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -211,7 +211,7 @@ impl BlameRenderer for GitBlameRenderer { .render(window, cx); let short_commit_id = sha - .get(..8) + .get(..git::SHORT_SHA_LENGTH) .map(|sha| sha.to_string().into()) .unwrap_or_else(|| sha.clone()); let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index e3ff9d4b158f28..86ab692e03e216 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1050,11 +1050,7 @@ impl PickerDelegate for BranchListDelegate { fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { match self.state { PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { - if self.is_select_only() { - "Select branch…" - } else { - "Switch branch…" - } + "Switch or type to create a branch…" } PickerState::CreateRemote(_) => "Enter a name for this remote…", } diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 13ac1c759cf4f2..9d4c8966068234 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -238,7 +238,7 @@ impl Render for CommitTooltip { let short_commit_id = self .commit .sha - .get(0..8) + .get(0..git::SHORT_SHA_LENGTH) .map(|sha| sha.to_string().into()) .unwrap_or_else(|| self.commit.sha.clone()); let full_sha = self.commit.sha.to_string(); diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 247c98c5698bf8..9fff74d3983897 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -15,7 +15,8 @@ use git::{ use gpui::{ AnyElement, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, - PromptLevel, Render, Styled, Task, WeakEntity, Window, actions, + PromptLevel, Render, ScrollHandle, StatefulInteractiveElement as _, Styled, Task, WeakEntity, + Window, actions, }; use language::{ Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _, @@ -32,7 +33,7 @@ use std::{ sync::Arc, }; use theme::ActiveTheme; -use ui::{ContextMenu, DiffStat, Disclosure, Divider, Tooltip, prelude::*}; +use ui::{ContextMenu, DiffStat, Disclosure, Divider, Tooltip, WithScrollbar, prelude::*}; use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff}; use workspace::item::TabTooltipContent; use workspace::{ @@ -77,9 +78,11 @@ pub struct CommitView { editor: Entity, message: Entity, message_expanded: bool, + message_scroll_handle: ScrollHandle, stash: Option, multibuffer: Entity, repository: Entity, + project: Entity, workspace: WeakEntity, remote: Option, } @@ -272,6 +275,7 @@ impl CommitView { window, cx, ); + editor.disable_diff_hunk_controls(cx); editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_bookmarks(false, cx); @@ -321,7 +325,9 @@ impl CommitView { .or(first_worktree_id) }) .context("project has no worktrees")?; - let short_sha = commit_sha.get(0..7).unwrap_or(&commit_sha); + let short_sha = commit_sha + .get(0..git::SHORT_SHA_LENGTH) + .unwrap_or(&commit_sha); let file_name = file .path .file_name() @@ -362,7 +368,14 @@ impl CommitView { let buffer_diff = if is_binary { cx.update(|_, cx| { let snapshot = buffer.read(cx).snapshot(); - cx.new(|cx| BufferDiff::new_unchanged(&snapshot, cx)) + cx.new(|cx| { + BufferDiff::new_unchanged( + &snapshot, + snapshot.language().cloned(), + Some(language_registry.clone()), + cx, + ) + }) })? } else { build_buffer_diff(old_text, &buffer, &language_registry, cx).await? @@ -462,9 +475,11 @@ impl CommitView { editor, message, message_expanded: false, + message_scroll_handle: ScrollHandle::new(), multibuffer, stash, repository, + project, workspace, remote, } @@ -707,6 +722,7 @@ impl CommitView { let has_more = message.contains('\n'); let collapsed = has_more && !is_expanded; let collapsed_height = window.line_height(); + let max_expanded_height = window.line_height() * 12.; Some( h_flex() @@ -718,9 +734,20 @@ impl CommitView { .relative() .flex_1() .min_w_0() - .text_sm() - .when(collapsed, |this| this.h(collapsed_height).overflow_hidden()) - .child(MarkdownElement::new(self.message.clone(), markdown_style)), + .child( + div() + .id("commit-message") + .size_full() + .text_sm() + .when(collapsed, |this| this.h(collapsed_height).overflow_hidden()) + .when(!collapsed, |this| { + this.max_h(max_expanded_height) + .overflow_y_scroll() + .track_scroll(&self.message_scroll_handle) + }) + .child(MarkdownElement::new(self.message.clone(), markdown_style)), + ) + .vertical_scrollbar_for(&self.message_scroll_handle, window, cx), ), ) } @@ -976,23 +1003,15 @@ async fn build_buffer_diff( let language = cx.update(|_, cx| buffer.read(cx).language().cloned())?; let buffer = cx.update(|_, cx| buffer.read(cx).snapshot())?; - let diff = cx.new(|cx| BufferDiff::new(&buffer.text, cx)); - - let update = diff - .update(cx, |diff, cx| { - diff.update_diff( - buffer.text.clone(), - old_text.map(|old_text| Arc::from(old_text.as_str())), - Some(true), - language.clone(), - cx, - ) - }) - .await; + let diff = + cx.new(|cx| BufferDiff::new(&buffer.text, language, Some(language_registry.clone()), cx)); diff.update(cx, |diff, cx| { - diff.language_changed(language, Some(language_registry.clone()), cx); - diff.set_snapshot(update, &buffer.text, cx) + diff.set_base_text( + old_text.map(|old_text| Arc::from(old_text.as_str())), + buffer.text.clone(), + cx, + ) }) .await; @@ -1155,7 +1174,7 @@ impl Item for CommitView { let Some(workspace_entity) = self.workspace.upgrade() else { return Task::ready(None); }; - let project = workspace_entity.read(cx).project().clone(); + let project = self.project.clone(); let diff_view_style = self.editor.read(cx).diff_view_style(); let multibuffer = self.multibuffer.clone(); Task::ready(Some(cx.new(|cx| { @@ -1174,6 +1193,7 @@ impl Item for CommitView { window, cx, ); + editor.disable_diff_hunk_controls(cx); editor.rhs_editor().update(cx, |editor, cx| { editor.set_show_bookmarks(false, cx); editor.set_show_breakpoints(false, cx); @@ -1199,10 +1219,12 @@ impl Item for CommitView { editor, message, message_expanded: self.message_expanded, + message_scroll_handle: ScrollHandle::new(), multibuffer: self.multibuffer.clone(), commit: self.commit.clone(), stash: self.stash, repository: self.repository.clone(), + project: self.project.clone(), workspace: self.workspace.clone(), remote: self.remote.clone(), } @@ -1306,7 +1328,7 @@ impl Render for CommitViewToolbar { .tooltip(Tooltip::text("Show in Git Graph")) .on_click(move |_, window, cx| { window.dispatch_action( - Box::new(crate::git_panel::OpenAtCommit { + Box::new(crate::git_graph::OpenAtCommit { sha: sha_for_graph.clone(), }), cx, diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 70e10168adf4de..5be215a166cc0b 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -2,7 +2,7 @@ use agent_settings::AgentSettings; use collections::{HashMap, HashSet}; use editor::{ ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker, - Editor, EditorEvent, MultiBuffer, RowHighlightOptions, + Editor, MultiBuffer, RowHighlightOptions, display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId}, }; use gpui::{ @@ -11,13 +11,13 @@ use gpui::{ }; use language::{Anchor, Buffer, BufferId}; use project::{ - ConflictRegion, ConflictSet, ConflictSetUpdate, Project, ProjectItem as _, + ConflictRegion, ConflictSet, ConflictSetUpdate, Project, git_store::{GitStore, GitStoreEvent, RepositoryEvent}, }; use settings::Settings; use std::{ops::Range, sync::Arc}; use ui::{ButtonLike, Divider, Tooltip, prelude::*}; -use util::{ResultExt as _, debug_panic, maybe}; +use util::{debug_panic, maybe}; use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle}; use zed_actions::agent::{ ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, @@ -27,14 +27,6 @@ pub(crate) struct ConflictAddon { buffers: HashMap, } -impl ConflictAddon { - pub(crate) fn conflict_set(&self, buffer_id: BufferId) -> Option> { - self.buffers - .get(&buffer_id) - .map(|entry| entry.conflict_set.clone()) - } -} - struct BufferConflicts { block_ids: Vec<(Range, CustomBlockId)>, conflict_set: Entity, @@ -52,10 +44,9 @@ impl editor::Addon for ConflictAddon { } pub fn register_editor(editor: &mut Editor, buffer: Entity, cx: &mut Context) { - // Only show conflict UI for singletons and in the project diff. + let is_singleton = editor.buffer().read(cx).is_singleton(); if !editor.mode().is_full() - || (!editor.buffer().read(cx).is_singleton() - && !editor.buffer().read(cx).all_diff_hunks_expanded()) + || (!is_singleton && !editor.buffer().read(cx).all_diff_hunks_expanded()) || editor.read_only(cx) { return; @@ -65,42 +56,75 @@ pub fn register_editor(editor: &mut Editor, buffer: Entity, cx: &mu buffers: Default::default(), }); - let buffers = buffer.read(cx).all_buffers(); - for buffer in buffers { - buffer_ranges_updated(editor, buffer, cx); + if is_singleton { + let buffers = buffer.read(cx).all_buffers(); + for buffer in buffers { + open_conflict_set_for_buffer(editor, buffer, cx); + } } +} - cx.subscribe(&cx.entity(), |editor, _, event, cx| match event { - EditorEvent::BufferRangesUpdated { buffer, .. } => { - buffer_ranges_updated(editor, buffer.clone(), cx) - } - EditorEvent::BuffersRemoved { removed_buffer_ids } => { - buffers_removed(editor, removed_buffer_ids, cx) +fn open_conflict_set_for_buffer( + _editor: &mut Editor, + buffer: Entity, + cx: &mut Context, +) { + let buffer = buffer.downgrade(); + + cx.spawn(async move |editor, cx| { + let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id())?; + if let Some(conflict_set) = editor.read_with(cx, |editor, _| { + editor + .addon::() + .and_then(|addon| addon.buffers.get(&buffer_id)) + .map(|buffer_conflicts| buffer_conflicts.conflict_set.clone()) + })? { + editor.update(cx, |editor, cx| { + buffer_ranges_updated(editor, conflict_set, cx); + })?; + return anyhow::Ok(()); } - _ => {} + + let Some(project) = editor.read_with(cx, |editor, _| editor.project().cloned())? else { + return anyhow::Ok(()); + }; + let git_store = project.read_with(cx, |project, _| project.git_store().clone()); + let Some(buffer) = buffer.upgrade() else { + return Ok(()); + }; + let conflict_set = git_store + .update(cx, |git_store, cx| { + git_store.open_conflict_set(buffer.clone(), cx) + }) + .await; + editor.update(cx, |editor, cx| { + buffer_ranges_updated(editor, conflict_set, cx); + })?; + Ok(()) }) .detach(); } -fn buffer_ranges_updated(editor: &mut Editor, buffer: Entity, cx: &mut Context) { - let Some(project) = editor.project() else { +pub(crate) fn buffer_ranges_updated( + editor: &mut Editor, + conflict_set: Entity, + cx: &mut Context, +) { + let buffer_id = conflict_set.read(cx).snapshot.buffer_id; + if editor.buffer().read(cx).buffer(buffer_id).is_none() { return; - }; - let git_store = project.read(cx).git_store().clone(); + } let buffer_conflicts = editor .addon_mut::() .unwrap() .buffers - .entry(buffer.read(cx).remote_id()) + .entry(buffer_id) .or_insert_with(|| { - let conflict_set = git_store.update(cx, |git_store, cx| { - git_store.open_conflict_set(buffer.clone(), cx) - }); let subscription = cx.subscribe(&conflict_set, conflicts_updated); BufferConflicts { block_ids: Vec::new(), - conflict_set, + conflict_set: conflict_set.clone(), _subscription: subscription, } }); @@ -120,7 +144,11 @@ fn buffer_ranges_updated(editor: &mut Editor, buffer: Entity, cx: &mut C ); } -fn buffers_removed(editor: &mut Editor, removed_buffer_ids: &[BufferId], cx: &mut Context) { +pub(crate) fn buffers_removed( + editor: &mut Editor, + removed_buffer_ids: &[BufferId], + cx: &mut Context, +) { let mut removed_block_ids = HashSet::default(); editor .addon_mut::() @@ -447,10 +475,8 @@ pub(crate) fn resolve_conflict( cx: &mut App, ) -> Task<()> { window.spawn(cx, async move |cx| { - let Some((workspace, project, multibuffer, buffer)) = editor + editor .update(cx, |editor, cx| { - let workspace = editor.workspace()?; - let project = editor.project()?.clone(); let multibuffer = editor.buffer().clone(); let buffer_id = resolved_conflict.ours.end.buffer_id; let buffer = multibuffer.read(cx).buffer(buffer_id)?; @@ -481,34 +507,9 @@ pub(crate) fn resolve_conflict( editor.remove_highlighted_rows::(vec![range.clone()], cx); editor.remove_highlighted_rows::(vec![range], cx); editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); - Some((workspace, project, multibuffer, buffer)) + Some(()) }) - .ok() - .flatten() - else { - return; - }; - let save = project.update(cx, |project, cx| { - if multibuffer.read(cx).all_diff_hunks_expanded() { - project.save_buffer(buffer.clone(), cx) - } else { - Task::ready(Ok(())) - } - }); - if save.await.log_err().is_none() { - let open_path = maybe!({ - let path = buffer.read_with(cx, |buffer, cx| buffer.project_path(cx))?; - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_path_preview(path, None, false, false, false, window, cx) - }) - .ok() - }); - - if let Some(open_path) = open_path { - open_path.await.log_err(); - } - } + .ok(); }) } diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index ac9e4175b65469..477f18be545dbc 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -8,7 +8,7 @@ use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, Font, IntoElement, Render, Task, WeakEntity, Window, }; -use language::{Buffer, HighlightedText, LanguageRegistry}; +use language::{Buffer, HighlightedText}; use project::{Project, ProjectPath}; use settings::Settings; use std::{ @@ -53,9 +53,8 @@ impl FileDiffView { let new_buffer = project .update(cx, |project, cx| project.open_local_buffer(&new_path, cx)) .await?; - let languages = project.update(cx, |project, _| project.languages().clone()); - let buffer_diff = build_buffer_diff(&old_buffer, &new_buffer, languages, cx).await?; + let buffer_diff = build_buffer_diff(&old_buffer, &new_buffer, cx).await?; workspace.update_in(cx, |workspace, window, cx| { let workspace_entity = cx.entity(); @@ -108,6 +107,7 @@ impl FileDiffView { editor.start_temporary_diff_override(); }); splittable.disable_diff_hunk_controls(cx); + splittable.set_render_diff_hunks_as_unstaged(cx); splittable }); @@ -154,13 +154,11 @@ impl FileDiffView { diff.update(cx, |diff, cx| { diff.set_base_text( Some(old_snapshot.text().as_str().into()), - old_snapshot.language().cloned(), new_snapshot.text.clone(), cx, ) }) - .await - .ok(); + .await; log::trace!("finish recalculating"); } Ok(()) @@ -170,36 +168,30 @@ impl FileDiffView { } #[ztracing::instrument(skip_all)] -async fn build_buffer_diff( +pub(crate) async fn build_buffer_diff( old_buffer: &Entity, new_buffer: &Entity, - language_registry: Arc, cx: &mut AsyncApp, ) -> Result> { let old_buffer_snapshot = old_buffer.read_with(cx, |buffer, _| buffer.snapshot()); let new_buffer_snapshot = new_buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let language_registry = new_buffer.read_with(cx, |buffer, _| buffer.language_registry()); - let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx)); - - let update = diff - .update(cx, |diff, cx| { - diff.update_diff( - new_buffer_snapshot.text.clone(), - Some(old_buffer_snapshot.text().into()), - Some(true), - new_buffer_snapshot.language().cloned(), - cx, - ) - }) - .await; + let diff = cx.new(|cx| { + BufferDiff::new( + &new_buffer_snapshot.text, + new_buffer_snapshot.language().cloned(), + language_registry, + cx, + ) + }); diff.update(cx, |diff, cx| { - diff.language_changed( - new_buffer_snapshot.language().cloned(), - Some(language_registry), + diff.set_base_text( + Some(old_buffer_snapshot.text().into()), + new_buffer_snapshot.text.clone(), cx, - ); - diff.set_snapshot(update, &new_buffer_snapshot.text, cx) + ) }) .await; diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_ui/src/git_graph.rs similarity index 87% rename from crates/git_graph/src/git_graph.rs rename to crates/git_ui/src/git_graph.rs index e176a34e2b731c..47716547046c67 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1,5 +1,11 @@ +use crate::{ + commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, + commit_view::CommitView, + git_status_icon, +}; use collections::{BTreeMap, HashMap, IndexSet}; use editor::Editor; +use file_icons::FileIcons; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, commit::ParsedCommitMessage, @@ -10,11 +16,6 @@ use git::{ }, status::{FileStatus, StatusCode, TrackedStatus}, }; -use git_ui::{ - commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, - commit_view::CommitView, - git_status_icon, -}; use gpui::{ Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, @@ -33,7 +34,6 @@ use project::{ RepositoryEvent, RepositoryId, }, }; -use project_panel::ProjectPanel; use search::{ SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, buffer_search, @@ -50,8 +50,8 @@ use task::{ResolvedTask, TaskContext, TaskVariables, VariableName}; use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry, - DiffStat, Divider, HeaderResizeInfo, HighlightedLabel, ListItem, ListItemSpacing, + Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry, DiffStat, + Divider, HeaderResizeInfo, HighlightedLabel, ListItem, ListItemSpacing, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles, render_table_header, @@ -263,64 +263,264 @@ impl ChangedFileEntry { fn render( &self, ix: usize, + depth: usize, + directory_label: Option, commit_sha: SharedString, repository: WeakEntity, workspace: WeakEntity, _cx: &App, ) -> AnyElement { + const TREE_INDENT: f32 = 12.0; + let file_name = self.file_name.clone(); let dir_path = self.dir_path.clone(); - div() - .w_full() + ListItem::new(("changed-file", ix)) + .spacing(ListItemSpacing::Sparse) + .indent_level(depth) + .indent_step_size(px(TREE_INDENT)) + .start_slot(git_status_icon(self.status)) .child( - ButtonLike::new(("changed-file", ix)) - .child( - h_flex() - .min_w_0() - .w_full() - .gap_1() - .overflow_hidden() - .child(git_status_icon(self.status)) - .child( - Label::new(file_name.clone()) - .size(LabelSize::Small) - .truncate(), - ) - .when(!dir_path.is_empty(), |this| { - this.child( - Label::new(dir_path.clone()) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate_start(), - ) - }), - ) - .tooltip({ - let meta = if dir_path.is_empty() { - file_name - } else { - format!("{}/{}", dir_path, file_name).into() - }; - move |_, cx| Tooltip::with_meta("View Changes", None, meta.clone(), cx) - }) - .on_click({ - let entry = self.clone(); - move |_, window, cx| { - entry.open_in_commit_view( - &commit_sha, - &repository, - &workspace, - window, - cx, - ); - } - }), + Label::new(file_name.clone()) + .size(LabelSize::Small) + .truncate(), + ) + .when_some(directory_label, |this, directory_label| { + this.child( + Label::new(directory_label) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate_start(), + ) + }) + .tooltip({ + let meta = if dir_path.is_empty() { + file_name + } else { + format!("{}/{}", dir_path, file_name).into() + }; + move |_, cx| Tooltip::with_meta("View Changes", None, meta.clone(), cx) + }) + .on_click({ + let entry = self.clone(); + move |_, window, cx| { + entry.open_in_commit_view(&commit_sha, &repository, &workspace, window, cx); + } + }) + .into_any_element() + } +} + +enum ChangedFileTreeEntry { + Directory(ChangedFileDirectoryEntry), + File(ChangedFileTreeStatusEntry), +} + +struct ChangedFileTreeStatusEntry { + entry: ChangedFileEntry, + depth: usize, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum ChangedFilesViewMode { + Flat, + #[default] + Tree, +} + +impl ChangedFilesViewMode { + fn toggled(self) -> Self { + match self { + Self::Flat => Self::Tree, + Self::Tree => Self::Flat, + } + } + + fn is_tree(self) -> bool { + matches!(self, Self::Tree) + } +} + +struct ChangedFileDirectoryEntry { + path: RepoPath, + name: SharedString, + depth: usize, + expanded: bool, +} + +impl ChangedFileDirectoryEntry { + fn render(&self, ix: usize, git_graph: WeakEntity, cx: &App) -> AnyElement { + const TREE_INDENT: f32 = 12.0; + + let path = self.path.clone(); + let expanded = self.expanded; + let folder_icon = FileIcons::get_folder_icon(expanded, path.as_std_path(), cx) + .map(|icon| { + Icon::from_path(icon) + .size(IconSize::Small) + .color(Color::Muted) + }) + .unwrap_or_else(|| { + let icon = if expanded { + IconName::FolderOpen + } else { + IconName::Folder + }; + Icon::new(icon).size(IconSize::Small).color(Color::Muted) + }); + + ListItem::new(("changed-file-dir", ix)) + .spacing(ListItemSpacing::Sparse) + .indent_level(self.depth) + .indent_step_size(px(TREE_INDENT)) + .toggle(Some(expanded)) + .always_show_disclosure_icon(true) + .on_toggle({ + let path = path.clone(); + let git_graph = git_graph.clone(); + move |_, _, cx| { + git_graph + .update(cx, |git_graph, cx| { + git_graph + .changed_files_expanded_dirs + .insert(path.clone(), !expanded); + cx.notify(); + }) + .ok(); + } + }) + .start_slot(folder_icon) + .child( + Label::new(self.name.clone()) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), ) + .tooltip({ + let name = self.name.clone(); + move |_, cx| Tooltip::with_meta("Toggle Folder", None, name.clone(), cx) + }) + .on_click(move |_, _, cx| { + git_graph + .update(cx, |git_graph, cx| { + git_graph + .changed_files_expanded_dirs + .insert(path.clone(), !expanded); + cx.notify(); + }) + .ok(); + }) .into_any_element() } } +#[derive(Default)] +struct ChangedFileTreeNode { + name: SharedString, + path: Option, + children: BTreeMap, + files: Vec, +} + +fn build_changed_file_tree_entries( + mut files: Vec, + expanded_dirs: &HashMap, +) -> Vec { + files.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); + + let mut root = ChangedFileTreeNode::default(); + for file in files { + let components: Vec<&str> = file.repo_path.components().collect(); + if components.is_empty() { + root.files.push(file); + continue; + } + + let mut current = &mut root; + let mut current_path = String::new(); + + for (ix, component) in components.iter().enumerate() { + if ix == components.len() - 1 { + current.files.push(file.clone()); + } else { + if !current_path.is_empty() { + current_path.push('/'); + } + current_path.push_str(component); + + let Ok(dir_path) = RepoPath::new(¤t_path) else { + continue; + }; + let component = SharedString::from(component.to_string()); + + current = current + .children + .entry(component.clone()) + .or_insert_with(|| ChangedFileTreeNode { + name: component, + path: Some(dir_path), + ..Default::default() + }); + } + } + } + + flatten_changed_file_tree(&root, 0, expanded_dirs) +} + +fn flatten_changed_file_tree( + node: &ChangedFileTreeNode, + depth: usize, + expanded_dirs: &HashMap, +) -> Vec { + let mut entries = Vec::new(); + + for child in node.children.values() { + let (terminal, name) = compact_changed_file_directory_chain(child); + let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else { + continue; + }; + let expanded = *expanded_dirs.get(&path).unwrap_or(&true); + let child_entries = flatten_changed_file_tree(terminal, depth + 1, expanded_dirs); + + entries.push(ChangedFileTreeEntry::Directory(ChangedFileDirectoryEntry { + path, + name, + depth, + expanded, + })); + + if expanded { + entries.extend(child_entries); + } + } + + entries.extend( + node.files + .iter() + .cloned() + .map(|entry| ChangedFileTreeEntry::File(ChangedFileTreeStatusEntry { entry, depth })), + ); + entries +} + +fn compact_changed_file_directory_chain( + mut node: &ChangedFileTreeNode, +) -> (&ChangedFileTreeNode, SharedString) { + let mut parts = vec![node.name.clone()]; + while node.files.is_empty() && node.children.len() == 1 { + let Some(child) = node.children.values().next() else { + continue; + }; + if child.path.is_none() { + break; + } + parts.push(child.name.clone()); + node = child; + } + (node, SharedString::from(parts.join("/"))) +} + enum QueryState { Pending(SharedString), Confirmed((SharedString, Task<()>)), @@ -340,24 +540,24 @@ struct SearchState { case_sensitive: bool, editor: Entity, state: QueryState, - pub matches: IndexSet, - pub selected_index: Option, + matches: IndexSet, + selected_index: Option, } -pub struct SplitState { +struct SplitState { left_ratio: f32, visible_left_ratio: f32, } impl SplitState { - pub fn new() -> Self { + fn new() -> Self { Self { left_ratio: 1.0, visible_left_ratio: 1.0, } } - pub fn right_ratio(&self) -> f32 { + fn right_ratio(&self) -> f32 { 1.0 - self.visible_left_ratio } @@ -391,6 +591,8 @@ impl SplitState { actions!( git_graph, [ + /// Opens the Git Graph Tab. + Open, /// Copies the SHA of the selected commit to the clipboard. CopyCommitSha, /// Copies a tag from the selected commit to the clipboard. @@ -407,9 +609,18 @@ actions!( ScrollUp, /// Selects a commit half a page below the current selection. ScrollDown, + /// Toggles the selected commit's changed files between flat and tree views. + ToggleChangedFilesView, ] ); +/// Opens the Git Graph Tab at a specific commit. +#[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, gpui::Action)] +#[action(namespace = git_graph)] +pub struct OpenAtCommit { + pub sha: String, +} + fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] { static FORMAT: OnceLock>> = OnceLock::new(); FORMAT.get_or_init(|| { @@ -610,7 +821,8 @@ type ActiveLaneIdx = usize; enum AllCommitCount { NotLoaded, - Loaded(usize), + Loading(usize), + FullyLoaded(usize), } #[derive(Debug)] @@ -853,7 +1065,7 @@ impl GraphData { })); } - self.max_commit_count = AllCommitCount::Loaded(self.commits.len()); + self.max_commit_count = AllCommitCount::Loading(self.commits.len()); } } @@ -893,7 +1105,7 @@ pub fn init(cx: &mut App) { div.on_action({ let workspace = workspace.clone(); - move |_: &git_ui::git_panel::Open, window, cx| { + move |_: &Open, window, cx| { workspace .update(cx, |workspace, cx| { let Some(repo) = @@ -918,33 +1130,29 @@ pub fn init(cx: &mut App) { .ok(); } }) - .on_action( - move |action: &git_ui::git_panel::OpenAtCommit, window, cx| { - let sha = action.sha.clone(); - workspace - .update(cx, |workspace, cx| { - let Some(repo) = - workspace.project().read(cx).active_repository(cx) - else { - return; - }; - let selected_repo_id = repo.read(cx).id; + .on_action(move |action: &OpenAtCommit, window, cx| { + let sha = action.sha.clone(); + workspace + .update(cx, |workspace, cx| { + let Some(repo) = workspace.project().read(cx).active_repository(cx) + else { + return; + }; + let selected_repo_id = repo.read(cx).id; - let git_store = - workspace.project().read(cx).git_store().clone(); - open_or_reuse_graph( - workspace, - selected_repo_id, - git_store, - LogSource::All, - Some(sha), - window, - cx, - ); - }) - .ok(); - }, - ) + let git_store = workspace.project().read(cx).git_store().clone(); + open_or_reuse_graph( + workspace, + selected_repo_id, + git_store, + LogSource::All, + Some(sha), + window, + cx, + ); + }) + .ok(); + }) }, ) }); @@ -952,28 +1160,32 @@ pub fn init(cx: &mut App) { .detach(); } +/// Resolves a `git::FileHistory` target from a known project path (used by +/// callers like `project_panel` that own a focused selection but cannot be +/// referenced from this module due to dependency direction). +pub fn resolve_file_history_target_from_project_path( + workspace: &Workspace, + project_path: &ProjectPath, + cx: &App, +) -> Option<(RepositoryId, LogSource)> { + let git_store = workspace.project().read(cx).git_store(); + let (repo, repo_path) = git_store + .read(cx) + .repository_and_path_for_project_path(project_path, cx)?; + let log_source = if repo_path.is_empty() { + LogSource::All + } else { + LogSource::Path(repo_path) + }; + Some((repo.read(cx).id, log_source)) +} + fn resolve_file_history_target( workspace: &Workspace, window: &Window, cx: &App, ) -> Option<(RepositoryId, LogSource)> { - if let Some(panel) = workspace.panel::(cx) - && panel.read(cx).focus_handle(cx).contains_focused(window, cx) - && let Some(project_path) = panel.read(cx).selected_entry_project_path(cx) - { - let git_store = workspace.project().read(cx).git_store(); - let (repo, repo_path) = git_store - .read(cx) - .repository_and_path_for_project_path(&project_path, cx)?; - let log_source = if repo_path.is_empty() { - LogSource::All - } else { - LogSource::Path(repo_path) - }; - return Some((repo.read(cx).id, log_source)); - } - - if let Some(panel) = workspace.panel::(cx) + if let Some(panel) = workspace.panel::(cx) && panel.read(cx).focus_handle(cx).contains_focused(window, cx) && let Some((repository, repo_path)) = panel.read(cx).selected_file_history_target() { @@ -997,7 +1209,7 @@ fn resolve_file_history_target( Some((repo.read(cx).id, LogSource::Path(repo_path))) } -fn open_or_reuse_graph( +pub fn open_or_reuse_graph( workspace: &mut Workspace, repo_id: RepositoryId, git_store: Entity, @@ -1125,6 +1337,8 @@ pub struct GitGraph { commit_details_split_state: Entity, repo_id: RepositoryId, changed_files_scroll_handle: UniformListScrollHandle, + changed_files_view_mode: ChangedFilesViewMode, + changed_files_expanded_dirs: HashMap, pending_select_sha: Option, } @@ -1345,6 +1559,8 @@ impl GitGraph { commit_details_split_state: cx.new(|_cx| SplitState::new()), repo_id, changed_files_scroll_handle: UniformListScrollHandle::new(), + changed_files_view_mode: ChangedFilesViewMode::default(), + changed_files_expanded_dirs: HashMap::default(), pending_select_sha: None, }; @@ -1374,9 +1590,17 @@ impl GitGraph { { self.select_entry(pending_sha_index, ScrollStrategy::Nearest, cx); } + let count = match self.graph_data.max_commit_count { + AllCommitCount::FullyLoaded(count) | AllCommitCount::Loading(count) => { + count + } + AllCommitCount::NotLoaded => 0, + }; + self.graph_data.max_commit_count = AllCommitCount::FullyLoaded(count); + cx.notify(); } GitGraphEvent::LoadingError => { - // todo(git_graph): Wire this up with the UI + cx.notify(); } GitGraphEvent::CountUpdated(commit_count) => { let old_count = self.graph_data.commits.len(); @@ -1465,6 +1689,19 @@ impl GitGraph { }) } + /// Extracts a ref name (branch, remote ref, or tag) from a decoration in + /// git's `%D` format, returning `None` for a detached `HEAD`. + fn ref_name_from_decoration(decoration: &str) -> Option { + let name = decoration + .strip_prefix("tag: ") + .or_else(|| decoration.strip_prefix("HEAD -> ")) + .unwrap_or(decoration); + if name.is_empty() || name == "HEAD" { + return None; + } + Some(SharedString::from(name.to_string())) + } + fn render_chip( &self, name: &SharedString, @@ -1486,6 +1723,40 @@ impl GitGraph { }) } + /// Renders a ref chip for the commit at `commit_idx`. Chips that name a ref + /// (branch, remote ref, or tag) get a right-click handler that opens a + /// ref-specific context menu, so that custom commands can be resolved + /// against the clicked ref. + fn render_ref_chip( + &self, + name: &SharedString, + accent_color: gpui::Hsla, + is_head: bool, + commit_idx: usize, + cx: &mut Context, + ) -> AnyElement { + let chip = self.render_chip(name, accent_color, is_head); + let Some(ref_name) = Self::ref_name_from_decoration(name) else { + return chip.into_any_element(); + }; + div() + .child(chip) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + this.deploy_entry_context_menu( + event.position, + commit_idx, + Some(ref_name.clone()), + window, + cx, + ); + cx.stop_propagation(); + }), + ) + .into_any_element() + } + fn render_table_rows( &mut self, range: Range, @@ -1662,7 +1933,13 @@ impl GitGraph { |name| { let is_head = Self::is_head_ref(name.as_ref(), &head_branch_name); - self.render_chip(name, accent_color, is_head) + self.render_ref_chip( + name, + accent_color, + is_head, + idx, + cx, + ) }, )) })) @@ -1681,6 +1958,7 @@ impl GitGraph { self.selected_entry_idx = None; self.selected_commit_diff = None; self.selected_commit_diff_stats = None; + self.changed_files_expanded_dirs.clear(); cx.emit(ItemEvent::Edit); cx.notify(); } @@ -1749,6 +2027,18 @@ impl GitGraph { self.open_selected_commit_view(window, cx); } + fn toggle_changed_files_view( + &mut self, + _: &ToggleChangedFilesView, + _window: &mut Window, + cx: &mut Context, + ) { + self.changed_files_view_mode = self.changed_files_view_mode.toggled(); + self.changed_files_scroll_handle + .scroll_to_item(0, ScrollStrategy::Top); + cx.notify(); + } + fn search(&mut self, query: SharedString, cx: &mut Context) { let Some(repo) = self.get_repository(cx) else { return; @@ -1872,6 +2162,7 @@ impl GitGraph { self.selected_entry_idx = Some(idx); self.selected_commit_diff = None; self.selected_commit_diff_stats = None; + self.changed_files_expanded_dirs.clear(); self.changed_files_scroll_handle .scroll_to_item(0, ScrollStrategy::Top); self.table_interaction_state.update(cx, |state, cx| { @@ -2080,7 +2371,12 @@ impl GitGraph { self.copy_commit_tag(selected_entry_index, window, cx); } - fn git_task_context(&self, commit_sha: Oid, cx: &App) -> Option { + fn git_task_context( + &self, + commit_sha: Oid, + ref_name: Option<&str>, + cx: &App, + ) -> Option { let repository_path = self .get_repository(cx)? .read(cx) @@ -2105,6 +2401,10 @@ impl GitGraph { task_variables.insert(VariableName::GitRepositoryName, repository_name); } + if let Some(ref_name) = ref_name { + task_variables.insert(VariableName::GitRef, ref_name.to_string()); + } + Some(TaskContext { cwd: Some(repository_path), task_variables, @@ -2160,6 +2460,7 @@ impl GitGraph { &mut self, position: Point, index: usize, + ref_name: Option, window: &mut Window, cx: &mut Context, ) { @@ -2169,16 +2470,21 @@ impl GitGraph { let sha = commit.data.sha; let sha_short = sha.display_short(); let git_tasks = self - .git_task_context(sha, cx) + .git_task_context(sha, ref_name.as_deref(), cx) .map(|task_context| self.git_context_menu_tasks(&task_context, cx)) .unwrap_or_default(); + let header = match &ref_name { + Some(ref_name) => format!("Ref {ref_name}"), + None => format!("Commit {sha_short}"), + }; + let focus_handle = self.focus_handle.clone(); let git_graph = cx.entity(); let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| { context_menu .context(focus_handle) - .header(format!("Commit {sha_short}")) + .header(header) .entry( "View Commit", Some(OpenCommitView.boxed_clone()), @@ -2193,49 +2499,57 @@ impl GitGraph { this.copy_commit_sha(index, cx); }), ) - .map(|menu| { - let tag_names = commit - .data - .tag_names() - .into_iter() - .map(|tag_name| SharedString::from(tag_name.to_string())) - .collect::>(); - let copy_tag_label = "Copy Tag"; - - match tag_names.as_slice() { - [] => menu.item( - ContextMenuEntry::new(copy_tag_label) - .action(CopyCommitTag.boxed_clone()) - .disabled(true), - ), - [tag_name] => { - let tag_name = tag_name.clone(); - let label = format!("{copy_tag_label}: {tag_name}"); - menu.entry( - label, - Some(CopyCommitTag.boxed_clone()), - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name.to_string(), - )); - }, - ) - } - _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { - let mut menu = menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); + .when_some(ref_name.clone(), |menu, ref_name| { + menu.entry("Copy Ref Name", None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(ref_name.to_string())); + }) + }) + .when(ref_name.is_none(), |menu| { + menu.map(|menu| { + let tag_names = commit + .data + .tag_names() + .into_iter() + .map(|tag_name| SharedString::from(tag_name.to_string())) + .collect::>(); + let copy_tag_label = "Copy Tag"; + + match tag_names.as_slice() { + [] => menu.item( + ContextMenuEntry::new(copy_tag_label) + .action(CopyCommitTag.boxed_clone()) + .disabled(true), + ), + [tag_name] => { + let tag_name = tag_name.clone(); + let label = format!("{copy_tag_label}: {tag_name}"); + menu.entry( + label, + Some(CopyCommitTag.boxed_clone()), + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name.to_string(), + )); + }, + ) + } + _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { + let mut menu = + menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); - for tag_name in tag_names.clone() { - let tag_name_to_copy = tag_name.clone(); + for tag_name in tag_names.clone() { + let tag_name_to_copy = tag_name.clone(); - menu = menu.entry(tag_name, None, move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name_to_copy.to_string(), - )); - }); - } - menu - }), - } + menu = menu.entry(tag_name, None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name_to_copy.to_string(), + )); + }); + } + menu + }), + } + }) }) .map(|mut menu| { menu = menu.separator().header("Custom Commands"); @@ -2314,22 +2628,6 @@ impl GitGraph { cx.notify(); } - fn get_remote( - &self, - repository: &Repository, - _window: &mut Window, - cx: &mut App, - ) -> Option { - let remote_url = repository.default_remote_url()?; - let provider_registry = GitHostingProviderRegistry::default_global(cx); - let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?; - Some(GitRemote { - host: provider, - owner: parsed.owner.into(), - repo: parsed.repo.into(), - }) - } - fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { let color = cx.theme().colors(); let query_focus_handle = self @@ -2536,7 +2834,16 @@ impl GitGraph { }) .unwrap_or_default(); - let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx)); + let remote = repository.update(cx, |repo, cx| { + let remote_url = repo.default_remote_url()?; + let provider_registry = GitHostingProviderRegistry::default_global(cx); + let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?; + Some(GitRemote { + host: provider, + owner: parsed.owner.into(), + repo: parsed.repo.into(), + }) + }); let avatar = { let author_email_for_avatar = if author_email.is_empty() { @@ -2559,19 +2866,30 @@ impl GitGraph { let (total_lines_added, total_lines_removed) = self.selected_commit_diff_stats.unwrap_or((0, 0)); - let sorted_file_entries: Rc> = Rc::new( - self.selected_commit_diff - .as_ref() - .map(|diff| { - let mut files: Vec<_> = diff.files.iter().collect(); + let changed_file_entries: Vec = self + .selected_commit_diff + .as_ref() + .map(|diff| { + let mut files = diff.files.iter().collect::>(); + if !self.changed_files_view_mode.is_tree() { files.sort_by_key(|file| file.status()); - files - .into_iter() - .map(|file| ChangedFileEntry::from_commit_file(file, cx)) - .collect() - }) - .unwrap_or_default(), - ); + } + files + .into_iter() + .map(|file| ChangedFileEntry::from_commit_file(file, cx)) + .collect() + }) + .unwrap_or_default(); + let changed_file_entries = Rc::new(changed_file_entries); + let tree_entries: Rc> = if self.changed_files_view_mode.is_tree() + { + Rc::new(build_changed_file_tree_entries( + changed_file_entries.as_ref().clone(), + &self.changed_files_expanded_dirs, + )) + } else { + Rc::default() + }; v_flex() .min_w(px(300.)) @@ -2594,6 +2912,7 @@ impl GitGraph { this.selected_entry_idx = None; this.selected_commit_diff = None; this.selected_commit_diff_stats = None; + this.changed_files_expanded_dirs.clear(); this._commit_diff_task = None; cx.notify(); })), @@ -2621,7 +2940,7 @@ impl GitGraph { h_flex().gap_1().flex_wrap().justify_center().children( ref_names.iter().map(|name| { let is_head = Self::is_head_ref(name.as_ref(), &head_branch_name); - self.render_chip(name, accent_color, is_head) + self.render_ref_chip(name, accent_color, is_head, selected_idx, cx) }), ) })) @@ -2733,7 +3052,7 @@ impl GitGraph { }) .when_some(remote.clone(), |this, remote| { let provider_name = remote.host.name(); - let icon = git_ui::get_provider_icon(provider_name.as_str()); + let icon = crate::get_provider_icon(provider_name.as_str()); let parsed_remote = ParsedGitRemote { owner: remote.owner.as_ref().into(), repo: remote.repo.as_ref().into(), @@ -2793,11 +3112,48 @@ impl GitGraph { .size(LabelSize::Small) .color(Color::Muted), ) - .child(DiffStat::new( - "commit-diff-stat", - total_lines_added, - total_lines_removed, - )), + .child( + h_flex() + .gap_1() + .child(DiffStat::new( + "commit-diff-stat", + total_lines_added, + total_lines_removed, + )) + .child( + IconButton::new( + "toggle-changed-files-view", + IconName::ListTree, + ) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .toggle_state(self.changed_files_view_mode.is_tree()) + .tooltip({ + let tooltip = if self.changed_files_view_mode.is_tree() + { + "Show Flat View" + } else { + "Show Tree View" + }; + move |_, cx| { + Tooltip::for_action( + tooltip, + &ToggleChangedFilesView, + cx, + ) + } + }) + .on_click( + cx.listener(|this, _, _window, cx| { + this.changed_files_view_mode = + this.changed_files_view_mode.toggled(); + this.changed_files_scroll_handle + .scroll_to_item(0, ScrollStrategy::Top); + cx.notify(); + }), + ), + ), + ), ) .child( div() @@ -2805,24 +3161,55 @@ impl GitGraph { .flex_1() .min_h_0() .child({ - let entries = sorted_file_entries; - let entry_count = entries.len(); + let flat_entries = changed_file_entries; + let is_tree_view = self.changed_files_view_mode.is_tree(); + let entry_count = if is_tree_view { + tree_entries.len() + } else { + flat_entries.len() + }; let commit_sha = full_sha.clone(); let repository = repository.downgrade(); let workspace = self.workspace.clone(); + let git_graph = cx.weak_entity(); uniform_list( "changed-files-list", entry_count, move |range, _window, cx| { range .map(|ix| { - entries[ix].render( - ix, - commit_sha.clone(), - repository.clone(), - workspace.clone(), - cx, - ) + if is_tree_view { + match &tree_entries[ix] { + ChangedFileTreeEntry::Directory(entry) => { + entry.render(ix, git_graph.clone(), cx) + } + ChangedFileTreeEntry::File(entry) => { + entry.entry.render( + ix, + entry.depth, + None, + commit_sha.clone(), + repository.clone(), + workspace.clone(), + cx, + ) + } + } + } else { + let directory_label = (!flat_entries[ix] + .dir_path + .is_empty()) + .then(|| flat_entries[ix].dir_path.clone()); + flat_entries[ix].render( + ix, + 0, + directory_label, + commit_sha.clone(), + repository.clone(), + workspace.clone(), + cx, + ) + } }) .collect() }, @@ -3207,7 +3594,7 @@ impl GitGraph { window: &mut Window, cx: &mut Context, ) { - self.deploy_entry_context_menu(event.position, entry_idx, window, cx); + self.deploy_entry_context_menu(event.position, entry_idx, None, window, cx); cx.stop_propagation(); } @@ -3239,18 +3626,62 @@ impl GitGraph { let viewport_height = table_state.scroll_handle.viewport().size.height; let commit_count = match self.graph_data.max_commit_count { - AllCommitCount::Loaded(count) => count, + AllCommitCount::Loading(count) => count, + AllCommitCount::FullyLoaded(count) => count, AllCommitCount::NotLoaded => self.graph_data.commits.len(), }; let content_height = Self::row_height(window, cx) * commit_count; let max_vertical_scroll = (viewport_height - content_height).min(px(0.)); - let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.)); - let new_offset = Point::new(current_offset.x, new_y); + let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.)); + let new_offset = Point::new(current_offset.x, new_y); + + if new_offset != current_offset { + table_state.set_scroll_offset(new_offset); + cx.notify(); + } + } + + fn commit_count_and_loading_state(&mut self, cx: &mut Context) -> (usize, bool) { + match self.graph_data.max_commit_count { + AllCommitCount::FullyLoaded(count) => (count, false), + AllCommitCount::Loading(count) => { + let is_loading = self + .get_repository(cx) + .map(|repository| { + repository.update(cx, |repository, cx| { + repository + .graph_data(self.log_source.clone(), self.log_order, 0..0, cx) + .is_loading + }) + }) + .unwrap_or(false); + + (count, is_loading) + } + AllCommitCount::NotLoaded => { + let (commit_count, is_loading) = if let Some(repository) = self.get_repository(cx) { + repository.update(cx, |repository, cx| { + // Start loading the graph data if we haven't started already + let GraphDataResponse { + commits, + is_loading, + error: _, + } = repository.graph_data( + self.log_source.clone(), + self.log_order, + 0..usize::MAX, + cx, + ); + self.graph_data.add_commits(commits); + (commits.len(), is_loading) + }) + } else { + (0, false) + }; - if new_offset != current_offset { - table_state.set_scroll_offset(new_offset); - cx.notify(); + (commit_count, is_loading) + } } } @@ -3297,32 +3728,7 @@ impl Render for GitGraph { self.search_state.state = QueryState::Empty; self.search(query, cx); } - let (commit_count, is_loading) = match self.graph_data.max_commit_count { - AllCommitCount::Loaded(count) => (count, true), - AllCommitCount::NotLoaded => { - let (commit_count, is_loading) = if let Some(repository) = self.get_repository(cx) { - repository.update(cx, |repository, cx| { - // Start loading the graph data if we haven't started already - let GraphDataResponse { - commits, - is_loading, - error: _, - } = repository.graph_data( - self.log_source.clone(), - self.log_order, - 0..usize::MAX, - cx, - ); - self.graph_data.add_commits(&commits); - (commits.len(), is_loading) - }) - } else { - (0, false) - }; - - (commit_count, is_loading) - } - }; + let (commit_count, is_loading) = self.commit_count_and_loading_state(cx); let error = self.get_repository(cx).and_then(|repo| { repo.read(cx) @@ -3610,6 +4016,7 @@ impl Render for GitGraph { .on_action(cx.listener(Self::scroll_up)) .on_action(cx.listener(Self::scroll_down)) .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::toggle_changed_files_view)) .on_action(cx.listener(Self::focus_next_tab_stop)) .on_action(cx.listener(Self::focus_previous_tab_stop)) .on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| { @@ -4084,6 +4491,17 @@ impl GitGraph { .map(|commit| commit.data.clone()) .collect() } + + pub fn commit_count_and_loading_state_for_test( + &mut self, + cx: &mut Context, + ) -> (usize, bool) { + self.commit_count_and_loading_state(cx) + } + + pub fn log_source_for_test(&self) -> &LogSource { + &self.log_source + } } /// Generates a random commit DAG suitable for testing git graph rendering. @@ -4219,9 +4637,7 @@ mod tests { cx.set_global(settings_store); theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init(cx); - git_ui::init(cx); - project_panel::init(cx); - init(cx); + crate::init(cx); }); } @@ -4794,24 +5210,14 @@ mod tests { }); repository.update(cx, |repo, cx| { - repo.graph_data( - crate::LogSource::default(), - crate::LogOrder::default(), - 0..usize::MAX, - cx, - ); + repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx); }); cx.run_until_parked(); let graph_commits: Vec> = repository.update(cx, |repo, cx| { - repo.graph_data( - crate::LogSource::default(), - crate::LogOrder::default(), - 0..usize::MAX, - cx, - ) - .commits - .to_vec() + repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx) + .commits + .to_vec() }); let mut graph_data = GraphData::new(8); @@ -4825,6 +5231,63 @@ mod tests { } } + #[gpui::test] + async fn test_empty_nested_repository_graph_stops_loading(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + "repo_a": { + ".git": {}, + "file_a.txt": "content", + }, + "repo_b": { + ".git": {}, + "file_b.txt": "content", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + assert_eq!(project.repositories(cx).len(), 2); + project + .active_repository(cx) + .expect("should have an active repository") + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace, + None, + window, + cx, + ) + }); + cx.run_until_parked(); + + let (commit_count, is_loading) = git_graph.update(cx, |graph, cx| { + graph.commit_count_and_loading_state_for_test(cx) + }); + + assert_eq!(commit_count, 0); + assert!(!is_loading, "empty graph data should stop loading"); + } + #[gpui::test] async fn test_initial_graph_data_not_cleared_on_initial_loading(cx: &mut TestAppContext) { init_test(cx); @@ -4865,12 +5328,7 @@ mod tests { }); repository.update(cx, |repo, cx| { - repo.graph_data( - crate::LogSource::default(), - crate::LogOrder::default(), - 0..usize::MAX, - cx, - ); + repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx); }); project @@ -4888,7 +5346,7 @@ mod tests { "initial repository scan should emit HeadChanged" ); let commit_count_after = repository.read_with(cx, |repo, _| { - repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default()) + repo.get_graph_data(LogSource::default(), LogOrder::default()) .map(|data| data.commit_data.len()) .unwrap() }); @@ -4927,18 +5385,13 @@ mod tests { }); repository.update(cx, |repo, cx| { - repo.graph_data( - crate::LogSource::default(), - crate::LogOrder::default(), - 0..usize::MAX, - cx, - ); + repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx); }); cx.run_until_parked(); let error = repository.read_with(cx, |repo, _| { - repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default()) + repo.get_graph_data(LogSource::default(), LogOrder::default()) .and_then(|data| data.error.clone()) }); @@ -5070,14 +5523,12 @@ mod tests { } #[gpui::test] - async fn test_file_history_action_uses_focused_source_and_reuses_matching_graph( - cx: &mut TestAppContext, - ) { + async fn test_file_history_action_uses_git_panel_and_editor_sources(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( - Path::new("/project"), + Path::new(util::path!("/project")), json!({ ".git": {}, "tracked1.txt": "tracked 1", @@ -5085,15 +5536,22 @@ mod tests { }), ) .await; + fs.set_status_for_repo( + Path::new(util::path!("/project/.git")), + &[ + ("tracked1.txt", StatusCode::Modified.worktree()), + ("tracked2.txt", StatusCode::Modified.worktree()), + ], + ); let commits = vec![Arc::new(InitialGraphCommitData { sha: Oid::from_bytes(&[1; 20]).unwrap(), parents: smallvec![], ref_names: vec!["HEAD".into(), "refs/heads/main".into()], })]; - fs.set_graph_commits(Path::new("/project/.git"), commits); + fs.set_graph_commits(Path::new(util::path!("/project/.git")), commits); - let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + let project = Project::test(fs.clone(), [Path::new(util::path!("/project"))], cx).await; cx.run_until_parked(); let repository = project.read_with(cx, |project, cx| { @@ -5127,18 +5585,10 @@ mod tests { }) .expect("window should be available"); cx.background_executor.allow_parking(); - let project_panel = cx - .foreground_executor() - .clone() - .block_test(ProjectPanel::load( - weak_workspace.clone(), - async_window_cx.clone(), - )) - .expect("project panel should load"); let git_panel = cx .foreground_executor() .clone() - .block_test(git_ui::git_panel::GitPanel::load( + .block_test(crate::git_panel::GitPanel::load( weak_workspace, async_window_cx, )) @@ -5149,21 +5599,20 @@ mod tests { .update(cx, |multi, window, cx| { let workspace = multi.workspace(); workspace.update(cx, |workspace, cx| { - workspace.add_panel(project_panel.clone(), window, cx); workspace.add_panel(git_panel.clone(), window, cx); }); }) .expect("workspace window should be available"); + cx.executor().advance_clock(Duration::from_millis(100)); cx.run_until_parked(); workspace_window - .update(cx, |multi, window, cx| { - let workspace = multi.workspace(); - project_panel.update(cx, |panel, cx| { - panel.select_path_for_test(tracked1.clone(), cx) + .update(cx, |_, window, cx| { + git_panel.update(cx, |panel, cx| { + panel.select_entry_by_path(tracked1.clone(), window, cx); }); - workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(window, cx); + git_panel.update(cx, |panel, cx| { + panel.focus_handle(cx).focus(window, cx); }); }) .expect("workspace window should be available"); @@ -5185,13 +5634,12 @@ mod tests { }); workspace_window - .update(cx, |multi, window, cx| { - let workspace = multi.workspace(); + .update(cx, |_, window, cx| { git_panel.update(cx, |panel, cx| { panel.select_entry_by_path(tracked1.clone(), window, cx); }); - workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(window, cx); + git_panel.update(cx, |panel, cx| { + panel.focus_handle(cx).focus(window, cx); }); }) .expect("workspace window should be available"); @@ -6258,7 +6706,7 @@ mod tests { git_graph.update_in(cx, |git_graph, window, cx| { assert_eq!(git_graph.graph_data.commits.len(), 1); - git_graph.deploy_entry_context_menu(point(px(20.), px(20.)), 0, window, cx); + git_graph.deploy_entry_context_menu(point(px(20.), px(20.)), 0, None, window, cx); }); cx.run_until_parked(); @@ -6306,4 +6754,151 @@ mod tests { Some(&"project".to_string()) ); } + + #[gpui::test] + async fn test_global_git_command_task_runs_from_ref_context_menu(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + + let commit_sha = Oid::try_from("abcdef1234567890abcdef1234567890abcdef12") + .expect("commit SHA should be valid"); + fs.set_graph_commits( + Path::new("/project/.git"), + vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: SmallVec::new(), + ref_names: vec!["HEAD -> feature-x".into()], + })], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("project should have an active repository") + }); + let task_inventory = project.read_with(cx, |project, cx| { + project + .task_store() + .read(cx) + .task_inventory() + .cloned() + .expect("project should have a task inventory") + }); + + task_inventory.update(cx, |inventory, _| { + inventory + .update_file_based_tasks( + TaskSettingsLocation::Global(Path::new("/tasks.json")), + Some( + &serde_json::to_string(&json!([ + { + "label": "Check out $ZED_GIT_REF", + "command": "git", + "args": ["checkout", "$ZED_GIT_REF"], + "cwd": "$ZED_GIT_REPOSITORY_PATH", + "tags": [GIT_COMMAND_TASK_TAG], + }, + ])) + .expect("tasks JSON should serialize"), + ), + ) + .expect("tasks should parse"); + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(&*cx, |multi_workspace, _| { + multi_workspace.workspace().clone() + }); + let workspace_weak = workspace.downgrade(); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx); + }); + cx.run_until_parked(); + + git_graph.update_in(cx, |git_graph, window, cx| { + assert_eq!(git_graph.graph_data.commits.len(), 1); + git_graph.deploy_entry_context_menu( + point(px(20.), px(20.)), + 0, + Some("feature-x".into()), + window, + cx, + ); + }); + cx.run_until_parked(); + + let context_menu = git_graph.read_with(&*cx, |git_graph, _| { + git_graph + .context_menu + .as_ref() + .expect("context menu should be open") + .menu + .clone() + }); + context_menu.update_in(cx, |context_menu, window, cx| { + context_menu + .select_last(window, cx) + .expect("custom Git task should be selectable"); + context_menu.confirm(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + let (_task_source_kind, resolved_task) = task_inventory.read_with(&*cx, |inventory, _| { + inventory + .last_scheduled_task(None) + .expect("custom Git task should be scheduled") + }); + + assert_eq!(resolved_task.resolved_label, "Check out feature-x"); + assert_eq!( + resolved_task.resolved.args, + vec!["checkout".to_string(), "feature-x".to_string()] + ); + } + + #[test] + fn test_ref_name_from_decoration() { + assert_eq!( + GitGraph::ref_name_from_decoration("HEAD -> main"), + Some("main".into()) + ); + assert_eq!( + GitGraph::ref_name_from_decoration("main"), + Some("main".into()) + ); + assert_eq!( + GitGraph::ref_name_from_decoration("origin/main"), + Some("origin/main".into()) + ); + assert_eq!( + GitGraph::ref_name_from_decoration("tag: v1.0"), + Some("v1.0".into()) + ); + assert_eq!(GitGraph::ref_name_from_decoration("HEAD"), None); + } } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 194b531e91a52a..c0e9b04614a3ab 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -82,7 +82,7 @@ use workspace::SERIALIZATION_THROTTLE_TIME; use workspace::{ Item, Workspace, dock::{DockPosition, Panel, PanelEvent}, - notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyTaskExt}, + notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt}, }; use zed_actions::{DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize}; @@ -134,21 +134,6 @@ actions!( ] ); -actions!( - git_graph, - [ - /// Opens the Git Graph Tab. - Open, - ] -); - -/// Opens the Git Graph Tab at a specific commit. -#[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, gpui::Action)] -#[action(namespace = git_graph)] -pub struct OpenAtCommit { - pub sha: String, -} - fn prompt( msg: &str, detail: Option<&str>, @@ -688,6 +673,8 @@ pub struct GitPanel { context_menu: Option<(Entity, Point, Subscription)>, modal_open: bool, show_placeholders: bool, + // Only read to compute collaborative co-authors, which requires the `call` feature. + #[cfg_attr(not(feature = "call"), allow(dead_code))] local_committer: Option, local_committer_task: Option>, commit_template: Option, @@ -839,7 +826,7 @@ impl GitPanel { GitStoreEvent::IndexWriteError(error) => { this.workspace .update(cx, |workspace, cx| { - workspace.show_error(error, cx); + workspace.show_error(format!("{error}"), cx); }) .ok(); } @@ -1493,6 +1480,44 @@ impl GitPanel { }); } + fn add_to_git_info_exclude( + &mut self, + _: &git::AddToGitInfoExclude, + _window: &mut Window, + cx: &mut Context, + ) { + maybe!({ + let list_entry = self.entries.get(self.selected_entry?)?.clone(); + let entry = list_entry.status_entry()?.to_owned(); + + if !entry.status.is_created() { + return Some(()); + } + + let active_repository = self.active_repository.clone()?; + let workspace = self.workspace.clone(); + let repo_path = entry.repo_path; + + let receiver = active_repository.update(cx, |repo, _| { + repo.add_path_to_git_info_exclude(&repo_path, false) + }); + + cx.spawn(async move |_, cx| { + if let Err(e) = receiver.await? { + if let Some(workspace) = workspace.upgrade() { + cx.update(|cx| { + show_error_toast(workspace, "add to .git/info/exclude", e, cx); + }); + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + Some(()) + }); + } + fn revert_entry( &mut self, entry: &GitStatusEntry, @@ -2288,7 +2313,7 @@ impl GitPanel { return; }; let error_spawn = |message, window: &mut Window, cx: &mut App| { - let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx); + let prompt = window.prompt(PromptLevel::Warning, message, None, &["OK"], cx); cx.spawn(async move |_| { prompt.await.ok(); }) @@ -2965,7 +2990,7 @@ impl GitPanel { PromptLevel::Warning, "Unable to initialize a git repository", Some("Open a directory first"), - &["Ok"], + &["OK"], cx, ); cx.background_executor() @@ -3187,7 +3212,7 @@ impl GitPanel { /// worktree to the `safe.directory` config, ensuring that, even if the user /// that's running the application is not the owner of `.git/`, it can still /// read the repository's contents. - fn add_safe_directory(&mut self, window: &mut Window, cx: &mut Context) { + fn add_safe_directory(&mut self, _window: &mut Window, cx: &mut Context) { let Some(active_repository) = &self.active_repository else { return; }; @@ -3205,12 +3230,10 @@ impl GitPanel { path_arg, ]; - cx.spawn_in(window, async move |git_panel, cx| { - git_panel.update(cx, |git_panel, cx| { - git_panel.project.read(cx).git_config(path, args, cx) - }) - }) - .detach(); + self.project + .read(cx) + .git_config(path, args, cx) + .detach_and_log_err(cx); } } @@ -3362,6 +3385,12 @@ impl GitPanel { } } + #[cfg(not(feature = "call"))] + fn potential_co_authors(&self, _cx: &App) -> Vec<(String, String)> { + Vec::new() + } + + #[cfg(feature = "call")] fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> { let mut new_co_authors = Vec::new(); let project = self.project.read(cx); @@ -3403,6 +3432,7 @@ impl GitPanel { new_co_authors } + #[cfg(feature = "call")] fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> { let user = room.local_participant_user(cx)?; let committer = self.local_committer.as_ref()?; @@ -4088,16 +4118,7 @@ impl GitPanel { { if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) { let _ = workspace.update(cx, |workspace, cx| { - struct CommitMessageError; - let notification_id = NotificationId::unique::(); - workspace.show_notification(notification_id, cx, |cx| { - cx.new(|cx| { - ErrorMessagePrompt::new( - format!("Failed to generate commit message: {err}"), - cx, - ) - }) - }); + workspace.show_error(format!("Failed to generate commit message: {err}"), cx); }); } } @@ -5027,10 +5048,14 @@ impl GitPanel { IconButton::new("git-graph-button", IconName::GitGraph) .icon_size(IconSize::Small) .tooltip(|_window, cx| { - Tooltip::for_action("Open Git Graph", &Open, cx) + Tooltip::for_action( + "Open Git Graph", + &crate::git_graph::Open, + cx, + ) }) .on_click(|_, window, cx| { - window.dispatch_action(Open.boxed_clone(), cx) + window.dispatch_action(crate::git_graph::Open.boxed_clone(), cx) }), ), ), @@ -5106,15 +5131,36 @@ impl GitPanel { fn render_history_tab(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex().flex_1().size_full().overflow_hidden().map(|this| { - if let Some(history) = self.render_commit_history(window, cx) { - this.child(history) - } else { + let has_repo = self.active_repository.is_some(); + let has_commits = self + .commit_history_shas + .as_ref() + .map_or(false, |shas| !shas.is_empty()); + let is_loading = self.commit_history_shas.is_none() && has_repo; + if is_loading { this.child( h_flex() .flex_1() .justify_center() .child(Label::new("Loading Commit History…").color(Color::Muted)), ) + } else if !has_repo || !has_commits { + this.child( + h_flex() + .flex_1() + .justify_center() + .child(Label::new("No commits yet").color(Color::Muted)), + ) + } else { + match self.render_commit_history(window, cx) { + Some(history) => this.child(history), + None => this.child( + h_flex() + .flex_1() + .justify_center() + .child(Label::new("Failed to load commits").color(Color::Muted)), + ), + } } }) } @@ -5959,11 +6005,17 @@ impl GitPanel { .context(self.focus_handle.clone()) .action(stage_title, ToggleStaged.boxed_clone()) .action(restore_title, git::RestoreFile::default().boxed_clone()) + .separator() .action_disabled_when( !is_created, "Add to .gitignore", git::AddToGitignore.boxed_clone(), ) + .action_disabled_when( + !is_created, + "Add to .git/info/exclude", + git::AddToGitInfoExclude.boxed_clone(), + ) .separator() .action("Open Diff", menu::Confirm.boxed_clone()) .action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone()) @@ -6244,7 +6296,7 @@ impl GitPanel { cx.listener(move |this, event: &ClickEvent, window, cx| { this.selected_entry = Some(ix); cx.notify(); - if event.click_count() > 1 || event.modifiers().secondary() { + if event.modifiers().secondary() { this.open_solo_diff(&Default::default(), window, cx) } else { this.open_diff(&Default::default(), window, cx); @@ -6280,7 +6332,7 @@ impl GitPanel { window: &Window, cx: &Context, ) -> AnyElement { - // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that + // TODO: Have not yet plugged in self.marked_entries. Not sure when and why we need that let selected = self.selected_entry == Some(ix); let label_color = Color::Muted; @@ -6647,19 +6699,24 @@ impl Render for GitPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let project = self.project.read(cx); let has_entries = !self.entries.is_empty(); - let room = self.workspace.upgrade().and_then(|_workspace| { - call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned()) - }); - let has_write_access = self.has_write_access(cx); - let has_co_authors = room.is_some_and(|room| { - self.load_local_committer(cx); - let room = room.read(cx); - room.remote_participants() - .values() - .any(|remote_participant| remote_participant.can_write()) - }); + #[cfg(feature = "call")] + let has_co_authors = self + .workspace + .upgrade() + .and_then(|_workspace| { + call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned()) + }) + .is_some_and(|room| { + self.load_local_committer(cx); + let room = room.read(cx); + room.remote_participants() + .values() + .any(|remote_participant| remote_participant.can_write()) + }); + #[cfg(not(feature = "call"))] + let has_co_authors = false; v_flex() .id("git_panel") @@ -6678,6 +6735,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::restore_tracked_files)) .on_action(cx.listener(Self::revert_selected)) .on_action(cx.listener(Self::add_to_gitignore)) + .on_action(cx.listener(Self::add_to_git_info_exclude)) .on_action(cx.listener(Self::clean_all)) .on_action(cx.listener(Self::generate_commit_message_action)) .on_action(cx.listener(Self::stash_all)) diff --git a/crates/git_ui/src/git_picker.rs b/crates/git_ui/src/git_picker.rs index 02299e5f5e68db..c972deb08568f1 100644 --- a/crates/git_ui/src/git_picker.rs +++ b/crates/git_ui/src/git_picker.rs @@ -20,14 +20,14 @@ actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GitPickerTab { Branches, - Stash, + Stashes, } impl Display for GitPickerTab { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let label = match self { GitPickerTab::Branches => "Branches", - GitPickerTab::Stash => "Stash", + GitPickerTab::Stashes => "Stashes", }; write!(f, "{}", label) } @@ -85,7 +85,7 @@ impl GitPicker { GitPickerTab::Branches => { self.ensure_branch_list(window, cx); } - GitPickerTab::Stash => { + GitPickerTab::Stashes => { self.ensure_stash_list(window, cx); } } @@ -140,7 +140,7 @@ impl GitPicker { }); let subscription = cx.subscribe(&stash_list, |this, _, _: &DismissEvent, cx| { - if this.tab == GitPickerTab::Stash { + if this.tab == GitPickerTab::Stashes { cx.emit(DismissEvent); } }); @@ -153,8 +153,8 @@ impl GitPicker { fn activate_next_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Branches => GitPickerTab::Stash, - GitPickerTab::Stash => GitPickerTab::Branches, + GitPickerTab::Branches => GitPickerTab::Stashes, + GitPickerTab::Stashes => GitPickerTab::Branches, }; self.ensure_active_picker(window, cx); self.focus_active_picker(window, cx); @@ -163,8 +163,8 @@ impl GitPicker { fn activate_previous_tab(&mut self, window: &mut Window, cx: &mut Context) { self.tab = match self.tab { - GitPickerTab::Branches => GitPickerTab::Stash, - GitPickerTab::Stash => GitPickerTab::Branches, + GitPickerTab::Branches => GitPickerTab::Stashes, + GitPickerTab::Stashes => GitPickerTab::Branches, }; self.ensure_active_picker(window, cx); self.focus_active_picker(window, cx); @@ -178,7 +178,7 @@ impl GitPicker { branch_list.focus_handle(cx).focus(window, cx); } } - GitPickerTab::Stash => { + GitPickerTab::Stashes => { if let Some(stash_list) = &self.stash_list { stash_list.focus_handle(cx).focus(window, cx); } @@ -213,9 +213,9 @@ impl GitPicker { ) }), ToggleButtonSimple::new( - GitPickerTab::Stash.to_string(), + GitPickerTab::Stashes.to_string(), cx.listener(|this, _, window, cx| { - this.tab = GitPickerTab::Stash; + this.tab = GitPickerTab::Stashes; this.ensure_active_picker(window, cx); this.focus_active_picker(window, cx); cx.notify(); @@ -236,7 +236,7 @@ impl GitPicker { .auto_width() .selected_index(match self.tab { GitPickerTab::Branches => 0, - GitPickerTab::Stash => 1, + GitPickerTab::Stashes => 1, }), ) } @@ -251,7 +251,7 @@ impl GitPicker { let branch_list = self.ensure_branch_list(window, cx); branch_list.into_any_element() } - GitPickerTab::Stash => { + GitPickerTab::Stashes => { let stash_list = self.ensure_stash_list(window, cx); stash_list.into_any_element() } @@ -272,7 +272,7 @@ impl GitPicker { }); } } - GitPickerTab::Stash => { + GitPickerTab::Stashes => { if let Some(stash_list) = &self.stash_list { stash_list.update(cx, |list, cx| { list.handle_modifiers_changed(ev, window, cx); @@ -359,7 +359,7 @@ impl Focusable for GitPicker { return branch_list.focus_handle(cx); } } - GitPickerTab::Stash => { + GitPickerTab::Stashes => { if let Some(stash_list) = &self.stash_list { return stash_list.focus_handle(cx); } @@ -387,7 +387,7 @@ impl Render for GitPicker { key_context.add("GitPicker"); match self.tab { GitPickerTab::Branches => key_context.add("GitBranchSelector"), - GitPickerTab::Stash => key_context.add("StashList"), + GitPickerTab::Stashes => key_context.add("StashList"), } key_context }) @@ -412,7 +412,7 @@ impl Render for GitPicker { cx.notify(); })) .on_action(cx.listener(|this, _: &ActivateStashTab, window, cx| { - this.tab = GitPickerTab::Stash; + this.tab = GitPickerTab::Stashes; this.ensure_active_picker(window, cx); this.focus_active_picker(window, cx); cx.notify(); @@ -423,7 +423,7 @@ impl Render for GitPicker { .on_action(cx.listener(Self::handle_force_delete_branch)) .on_action(cx.listener(Self::handle_filter_remotes)) }) - .when(self.tab == GitPickerTab::Stash, |el| { + .when(self.tab == GitPickerTab::Stashes, |el| { el.on_action(cx.listener(Self::handle_drop_stash)) .on_action(cx.listener(Self::handle_show_stash)) }) @@ -447,7 +447,7 @@ pub fn open_stash( window: &mut Window, cx: &mut Context, ) { - open_with_tab(workspace, GitPickerTab::Stash, window, cx); + open_with_tab(workspace, GitPickerTab::Stashes, window, cx); } fn open_with_tab( @@ -493,6 +493,6 @@ pub fn register(workspace: &mut Workspace) { }, ); workspace.register_action(|workspace, _: &zed_actions::git::ViewStash, window, cx| { - open_with_tab(workspace, GitPickerTab::Stash, window, cx); + open_with_tab(workspace, GitPickerTab::Stashes, window, cx); }); } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index d19ac552067f14..5c211a87466f9e 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -36,6 +36,7 @@ pub mod commit_tooltip; pub mod commit_view; mod conflict_view; pub mod file_diff_view; +pub mod git_graph; pub mod git_panel; mod git_panel_settings; pub mod git_picker; @@ -57,11 +58,13 @@ pub use conflict_view::MergeConflictIndicator; pub fn get_provider_icon(name: &str) -> IconName { match name { "Bitbucket" => IconName::Bitbucket, + "Chromium" => IconName::Gerrit, "Codeberg" => IconName::Codeberg, "Forgejo Self-Hosted" => IconName::Forgejo, "GitHub" => IconName::Github, "GitLab" => IconName::Gitlab, "Gitea" => IconName::Gitea, + "SourceHut" => IconName::Sourcehut, _ => IconName::Link, } } @@ -69,6 +72,7 @@ pub fn get_provider_icon(name: &str) -> IconName { pub fn init(cx: &mut App) { editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx); commit_view::init(cx); + git_graph::init(cx); cx.observe_new(|editor: &mut Editor, _, cx| { conflict_view::register_editor(editor, editor.buffer().clone(), cx); diff --git a/crates/git_ui/src/multi_diff_view.rs b/crates/git_ui/src/multi_diff_view.rs index a3b55fabe9a74c..f8097e68f5c8d8 100644 --- a/crates/git_ui/src/multi_diff_view.rs +++ b/crates/git_ui/src/multi_diff_view.rs @@ -1,3 +1,4 @@ +use crate::file_diff_view::build_buffer_diff; use anyhow::Result; use buffer_diff::BufferDiff; use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines}; @@ -140,36 +141,6 @@ fn common_prefix(paths: &[PathBuf]) -> Option { Some(prefix) } -async fn build_buffer_diff( - old_buffer: &Entity, - new_buffer: &Entity, - cx: &mut AsyncApp, -) -> Result> { - let old_buffer_snapshot = old_buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let new_buffer_snapshot = new_buffer.read_with(cx, |buffer, _| buffer.snapshot()); - - let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx)); - - let update = diff - .update(cx, |diff, cx| { - diff.update_diff( - new_buffer_snapshot.text.clone(), - Some(old_buffer_snapshot.text().into()), - Some(true), - new_buffer_snapshot.language().cloned(), - cx, - ) - }) - .await; - - diff.update(cx, |diff, cx| { - diff.set_snapshot(update, &new_buffer_snapshot.text, cx) - }) - .await; - - Ok(diff) -} - impl MultiDiffView { pub fn open( diff_pairs: Vec<[String; 2]>, @@ -228,6 +199,7 @@ impl MultiDiffView { editor.start_temporary_diff_override(); editor.disable_diagnostics(cx); editor.set_expand_all_diff_hunks(cx); + editor.set_render_diff_hunks_as_unstaged(true, cx); editor.set_render_diff_hunk_controls( Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()), cx, diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 89bb54d0a5e3d3..cd929c22e670f0 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1,6 +1,5 @@ use crate::{ - branch_picker, - conflict_view::ConflictAddon, + branch_picker, conflict_view, git_panel::{GitPanel, GitPanelAddon, GitStatusEntry}, git_panel_settings::GitPanelSettings, }; @@ -28,7 +27,7 @@ use gpui::{ use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt}; use multi_buffer::{MultiBuffer, PathKey}; use project::{ - Project, ProjectPath, + ConflictSet, Project, ProjectPath, git_store::{ Repository, branch_diff::{self, BranchDiffEvent, DiffBase}, @@ -36,6 +35,7 @@ use project::{ }; use settings::{Settings, SettingsStore}; use std::any::{Any, TypeId}; +use std::collections::BTreeMap; use std::sync::Arc; use theme::ActiveTheme; use ui::{ @@ -71,12 +71,19 @@ actions!( ] ); +struct BufferSubscriptions { + _diff: Entity, + _diff_subscription: Subscription, + _conflict_set: Entity, + _conflict_set_subscription: Subscription, +} + pub struct ProjectDiff { project: Entity, multibuffer: Entity, branch_diff: Entity, editor: Entity, - buffer_diff_subscriptions: HashMap, (Entity, Subscription)>, + buffer_subscriptions: HashMap, BufferSubscriptions>, workspace: WeakEntity, focus_handle: FocusHandle, pending_scroll: Option, @@ -85,13 +92,6 @@ pub struct ProjectDiff { _subscription: Subscription, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RefreshReason { - DiffChanged, - StatusesChanged, - EditorSaved, -} - const CONFLICT_SORT_PREFIX: u64 = 1; const TRACKED_SORT_PREFIX: u64 = 2; const NEW_SORT_PREFIX: u64 = 3; @@ -562,14 +562,14 @@ impl ProjectDiff { BranchDiffEvent::FileListChanged => { this._task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await + async |cx| Self::refresh(this, cx).await }) } BranchDiffEvent::DiffBaseChanged => { this.pending_scroll.take(); this._task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await + async |cx| Self::refresh(this, cx).await }) } }, @@ -588,7 +588,7 @@ impl ProjectDiff { this._task = { window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await + async |cx| Self::refresh(this, cx).await }) } } @@ -599,7 +599,7 @@ impl ProjectDiff { let task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await + async |cx| Self::refresh(this, cx).await }); Self { @@ -609,7 +609,7 @@ impl ProjectDiff { focus_handle, editor, multibuffer, - buffer_diff_subscriptions: Default::default(), + buffer_subscriptions: Default::default(), pending_scroll: None, review_comment_count: 0, _task: task, @@ -795,9 +795,8 @@ impl ProjectDiff { .ok(); } EditorEvent::Saved => { - self._task = cx.spawn_in(window, async move |this, cx| { - Self::refresh(this, RefreshReason::EditorSaved, cx).await - }); + self._task = + cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await); } _ => {} } @@ -815,26 +814,57 @@ impl ProjectDiff { file_status: FileStatus, buffer: Entity, diff: Entity, + conflict_set: Entity, window: &mut Window, cx: &mut Context, ) -> Option { - let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| { - this._task = window.spawn(cx, { - let this = cx.weak_entity(); - async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await - }) + let diff_subscription = cx.subscribe_in(&diff, window, { + let path_key = path_key.clone(); + let buffer = buffer.clone(); + let diff = diff.clone(); + let conflict_set = conflict_set.clone(); + move |this, _, event, window, cx| match event { + buffer_diff::BufferDiffEvent::DiffChanged(_) => { + this.buffer_ranges_changed( + path_key.clone(), + file_status, + buffer.clone(), + diff.clone(), + conflict_set.clone(), + window, + cx, + ); + } + buffer_diff::BufferDiffEvent::BaseTextChanged + | buffer_diff::BufferDiffEvent::HunksStagedOrUnstaged(_) => {} + } }); - self.buffer_diff_subscriptions - .insert(path_key.path.clone(), (diff.clone(), subscription)); - - // TODO(split-diff) we shouldn't have a conflict addon when split - let conflict_addon = self - .editor - .read(cx) - .rhs_editor() - .read(cx) - .addon::() - .expect("project diff editor should have a conflict addon"); + let conflict_set_subscription = cx.subscribe_in(&conflict_set, window, { + let path_key = path_key.clone(); + let buffer = buffer.clone(); + let diff = diff.clone(); + let conflict_set = conflict_set.clone(); + move |this, _, _, window, cx| { + this.buffer_ranges_changed( + path_key.clone(), + file_status, + buffer.clone(), + diff.clone(), + conflict_set.clone(), + window, + cx, + ) + } + }); + self.buffer_subscriptions.insert( + path_key.path.clone(), + BufferSubscriptions { + _diff: diff.clone(), + _diff_subscription: diff_subscription, + _conflict_set: conflict_set.clone(), + _conflict_set_subscription: conflict_set_subscription, + }, + ); let snapshot = buffer.read(cx).snapshot(); let diff_snapshot = diff.read(cx).snapshot(cx); @@ -846,11 +876,9 @@ impl ProjectDiff { &snapshot, ) .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); - let conflicts = conflict_addon - .conflict_set(snapshot.remote_id()) - .map(|conflict_set| conflict_set.read(cx).snapshot().conflicts) - .unwrap_or_default(); + let conflicts = conflict_set.read(cx).snapshot(); let mut conflicts = conflicts + .conflicts .iter() .map(|conflict| conflict.range.to_point(&snapshot)) .peekable(); @@ -874,6 +902,9 @@ impl ProjectDiff { diff, cx, ); + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffer_ranges_updated(editor, conflict_set, cx); + }); (was_empty, is_newly_added) }); @@ -921,19 +952,38 @@ impl ProjectDiff { needs_fold } + fn buffer_ranges_changed( + &mut self, + path_key: PathKey, + file_status: FileStatus, + buffer: Entity, + diff: Entity, + conflict_set: Entity, + window: &mut Window, + cx: &mut Context, + ) { + if buffer.read(cx).is_dirty() { + return; + } + self.register_buffer( + path_key, + file_status, + buffer, + diff, + conflict_set, + window, + cx, + ); + } + #[instrument(skip(this, cx))] - pub async fn refresh( - this: WeakEntity, - reason: RefreshReason, - cx: &mut AsyncWindowContext, - ) -> Result<()> { - let mut path_keys = Vec::new(); - let buffers_to_load = this.update(cx, |this, cx| { + pub async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { + let entries = this.update(cx, |this, cx| { let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { let load_buffers = branch_diff.load_buffers(cx); (branch_diff.repo().cloned(), load_buffers) }); - let mut previous_buffers = this + let mut previous_paths = this .multibuffer .read(cx) .snapshot(cx) @@ -941,73 +991,56 @@ impl ProjectDiff { .map(|(buffer_snapshot, path_key)| (path_key.clone(), buffer_snapshot.remote_id())) .collect::>(); + let mut entries = BTreeMap::new(); if let Some(repo) = repo { let repo = repo.read(cx); - path_keys = Vec::with_capacity(buffers_to_load.len()); - for entry in buffers_to_load.iter() { - let sort_prefix = sort_prefix(&repo, &entry.repo_path, entry.file_status, cx); - let path_key = - PathKey::with_sort_prefix(sort_prefix, entry.repo_path.as_ref().clone()); - previous_buffers.remove(&path_key); - path_keys.push(path_key) + for diff_buffer in buffers_to_load { + let sort_prefix = + sort_prefix(&repo, &diff_buffer.repo_path, diff_buffer.file_status, cx); + let path_key = PathKey::with_sort_prefix( + sort_prefix, + diff_buffer.repo_path.as_ref().clone(), + ); + previous_paths.remove(&path_key); + entries.insert(path_key, diff_buffer); } } this.editor.update(cx, |editor, cx| { - for (path, buffer_id) in previous_buffers { - if let Some(buffer) = this.multibuffer.read(cx).buffer(buffer_id) { - let skip = match reason { - RefreshReason::DiffChanged | RefreshReason::EditorSaved => { - buffer.read(cx).is_dirty() - } - RefreshReason::StatusesChanged => false, - }; - if skip { - continue; - } - } - - this.buffer_diff_subscriptions.remove(&path.path); + for (path, buffer_id) in previous_paths { + this.buffer_subscriptions.remove(&path.path); + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffers_removed(editor, &[buffer_id], cx); + }); let _span = ztracing::info_span!("remove_excerpts_for_path"); _span.enter(); editor.remove_excerpts_for_path(path, cx); } }); - buffers_to_load + + entries })?; let mut buffers_to_fold = Vec::new(); - for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys) { - if let Some((buffer, diff)) = entry.load.await.log_err() { + for (path_key, entry) in entries { + if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() { // We might be lagging behind enough that all future entry.load futures are no longer pending. // If that is the case, this task will never yield, starving the foreground thread of execution time. yield_now().await; cx.update(|window, cx| { this.update(cx, |this, cx| { - let multibuffer = this.multibuffer.read(cx); - let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some() - && multibuffer - .diff_for(buffer.read(cx).remote_id()) - .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id()) - && match reason { - RefreshReason::DiffChanged | RefreshReason::EditorSaved => { - buffer.read(cx).is_dirty() - } - RefreshReason::StatusesChanged => false, - }; - if !skip { - if let Some(buffer_id) = this.register_buffer( - path_key, - entry.file_status, - buffer, - diff, - window, - cx, - ) { - buffers_to_fold.push(buffer_id); - } + if let Some(buffer_id) = this.register_buffer( + path_key, + entry.file_status, + buffer, + diff, + conflict_set, + window, + cx, + ) { + buffers_to_fold.push(buffer_id); } }) .ok(); @@ -2242,10 +2275,7 @@ mod tests { ); } - use crate::{ - conflict_view::resolve_conflict, - project_diff::{self, ProjectDiff}, - }; + use crate::project_diff::{self, ProjectDiff}; #[gpui::test] async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) { @@ -2294,14 +2324,13 @@ mod tests { let mut cx = EditorTestContext::for_editor_in(editor, cx).await; - cx.assert_excerpts_with_selections(indoc!( + cx.set_selections_state(indoc!( " - [EXCERPT] before really changed - [EXCERPT] - [FOLDED] - [EXCERPT] + + deleted + ˇcreated " )); @@ -2419,89 +2448,6 @@ mod tests { cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}")); } - #[gpui::test] - async fn test_saving_resolved_conflicts(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - path!("/project"), - json!({ - ".git": {}, - "foo": "<<<<<<< x\nours\n=======\ntheirs\n>>>>>>> y\n", - }), - ) - .await; - fs.set_status_for_repo( - Path::new(path!("/project/.git")), - &[( - "foo", - UnmergedStatus { - first_head: UnmergedStatusCode::Updated, - second_head: UnmergedStatusCode::Updated, - } - .into(), - )], - ); - let project = Project::test(fs.clone(), [path!("/project").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, |mw, _| mw.workspace().clone()); - let diff = cx.new_window_entity(|window, cx| { - ProjectDiff::new(project.clone(), workspace, window, cx) - }); - cx.run_until_parked(); - - cx.update(|window, cx| { - let editor = diff.read(cx).editor.read(cx).rhs_editor().clone(); - let excerpts = editor - .read(cx) - .buffer() - .read(cx) - .snapshot(cx) - .excerpts() - .collect::>(); - assert_eq!(excerpts.len(), 1); - let buffer = editor - .read(cx) - .buffer() - .read(cx) - .all_buffers() - .into_iter() - .next() - .unwrap(); - let buffer_id = buffer.read(cx).remote_id(); - let conflict_set = diff - .read(cx) - .editor - .read(cx) - .rhs_editor() - .read(cx) - .addon::() - .unwrap() - .conflict_set(buffer_id) - .unwrap(); - assert!(conflict_set.read(cx).has_conflict); - let snapshot = conflict_set.read(cx).snapshot(); - assert_eq!(snapshot.conflicts.len(), 1); - - let ours_range = snapshot.conflicts[0].ours.clone(); - - resolve_conflict( - editor.downgrade(), - snapshot.conflicts[0].clone(), - vec![ours_range], - window, - cx, - ) - }) - .await; - - let contents = fs.read_file_sync(path!("/project/foo")).unwrap(); - let contents = String::from_utf8(contents).unwrap(); - assert_eq!(contents, "ours\n"); - } - #[gpui::test(iterations = 50)] async fn test_split_diff_conflict_path_transition_with_dirty_buffer_invalid_anchor_panics( cx: &mut TestAppContext, diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index aae52ffe438e97..5312ae6d08814d 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -11,7 +11,7 @@ use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, Render, Task, Window, }; -use language::{self, Buffer, OffsetRangeExt, Point}; +use language::{self, Buffer, Capability, OffsetRangeExt, Point}; use project::{Project, ProjectPath}; use settings::Settings; use std::{ @@ -110,16 +110,22 @@ impl TextDiffView { } let workspace = workspace.weak_handle(); - let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx)); let clipboard_buffer = build_clipboard_buffer( clipboard_text, &source_buffer, expanded_selection_range.clone(), cx, ); + let diff_buffer = cx.new(|cx| { + BufferDiff::new_with_base_text_buffer( + &source_buffer_snapshot.text, + clipboard_buffer.clone(), + cx, + ) + }); let task = window.spawn(cx, async move |cx| { - update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?; + update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await; workspace.update_in(cx, |workspace, window, cx| { let project = workspace.project().clone(); @@ -179,6 +185,7 @@ impl TextDiffView { cx, ); splittable.disable_diff_hunk_controls(cx); + splittable.set_render_diff_hunks_as_unstaged(cx); splittable.rhs_editor().update(cx, |editor, _cx| { editor.start_temporary_diff_override(); }); @@ -240,7 +247,7 @@ impl TextDiffView { } log::trace!("start recalculating"); - update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?; + update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await; log::trace!("finish recalculating"); } Ok(()) @@ -259,12 +266,17 @@ fn build_clipboard_buffer( cx.new(|cx| { let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx); let language = source_buffer.read(cx).language().cloned(); + if let Some(language_registry) = source_buffer.read(cx).language_registry() { + buffer.set_language_registry(language_registry); + } buffer.set_language(language, cx); let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start); let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end); buffer.edit([(range_start..range_end, text)], None, cx); + buffer.set_capability(Capability::ReadOnly, cx); + buffer }) } @@ -274,32 +286,23 @@ async fn update_diff_buffer( source_buffer: &Entity, clipboard_buffer: &Entity, cx: &mut AsyncApp, -) -> Result<()> { +) { let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let language = source_buffer_snapshot.language().cloned(); - let language_registry = source_buffer.read_with(cx, |buffer, _| buffer.language_registry()); - let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot()); - let base_text = base_buffer_snapshot.text(); + let base_text = Arc::::from(base_buffer_snapshot.text()); let update = diff .update(cx, |diff, cx| { diff.update_diff( source_buffer_snapshot.text.clone(), - Some(Arc::from(base_text.as_str())), - Some(true), - language.clone(), + &base_buffer_snapshot, + Some(base_text.clone()), cx, ) }) .await; - diff.update(cx, |diff, cx| { - diff.language_changed(language, language_registry, cx); - diff.set_snapshot(update, &source_buffer_snapshot.text, cx) - }) - .await; - Ok(()) + diff.update(cx, |diff, cx| diff.set_snapshot(update, cx)); } impl EventEmitter for TextDiffView {} diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 61b60b41252cd4..312697bf1b944d 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -15,7 +15,7 @@ use project::Project; use project::git_store::RepositoryEvent; use ui::{ Button, CommonAnimationExt as _, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem, - ListItemSpacing, Tooltip, prelude::*, + ListItemSpacing, ListSubHeader, Tooltip, prelude::*, }; use util::ResultExt as _; use util::paths::PathExt; @@ -76,11 +76,14 @@ impl WorktreePicker { cx: &mut Context, ) -> Self { let project_ref = project.read(cx); - let project_worktree_paths: HashSet = project_ref + + let active_worktree_paths: HashSet = project_ref .visible_worktrees(cx) .map(|wt| wt.read(cx).abs_path().to_path_buf()) .collect(); + let project_worktree_paths = active_worktree_paths.clone(); + let has_multiple_repositories = project_ref.repositories(cx).len() > 1; let repository = project_ref.active_repository(cx); @@ -115,6 +118,7 @@ impl WorktreePicker { focus_handle: cx.focus_handle(), show_footer, modifiers: Modifiers::default(), + active_worktree_paths, hovered_delete_index: None, deleting_worktree_paths: HashSet::default(), }; @@ -163,6 +167,7 @@ impl WorktreePicker { picker.delegate.all_worktrees = all_worktrees; picker.delegate.default_branch = default_branch.and_then(|branch| RemoteBranchName::parse(&branch)); + picker.delegate.refresh_project_worktree_paths(window, cx); picker.refresh(window, cx); })?; @@ -264,6 +269,7 @@ enum WorktreeEntry { default_branch: RemoteBranchName, }, Separator, + SectionHeader(SharedString), Worktree { worktree: GitWorktree, positions: Vec, @@ -303,6 +309,7 @@ struct WorktreePickerDelegate { matches: Vec, all_worktrees: Vec, project_worktree_paths: HashSet, + active_worktree_paths: HashSet, selected_index: usize, project: Entity, workspace: WeakEntity, @@ -461,6 +468,33 @@ impl WorktreePickerDelegate { !worktree.is_main && !self.project_worktree_paths.contains(&worktree.path) } + fn refresh_project_worktree_paths(&mut self, window: &mut Window, cx: &mut App) { + let mut paths = self.active_worktree_paths.clone(); + + if let Some(multi_workspace) = window.root::().flatten() + && let Some(workspace) = self.workspace.upgrade() + { + let group_key = workspace.read(cx).project_group_key(cx); + if let Some(group_workspaces) = multi_workspace + .read(cx) + .workspaces_for_project_group(&group_key, cx) + { + for group_workspace in group_workspaces { + for worktree in group_workspace + .read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + { + paths.insert(worktree.read(cx).abs_path().to_path_buf()); + } + } + } + } + + self.project_worktree_paths = paths; + } + fn is_force_delete_hovering_index(&self, index: usize) -> bool { self.modifiers.alt && self.hovered_delete_index == Some(index) } @@ -637,6 +671,72 @@ impl WorktreePickerDelegate { .detach_and_log_err(cx); } + /// Finds the workspace in this window (other than the picker's own + /// workspace) that has `worktree_path` open as a visible worktree. + fn workspace_for_open_worktree( + &self, + worktree_path: &Path, + window: &Window, + cx: &App, + ) -> Option> { + if self.active_worktree_paths.contains(worktree_path) { + return None; + } + let multi_workspace = window.root::().flatten()?; + let workspace = self.workspace.upgrade()?; + let group_key = workspace.read(cx).project_group_key(cx); + multi_workspace + .read(cx) + .workspaces_for_project_group(&group_key, cx)? + .into_iter() + .find(|group_workspace| { + *group_workspace != workspace + && group_workspace + .read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + .any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path) + }) + } + + fn remove_worktree_from_window( + &mut self, + ix: usize, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(WorktreeEntry::Worktree { worktree, .. }) = self.matches.get(ix) else { + return; + }; + let Some(workspace_to_remove) = + self.workspace_for_open_worktree(&worktree.path, window, cx) + else { + return; + }; + let Some(window_handle) = window.window_handle().downcast::() else { + return; + }; + + cx.spawn_in(window, async move |picker, cx| { + let removed = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.close_workspace(&workspace_to_remove, window, cx) + })? + .await?; + + if removed { + picker.update_in(cx, |picker, window, cx| { + picker.delegate.refresh_project_worktree_paths(window, cx); + picker.refresh(window, cx); + })?; + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn sync_selected_index(&mut self, has_query: bool) { if !has_query { return; @@ -664,7 +764,7 @@ impl PickerDelegate for WorktreePickerDelegate { type ListItem = AnyElement; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select a worktree…".into() + "Select or type to create a worktree…".into() } fn editor_position(&self) -> PickerEditorPosition { @@ -689,7 +789,10 @@ impl PickerDelegate for WorktreePickerDelegate { } fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { - !matches!(self.matches.get(ix), Some(WorktreeEntry::Separator)) + !matches!( + self.matches.get(ix), + Some(WorktreeEntry::Separator | WorktreeEntry::SectionHeader(_)) + ) } fn update_matches( @@ -730,24 +833,47 @@ impl PickerDelegate for WorktreePickerDelegate { .find(|wt| wt.is_main) .map(|wt| wt.path.clone()); - let mut sorted = repo_worktrees; let project_paths = &self.project_worktree_paths; - sorted.sort_by(|a, b| { - let a_is_current = project_paths.contains(&a.path); - let b_is_current = project_paths.contains(&b.path); - b_is_current.cmp(&a_is_current).then_with(|| { - a.directory_name(main_worktree_path.as_deref()) - .cmp(&b.directory_name(main_worktree_path.as_deref())) - }) - }); + let sort_by_name = |a: &GitWorktree, b: &GitWorktree| { + a.directory_name(main_worktree_path.as_deref()) + .cmp(&b.directory_name(main_worktree_path.as_deref())) + }; + + let (mut open_here, mut others): (Vec<_>, Vec<_>) = repo_worktrees + .into_iter() + .partition(|worktree| project_paths.contains(&worktree.path)); + open_here.sort_by(sort_by_name); + others.sort_by(sort_by_name); matches.push(WorktreeEntry::Separator); - for worktree in sorted { - matches.push(WorktreeEntry::Worktree { - worktree, - positions: Vec::new(), - }); + + if open_here.len() > 1 { + matches.push(WorktreeEntry::SectionHeader("This Window".into())); + for worktree in open_here { + matches.push(WorktreeEntry::Worktree { + worktree, + positions: Vec::new(), + }); + } + + if !others.is_empty() { + matches.push(WorktreeEntry::Separator); + } + + for worktree in others { + matches.push(WorktreeEntry::Worktree { + worktree, + positions: Vec::new(), + }); + } + } else { + for worktree in open_here.into_iter().chain(others) { + matches.push(WorktreeEntry::Worktree { + worktree, + positions: Vec::new(), + }); + } } } @@ -835,7 +961,7 @@ impl PickerDelegate for WorktreePickerDelegate { }; match entry { - WorktreeEntry::Separator => return, + WorktreeEntry::Separator | WorktreeEntry::SectionHeader(_) => return, WorktreeEntry::CreateFromCurrentBranch => { if self.creation_blocked_reason(cx).is_some() { return; @@ -882,7 +1008,7 @@ impl PickerDelegate for WorktreePickerDelegate { return; } - let is_current = self.project_worktree_paths.contains(&worktree.path); + let is_current = self.active_worktree_paths.contains(&worktree.path); if !is_current { if secondary { @@ -971,6 +1097,11 @@ impl PickerDelegate for WorktreePickerDelegate { .child(Divider::horizontal()) .into_any_element(), ), + WorktreeEntry::SectionHeader(label) => Some( + ListSubHeader::new(label.clone()) + .inset(true) + .into_any_element(), + ), WorktreeEntry::CreateFromCurrentBranch => { let branch_label = if self.has_multiple_repositories { "current branches".to_string() @@ -1023,9 +1154,11 @@ impl PickerDelegate for WorktreePickerDelegate { let path = worktree.path.compact().to_string_lossy().to_string(); let sha = worktree.sha.chars().take(7).collect::(); - let is_current = self.project_worktree_paths.contains(&worktree.path); + let is_current = self.active_worktree_paths.contains(&worktree.path); let is_deleting = self.deleting_worktree_paths.contains(&worktree.path); let can_delete = self.can_delete_worktree(worktree); + let can_remove_from_window = + !is_current && self.project_worktree_paths.contains(&worktree.path); let entry_icon = if is_current { IconName::Check @@ -1181,10 +1314,23 @@ impl PickerDelegate for WorktreePickerDelegate { })), ); + let remove_from_window_button = IconButton::new( + ("remove-worktree-from-window", ix), + IconName::Close, + ) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Remove Worktree from Window")) + .on_click(cx.listener(move |picker, _, window, cx| { + picker.delegate.remove_worktree_from_window(ix, window, cx); + })); + this.end_slot( h_flex() .gap_0p5() .child(open_in_new_window_button) + .when(can_remove_from_window, |this| { + this.child(remove_from_window_button) + }) .when(can_delete, |this| this.child(delete_button)), ) .show_end_slot_on_hover() @@ -1553,13 +1699,13 @@ mod tests { let workspace = window_handle .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .unwrap(); - let worktree_picker = window_handle - .update(cx, |_multi_workspace, window, cx| { - cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx)) - }) - .unwrap(); - let cx = VisualTestContext::from_window(window_handle.into(), cx); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + let worktree_picker = cx.update(|window, cx| { + cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx)) + }); + cx.run_until_parked(); (fs, worktree_picker, repository, worktree_path, cx) @@ -1890,4 +2036,229 @@ mod tests { "worktree should be removed by explicit force delete" ); } + + #[gpui::test] + async fn test_open_worktrees_are_grouped_under_section_header(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "project": { + ".git": {}, + "file.txt": "buffer_text", + }, + "worktrees": {}, + }), + ) + .await; + fs.set_head_for_repo( + path!("/root/project/.git").as_ref(), + &[("file.txt", "buffer_text".to_string())], + "deadbeef", + ); + + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project.repositories(cx).values().next().unwrap().clone() + }); + let second_worktree_path = PathBuf::from(path!("/root/worktrees/second-wt")); + + cx.update(|cx| { + repository.update(cx, |repository, _| { + repository.create_worktree( + git::repository::CreateWorktreeTarget::NewBranch { + branch_name: "second-wt".to_string(), + base_sha: Some("deadbeef".to_string()), + }, + second_worktree_path.clone(), + ) + }) + }) + .await + .unwrap() + .unwrap(); + + // Open the second worktree as a visible worktree of the active project so + // that two worktrees of the same repo are open in this window. + project + .update(cx, |project, cx| { + project.create_worktree(&second_worktree_path, true, cx) + }) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + let worktree_picker = cx.update(|window, cx| { + cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx)) + }); + cx.run_until_parked(); + + let project_path = PathBuf::from(path!("/root/project")); + worktree_picker.update(&mut cx, |worktree_picker, cx| { + worktree_picker.picker.update(cx, |picker, _| { + let matches = &picker.delegate.matches; + + let header_index = matches + .iter() + .position(|entry| { + matches!(entry, WorktreeEntry::SectionHeader(label) if label.as_ref() == "This Window") + }) + .expect("section header should be present when multiple worktrees are open"); + + let grouped_paths: Vec<&Path> = matches[header_index + 1..] + .iter() + .map_while(|entry| match entry { + WorktreeEntry::Worktree { worktree, .. } => Some(worktree.path.as_path()), + _ => None, + }) + .collect(); + + assert!( + grouped_paths.contains(&project_path.as_path()), + "main worktree should be grouped under the header" + ); + assert!( + grouped_paths.contains(&second_worktree_path.as_path()), + "second open worktree should be grouped under the header" + ); + }) + }); + } + + #[gpui::test] + async fn test_remove_open_worktree_workspace_from_window(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "project": { + ".git": {}, + "file.txt": "buffer_text", + }, + "worktrees": {}, + }), + ) + .await; + fs.set_head_for_repo( + path!("/root/project/.git").as_ref(), + &[("file.txt", "buffer_text".to_string())], + "deadbeef", + ); + + let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project.repositories(cx).values().next().unwrap().clone() + }); + let worktree_path = PathBuf::from(path!("/root/worktrees/open-wt")); + cx.update(|cx| { + repository.update(cx, |repository, _| { + repository.create_worktree( + git::repository::CreateWorktreeTarget::NewBranch { + branch_name: "open-wt".to_string(), + base_sha: Some("deadbeef".to_string()), + }, + worktree_path.clone(), + ) + }) + }) + .await + .unwrap() + .unwrap(); + + let worktree_project = Project::test(fs.clone(), [worktree_path.as_path()], cx).await; + cx.executor().run_until_parked(); + + let main_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx)); + let worktree_group_key = + worktree_project.read_with(cx, |project, cx| project.project_group_key(cx)); + assert_eq!( + main_group_key, worktree_group_key, + "the worktree workspace should belong to the same project group as the main repo" + ); + + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let worktree_workspace = window_handle + .update(cx, |multi_workspace, window, cx| { + let worktree_workspace = + cx.new(|cx| Workspace::test_new(worktree_project.clone(), window, cx)); + multi_workspace.add(worktree_workspace.clone(), window, cx); + worktree_workspace + }) + .unwrap(); + + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + let worktree_picker = cx.update(|window, cx| { + cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx)) + }); + cx.run_until_parked(); + + let index = worktree_index(&worktree_picker, &worktree_path, &mut cx); + worktree_picker.update(&mut cx, |worktree_picker, cx| { + worktree_picker.picker.update(cx, |picker, _| { + assert!( + picker + .delegate + .project_worktree_paths + .contains(&worktree_path), + "the worktree should be considered open in this window" + ); + }) + }); + + worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| { + worktree_picker.picker.update(cx, |picker, cx| { + picker + .delegate + .remove_worktree_from_window(index, window, cx); + }) + }); + cx.run_until_parked(); + + window_handle + .read_with(&cx, |multi_workspace, _| { + assert!( + multi_workspace + .workspaces() + .all(|workspace| *workspace != worktree_workspace), + "the worktree workspace should be removed from the window" + ); + }) + .unwrap(); + + worktree_picker.update(&mut cx, |worktree_picker, cx| { + worktree_picker.picker.update(cx, |picker, _| { + assert!( + !picker + .delegate + .project_worktree_paths + .contains(&worktree_path), + "the worktree should no longer be considered open in this window" + ); + }) + }); + + assert!( + repo_contains_worktree(&repository, &worktree_path, &mut cx).await, + "removing the worktree from the window should not delete the git worktree" + ); + } } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 960963ea3552b6..2f9c47dbef9935 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -26,6 +26,7 @@ test-support = [ "x11", "proptest", ] +bench = ["test-support", "dep:criterion", "dep:hdrhistogram"] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] wayland = [ @@ -39,6 +40,7 @@ screen-capture = [ ] windows-manifest = ["dep:embed-resource"] input-latency-histogram = ["dep:hdrhistogram"] +profiler = [] [lib] path = "src/gpui.rs" @@ -52,6 +54,7 @@ backtrace = { workspace = true, optional = true } bitflags = { workspace = true, optional = true } collections.workspace = true +criterion = { workspace = true, optional = true } ctor.workspace = true derive_more.workspace = true etagere = "0.2" diff --git a/crates/gpui/README.md b/crates/gpui/README.md index 6d4a37932ff6fe..132e5ac1e9f177 100644 --- a/crates/gpui/README.md +++ b/crates/gpui/README.md @@ -5,17 +5,48 @@ for Rust, designed to support a wide variety of applications. ## Getting Started -GPUI is still in active development as we work on the Zed code editor, and is still pre-1.0. There will often be breaking changes between versions. You'll also need to use the latest version of stable Rust and be on macOS or Linux. Add the following to your `Cargo.toml`: +GPUI is still in active development as we work on the Zed code editor, and is still pre-1.0. There will often be breaking changes between versions. You'll also need to use the latest version of stable Rust. Add `gpui`, and optionally `gpui_platform`, to your `Cargo.toml`: ```toml gpui = { version = "*" } +gpui_platform = { version = "*", features = ["font-kit", "wayland", "x11"] } ``` +Everything in a standalone GPUI app starts with an `Application`. You can create one with `gpui_platform::application()`, which picks the windowing and text backends for the host OS, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()` and register your first root view. + +```rust,no_run +use gpui::*; + +fn main() { + gpui_platform::application().run(|cx: &mut App| { + // .. + }); +} +``` + +### `gpui_platform` + +The features on `gpui_platform` are platform-specific, so the list above is a safe cross-platform default. If you build for a single platform, you can trim it: + +- **macOS** — Rendering uses Metal and is always available, but glyph rasterization needs `font-kit`. Without it, GPUI falls back to a placeholder text system that lays text out but renders no glyphs. + + ```toml + gpui_platform = { version = "*", features = ["font-kit"] } + ``` + +- **Linux / FreeBSD** — enable at least one windowing backend for desktop windows: `wayland`, `x11`, or both. These features also compile the renderer and text system, so no separate text feature is needed. + + ```toml + gpui_platform = { version = "*", features = ["wayland", "x11"] } + ``` + +- **Windows** — no features are required. Windowing uses Win32 and text uses DirectWrite. `font-kit` has no effect here. + +### Additional Topics + - [Ownership and data flow](_ownership_and_data_flow) - [Accessibility](_accessibility) -Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. - ### Dependencies GPUI has various system dependencies that it needs in order to work. diff --git a/crates/gpui/examples/opacity.rs b/crates/gpui/examples/opacity.rs index 1d74127f4cd355..413c40c8264c0a 100644 --- a/crates/gpui/examples/opacity.rs +++ b/crates/gpui/examples/opacity.rs @@ -5,7 +5,7 @@ use std::{fs, path::PathBuf}; use anyhow::Result; use gpui::{ App, AssetSource, Bounds, BoxShadow, ClickEvent, Context, SharedString, Task, Window, - WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size, svg, + WindowBounds, WindowOptions, div, hsla, img, prelude::*, px, rgb, size, svg, }; use gpui_platform::application; @@ -114,13 +114,11 @@ impl Render for HelloWorld { .bg(gpui::blue()) .border_3() .border_color(gpui::black()) - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - blur_radius: px(1.0), - spread_radius: px(5.0), - offset: point(px(10.0), px(10.0)), - inset: false, - }]) + .shadow(vec![ + BoxShadow::new(px(10.0), px(10.0), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(1.0)) + .spread_radius(px(5.0)), + ]) .child(img("image/app-icon.png").size_8()) .child("Opacity Panel (Click to test)") .child( diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index 11c3b333717c6b..41cbcb28026a1c 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -67,8 +67,7 @@ impl PaintingViewer { builder.line_to(point(px(50.), px(130.))); builder.close(); let path = builder.build().unwrap(); - let mut red = rgb(0xFF0000); - red.a = 0.5; + let red = rgb(0xFF0000).alpha(0.5); lines.push((path, red.into())); // 50% opaque blue path that extends across black quad. @@ -79,8 +78,7 @@ impl PaintingViewer { builder.line_to(point(px(150.), px(130.))); builder.close(); let path = builder.build().unwrap(); - let mut blue = rgb(0x0000FF); - blue.a = 0.5; + let blue = rgb(0x0000FF).alpha(0.5); lines.push((path, blue.into())); // 50% opaque green path that extends across black quad. @@ -91,8 +89,7 @@ impl PaintingViewer { builder.line_to(point(px(250.), px(130.))); builder.close(); let path = builder.build().unwrap(); - let mut green = rgb(0x00FF00); - green.a = 0.5; + let green = rgb(0x00FF00).alpha(0.5); lines.push((path, green.into())); // 50% opaque black path that extends across black quad. @@ -103,8 +100,7 @@ impl PaintingViewer { builder.line_to(point(px(350.), px(130.))); builder.close(); let path = builder.build().unwrap(); - let mut black = rgb(0x000000); - black.a = 0.5; + let black = rgb(0x000000).alpha(0.5); lines.push((path, black.into())); // Two 50% opaque red circles overlapping - center should be darker red @@ -128,8 +124,7 @@ impl PaintingViewer { ); builder.close(); let path = builder.build().unwrap(); - let mut red1 = rgb(0xFF0000); - red1.a = 0.5; + let red1 = rgb(0xFF0000).alpha(0.5); lines.push((path, red1.into())); let mut builder = PathBuilder::fill(); @@ -152,8 +147,7 @@ impl PaintingViewer { ); builder.close(); let path = builder.build().unwrap(); - let mut red2 = rgb(0xFF0000); - red2.a = 0.5; + let red2 = rgb(0xFF0000).alpha(0.5); lines.push((path, red2.into())); // draw a Rust logo diff --git a/crates/gpui/examples/shadow.rs b/crates/gpui/examples/shadow.rs index 5cb54fe9a66485..375ccac68d566f 100644 --- a/crates/gpui/examples/shadow.rs +++ b/crates/gpui/examples/shadow.rs @@ -2,7 +2,7 @@ use gpui::{ App, Bounds, BoxShadow, Context, Div, SharedString, Window, WindowBounds, WindowOptions, div, - hsla, point, prelude::*, px, relative, rgb, size, + hsla, prelude::*, px, relative, rgb, size, }; use gpui_platform::application; @@ -95,609 +95,495 @@ impl Render for Shadow { .size_full() .text_xs() .child(div().flex().flex_col().w_full().children(vec![ - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .flex_row() - .children(vec![ - example( - "Square", - Shadow::square() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded 4", - Shadow::rounded_small() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded 8", - Shadow::rounded_medium() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded 16", - Shadow::rounded_large() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Circle", - Shadow::base() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .w_full() - .children(vec![ - example("None", Shadow::base()), - // 2Xsmall shadow - example("2X Small", Shadow::base().shadow_2xs()), - // Xsmall shadow - example("Extra Small", Shadow::base().shadow_xs()), - // Small shadow - example("Small", Shadow::base().shadow_sm()), - // Medium shadow - example("Medium", Shadow::base().shadow_md()), - // Large shadow - example("Large", Shadow::base().shadow_lg()), - example("Extra Large", Shadow::base().shadow_xl()), - example("2X Large", Shadow::base().shadow_2xl()), - ]), - // Horizontal list of increasing blur radii - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Blur 0", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(0.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Blur 2", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(2.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Blur 4", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(4.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Blur 8", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Blur 16", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(16.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - // Horizontal list of increasing spread radii - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Spread 0", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Spread 2", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }]), - ), - example( - "Spread 4", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(4.), - inset: false, - }]), - ), - example( - "Spread 8", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(8.), - inset: false, - }]), - ), - example( - "Spread 16", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(16.), - inset: false, - }]), - ), - ]), - // Square spread examples - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Square Spread 0", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Square Spread 8", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(8.), - inset: false, - }]), - ), - example( - "Square Spread 16", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(16.), - inset: false, - }]), - ), - ]), - // Rounded large spread examples - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Rounded Large Spread 0", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded Large Spread 8", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(8.), - inset: false, - }]), - ), - example( - "Rounded Large Spread 16", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(16.), - inset: false, - }]), - ), - ]), - // Directional shadows - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Left", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(-8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Right", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Top", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(-8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Bottom", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - // Square directional shadows - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Square Left", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(-8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Square Right", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Square Top", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(-8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Square Bottom", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - // Rounded large directional shadows - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Rounded Large Left", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(-8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded Large Right", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded Large Top", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(-8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded Large Bottom", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - // Multiple shadows for different shapes - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Circle Multiple", - Shadow::base().shadow(vec![ - BoxShadow { - color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red - offset: point(px(0.), px(-12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow - offset: point(px(12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green - offset: point(px(0.), px(12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue - offset: point(px(-12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - ]), - ), - example( - "Square Multiple", - Shadow::square().shadow(vec![ - BoxShadow { - color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red - offset: point(px(0.), px(-12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow - offset: point(px(12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green - offset: point(px(0.), px(12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue - offset: point(px(-12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - ]), - ), - example( - "Rounded Large Multiple", - Shadow::rounded_large().shadow(vec![ - BoxShadow { - color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red - offset: point(px(0.), px(-12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow - offset: point(px(12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green - offset: point(px(0.), px(12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue - offset: point(px(-12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - ]), - ), - ]), - // Inset shadows (CSS `box-shadow: inset ...`). - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .w_full() - .children(vec![ - example( - "Inset basic", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - offset: point(px(0.), px(0.)), - blur_radius: px(12.), - spread_radius: px(0.), - inset: true, - }]), - ), - example( - "Inset offset", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - offset: point(px(6.), px(6.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: true, - }]), - ), - example( - "Inset spread", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - offset: point(px(0.), px(0.)), - blur_radius: px(4.), - spread_radius: px(8.), - inset: true, - }]), - ), - example( - "Inset rounded", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - offset: point(px(0.), px(4.)), - blur_radius: px(10.), - spread_radius: px(2.), - inset: true, - }]), - ), - example( - "Inset sharp", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.6), - offset: point(px(0.), px(0.)), - blur_radius: px(0.), - spread_radius: px(6.), - inset: true, - }]), - ), - ]), - // Combined: drop + inset shadows on the same element. - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .w_full() - .children(vec![ - example( + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .flex_row() + .children(vec![ + example( + "Square", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded 4", + Shadow::rounded_small().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded 8", + Shadow::rounded_medium().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded 16", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Circle", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + ]), + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![ + example("None", Shadow::base()), + // 2Xsmall shadow + example("2X Small", Shadow::base().shadow_2xs()), + // Xsmall shadow + example("Extra Small", Shadow::base().shadow_xs()), + // Small shadow + example("Small", Shadow::base().shadow_sm()), + // Medium shadow + example("Medium", Shadow::base().shadow_md()), + // Large shadow + example("Large", Shadow::base().shadow_lg()), + example("Extra Large", Shadow::base().shadow_xl()), + example("2X Large", Shadow::base().shadow_2xl()), + ]), + // Horizontal list of increasing blur radii + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Blur 0", + Shadow::base().shadow(vec![BoxShadow::new( + px(0.), + px(8.), + hsla(0.0, 0.0, 0.0, 0.3), + )]), + ), + example( + "Blur 2", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(2.)), + ]), + ), + example( + "Blur 4", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(4.)), + ]), + ), + example( + "Blur 8", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Blur 16", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(16.)), + ]), + ), + ]), + // Horizontal list of increasing spread radii + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Spread 0", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Spread 2", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + ]), + ), + example( + "Spread 4", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(4.)), + ]), + ), + example( + "Spread 8", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(8.)), + ]), + ), + example( + "Spread 16", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(16.)), + ]), + ), + ]), + // Square spread examples + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Square Spread 0", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Square Spread 8", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(8.)), + ]), + ), + example( + "Square Spread 16", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(16.)), + ]), + ), + ]), + // Rounded large spread examples + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Rounded Large Spread 0", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded Large Spread 8", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(8.)), + ]), + ), + example( + "Rounded Large Spread 16", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(16.)), + ]), + ), + ]), + // Directional shadows + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Left", + Shadow::base().shadow(vec![ + BoxShadow::new(px(-8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Right", + Shadow::base().shadow(vec![ + BoxShadow::new(px(8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Top", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(-8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Bottom", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + ]), + // Square directional shadows + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Square Left", + Shadow::square().shadow(vec![ + BoxShadow::new(px(-8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Square Right", + Shadow::square().shadow(vec![ + BoxShadow::new(px(8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Square Top", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(-8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Square Bottom", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + ]), + // Rounded large directional shadows + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Rounded Large Left", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(-8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded Large Right", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded Large Top", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(-8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded Large Bottom", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + ]), + // Multiple shadows for different shapes + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Circle Multiple", + Shadow::base().shadow(vec![ + BoxShadow::new( + px(0.), + px(-12.), + hsla(0.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(12.), + px(0.), + hsla(60.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(0.), + px(12.), + hsla(120.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(-12.), + px(0.), + hsla(240.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + ]), + ), + example( + "Square Multiple", + Shadow::square().shadow(vec![ + BoxShadow::new( + px(0.), + px(-12.), + hsla(0.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(12.), + px(0.), + hsla(60.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(0.), + px(12.), + hsla(120.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(-12.), + px(0.), + hsla(240.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + ]), + ), + example( + "Rounded Large Multiple", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new( + px(0.), + px(-12.), + hsla(0.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(12.), + px(0.), + hsla(60.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(0.), + px(12.), + hsla(120.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(-12.), + px(0.), + hsla(240.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + ]), + ), + ]), + // Inset shadows (CSS `box-shadow: inset ...`). + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![ + example( + "Inset basic", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(0.), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(12.)) + .inset(), + ]), + ), + example( + "Inset offset", + Shadow::base().shadow(vec![ + BoxShadow::new(px(6.), px(6.), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(8.)) + .inset(), + ]), + ), + example( + "Inset spread", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(0.), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(4.)) + .spread_radius(px(8.)) + .inset(), + ]), + ), + example( + "Inset rounded", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(4.), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(10.)) + .spread_radius(px(2.)) + .inset(), + ]), + ), + example( + "Inset sharp", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(0.), hsla(0.0, 0.0, 0.0, 0.6)) + .spread_radius(px(6.)) + .inset(), + ]), + ), + ]), + // Combined: drop + inset shadows on the same element. + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![example( "Drop + Inset", Shadow::rounded_medium().shadow(vec![ - BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.25), - offset: point(px(0.), px(8.)), - blur_radius: px(12.), - spread_radius: px(0.), - inset: false, - }, - BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.4), - offset: point(px(0.), px(2.)), - blur_radius: px(4.), - spread_radius: px(0.), - inset: true, - }, + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.25)) + .blur_radius(px(12.)), + BoxShadow::new(px(0.), px(2.), hsla(0.0, 0.0, 0.0, 0.4)) + .blur_radius(px(4.)) + .inset(), ]), - ), - ]), - ])) + )]), + ])) } } diff --git a/crates/gpui/examples/window.rs b/crates/gpui/examples/window.rs index c51f43fe66deff..959ea4981c49b4 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -272,7 +272,7 @@ impl Render for WindowDemo { PromptLevel::Info, "Are you sure?", None, - &["Ok", "Cancel"], + &["OK", "Cancel"], cx, ); diff --git a/crates/gpui/examples/window_shadow.rs b/crates/gpui/examples/window_shadow.rs index 3f06098c1981c6..f4a55b4bc354ad 100644 --- a/crates/gpui/examples/window_shadow.rs +++ b/crates/gpui/examples/window_shadow.rs @@ -107,18 +107,19 @@ impl Render for WindowShadow { .when(!tiling.left, |div| div.border_l(border_size)) .when(!tiling.right, |div| div.border_r(border_size)) .when(!tiling.is_tiled(), |div| { - div.shadow(vec![gpui::BoxShadow { - color: Hsla { - h: 0., - s: 0., - l: 0., - a: 0.4, - }, - blur_radius: shadow_size / 2., - spread_radius: px(0.), - inset: false, - offset: point(px(0.0), px(0.0)), - }]) + div.shadow(vec![ + gpui::BoxShadow::new( + px(0.), + px(0.), + Hsla { + h: 0., + s: 0., + l: 0., + a: 0.4, + }, + ) + .blur_radius(shadow_size / 2.), + ]) }), }) .on_mouse_move(|_e, _, cx| { @@ -148,18 +149,19 @@ impl Render for WindowShadow { .w(px(200.0)) .h(px(100.0)) .bg(green()) - .shadow(vec![gpui::BoxShadow { - color: Hsla { - h: 0., - s: 0., - l: 0., - a: 1.0, - }, - blur_radius: px(20.0), - spread_radius: px(0.0), - inset: false, - offset: point(px(0.0), px(0.0)), - }]) + .shadow(vec![ + gpui::BoxShadow::new( + px(0.), + px(0.), + Hsla { + h: 0., + s: 0., + l: 0., + a: 1.0, + }, + ) + .blur_radius(px(20.0)), + ]) .map(|div| match decorations { Decorations::Server => div, Decorations::Client { .. } => div diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index b270bd0965e5ef..07c47fc2e4ade9 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -1,5 +1,5 @@ use anyhow::{Context as _, Result}; -use collections::HashMap; +use collections::{HashMap, TypeIdHashMap}; pub use gpui_macros::Action; pub use no_action::{NoAction, Unbind, is_no_action, is_unbind}; use serde_json::json; @@ -232,7 +232,7 @@ type ActionBuilder = fn(json: serde_json::Value) -> anyhow::Result, - names_by_type_id: HashMap, + names_by_type_id: TypeIdHashMap<&'static str>, all_names: Vec<&'static str>, // So we can return a static slice. deprecated_aliases: HashMap<&'static str, &'static str>, // deprecated name -> preferred name deprecation_messages: HashMap<&'static str, &'static str>, // action name -> deprecation message @@ -342,6 +342,7 @@ impl ActionRegistry { Ok(self.build_action(name, None)?) } + #[cfg(feature = "profiler")] pub(crate) fn try_resolve_action(&self, type_id: &TypeId) -> Option<&'static str> { self.names_by_type_id.get(type_id).copied() } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 22a05b450eab48..794009f5189455 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -23,9 +23,9 @@ use parking_lot::RwLock; use slotmap::SlotMap; pub use async_context::*; -#[cfg(any(test, feature = "test-support"))] -pub use bench_context::{BenchAppContext, BenchWindowContext}; -use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; +#[cfg(feature = "bench")] +pub use bench_context::{BenchAppContext, BenchReport, BenchWindowContext, bench_platform}; +use collections::{FxHashMap, FxHashSet, HashMap, TypeIdHashMap, TypeIdHashSet, VecDeque}; pub use context::*; pub use entity_map::*; use gpui_util::{ResultExt, debug_panic}; @@ -58,7 +58,7 @@ use crate::{ }; mod async_context; -#[cfg(any(test, feature = "test-support"))] +#[cfg(feature = "bench")] mod bench_context; mod context; mod entity_map; @@ -630,7 +630,7 @@ pub struct App { pub(crate) keyboard_layout: Box, pub(crate) keyboard_mapper: Rc, pub(crate) global_action_listeners: - FxHashMap>>, + TypeIdHashMap>>, pending_effects: VecDeque, pub(crate) observers: SubscriberSet, @@ -655,7 +655,7 @@ pub struct App { // callbacks are marked cancelled at this point as this will also shutdown // the tokio runtime. As any task attempting to spawn a blocking tokio task, // might panic. - pub(crate) globals_by_type: FxHashMap>, + pub(crate) globals_by_type: TypeIdHashMap>, // assets pub(crate) loading_assets: FxHashMap<(TypeId, u64), Box>, @@ -665,7 +665,7 @@ pub struct App { // below is plain data, the drop order is insignificant here pub(crate) pending_notifications: FxHashSet, - pub(crate) pending_global_notifications: FxHashSet, + pub(crate) pending_global_notifications: TypeIdHashSet, pub(crate) restart_path: Option, pub(crate) layout_id_buffer: Vec, // We recycle this memory across layout requests. pub(crate) propagate_event: bool, @@ -738,7 +738,7 @@ impl App { loading_assets: Default::default(), asset_source, http_client, - globals_by_type: FxHashMap::default(), + globals_by_type: Default::default(), entities, new_entity_observers: SubscriberSet::new(), windows: SlotMap::with_key(), @@ -748,10 +748,10 @@ impl App { keymap: Rc::new(RefCell::new(Keymap::default())), keyboard_layout, keyboard_mapper, - global_action_listeners: FxHashMap::default(), + global_action_listeners: Default::default(), pending_effects: VecDeque::new(), pending_notifications: FxHashSet::default(), - pending_global_notifications: FxHashSet::default(), + pending_global_notifications: Default::default(), observers: SubscriberSet::new(), tracked_entities: FxHashMap::default(), window_invalidators_by_entity: FxHashMap::default(), @@ -1489,7 +1489,7 @@ impl App { } } } else { - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench"))] for window in self .windows .values() diff --git a/crates/gpui/src/app/bench_context.rs b/crates/gpui/src/app/bench_context.rs index 6af1b7a9fa9088..aee68633f44037 100644 --- a/crates/gpui/src/app/bench_context.rs +++ b/crates/gpui/src/app/bench_context.rs @@ -1,14 +1,267 @@ -use std::{future::Future, rc::Rc, sync::Arc}; +use std::{ + cell::{OnceCell, RefCell}, + future::Future, + rc::Rc, + sync::Arc, + time::Duration, +}; use anyhow::{Result, anyhow}; +use hdrhistogram::Histogram; use crate::{ - AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, Bounds, Context, Empty, - Entity, EntityId, Focusable, ForegroundExecutor, Global, Render, Reservation, Task, - TestDispatcher, TestPlatform, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, - app::{GpuiBorrow, GpuiMode}, + AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, BenchDispatcher, + Bounds, Context, Empty, Entity, EntityId, Focusable, ForegroundExecutor, Global, + NoopTextSystem, Platform, PlatformHeadlessRenderer, Render, Reservation, Task, TestPlatform, + VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, + app::GpuiBorrow, + profiler::{self, FrameTiming, FrameTimingCollector}, }; +/// Returns this thread's shared benchmark platform, creating it on first use. +/// +/// The platform is a [`TestPlatform`] backed by a multithreaded +/// [`BenchDispatcher`], so background work runs with production concurrency in +/// real time. It is cached per thread and reused across benchmark invocations +/// so worker and timer threads persist for the whole process instead of being +/// recreated for every Criterion calibration pass. +/// +/// Text is shaped with [`NoopTextSystem`] (one glyph per character at fixed +/// advances). This keeps results deterministic across machines and font +/// installations while preserving the structure of downstream layout and paint +/// work; absolute timings exclude production text shaping (roughly 10% of draw +/// time for a full editor frame). Benchmarks that need real shaping can build +/// a [`TestPlatform`] with a platform text system and pass it to +/// [`BenchAppContext::new_with_platform_and_report`]. +/// +/// `headless_renderer_factory` (only used on first call) supplies a renderer +/// for benchmark windows, e.g. `gpui_platform::current_headless_renderer`. +/// When present, scenes drawn by benchmarks are rasterized through the real +/// sprite atlas and submitted to the GPU on present, so quad/sprite +/// regressions show up in measurements. When `None`, presenting discards the +/// scene. Currently only macOS provides a headless renderer (Metal), so GPU +/// submission is excluded from benchmark measurements on other platforms. +pub fn bench_platform( + headless_renderer_factory: Option Option>>>, +) -> Rc { + thread_local! { + static PLATFORM: OnceCell> = const { OnceCell::new() }; + } + PLATFORM.with(|cell| { + cell.get_or_init(|| { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background_executor = BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(dispatcher); + TestPlatform::with_platform( + background_executor, + foreground_executor, + Arc::new(NoopTextSystem::new()), + headless_renderer_factory, + ) + }) + .clone() as Rc + }) +} + +/// Default target frame rate when a benchmark doesn't specify `fps = N`. +const DEFAULT_FPS: u64 = 120; + +const NANOS_PER_SECOND: u128 = 1_000_000_000; + +/// A small report produced by GPUI benchmarks. +#[derive(Clone)] +pub struct BenchReport { + frame_snapshot: Rc>, + frame_budget_nanos: u128, +} + +impl Default for BenchReport { + fn default() -> Self { + Self::with_fps(DEFAULT_FPS) + } +} + +impl BenchReport { + /// Creates a report whose per-frame budget is one frame at `fps` when + /// counting frame budget overruns. + pub fn with_fps(fps: u64) -> Self { + assert!(fps > 0, "frame rate must be greater than zero"); + Self::with_frame_budget_nanos(NANOS_PER_SECOND / fps as u128) + } + + /// Creates a report that treats `frame_budget_nanos` as the per-frame budget + /// when counting frame budget overruns. + pub fn with_frame_budget_nanos(frame_budget_nanos: u128) -> Self { + Self { + frame_snapshot: Rc::new(RefCell::new(WindowFrameSnapshot::new())), + frame_budget_nanos, + } + } + + fn record_frame_timings<'i>(&self, timings: impl IntoIterator) { + let mut snapshot = self.frame_snapshot.borrow_mut(); + // `.ok()` on `record`: this operation is infallible (the histograms auto-resize). + for timing in timings { + snapshot + .draw + .record(timing.draw_duration().as_nanos() as u64) + .ok(); + if let Some(dirty_to_draw) = timing.dirty_to_draw_duration() { + snapshot + .dirty_to_draw + .record(dirty_to_draw.as_nanos() as u64) + .ok(); + } + if timing.invalidations > 0 { + snapshot + .invalidations_per_frame + .record(timing.invalidations) + .ok(); + } + } + } + + fn total_budget_overruns(&self, histogram: &Histogram) -> u64 { + histogram + .iter_recorded() + .map(|value| { + self.budget_overruns(Duration::from_nanos(value.value_iterated_to())) + * value.count_at_value() + }) + .sum() + } + + /// Returns how many whole frame budgets `foreground_time` exceeded the + /// per frame budget by. This is a synthetic proxy for missed frames: the + /// benchmark harness has no vsync, so it counts how many frame deadlines + /// would have elapsed while the foreground thread was busy. + fn budget_overruns(&self, foreground_time: Duration) -> u64 { + let foreground_nanos = foreground_time.as_nanos(); + if foreground_nanos <= self.frame_budget_nanos { + return 0; + } + + let over_budget_nanos = foreground_nanos - self.frame_budget_nanos; + over_budget_nanos.div_ceil(self.frame_budget_nanos) as u64 + } + + /// Prints this report to stderr. + pub fn print(&self, benchmark_name: Option<&'static str>) { + let frame_snapshot = self.frame_snapshot.borrow(); + if frame_snapshot.is_empty() { + return; + } + + let benchmark_name = benchmark_name.unwrap_or("unknown benchmark"); + eprintln!("GPUI bench report (all observed iterations): {benchmark_name}"); + eprintln!(" note: includes Criterion warmup/calibration"); + self.print_histogram("window dirty-to-draw", &frame_snapshot.dirty_to_draw); + self.print_histogram("window draw", &frame_snapshot.draw); + if !frame_snapshot.invalidations_per_frame.is_empty() { + eprintln!( + " invalidations per frame: mean {:.2}, max {}", + frame_snapshot.invalidations_per_frame.mean(), + frame_snapshot.invalidations_per_frame.max() + ); + } + } + + fn print_histogram(&self, name: &str, histogram: &Histogram) { + if histogram.is_empty() { + return; + } + + let max_foreground_time = Duration::from_nanos(histogram.max()); + eprintln!(" {name}:"); + eprintln!(" samples: {}", histogram.len()); + eprintln!( + " mean: {}", + format_duration(Duration::from_nanos(histogram.mean() as u64)) + ); + eprintln!( + " p50: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.50))) + ); + eprintln!( + " p90: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.90))) + ); + eprintln!( + " p95: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.95))) + ); + eprintln!( + " p99: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.99))) + ); + eprintln!(" max: {}", format_duration(max_foreground_time)); + eprintln!( + " frame budget overruns total: {}", + self.total_budget_overruns(histogram) + ); + eprintln!( + " frame budget overruns max: {}", + self.budget_overruns(max_foreground_time) + ); + } +} + +struct WindowFrameSnapshot { + dirty_to_draw: Histogram, + draw: Histogram, + invalidations_per_frame: Histogram, +} + +impl WindowFrameSnapshot { + fn new() -> Self { + Self { + dirty_to_draw: Histogram::new(3).expect("3 significant digits is valid"), + draw: Histogram::new(3).expect("3 significant digits is valid"), + invalidations_per_frame: Histogram::new(3).expect("3 significant digits is valid"), + } + } + + fn is_empty(&self) -> bool { + self.dirty_to_draw.is_empty() && self.draw.is_empty() + } +} + +fn format_duration(duration: Duration) -> String { + format!("{:.3}ms", duration.as_secs_f64() * 1000.) +} + +/// Enables frame tracing for the duration of a measurement and collects the +/// frames recorded within it. The previous tracing state is restored on drop, +/// so a panicking measurement doesn't leave tracing enabled for unrelated code +/// (e.g. a later benchmark in the same process). +struct FrameTraceScope { + collector: FrameTimingCollector, + was_already_enabled: bool, +} + +impl FrameTraceScope { + fn start() -> Self { + let was_already_enabled = !profiler::set_frame_trace_enabled(true); + Self { + collector: FrameTimingCollector::new(), + was_already_enabled, + } + } + + fn finish(mut self) -> Vec { + self.collector.collect_unseen() + // Dropping `self` restores the previous tracing state. + } +} + +impl Drop for FrameTraceScope { + fn drop(&mut self) { + if !self.was_already_enabled { + profiler::set_frame_trace_enabled(false); + } + } +} + /// A GPUI app context for Criterion benchmarks. /// /// `BenchAppContext` is intentionally separate from `TestAppContext`: it owns a @@ -16,41 +269,70 @@ use crate::{ /// benchmark setup. Criterion remains responsible for the measured loop via its /// `Bencher` API. #[derive(Clone)] -pub struct BenchAppContext { +pub struct BenchAppContext<'a, 'measurement> { app: Rc, background_executor: BackgroundExecutor, foreground_executor: ForegroundExecutor, - dispatcher: TestDispatcher, benchmark_name: Option<&'static str>, + bencher: Rc>>>, + report: BenchReport, } -impl BenchAppContext { - /// Creates a new benchmark app context. - pub fn new(benchmark_name: Option<&'static str>) -> Self { - Self::with_seed(benchmark_name, 0) - } - - /// Creates a new benchmark app context with the provided scheduler seed. - pub fn with_seed(benchmark_name: Option<&'static str>, seed: u64) -> Self { - Self::build(TestDispatcher::new(seed), benchmark_name) - } - - fn build(dispatcher: TestDispatcher, benchmark_name: Option<&'static str>) -> Self { - let dispatcher = Arc::new(dispatcher); - let background_executor = BackgroundExecutor::new(dispatcher.clone()); - let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); - let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); +impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { + /// Creates a new benchmark app context backed by the provided platform. + /// + /// The platform's executors must be backed by a [`BenchDispatcher`] + /// (see [`bench_platform`]) so the context can drain foreground work via + /// [`Self::run_until_idle`]; panics otherwise. + pub fn new( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + ) -> Self { + Self::build(platform, benchmark_name, bencher, BenchReport::default()) + } + + /// Creates a new benchmark app context backed by the provided platform. + /// + /// The platform's executors must be backed by a [`BenchDispatcher`] + /// (see [`bench_platform`]) so the context can drain foreground work via + /// [`Self::run_until_idle`]; panics otherwise. + #[doc(hidden)] + pub fn new_with_platform_and_report( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + report: BenchReport, + ) -> Self { + Self::build(platform, benchmark_name, bencher, report) + } + + fn build( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + report: BenchReport, + ) -> Self { + let background_executor = platform.background_executor(); + // Validate up front so misconfiguration fails at construction with a + // clear message instead of deep inside `run_until_idle`. + assert!( + background_executor.dispatcher().as_bench().is_some(), + "BenchAppContext requires a platform whose executors are backed by a \ + BenchDispatcher; construct one with gpui::bench_platform" + ); + let foreground_executor = platform.foreground_executor(); let asset_source = Arc::new(()); 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: (*dispatcher).clone(), benchmark_name, + bencher: Rc::new(RefCell::new(Some(bencher))), + report, } } @@ -69,11 +351,6 @@ impl BenchAppContext { &self.foreground_executor } - /// Runs pending scheduled work until the benchmark app is idle. - pub fn run_until_idle(&self) { - self.dispatcher.run_until_parked(); - } - /// Updates the app and flushes synchronous GPUI effects afterward. pub fn update(&mut self, update: impl FnOnce(&mut App) -> R) -> R { let mut app = self.app.borrow_mut(); @@ -86,8 +363,85 @@ impl BenchAppContext { read(&app) } + /// Runs queued foreground tasks on this thread and waits for in flight + /// background work to finish. Timers that aren't due yet are not waited + /// for (see [`BenchDispatcher::run_until_idle`]). + pub fn run_until_idle(&self) { + self.background_executor + .dispatcher() + .as_bench() + .expect("validated in BenchAppContext::build") + .run_until_idle(); + } + + /// Measures a generic benchmark workload using Criterion's iteration loop. + /// + /// The closure is invoked once per Criterion iteration with this + /// benchmark app context so it can update GPUI state. + /// + /// Any window draws triggered by the workload are recorded into the + /// benchmark's frame report through the GPUI frame profiler. + pub fn bench_iter(&mut self, mut benchmark: impl FnMut(&mut Self)) { + let bencher = self.take_bencher("bench_iter"); + let collector = FrameTraceScope::start(); + let mut benchmark = || benchmark(self); + bencher.iter(&mut benchmark); + self.report.record_frame_timings(collector.finish().iter()); + self.replace_bencher(bencher); + } + + /// Measures frame latency after updating a GPUI entity in its current window. + /// + /// Each iteration runs `update` against the entity in its current window. In + /// bench builds, flushing the update's effects synchronously draws dirty + /// windows. The entity should be part of the window's render tree, such as the + /// root view or a child of it. + /// + /// Frame timings are collected through the GPUI frame profiler + /// ([`crate::profiler::record_frame_timing`]), which is enabled for the + /// duration of the measurement. + pub fn bench_renderer( + &mut self, + view: Entity, + mut update: impl FnMut(&mut V, &mut Window, &mut Context), + ) where + V: 'static + Render, + { + let bencher = self.take_bencher("bench_renderer"); + let window_id = self + .with_window(view.entity_id(), |window, _| { + window.window_handle().window_id() + }) + .expect("cannot benchmark renderer for entity without a current window"); + + let collector = FrameTraceScope::start(); + + let mut benchmark = || { + self.with_window(view.entity_id(), |window, cx| { + view.update(cx, |view, cx| update(view, window, cx)); + }) + .expect("cannot benchmark renderer for entity without a current window"); + // Submit the frame drawn by the update's effect flush, mirroring + // production where every drawn frame is presented. With a headless + // renderer this includes scene submission to the GPU. + self.with_window(view.entity_id(), |window, _| { + window.present_if_needed(); + }) + .expect("cannot benchmark renderer for entity without a current window"); + }; + bencher.iter(&mut benchmark); + + let timings = collector.finish(); + self.report.record_frame_timings( + timings + .iter() + .filter(|timing| timing.window_id == window_id), + ); + self.replace_bencher(bencher); + } + /// Adds a window with an empty root view for benchmark setup. - pub fn add_empty_window(&mut self) -> BenchWindowContext { + pub fn add_empty_window(&mut self) -> BenchWindowContext<'a, 'measurement> { let window = { let mut app = self.app.borrow_mut(); let bounds = Bounds::maximized(None, &app); @@ -111,18 +465,40 @@ impl BenchAppContext { } } + fn take_bencher(&self, benchmark_kind: &str) -> &'a mut criterion::Bencher<'measurement> { + self.bencher.borrow_mut().take().unwrap_or_else(|| { + panic!("cannot start {benchmark_kind}: benchmark measurement is already running") + }) + } + + fn replace_bencher(&self, bencher: &'a mut criterion::Bencher<'measurement>) { + let previous = self.bencher.borrow_mut().replace(bencher); + assert!( + previous.is_none(), + "benchmark bencher was unexpectedly present after measurement" + ); + } + /// Runs GPUI benchmark teardown. + /// + /// Forgets any timers still armed on the shared dispatcher so they can't + /// fire during a later benchmark; assumes no other `BenchAppContext` is + /// live on this thread. pub fn teardown(mut self) { self.run_until_idle(); self.update(|cx| { - cx.background_executor().forbid_parking(); cx.quit(); }); self.run_until_idle(); + self.background_executor + .dispatcher() + .as_bench() + .expect("validated in BenchAppContext::build") + .forget_pending_timers(); } } -impl AppContext for BenchAppContext { +impl AppContext for BenchAppContext<'_, '_> { fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { let mut app = self.app.borrow_mut(); app.new(build_entity) @@ -151,7 +527,7 @@ impl AppContext for BenchAppContext { app.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, _: &Entity) -> GpuiBorrow<'a, T> + fn as_mut<'b, T>(&'b mut self, _: &Entity) -> GpuiBorrow<'b, T> where T: 'static, { @@ -216,14 +592,14 @@ impl AppContext for BenchAppContext { /// This is separate from `VisualTestContext`; it provides access to a benchmark /// window without exposing test-only helpers such as input simulation. #[derive(Clone)] -pub struct BenchWindowContext { - cx: BenchAppContext, +pub struct BenchWindowContext<'a, 'measurement> { + cx: BenchAppContext<'a, 'measurement>, window: AnyWindowHandle, } -impl BenchWindowContext { +impl<'a, 'measurement> BenchWindowContext<'a, 'measurement> { /// Returns the underlying benchmark app context. - pub fn app_context(&mut self) -> &mut BenchAppContext { + pub fn app_context(&mut self) -> &mut BenchAppContext<'a, 'measurement> { &mut self.cx } @@ -232,20 +608,21 @@ impl BenchWindowContext { self.window } + /// Runs queued foreground tasks on this thread and waits for in-flight + /// background work to finish. Pending timers are not waited for. + pub fn run_until_idle(&self) { + self.cx.run_until_idle(); + } + /// Updates the benchmark window. pub fn update(&mut self, update: impl FnOnce(&mut Window, &mut App) -> R) -> R { self.cx .update_window(self.window, |_, window, cx| update(window, cx)) .expect("benchmark window was unexpectedly closed") } - - /// Runs pending scheduled work until the benchmark app is idle. - pub fn run_until_idle(&self) { - self.cx.run_until_idle(); - } } -impl AppContext for BenchWindowContext { +impl AppContext for BenchWindowContext<'_, '_> { fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { self.window .update(&mut self.cx, |_, _, cx| cx.new(build_entity)) @@ -276,7 +653,7 @@ impl AppContext for BenchWindowContext { self.cx.update_entity(handle, update) } - fn as_mut<'a, T>(&'a mut self, handle: &Entity) -> GpuiBorrow<'a, T> + fn as_mut<'b, T>(&'b mut self, handle: &Entity) -> GpuiBorrow<'b, T> where T: 'static, { @@ -331,7 +708,7 @@ impl AppContext for BenchWindowContext { } } -impl VisualContext for BenchWindowContext { +impl VisualContext for BenchWindowContext<'_, '_> { type Result = Result; fn window_handle(&self) -> AnyWindowHandle { diff --git a/crates/gpui/src/asset_cache.rs b/crates/gpui/src/asset_cache.rs index 9afbba8a0edfc6..bab0b2682ef459 100644 --- a/crates/gpui/src/asset_cache.rs +++ b/crates/gpui/src/asset_cache.rs @@ -2,7 +2,7 @@ use crate::{App, SharedString, SharedUri}; use futures::{Future, TryFutureExt}; use std::fmt::Debug; -use std::hash::{Hash, Hasher}; +use std::hash::{BuildHasher, Hash}; use std::marker::PhantomData; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -78,7 +78,5 @@ where /// Use a quick, non-cryptographically secure hash function to get an identifier from data pub fn hash(data: &T) -> u64 { - let mut hasher = collections::FxHasher::default(); - data.hash(&mut hasher); - hasher.finish() + collections::FxBuildHasher.hash_one(data) } diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index be8169e81d28dd..8ec816f49d7b69 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -69,6 +69,68 @@ impl Rgba { } } } + + /// Returns a new RGBA color with the same red, green and blue channels, but + /// with a new alpha value. + /// + /// Example: + /// ``` + /// use gpui::rgba; + /// let color = rgba(0xFF0000FF); + /// let faded = color.alpha(0.25); + /// assert_eq!(faded.a, 0.25); + /// ``` + /// + /// This will return a red color with 25% opacity. + /// + /// Example: + /// ``` + /// use gpui::rgba; + /// let color = rgba(0x3399FFCC); + /// let transparent = color.alpha(0.0); + /// assert_eq!(transparent.a, 0.0); + /// ``` + /// + /// This will return the same blue color, fully transparent. + pub fn alpha(&self, a: f32) -> Self { + Rgba { + r: self.r, + g: self.g, + b: self.b, + a: a.clamp(0., 1.), + } + } + + /// Returns a new RGBA color with the same red, green, and blue channels, + /// but with the alpha channel multiplied by the given factor. + /// + /// Example: + /// ``` + /// use gpui::rgba; + /// let color = rgba(0xFF0000FF); // Fully opaque red + /// let faded = color.opacity(0.5); + /// assert_eq!(faded.a, 0.5); + /// ``` + /// + /// This will return a red color with 50% opacity. + /// + /// Example: + /// ``` + /// use gpui::rgba; + /// let color = rgba(0x3399FFCC); // A light blue with 80% opacity + /// let faded = color.opacity(0.5); + /// assert!((faded.a - 0.4).abs() < 1e-6); + /// ``` + /// + /// This will return the same blue color scaled down to 40% opacity. + pub fn opacity(&self, factor: f32) -> Self { + Rgba { + r: self.r, + g: self.g, + b: self.b, + a: self.a * factor.clamp(0., 1.), + } + } } impl From for u32 { @@ -591,7 +653,7 @@ impl Hsla { /// assert_eq!(red_color.a, 0.25); /// ``` /// - /// This will return a red color with half the opacity. + /// This will return a red color with 25% opacity. /// /// Example: /// ``` @@ -979,4 +1041,29 @@ mod tests { assert!(!background.is_transparent()); assert!(background.opacity(0.0).is_transparent()); } + + #[test] + fn test_rgba_alpha() { + let color = Rgba { + r: 0.2, + g: 0.6, + b: 1.0, + a: 0.8, + }; + + assert_eq!(color.alpha(0.25).a, 0.25); + assert_eq!(color.alpha(1.5).a, 1.0); + } + + #[test] + fn test_rgba_opacity() { + let color = Rgba { + r: 0.2, + g: 0.6, + b: 1.0, + a: 0.8, + }; + assert!((color.opacity(0.5).a - 0.4).abs() < 1e-6); + assert_eq!(color.opacity(2.0).a, 0.8); + } } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 5287e12bfedc8a..436cc1a9d6a332 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -46,7 +46,7 @@ use std::{ use super::ImageCacheProvider; const DRAG_THRESHOLD: f64 = 2.; -const TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500); +const DEFAULT_TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500); const HOVERABLE_TOOLTIP_HIDE_DELAY: Duration = Duration::from_millis(500); /// The styling information for a given group. @@ -645,6 +645,12 @@ impl Interactivity { }); } + /// Set the delay before this element's tooltip is shown. + /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip_show_delay`]. + pub fn tooltip_show_delay(&mut self, delay: Duration) { + self.tooltip_show_delay = Some(delay); + } + /// Block the mouse from all interactions with elements behind this element's hitbox. Typically /// `block_mouse_except_scroll` should be preferred. /// @@ -1441,6 +1447,16 @@ pub trait StatefulInteractiveElement: InteractiveElement { self.interactivity().hoverable_tooltip(build_tooltip); self } + + /// Set the delay before this element's tooltip is shown. + /// The fluent API equivalent to [`Interactivity::tooltip_show_delay`]. + fn tooltip_show_delay(mut self, delay: Duration) -> Self + where + Self: Sized, + { + self.interactivity().tooltip_show_delay(delay); + self + } } pub(crate) type MouseDownListener = @@ -1836,6 +1852,7 @@ pub struct Interactivity { pub(crate) drag_listener: Option<(Arc, DragListener)>, pub(crate) hover_listener: Option>, pub(crate) tooltip_builder: Option, + pub(crate) tooltip_show_delay: Option, pub(crate) window_control: Option, pub(crate) hitbox_behavior: HitboxBehavior, pub(crate) tab_index: Option, @@ -2748,6 +2765,7 @@ impl Interactivity { build_tooltip, check_is_hovered, check_is_hovered_during_prepaint, + self.tooltip_show_delay, window, ); } @@ -3195,9 +3213,11 @@ pub(crate) fn register_tooltip_mouse_handlers( build_tooltip: Rc Option<(AnyView, bool)>>, check_is_hovered: Rc bool>, check_is_hovered_during_prepaint: Rc bool>, + show_delay: Option, window: &mut Window, ) { let current_view = window.current_view(); + let show_delay = show_delay.unwrap_or(DEFAULT_TOOLTIP_SHOW_DELAY); window.on_mouse_event({ let active_tooltip = active_tooltip.clone(); @@ -3212,6 +3232,7 @@ pub(crate) fn register_tooltip_mouse_handlers( tooltip_id, current_view, phase, + show_delay, window, cx, ) @@ -3256,6 +3277,7 @@ fn handle_tooltip_mouse_move( tooltip_id: Option, current_view: EntityId, phase: DispatchPhase, + show_delay: Duration, window: &mut Window, cx: &mut App, ) { @@ -3320,7 +3342,7 @@ fn handle_tooltip_mouse_move( let build_tooltip = build_tooltip.clone(); let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone(); async move |cx| { - cx.background_executor().timer(TOOLTIP_SHOW_DELAY).await; + cx.background_executor().timer(show_delay).await; let Some(active_tooltip) = weak_active_tooltip.upgrade() else { return; }; @@ -3846,7 +3868,10 @@ impl ScrollHandle { #[cfg(test)] mod tests { use super::*; - use crate::{AppContext as _, Context, InputEvent, MouseMoveEvent, TestAppContext}; + use crate::{ + AppContext as _, Context, InputEvent, MouseMoveEvent, TestAppContext, + util::FluentBuilder as _, + }; use std::rc::Weak; struct TestTooltipView; @@ -3933,6 +3958,7 @@ mod tests { struct TooltipOwner { captured_active_tooltip: CapturedActiveTooltip, + show_delay_override: Option, } impl Render for TooltipOwner { @@ -3945,7 +3971,10 @@ mod tests { .id("target") .w(px(50.)) .h(px(50.)) - .tooltip(|_, cx| cx.new(|_| TestTooltipView).into()), + .tooltip(|_, cx| cx.new(|_| TestTooltipView).into()) + .when_some(self.show_delay_override, |this, delay| { + this.tooltip_show_delay(delay) + }), ) .into_any_element(), captured_active_tooltip: self.captured_active_tooltip.clone(), @@ -3991,7 +4020,9 @@ mod tests { assert_eq!(handle.offset().y, px(-25.)); } - fn setup_tooltip_owner_test() -> ( + fn setup_tooltip_owner_test( + show_delay_override: Option, + ) -> ( TestAppContext, crate::AnyWindowHandle, CapturedActiveTooltip, @@ -4002,6 +4033,7 @@ mod tests { let captured_active_tooltip = captured_active_tooltip.clone(); move |_, _| TooltipOwner { captured_active_tooltip, + show_delay_override, } }); let any_window = window.into(); @@ -4037,7 +4069,7 @@ mod tests { #[test] fn tooltip_waiting_for_show_is_released_when_its_owner_disappears() { - let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(); + let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(None); let weak_active_tooltip = captured_active_tooltip.borrow().clone().unwrap(); let active_tooltip = weak_active_tooltip.upgrade().unwrap(); @@ -4057,14 +4089,45 @@ mod tests { assert!(weak_active_tooltip.upgrade().is_none()); } + #[test] + fn tooltip_respects_custom_show_delay() { + let extra_delay = Duration::from_secs(1); + let show_delay_override = DEFAULT_TOOLTIP_SHOW_DELAY + extra_delay; + let (mut test_app, _any_window, captured_active_tooltip) = + setup_tooltip_owner_test(Some(show_delay_override)); + + let weak_active_tooltip = captured_active_tooltip.borrow().clone().unwrap(); + let active_tooltip = weak_active_tooltip.upgrade().unwrap(); + + test_app + .dispatcher + .advance_clock(DEFAULT_TOOLTIP_SHOW_DELAY); + test_app.run_until_parked(); + + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::WaitingForShow { .. }) + )); + + test_app.dispatcher.advance_clock(extra_delay); + test_app.run_until_parked(); + + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::Visible { .. }) + )); + } + #[test] fn tooltip_is_released_when_its_owner_disappears() { - let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(); + let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(None); let weak_active_tooltip = captured_active_tooltip.borrow().clone().unwrap(); let active_tooltip = weak_active_tooltip.upgrade().unwrap(); - test_app.dispatcher.advance_clock(TOOLTIP_SHOW_DELAY); + test_app + .dispatcher + .advance_clock(DEFAULT_TOOLTIP_SHOW_DELAY); test_app.run_until_parked(); assert!(matches!( @@ -4085,12 +4148,14 @@ mod tests { #[test] fn tooltip_hides_after_mouse_leaves_origin() { - let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(); + let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(None); let weak_active_tooltip = captured_active_tooltip.borrow().clone().unwrap(); let active_tooltip = weak_active_tooltip.upgrade().unwrap(); - test_app.dispatcher.advance_clock(TOOLTIP_SHOW_DELAY); + test_app + .dispatcher + .advance_clock(DEFAULT_TOOLTIP_SHOW_DELAY); test_app.run_until_parked(); assert!(matches!( diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 5a729dcc5f560f..28f47a6b7b0591 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -347,6 +347,7 @@ impl ListState { state.reset = true; state.measuring_behavior.reset(); state.logical_scroll_top = None; + state.pending_scroll = None; state.scrollbar_drag_start_height = None; state.items.summary().count }; @@ -546,10 +547,13 @@ impl ListState { cursor.seek(&Height(new_pixel_offset), Bias::Right); } - state.logical_scroll_top = Some(ListOffset { + let scroll_top = ListOffset { item_ix: cursor.start().count, offset_in_item: new_pixel_offset - cursor.start().height, - }); + }; + drop(cursor); + state.rebase_pending_scroll(scroll_top); + state.logical_scroll_top = Some(scroll_top); } /// Scroll the list to the very end (past the last item). @@ -561,6 +565,7 @@ impl ListState { pub fn scroll_to_end(&self) { let state = &mut *self.0.borrow_mut(); let item_count = state.items.summary().count; + state.pending_scroll = None; state.logical_scroll_top = Some(ListOffset { item_ix: item_count, offset_in_item: px(0.), @@ -613,6 +618,7 @@ impl ListState { state.follow_state.stop_following(); } + state.rebase_pending_scroll(scroll_top); state.logical_scroll_top = Some(scroll_top); } @@ -645,6 +651,7 @@ impl ListState { } } + state.rebase_pending_scroll(scroll_top); state.logical_scroll_top = Some(scroll_top); } @@ -744,11 +751,13 @@ impl ListState { /// Returns whether the item is entirely above the viewport, or `None` if /// the list has not measured enough layout to know. + /// + /// A zero-height viewport still yields a definitive answer: callers may + /// size sibling UI based on this query (potentially squeezing the list + /// itself to zero height), so returning `None` in that case would make + /// the answer oscillate from frame to frame. pub fn item_is_above_viewport(&self, ix: usize) -> Option { - let viewport_bounds = self.viewport_bounds(); - if viewport_bounds.size.height == px(0.0) { - return None; - } + let viewport_bounds = self.0.borrow().last_layout_bounds?; let scroll_top = self.logical_scroll_top(); if ix < scroll_top.item_ix { @@ -763,11 +772,11 @@ impl ListState { /// Returns whether the item is entirely below the viewport, or `None` if /// the list has not measured enough layout to know. + /// + /// See [`Self::item_is_above_viewport`] for why a zero-height viewport + /// still yields a definitive answer. pub fn item_is_below_viewport(&self, ix: usize) -> Option { - let viewport_bounds = self.viewport_bounds(); - if viewport_bounds.size.height == px(0.0) { - return None; - } + let viewport_bounds = self.0.borrow().last_layout_bounds?; let scroll_top = self.logical_scroll_top(); if ix < scroll_top.item_ix { @@ -782,6 +791,39 @@ impl ListState { } impl StateInner { + /// Re-anchor a pending scroll adjustment from a remeasure onto a newly set + /// scroll position, so it clamps to the remeasured item's new height on + /// the next layout instead of reverting the scroll. + fn rebase_pending_scroll(&mut self, scroll_top: ListOffset) { + let Some(pending) = self.pending_scroll.take() else { + return; + }; + if scroll_top.item_ix >= self.items.summary().count { + return; + } + + self.pending_scroll = match pending { + PendingScroll::Absolute { .. } => Some(PendingScroll::Absolute { + item_ix: scroll_top.item_ix, + offset: scroll_top.offset_in_item, + }), + PendingScroll::Proportional(_) => { + let mut cursor = self.items.cursor::(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + cursor + .item() + .and_then(|item| item.size_hint()) + .filter(|size| size.height.0 > 0.0) + .map(|size| { + PendingScroll::Proportional(PendingScrollFraction { + item_ix: scroll_top.item_ix, + fraction: (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0), + }) + }) + } + }; + } + fn max_scroll_offset(&self) -> Pixels { let bounds = self.last_layout_bounds.unwrap_or_default(); let height = self @@ -825,17 +867,21 @@ impl StateInner { .min(scroll_max); if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { + self.pending_scroll = None; self.logical_scroll_top = None; } else { let (start, ..) = self.items .find::((), &Height(new_scroll_top), Bias::Right); - let item_ix = start.count; - let offset_in_item = new_scroll_top - start.height; - self.logical_scroll_top = Some(ListOffset { - item_ix, - offset_in_item, - }); + let scroll_top = ListOffset { + item_ix: start.count, + offset_in_item: new_scroll_top - start.height, + }; + // The user's scroll supersedes the position stashed by a + // remeasure; re-anchor the pending adjustment so it doesn't revert + // this scroll on the next layout. + self.rebase_pending_scroll(scroll_top); + self.logical_scroll_top = Some(scroll_top); } if delta.y > px(0.) { @@ -1264,6 +1310,7 @@ impl StateInner { if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) { self.follow_state = FollowState::Tail { is_following: true }; let item_count = self.items.summary().count; + self.pending_scroll = None; self.logical_scroll_top = Some(ListOffset { item_ix: item_count, offset_in_item: px(0.), @@ -1274,18 +1321,19 @@ impl StateInner { self.follow_state.stop_following(); if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { + self.pending_scroll = None; self.logical_scroll_top = None; } else { let (start, _, _) = self.items .find::((), &Height(new_scroll_top), Bias::Right); - let item_ix = start.count; - let offset_in_item = new_scroll_top - start.height; - self.logical_scroll_top = Some(ListOffset { - item_ix, - offset_in_item, - }); + let scroll_top = ListOffset { + item_ix: start.count, + offset_in_item: new_scroll_top - start.height, + }; + self.rebase_pending_scroll(scroll_top); + self.logical_scroll_top = Some(scroll_top); } } } @@ -1773,6 +1821,37 @@ mod test { assert_eq!(state.item_is_below_viewport(3), Some(true)); } + #[gpui::test] + fn test_item_viewport_queries_remain_stable_with_zero_height_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(3), Some(false)); + assert_eq!(state.item_is_below_viewport(3), Some(true)); + + // Squeeze the list to zero height, e.g. because a sibling element + // (sized based on the queries above) consumed all the space. The + // answers must remain definitive rather than becoming `None`, + // otherwise the sibling's size can oscillate between frames. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(0.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(1), Some(true)); + assert_eq!(state.item_is_below_viewport(1), Some(false)); + assert_eq!(state.item_is_above_viewport(3), Some(false)); + assert_eq!(state.item_is_below_viewport(3), Some(true)); + } + #[gpui::test] fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); @@ -1945,6 +2024,124 @@ mod test { assert_eq!(offset.offset_in_item, px(40.)); } + #[gpui::test] + fn test_remeasure_then_scroll_does_not_revert_scroll_position(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(100.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = { + let state = state.clone(); + cx.update(|_, cx| cx.new(|_| TestView(state))) + }; + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.remeasure_items(5..6); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-30.))), + ..Default::default() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(70.)); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!( + offset.offset_in_item, + px(70.), + "scrolling after a remeasure should not be reverted by the stale pending scroll" + ); + } + + #[gpui::test] + fn test_scroll_after_remeasure_clamps_to_shrunk_item_height(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let item_height = Rc::new(Cell::new(100usize)); + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 5 { height } else { 100 }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = { + let state = state.clone(); + let item_height = item_height.clone(); + cx.update(|_, cx| cx.new(|_| TestView { state, item_height })) + }; + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // Item 5 shrinks from 100px to 50px and is remeasured... + item_height.set(50); + state.remeasure_items(5..6); + + // ...and then the user scrolls down by 30px before the next frame, + // landing at offset 70. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-30.))), + ..Default::default() + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // The rebased pending scroll clamps the user's offset to the item's + // new height instead of leaving it pointing past the end of the item. + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(50.)); + } + #[gpui::test] fn test_follow_tail_stays_at_bottom_as_items_grow(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 82d23c83b75b1f..ae37d3fdd91513 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -669,6 +669,7 @@ impl TextLayout { match text_overflow { TextOverflow::Truncate(s) => (width, s, TruncateFrom::End), TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start), + TextOverflow::TruncateMiddle(s) => (width, s, TruncateFrom::Middle), } } else { (None, "".into(), TruncateFrom::End) @@ -1227,6 +1228,7 @@ impl Element for InteractiveText { build_tooltip, check_is_hovered, check_is_hovered_during_prepaint, + None, window, ); } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index b792718f88120b..a81ff265c3edd8 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -33,9 +33,15 @@ mod keymap; mod path_builder; mod platform; pub mod prelude; -/// Profiling utilities for task timing and thread performance tracking. +/// Profiling utilities for task, frame, and thread performance tracking. pub mod profiler; -#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] +#[cfg(any( + test, + target_os = "windows", + target_os = "linux", + target_family = "wasm", + feature = "bench" +))] #[expect(missing_docs)] pub mod queue; mod scene; diff --git a/crates/gpui/src/inspector.rs b/crates/gpui/src/inspector.rs index ad3ba6a4b693ef..12995f03a8ad5c 100644 --- a/crates/gpui/src/inspector.rs +++ b/crates/gpui/src/inspector.rs @@ -22,7 +22,7 @@ pub use conditional::*; mod conditional { use super::*; use crate::{AnyElement, App, Context, Empty, IntoElement, Render, Window}; - use collections::FxHashMap; + use collections::{FxHashMap, TypeIdHashMap}; use std::any::{Any, TypeId}; /// `GlobalElementId` qualified by source location of element construction. @@ -64,14 +64,14 @@ mod conditional { struct InspectedElement { id: InspectorElementId, - states: FxHashMap>, + states: TypeIdHashMap>, } impl InspectedElement { fn new(id: InspectorElementId) -> Self { InspectedElement { id, - states: FxHashMap::default(), + states: Default::default(), } } } diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index eaf582a0074d4e..ade499b890bd82 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -5,9 +5,8 @@ pub use binding::*; pub use context::*; use crate::{Action, AsKeystroke, Keystroke, Unbind, is_no_action, is_unbind}; -use collections::{HashMap, HashSet}; +use collections::{HashSet, TypeIdHashMap}; use smallvec::SmallVec; -use std::any::TypeId; /// An opaque identifier of which version of the keymap is currently active. /// The keymap's version is changed whenever bindings are added or removed. @@ -18,7 +17,7 @@ pub struct KeymapVersion(usize); #[derive(Default)] pub struct Keymap { bindings: Vec, - binding_indices_by_action_id: HashMap>, + binding_indices_by_action_id: TypeIdHashMap>, disabled_binding_indices: Vec, version: KeymapVersion, } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 2cc32107f18244..60355b8e2446bd 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -6,6 +6,9 @@ mod keystroke; #[expect(missing_docs)] pub mod layer_shell; +#[cfg(any(test, feature = "bench"))] +mod bench_dispatcher; + #[cfg(any(test, feature = "test-support"))] mod test; @@ -77,6 +80,9 @@ pub(crate) use test::*; #[cfg(any(test, feature = "test-support"))] pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}; +#[cfg(any(test, feature = "bench"))] +pub use bench_dispatcher::BenchDispatcher; + #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test::VisualTestPlatform; @@ -745,6 +751,13 @@ pub trait PlatformHeadlessRenderer { size: Size, ) -> Result; + /// Render a scene to an offscreen target without reading the result back. + /// + /// This is the headless analogue of presenting a frame: it performs the + /// same CPU-side scene encoding and GPU submission as drawing to a real + /// window, but doesn't block on GPU completion or copy pixels back. + fn render_scene(&mut self, scene: &Scene, size: Size) -> Result<()>; + /// Returns the sprite atlas used by this renderer. fn sprite_atlas(&self) -> Arc; } @@ -786,6 +799,13 @@ pub trait PlatformDispatcher: Send + Sync { fn as_test(&self) -> Option<&TestDispatcher> { None } + + // This cfg must match the `bench_dispatcher` module's, which implements + // this method whenever it compiles. + #[cfg(any(test, feature = "bench"))] + fn as_bench(&self) -> Option<&BenchDispatcher> { + None + } } #[expect(missing_docs)] @@ -1250,17 +1270,55 @@ impl PlatformInputHandler { self.handler.replace_text_in_range(None, input, window, cx); } + pub fn compute_ime_candidate_bounds( + marked_range: Option>, + selection: &UTF16Selection, + mut bounds_for_range: impl FnMut(Range) -> Option>, + ) -> Option> { + if let Some(marked_range) = marked_range { + // Default to the start of the marked (composing) range. + let mut line_start = marked_range.start; + + // Walk backward from the caret looking for a line break. A change in + // the Y coordinate means we crossed into the previous visual line, so + // the line start is one position after the break point. + let caret = selection.range.end; + if let Some(caret_bounds) = bounds_for_range(caret..caret) { + for i in (marked_range.start..caret).rev() { + if let Some(b) = bounds_for_range(i..i) { + if (b.origin.y - caret_bounds.origin.y).abs() > px(0.1) { + line_start = i + 1; + break; + } + } + } + } + bounds_for_range(line_start..line_start) + } else { + // No active composition — use the selection endpoint. + let offset = if selection.reversed { + selection.range.start + } else { + selection.range.end + }; + bounds_for_range(offset..offset) + } + } + pub fn selected_bounds(&mut self, window: &mut Window, cx: &mut App) -> Option> { + let marked_range = self.handler.marked_text_range(window, cx); let selection = self.handler.selected_text_range(true, window, cx)?; - self.handler.bounds_for_range( - if selection.reversed { - selection.range.start..selection.range.start - } else { - selection.range.end..selection.range.end - }, - window, - cx, - ) + Self::compute_ime_candidate_bounds(marked_range, &selection, |range| { + self.handler.bounds_for_range(range, window, cx) + }) + } + + pub fn ime_candidate_bounds(&mut self) -> Option> { + let marked_range = self.marked_text_range(); + let selection = self.selected_text_range(true)?; + Self::compute_ime_candidate_bounds(marked_range, &selection, |range| { + self.bounds_for_range(range) + }) } #[allow(unused)] @@ -1758,7 +1816,7 @@ impl PromptButton { impl From<&str> for PromptButton { fn from(value: &str) -> Self { match value.to_lowercase().as_str() { - "ok" => PromptButton::Ok("Ok".into()), + "ok" => PromptButton::Ok("OK".into()), "cancel" => PromptButton::Cancel("Cancel".into()), _ => PromptButton::Other(SharedString::from(value.to_owned())), } diff --git a/crates/gpui/src/platform/bench_dispatcher.rs b/crates/gpui/src/platform/bench_dispatcher.rs new file mode 100644 index 00000000000000..80aff1c3895f83 --- /dev/null +++ b/crates/gpui/src/platform/bench_dispatcher.rs @@ -0,0 +1,441 @@ +use std::{ + collections::BinaryHeap, + sync::Arc, + thread, + time::{Duration, Instant}, +}; + +use parking_lot::{Condvar, Mutex}; + +use crate::{ + PlatformDispatcher, Priority, RunnableVariant, profiler, + queue::{PriorityQueueReceiver, PriorityQueueSender}, +}; + +const MIN_THREADS: usize = 2; + +/// A multithreaded [`PlatformDispatcher`] for benchmarks. +/// +/// Background tasks run in parallel on a pool of worker threads and timers fire +/// in real time on a dedicated timer thread, mirroring the production +/// dispatchers (see `LinuxDispatcher`). Main-thread tasks are queued until the +/// benchmark thread drains them via [`Self::run_until_idle`], since there is no +/// platform run loop pumping them. +/// +/// Unlike [`TestDispatcher`](crate::TestDispatcher), which runs everything on a +/// single thread with a virtual clock, work dispatched through this dispatcher +/// executes with production concurrency, so wall-clock measurements reflect +/// real parallelism. +pub struct BenchDispatcher { + background_sender: PriorityQueueSender, + main_sender: PriorityQueueSender, + main_receiver: Mutex>, + timers: Arc, + idle: Arc, + main_thread_id: thread::ThreadId, +} + +/// Tracks how many background and timer runnables are queued or running so +/// [`BenchDispatcher::run_until_idle`] knows when to stop waiting. +#[derive(Default)] +struct IdleTracker { + inflight: Mutex, + condvar: Condvar, +} + +impl IdleTracker { + fn increment(&self) { + *self.inflight.lock() += 1; + } + + fn decrement(&self) { + let mut inflight = self.inflight.lock(); + *inflight -= 1; + if *inflight == 0 { + self.condvar.notify_all(); + } + } + + /// Returns a guard that decrements the in-flight count when dropped, so + /// the count stays correct even if the runnable being executed panics. + fn decrement_on_drop(&self) -> impl Drop + '_ { + gpui_util::defer(|| self.decrement()) + } + + /// Notifies waiters while holding the in-flight lock. `run_until_idle` + /// re-checks its wake conditions under this lock before waiting, so the + /// notification can't slip between its check and its wait and be lost. + fn notify_under_lock(&self) { + let _inflight = self.inflight.lock(); + self.condvar.notify_all(); + } +} + +struct TimerQueue { + state: Mutex, + condvar: Condvar, +} + +struct TimerQueueState { + heap: BinaryHeap, + next_seq: u64, +} + +struct TimerEntry { + due: Instant, + seq: u64, + runnable: RunnableVariant, +} + +impl PartialEq for TimerEntry { + fn eq(&self, other: &Self) -> bool { + self.due == other.due && self.seq == other.seq + } +} + +impl Eq for TimerEntry {} + +impl PartialOrd for TimerEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TimerEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reversed so that the entry with the earliest due time (breaking ties + // by insertion order) is at the top of the max-heap. + other + .due + .cmp(&self.due) + .then_with(|| other.seq.cmp(&self.seq)) + } +} + +impl Default for BenchDispatcher { + fn default() -> Self { + Self::new() + } +} + +impl BenchDispatcher { + /// Creates a dispatcher whose main thread is the calling thread. + /// + /// Worker and timer threads live for the lifetime of the process; the + /// dispatcher is expected to be created once and reused across benchmarks. + pub fn new() -> Self { + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + let (main_sender, main_receiver) = PriorityQueueReceiver::new(); + let idle = Arc::new(IdleTracker::default()); + + let thread_count = + thread::available_parallelism().map_or(MIN_THREADS, |i| i.get().max(MIN_THREADS)); + for i in 0..thread_count { + let mut receiver: PriorityQueueReceiver = background_receiver.clone(); + let idle = idle.clone(); + thread::Builder::new() + .name(format!("BenchWorker-{i}")) + .spawn(move || { + while let Ok(runnable) = receiver.pop() { + let _decrement = idle.decrement_on_drop(); + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + runnable.run(); + profiler::save_task_timing(); + } + }) + .expect("failed to spawn benchmark worker thread"); + } + drop(background_receiver); + + let timers = Arc::new(TimerQueue { + state: Mutex::new(TimerQueueState { + heap: BinaryHeap::new(), + next_seq: 0, + }), + condvar: Condvar::new(), + }); + { + let timers = timers.clone(); + let idle = idle.clone(); + thread::Builder::new() + .name("BenchTimer".to_owned()) + .spawn(move || { + let mut state = timers.state.lock(); + loop { + let Some(entry) = state.heap.peek() else { + timers.condvar.wait(&mut state); + continue; + }; + let due = entry.due; + if due > Instant::now() { + timers.condvar.wait_until(&mut state, due); + continue; + } + let Some(entry) = state.heap.pop() else { + continue; + }; + // Count the firing timer as in-flight before releasing + // the lock so it can spawn follow-up work that + // `run_until_idle` will wait for. Lock order is always + // timer state, then in-flight count; `run_until_idle` + // never takes them in the opposite order. + idle.increment(); + drop(state); + + { + let _decrement = idle.decrement_on_drop(); + let location = entry.runnable.metadata().location; + let spawned = entry.runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + entry.runnable.run(); + profiler::save_task_timing(); + } + + state = timers.state.lock(); + } + }) + .expect("failed to spawn benchmark timer thread"); + } + + Self { + background_sender, + main_sender, + main_receiver: Mutex::new(main_receiver), + timers, + idle, + main_thread_id: thread::current().id(), + } + } + + /// Runs queued main thread tasks and waits until no background or timer + /// work is queued, running, or already due. + /// + /// Timers that haven't reached their due time yet are *not* waited for: + /// the dispatcher runs in real time and cannot skip ahead like the + /// `TestDispatcher`'s virtual clock, so waiting on a future timer would + /// block for its full real duration. Tasks sleeping on such timers are + /// considered idle. Must be called on the thread that created this + /// dispatcher. + pub fn run_until_idle(&self) { + assert!( + self.is_main_thread(), + "run_until_idle must be called on the benchmark main thread" + ); + loop { + if self.drain_main_queue() { + continue; + } + + // Checked before taking the in-flight lock; the timer thread + // locks them in the opposite order, so nesting would deadlock. + if self.has_due_timer() { + // Poll briefly: a firing timer leaves the heap just before it + // registers as in-flight. + let mut inflight = self.idle.inflight.lock(); + self.idle + .condvar + .wait_for(&mut inflight, Duration::from_millis(1)); + continue; + } + + let mut inflight = self.idle.inflight.lock(); + // Re-checked under the lock that `dispatch_on_main_thread` + // notifies under, so the notification can't be lost. + if self.main_queue_has_work() { + continue; + } + if *inflight == 0 { + // Main-thread sends happen before in-flight decrements, and + // decrements happen under this lock, so the check above + // observed all completed work. + return; + } + // Woken when main-thread work arrives or the in-flight count + // reaches zero; both notify under this lock. + self.idle.condvar.wait(&mut inflight); + } + } + + /// Forgets all pending timers so timers armed by one benchmark can't fire + /// during a later benchmark sharing this process-lifetime dispatcher. + /// + /// The runnables are leaked rather than dropped, since dropping one wakes + /// the awaiting task as if the timer had fired. + pub fn forget_pending_timers(&self) { + let mut state = self.timers.state.lock(); + for entry in state.heap.drain() { + std::mem::forget(entry.runnable); + } + } + + fn has_due_timer(&self) -> bool { + let state = self.timers.state.lock(); + state + .heap + .peek() + .is_some_and(|entry| entry.due <= Instant::now()) + } + + fn main_queue_has_work(&self) -> bool { + !self.main_receiver.lock().is_empty() + } + + fn drain_main_queue(&self) -> bool { + let mut ran_any = false; + loop { + // Lock only around the pop so runnables can re-entrantly dispatch + // more main-thread work through the sender while they run. + let runnable = self.main_receiver.lock().try_pop(); + match runnable { + Ok(Some(runnable)) => { + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + runnable.run(); + profiler::save_task_timing(); + ran_any = true; + } + Ok(None) | Err(_) => return ran_any, + } + } + } +} + +impl PlatformDispatcher for BenchDispatcher { + fn is_main_thread(&self) -> bool { + thread::current().id() == self.main_thread_id + } + + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + self.idle.increment(); + self.background_sender + .send(priority, runnable) + .unwrap_or_else(|_| panic!("benchmark worker threads are no longer running")); + } + + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + if let Err(error) = self.main_sender.send(priority, runnable) { + // The main receiver lives as long as this dispatcher, so a failed + // send means we're mid-teardown. The runnable may wrap a !Send + // future, so forget it rather than dropping it on this thread + // (mirrors LinuxDispatcher). + std::mem::forget(error); + return; + } + // Wake `run_until_idle` if it's waiting for main-thread work. + self.idle.notify_under_lock(); + } + + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { + let mut state = self.timers.state.lock(); + let seq = state.next_seq; + state.next_seq += 1; + state.heap.push(TimerEntry { + due: Instant::now() + duration, + seq, + runnable, + }); + self.timers.condvar.notify_one(); + } + + fn spawn_realtime(&self, f: Box) { + // Benchmarks don't need realtime scheduling priority; a plain thread + // keeps this portable. + thread::Builder::new() + .name("BenchRealtime".to_owned()) + .spawn(f) + .expect("failed to spawn benchmark realtime thread"); + } + + fn as_bench(&self) -> Option<&BenchDispatcher> { + Some(self) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + use crate::{BackgroundExecutor, ForegroundExecutor}; + + #[test] + fn run_until_idle_completes_background_to_main_handoffs() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher.clone()); + let foreground = ForegroundExecutor::new(dispatcher.clone()); + + let (sender, receiver) = futures::channel::oneshot::channel(); + background + .spawn(async move { + thread::sleep(Duration::from_millis(10)); + sender.send(()).ok(); + }) + .detach(); + + let completed = Arc::new(AtomicBool::new(false)); + foreground + .spawn({ + let completed = completed.clone(); + async move { + receiver.await.ok(); + completed.store(true, Ordering::SeqCst); + } + }) + .detach(); + + dispatcher.run_until_idle(); + assert!(completed.load(Ordering::SeqCst)); + } + + #[test] + fn timers_fire_in_real_time() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher); + + let fired = Arc::new(AtomicBool::new(false)); + let timer = background.timer(Duration::from_millis(10)); + background + .spawn({ + let fired = fired.clone(); + async move { + timer.await; + fired.store(true, Ordering::SeqCst); + } + }) + .detach(); + + let deadline = Instant::now() + Duration::from_secs(10); + while !fired.load(Ordering::SeqCst) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(1)); + } + assert!(fired.load(Ordering::SeqCst)); + } + + #[test] + fn forget_pending_timers_prevents_stale_timers_from_firing() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher.clone()); + + let fired = Arc::new(AtomicBool::new(false)); + let timer = background.timer(Duration::from_millis(250)); + background + .spawn({ + let fired = fired.clone(); + async move { + timer.await; + fired.store(true, Ordering::SeqCst); + } + }) + .detach(); + + dispatcher.run_until_idle(); + dispatcher.forget_pending_timers(); + + thread::sleep(Duration::from_millis(400)); + dispatcher.run_until_idle(); + assert!(!fired.load(Ordering::SeqCst)); + } +} diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 2b5399ca9840e3..8894350edc5edb 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -6,6 +6,7 @@ use crate::{ WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, }; use collections::HashMap; +use gpui_util::ResultExt as _; use image::RgbaImage; use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; @@ -291,7 +292,14 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} - fn draw(&self, _scene: &Scene) {} + fn draw(&self, scene: &Scene) { + let scale_factor = self.scale_factor(); + let mut state = self.0.lock(); + let device_size: Size = state.bounds.size.to_device_pixels(scale_factor); + if let Some(renderer) = &mut state.renderer { + renderer.render_scene(scene, device_size).warn_on_err(); + } + } fn sprite_atlas(&self) -> sync::Arc { self.0.lock().sprite_atlas.clone() @@ -299,10 +307,10 @@ impl PlatformWindow for TestWindow { #[cfg(any(test, feature = "test-support"))] fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + let scale_factor = self.scale_factor(); 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 { diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index 0aa54fe1319d5a..c9e9e8c58784da 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -4,7 +4,6 @@ use std::{ cell::LazyCell, collections::{HashMap, VecDeque}, hash::{DefaultHasher, Hash, Hasher}, - hint::cold_path, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -19,25 +18,50 @@ pub(crate) use actions::{save_action_timing, update_running_action}; use serde::{Deserialize, Serialize}; -use crate::{SharedString, TasksIncluded}; +use crate::{SharedString, TasksIncluded, WindowId}; +#[cfg(feature = "profiler")] #[doc(hidden)] pub fn get_all_timings(included: gpui::TasksIncluded) -> Vec { let global_thread_timings = GLOBAL_THREAD_TIMINGS.lock(); ThreadTaskTimings::collect(&global_thread_timings, included) } +#[cfg(feature = "profiler")] #[doc(hidden)] pub fn get_current_thread_timings(included: TasksIncluded) -> gpui::ThreadTaskTimings { gpui::profiler::get_current_thread_task_timings(included) } +#[cfg(feature = "profiler")] #[doc(hidden)] pub fn take_all_stats(included: TasksIncluded) -> Vec { let global_timings = GLOBAL_THREAD_TIMINGS.lock(); ThreadTaskStatistics::collect_and_reset(&global_timings, included) } +#[cfg(not(feature = "profiler"))] +#[doc(hidden)] +pub fn get_all_timings(_included: gpui::TasksIncluded) -> Vec { + Vec::new() +} +#[cfg(not(feature = "profiler"))] +#[doc(hidden)] +pub fn get_current_thread_timings(_included: TasksIncluded) -> gpui::ThreadTaskTimings { + gpui::ThreadTaskTimings { + thread_name: None, + thread_id: std::thread::current().id(), + timings: Vec::new(), + stats: TaskStatistics::default(), + total_pushed: 0, + } +} +#[cfg(not(feature = "profiler"))] +#[doc(hidden)] +pub fn take_all_stats(_included: TasksIncluded) -> Vec { + Vec::new() +} + #[doc(hidden)] #[derive(Debug, Copy, Clone)] pub struct YieldTime(pub Instant); @@ -378,6 +402,7 @@ impl ProfilingCollector { // Allow 16MiB of task timing entries. // VecDeque grows by doubling its capacity when full, so keep this a power of 2 to avoid wasting // memory. +#[cfg(feature = "profiler")] const MAX_TASK_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); #[doc(hidden)] @@ -443,7 +468,7 @@ impl TaskStatistics { fn add_yield_timing(&mut self, task: TaskTiming) { let yielded_after = task.poll_duration(); if yielded_after >= self.poll_time_to_beat { - cold_path(); // most tasks are not the worst, optimize for that + std::hint::cold_path(); // most tasks are not the worst, optimize for that let to_replace = self .longest_poll_times .iter() @@ -464,7 +489,7 @@ impl TaskStatistics { fn add_runtime(&mut self, task: TaskTiming) { let runtime = task.since_spawn(); if runtime >= self.runtime_to_beat { - cold_path(); // most tasks are not the worst, optimize for that + std::hint::cold_path(); // most tasks are not the worst, optimize for that let to_replace = self .longest_runtimes .iter() @@ -530,6 +555,7 @@ impl ThreadTimings { } } + #[cfg(feature = "profiler")] pub fn update_running_task( &mut self, spawned: SpawnTime, @@ -542,7 +568,10 @@ impl ThreadTimings { start, }); } + #[cfg(not(feature = "profiler"))] + pub fn update_running_task(&mut self, _: SpawnTime, _: &'static std::panic::Location<'_>) {} + #[cfg(feature = "profiler")] pub fn save_task_timing(&mut self, ended: YieldTime) { let ActiveTiming { location, @@ -563,7 +592,7 @@ impl ThreadTimings { self.stats.add_runtime(timing); if trace_enabled() { - cold_path(); // optimize for when the profiling is off + std::hint::cold_path(); // optimize for when the profiling is off if self.timings.len() >= MAX_TASK_TIMINGS { self.timings.pop_front(); } @@ -571,6 +600,8 @@ impl ThreadTimings { self.total_pushed += 1; } } + #[cfg(not(feature = "profiler"))] + pub fn save_task_timing(&mut self, _: YieldTime) {} // Running tasks are included in the reliability trace, which is written // whenever the foreground executor makes no progress for > n seconds @@ -664,3 +695,128 @@ pub fn set_trace_enabled(enabled: bool) -> bool { pub fn trace_enabled() -> bool { PROFILER_ENABLED.load(Ordering::Relaxed) } + +/// Timing for a single drawn window frame. +#[derive(Debug, Copy, Clone)] +pub struct FrameTiming { + /// The window that was drawn. + pub window_id: WindowId, + /// When the frame first became dirty (its first invalidation). `None` if + /// frame tracing was not yet enabled when the invalidation occurred. + pub dirty_at: Option, + /// Number of invalidations coalesced into this frame. + pub invalidations: u64, + /// When `Window::draw` started. + pub draw_start: Instant, + /// When `Window::draw` finished. + pub draw_end: Instant, +} + +impl FrameTiming { + /// Time spent inside `Window::draw`. + pub fn draw_duration(&self) -> Duration { + self.draw_end.duration_since(self.draw_start) + } + + /// Time from the frame's first invalidation to the end of its draw, if the + /// first invalidation was observed. + pub fn dirty_to_draw_duration(&self) -> Option { + self.dirty_at + .map(|dirty_at| self.draw_end.duration_since(dirty_at)) + } +} + +// Allow 16MiB of frame timing entries. +const MAX_FRAME_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); + +struct FrameTimings { + timings: VecDeque, + total_pushed: u64, +} + +static FRAME_TIMINGS: spin::Mutex = spin::Mutex::new(FrameTimings { + timings: VecDeque::new(), + total_pushed: 0, +}); + +static FRAME_TRACE_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Enables or disables frame timing collection at runtime. +/// +/// When transitioning from enabled to disabled, the buffered frame timings are +/// cleared so stale data isn't reported after a later re-enable. Returns false +/// if the value was unchanged. +pub fn set_frame_trace_enabled(enabled: bool) -> bool { + if FRAME_TRACE_ENABLED.swap(enabled, Ordering::AcqRel) == enabled { + return false; + } + + if !enabled { + let mut frames = FRAME_TIMINGS.lock(); + frames.timings.clear(); + frames.timings.shrink_to_fit(); + frames.total_pushed = 0; + } + true +} + +/// Returns whether frame timing collection is enabled. +pub fn frame_trace_enabled() -> bool { + FRAME_TRACE_ENABLED.load(Ordering::Relaxed) +} + +/// Records the timing of a drawn window frame. +/// +/// No-op unless frame tracing is enabled via [`set_frame_trace_enabled`]. +pub fn record_frame_timing(timing: FrameTiming) { + if !frame_trace_enabled() { + return; + } + std::hint::cold_path(); // optimize for when profiling is off + + let mut frames = FRAME_TIMINGS.lock(); + if frames.timings.len() >= MAX_FRAME_TIMINGS { + frames.timings.pop_front(); + } + frames.timings.push_back(timing); + frames.total_pushed += 1; +} + +/// Drains frame timings recorded after this collector was created, tracking a +/// cursor so each call to [`Self::collect_unseen`] returns only new entries. +pub struct FrameTimingCollector { + cursor: u64, +} + +impl Default for FrameTimingCollector { + fn default() -> Self { + Self::new() + } +} + +impl FrameTimingCollector { + /// Creates a collector that only sees frames recorded from this point on. + pub fn new() -> Self { + Self { + cursor: FRAME_TIMINGS.lock().total_pushed, + } + } + + /// Returns frame timings recorded since the previous call (or since the + /// collector was created). If the ring buffer wrapped around since the + /// previous poll, the evicted entries are lost. + pub fn collect_unseen(&mut self) -> Vec { + let frames = FRAME_TIMINGS.lock(); + let buffer_len = frames.timings.len() as u64; + let buffer_start = frames.total_pushed.saturating_sub(buffer_len); + let skip = self.cursor.saturating_sub(buffer_start) as usize; + let unseen = frames + .timings + .iter() + .skip(skip.min(frames.timings.len())) + .copied() + .collect(); + self.cursor = frames.total_pushed; + unseen + } +} diff --git a/crates/gpui/src/profiler/actions.rs b/crates/gpui/src/profiler/actions.rs index a055fc21e35eaa..dcb68dfe15d5c3 100644 --- a/crates/gpui/src/profiler/actions.rs +++ b/crates/gpui/src/profiler/actions.rs @@ -1,7 +1,4 @@ -use std::{ - hint::cold_path, - time::{Duration, Instant}, -}; +use std::time::{Duration, Instant}; use itertools::Itertools; @@ -75,20 +72,31 @@ impl ActionStatistics { self.longest_runtimes.is_empty() } + #[cfg(feature = "profiler")] pub fn update_running_action(&mut self, action: &'static str, started: Instant) { self.running = Some((action, started)); } + #[cfg(not(feature = "profiler"))] + pub fn update_running_action(&mut self, _action: &'static str, _started: Instant) {} + #[cfg(feature = "profiler")] pub fn save_action_timing(&mut self) { let now = Instant::now(); - let (action, started) = self - .running - .take() - .expect("only called after `update_running_action`"); + + let Some((action, started)) = self.running.take() else { + // Actions are ran only on the foreground executor and therefore + // sequentially _except_ in tests where they can run concurrently. + // + // When ran sequentially self.running will always be Some. When ran + // concurrently that is no longer true. But that is fine, we do not + // need to track action timings in tests. + std::hint::cold_path(); + return; + }; let runtime = now.duration_since(started); if runtime >= self.runtime_to_beat { - cold_path(); // most actions are not the worst, optimize for that + std::hint::cold_path(); // most actions are not the worst, optimize for that if self.longest_runtimes.is_full() && let Some(to_replace) = self @@ -119,6 +127,8 @@ impl ActionStatistics { .expect("never empty"); } } + #[cfg(not(feature = "profiler"))] + pub fn save_action_timing(&mut self) {} pub fn longest_runtimes(&self, include_running: bool) -> impl Iterator { self.longest_runtimes.iter().copied().chain( @@ -167,10 +177,12 @@ impl ActionTiming { // The profiler is careful to never block when the lock is held, therefore a // spinlock is optimal. +#[cfg(feature = "profiler")] static ACTION_STATISTICS: spin::Mutex = const { spin::Mutex::new(ActionStatistics::new()) }; #[doc(hidden)] +#[cfg(feature = "profiler")] pub(crate) fn update_running_action(action: &(dyn Action + 'static), cx: &mut crate::App) { let now = Instant::now(); let action = action.type_id(); @@ -179,11 +191,27 @@ pub(crate) fn update_running_action(action: &(dyn Action + 'static), cx: &mut cr } #[doc(hidden)] +#[cfg(not(feature = "profiler"))] +pub(crate) fn update_running_action(_: &(dyn Action + 'static), _: &mut crate::App) {} + +#[doc(hidden)] +#[cfg(feature = "profiler")] pub(crate) fn save_action_timing() { ACTION_STATISTICS.lock().save_action_timing(); } #[doc(hidden)] +#[cfg(not(feature = "profiler"))] +pub(crate) fn save_action_timing() {} + +#[doc(hidden)] +#[cfg(feature = "profiler")] pub fn take_action_stats() -> ActionStatistics { ACTION_STATISTICS.lock().take() } + +#[doc(hidden)] +#[cfg(not(feature = "profiler"))] +pub fn take_action_stats() -> ActionStatistics { + ActionStatistics::default() +} diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs index 6e7cf2445e3d6d..f2890488159834 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -220,6 +220,11 @@ impl PriorityQueueReceiver { (sender, receiver) } + /// Returns whether the queue currently contains no elements. + pub fn is_empty(&self) -> bool { + self.state.queues.lock().is_empty() + } + /// Tries to pop one element from the priority queue without blocking. /// /// This will early return if there are no elements in the queue. diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 54e09bc37dd994..292b80634796ca 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -9,7 +9,7 @@ use crate::{ CornersRefinement, CursorStyle, DefiniteLength, DevicePixels, Edges, EdgesRefinement, Font, FontFallbacks, FontFeatures, FontStyle, FontWeight, GridLocation, Hsla, Length, Pixels, Point, PointRefinement, Rgba, SharedString, Size, SizeRefinement, Styled, TextRun, Window, black, phi, - point, quad, rems, size, + point, px, quad, rems, size, }; use collections::HashSet; use refineable::Refineable; @@ -355,6 +355,39 @@ pub struct BoxShadow { pub inset: bool, } +impl BoxShadow { + /// Creates a new [`BoxShadow`] with the given offset and color, matching the order + /// of the CSS `box-shadow` property. Use the builder methods to set blur radius, + /// spread radius, and inset. + pub fn new(offset_x: Pixels, offset_y: Pixels, color: Hsla) -> Self { + Self { + color, + offset: point(offset_x, offset_y), + blur_radius: px(0.), + spread_radius: px(0.), + inset: false, + } + } + + /// Sets the shadow blur radius. + pub fn blur_radius(mut self, blur_radius: Pixels) -> Self { + self.blur_radius = blur_radius; + self + } + + /// Sets the shadow spread radius. + pub fn spread_radius(mut self, spread_radius: Pixels) -> Self { + self.spread_radius = spread_radius; + self + } + + /// Marks the shadow as inset (drawn inside the element's bounds). + pub fn inset(mut self) -> Self { + self.inset = true; + self + } +} + /// How to handle whitespace in text #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum WhiteSpace { @@ -375,6 +408,10 @@ pub enum TextOverflow { /// displaying the provided string at the beginning (e.g., "…ong text here"). /// Typically more adequate for file paths where the end is more important than the beginning. TruncateStart(SharedString), + /// Truncate the text in the middle when it doesn't fit, preserving both the start and end + /// of the string (e.g., "long fi…name.rs"). Useful for filenames where both the prefix + /// and the extension are important context. + TruncateMiddle(SharedString), } /// How to align text within the element diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 3004e157e47642..901e64d5166c2a 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -99,6 +99,14 @@ pub trait Styled: Sized { self } + /// Sets the truncate overflowing text with an ellipsis (…) in the middle if needed. + /// Preserves the beginning and end of the text. Useful for filenames. + /// Note: This doesn't exist in Tailwind CSS. + fn text_ellipsis_middle(mut self) -> Self { + self.text_style().text_overflow = Some(TextOverflow::TruncateMiddle(ELLIPSIS)); + self + } + /// Sets the text overflow behavior of the element. fn text_overflow(mut self, overflow: TextOverflow) -> Self { self.text_style().text_overflow = Some(overflow); diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 043d37f679b4e8..f3dc95bc579a89 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -93,7 +93,7 @@ impl TextSystem { .map(|font| font.family.to_string()), ); names.push(".SystemUIFont".to_string()); - names.sort(); + names.sort_unstable(); names.dedup(); names } diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 3335e7b31d158a..dd6d2d987079dc 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -9,6 +9,8 @@ pub enum TruncateFrom { Start, /// Truncate text from the end. End, + /// Truncate text from the middle, preserving the start and end. + Middle, } /// The GPUI line wrapper, used to wrap lines of text to a given width. @@ -179,11 +181,69 @@ impl LineWrapper { } } } + TruncateFrom::Middle => {} } None } + fn should_truncate_line_middle( + &mut self, + line: &str, + truncate_width: Pixels, + truncation_affix: &str, + ) -> Option<(usize, usize)> { + let suffix_width = truncation_affix + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.0), |a, x| a + x); + + let total_width: Pixels = line + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.0), |a, x| a + x); + + if total_width <= truncate_width { + return None; + } + + let content_budget = truncate_width - suffix_width; + if content_budget <= px(0.) { + return Some((0, line.len())); + } + + let front_budget = content_budget * (2.0 / 3.0); + let back_budget = content_budget - front_budget; + + let mut front_width = px(0.); + let mut front_end_ix = 0usize; + for (ix, c) in line.char_indices() { + let char_width = self.width_for_char(c); + if front_width + char_width > front_budget { + break; + } + front_width += char_width; + front_end_ix = ix + c.len_utf8(); + } + + let mut back_width = px(0.); + let mut back_start_ix = line.len(); + for (ix, c) in line.char_indices().rev() { + let char_width = self.width_for_char(c); + if back_width + char_width > back_budget { + break; + } + back_width += char_width; + back_start_ix = ix; + } + + if front_end_ix >= back_start_ix { + return Some((0, line.len())); + } + + Some((front_end_ix, back_start_ix)) + } + /// Truncate a line of text to the given width with this wrapper's font and font size. pub fn truncate_line<'a>( &mut self, @@ -193,6 +253,28 @@ impl LineWrapper { runs: &'a [TextRun], truncate_from: TruncateFrom, ) -> (SharedString, Cow<'a, [TextRun]>) { + if truncate_from == TruncateFrom::Middle { + if let Some((front_end_ix, back_start_ix)) = + self.should_truncate_line_middle(&line, truncate_width, truncation_affix) + { + let result = SharedString::from(format!( + "{}{truncation_affix}{}", + &line[..front_end_ix], + &line[back_start_ix..] + )); + let mut runs = runs.to_vec(); + update_runs_after_middle_truncation( + truncation_affix, + &mut runs, + front_end_ix, + back_start_ix, + ); + return (result, Cow::Owned(runs)); + } else { + return (line, Cow::Borrowed(runs)); + } + } + if let Some(truncate_ix) = self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from) { @@ -206,6 +288,7 @@ impl LineWrapper { line[..truncate_ix] .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation()) )), + TruncateFrom::Middle => unreachable!("Middle truncation is handled above"), }; let mut runs = runs.to_vec(); update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); @@ -242,6 +325,9 @@ impl LineWrapper { truncate_from, ); } + if truncate_from == TruncateFrom::Middle { + return self.truncate_line(text, wrap_width, truncation_affix, runs, truncate_from); + } let affix_width: Pixels = truncation_affix .chars() @@ -448,7 +534,71 @@ fn update_runs_after_truncation( } } } + TruncateFrom::Middle => { + unreachable!("Middle truncation calls this function with TruncateFrom::End directly") + } + } +} + +fn update_runs_after_middle_truncation( + ellipsis: &str, + runs: &mut Vec, + front_end_ix: usize, + back_start_ix: usize, +) { + let original_runs = std::mem::take(runs); + let mut result_runs: Vec = Vec::with_capacity(original_runs.len()); + + // Front segment [0, front_end_ix) + ellipsis: walk forward until the run + // that straddles or ends at front_end_ix, then extend that run's length + // to include the ellipsis. + let mut front_remaining = front_end_ix; + let mut front_done = false; + for run in &original_runs { + if front_done { + break; + } + if run.len <= front_remaining { + result_runs.push(run.clone()); + front_remaining -= run.len; + } else { + let mut partial = run.clone(); + partial.len = front_remaining + ellipsis.len(); + result_runs.push(partial); + front_done = true; + } + } + if !front_done { + // front_end_ix landed exactly on a run boundary; append ellipsis to + // the last front run (or, if the front is empty, to the first back run). + if let Some(last) = result_runs.last_mut() { + last.len += ellipsis.len(); + } else if let Some(first) = original_runs.first() { + let mut affix_run = first.clone(); + affix_run.len = ellipsis.len(); + result_runs.push(affix_run); + } + } + + // Back segment [back_start_ix, original.len()): skip runs entirely in the + // removed middle, keep the rest. + let mut byte_pos = 0usize; + for run in &original_runs { + let run_end = byte_pos + run.len; + if run_end > back_start_ix { + if byte_pos < back_start_ix { + // Run straddles back_start_ix; keep only the tail. + let mut partial = run.clone(); + partial.len = run_end - back_start_ix; + result_runs.push(partial); + } else { + result_runs.push(run.clone()); + } + } + byte_pos = run_end; } + + *runs = result_runs; } /// A fragment of a line that can be wrapped. @@ -1275,6 +1425,81 @@ mod tests { ); } + #[test] + fn test_truncate_line_middle() { + let mut wrapper = build_wrapper(); + + // No truncation when text fits within a very wide budget. + let short_text = "hello world"; + let runs = generate_test_runs(&[short_text.len()]); + let (result, result_runs) = wrapper.truncate_line( + short_text.into(), + px(10000.), + "…", + &runs, + TruncateFrom::Middle, + ); + assert_eq!(result.as_ref(), short_text); + assert_eq!(result_runs.len(), 1); + assert_eq!(result_runs[0].len, short_text.len()); + + // Basic middle truncation: long string with px(100.) budget. + let long_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz"; + let runs = generate_test_runs(&[long_text.len()]); + let (result, _result_runs) = + wrapper.truncate_line(long_text.into(), px(100.), "…", &runs, TruncateFrom::Middle); + assert!( + result.contains('…'), + "Middle-truncated result should contain '…', got: '{}'", + result + ); + assert!( + result.chars().count() < long_text.chars().count(), + "Middle-truncated result should be shorter than original" + ); + assert_eq!( + result.chars().next(), + long_text.chars().next(), + "Result should start with the same first character as original" + ); + assert_eq!( + result.chars().last(), + long_text.chars().last(), + "Result should end with the same last character as original" + ); + + // Degenerate case: budget so narrow that middle truncation cannot find a valid split. + // Still show the truncation affix instead of returning the original overflowing text. + let text = "abcdef"; + let runs = generate_test_runs(&[text.len()]); + let (result, result_runs) = + wrapper.truncate_line(text.into(), px(1.), "…", &runs, TruncateFrom::Middle); + assert_eq!(result.as_ref(), "…"); + assert_eq!(result_runs.len(), 1); + assert_eq!(result_runs[0].len, "…".len()); + + // Run adjustment correctness: multiple runs across the string. + // Verify that the returned runs' lengths sum to result.len(). + let multi_run_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz"; + let run_lens = [20, 20, multi_run_text.len() - 40]; + let runs = generate_test_runs(&run_lens); + let (result, result_runs) = wrapper.truncate_line( + multi_run_text.into(), + px(100.), + "…", + &runs, + TruncateFrom::Middle, + ); + let total_run_len: usize = result_runs.iter().map(|r| r.len).sum(); + assert_eq!( + total_run_len, + result.len(), + "Sum of run lengths ({}) should equal result byte length ({})", + total_run_len, + result.len() + ); + } + #[test] fn test_multiline_truncation_trailing_newline() { let mut wrapper = build_wrapper(); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index c3a84c0b727967..d5fce7a0aafb81 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -20,6 +20,7 @@ use crate::{ WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, profiler, px, rems, size, transparent_black, }; + use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; #[cfg(target_os = "macos")] @@ -116,6 +117,17 @@ struct WindowInvalidatorInner { pub draw_phase: DrawPhase, pub dirty_views: FxHashSet, pub update_count: usize, + pub frame_dirty: FrameDirtyAccumulator, +} + +/// Per-frame invalidation bookkeeping, drained at draw time and emitted to the +/// frame profiler. Tracks when the current frame first became dirty and how +/// many invalidations were coalesced into it. Only populated while +/// `profiler::frame_trace_enabled()` is set. +#[derive(Default)] +struct FrameDirtyAccumulator { + dirty_at: Option, + invalidations: u64, } #[derive(Clone)] @@ -131,6 +143,7 @@ impl WindowInvalidator { draw_phase: DrawPhase::None, dirty_views: FxHashSet::default(), update_count: 0, + frame_dirty: FrameDirtyAccumulator::default(), })), } } @@ -140,6 +153,7 @@ impl WindowInvalidator { inner.update_count += 1; inner.dirty_views.insert(entity); if inner.draw_phase == DrawPhase::None { + Self::record_frame_dirty(&mut inner); inner.dirty = true; cx.push_effect(Effect::Notify { emitter: entity }); true @@ -157,6 +171,7 @@ impl WindowInvalidator { inner.dirty = dirty; if dirty { inner.update_count += 1; + Self::record_frame_dirty(&mut inner); } } @@ -168,6 +183,17 @@ impl WindowInvalidator { self.inner.borrow().update_count } + fn record_frame_dirty(inner: &mut WindowInvalidatorInner) { + if profiler::frame_trace_enabled() { + inner.frame_dirty.dirty_at.get_or_insert_with(Instant::now); + inner.frame_dirty.invalidations += 1; + } + } + + fn take_frame_dirty(&self) -> FrameDirtyAccumulator { + mem::take(&mut self.inner.borrow_mut().frame_dirty) + } + pub fn take_views(&self) -> FxHashSet { mem::take(&mut self.inner.borrow_mut().dirty_views) } @@ -2575,6 +2601,11 @@ impl Window { /// the contents of the new [`Scene`], use [`Self::present`]. #[profiling::function] pub fn draw(&mut self, cx: &mut App) -> ArenaClearNeeded { + // Drain unconditionally so a stale first-invalidation timestamp can't + // leak into a later frame across enable/disable of frame tracing. + let frame_dirty = self.invalidator.take_frame_dirty(); + let draw_started_at = profiler::frame_trace_enabled().then(Instant::now); + // Set up the per-App arena for element allocation during this draw. // This ensures that multiple test Apps have isolated arenas. let _arena_scope = ElementArenaScope::enter(&cx.element_arena); @@ -2668,6 +2699,16 @@ impl Window { self.invalidator.set_phase(DrawPhase::None); self.needs_present.set(true); + if let Some(draw_start) = draw_started_at { + profiler::record_frame_timing(profiler::FrameTiming { + window_id: self.handle.window_id(), + dirty_at: frame_dirty.dirty_at, + invalidations: frame_dirty.invalidations, + draw_start, + draw_end: Instant::now(), + }); + } + ArenaClearNeeded::new(&cx.element_arena) } @@ -2702,6 +2743,18 @@ impl Window { profiling::finish_frame!(); } + /// Presents the most recently drawn frame if it hasn't been presented yet. + /// + /// Benchmarks drive drawing synchronously rather than through a platform + /// frame-request loop, so they call this after each measured update to + /// submit the frame like production presentation would. + #[cfg(feature = "bench")] + pub fn present_if_needed(&mut self) { + if self.needs_present.get() { + self.present(); + } + } + /// Returns a snapshot of the current input-latency histograms. #[cfg(feature = "input-latency-histogram")] pub fn input_latency_snapshot(&self) -> InputLatencySnapshot { diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 343600cd862f45..be41f241935c3f 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -9,7 +9,7 @@ use std::{ ffi::OsString, fs::File, io::Read as _, - os::fd::{AsFd, FromRawFd, IntoRawFd}, + os::fd::{AsFd, AsRawFd}, time::Duration, }; @@ -752,11 +752,43 @@ pub(super) fn get_xkb_compose_state(cx: &xkb::Context) -> Option Result> { - let mut file = unsafe { File::from_raw_fd(fd.into_raw_fd()) }; +pub(super) const PIPE_READ_TIMEOUT: Duration = Duration::from_secs(4); + +#[cfg(any(feature = "wayland", feature = "x11"))] +pub(super) fn read_fd_with_timeout( + mut fd: filedescriptor::FileDescriptor, + timeout: Duration, +) -> Result> { + fd.set_non_blocking(true)?; let mut buffer = Vec::new(); - file.read_to_end(&mut buffer)?; - Ok(buffer) + let mut chunk = [0u8; 8192]; + loop { + let mut poll_fds = [filedescriptor::pollfd { + fd: fd.as_raw_fd(), + events: filedescriptor::POLLIN, + revents: 0, + }]; + let ready = match filedescriptor::poll(&mut poll_fds, Some(timeout)) { + Ok(ready) => ready, + Err(filedescriptor::Error::Poll(err)) + if err.kind() == std::io::ErrorKind::Interrupted => + { + continue; + } + Err(err) => return Err(err.into()), + }; + if ready == 0 { + anyhow::bail!("timed out waiting for data on pipe after {timeout:?}"); + } + match fd.read(&mut chunk) { + Ok(0) => return Ok(buffer), + Ok(len) => buffer.extend_from_slice(&chunk[..len]), + Err(err) + if err.kind() == std::io::ErrorKind::WouldBlock + || err.kind() == std::io::ErrorKind::Interrupted => {} + Err(err) => return Err(err.into()), + } + } } #[cfg(any(feature = "wayland", feature = "x11"))] @@ -1126,4 +1158,100 @@ mod tests { Point::new(px(5.0), px(5.1)) ),); } + + #[cfg(any(feature = "wayland", feature = "x11"))] + mod read_fd_with_timeout { + use super::super::{PIPE_READ_TIMEOUT, read_fd_with_timeout}; + use std::io::Write as _; + use std::time::{Duration, Instant}; + + #[test] + fn reads_data_written_before_close() { + let mut pipe = filedescriptor::Pipe::new().unwrap(); + pipe.write.write_all(b"hello clipboard").unwrap(); + drop(pipe.write); + + let bytes = read_fd_with_timeout(pipe.read, PIPE_READ_TIMEOUT).unwrap(); + assert_eq!(bytes, b"hello clipboard"); + } + + #[test] + fn returns_empty_when_writer_closes_without_writing() { + let pipe = filedescriptor::Pipe::new().unwrap(); + drop(pipe.write); + + let bytes = read_fd_with_timeout(pipe.read, PIPE_READ_TIMEOUT).unwrap(); + assert!(bytes.is_empty()); + } + + #[test] + fn times_out_when_writer_never_writes() { + let pipe = filedescriptor::Pipe::new().unwrap(); + let _open_writer = pipe.write; + + let timeout = Duration::from_millis(50); + let started = Instant::now(); + let result = read_fd_with_timeout(pipe.read, timeout); + let elapsed = started.elapsed(); + + let err = result.unwrap_err(); + assert!( + err.to_string().contains("timed out"), + "unexpected error: {err}" + ); + assert!(elapsed >= timeout, "returned before the timeout elapsed"); + } + + #[test] + fn times_out_when_writer_stalls_after_partial_write() { + let mut pipe = filedescriptor::Pipe::new().unwrap(); + pipe.write.write_all(b"partial").unwrap(); + let _open_writer = pipe.write; + + let err = read_fd_with_timeout(pipe.read, Duration::from_millis(50)).unwrap_err(); + assert!( + err.to_string().contains("timed out"), + "unexpected error: {err}" + ); + } + + #[test] + fn slow_writer_resets_deadline_between_chunks() { + let pipe = filedescriptor::Pipe::new().unwrap(); + let chunks = 12; + let gap = Duration::from_millis(40); + let timeout = Duration::from_millis(400); + + let writer = std::thread::spawn({ + let mut write = pipe.write; + move || { + for _ in 0..chunks { + std::thread::sleep(gap); + write.write_all(&[b'x'; 1000]).unwrap(); + } + } + }); + // The total transfer (~480ms) exceeds the timeout; this only + // passes because the timeout is re-armed per chunk. + let bytes = read_fd_with_timeout(pipe.read, timeout).unwrap(); + writer.join().unwrap(); + assert_eq!(bytes, vec![b'x'; 1000 * chunks]); + } + + #[test] + fn reads_payload_larger_than_pipe_capacity() { + let pipe = filedescriptor::Pipe::new().unwrap(); + // Exceeds the 64 KiB pipe capacity, forcing the writer to block. + let payload = vec![b'z'; 1024 * 1024]; + + let writer = std::thread::spawn({ + let mut write = pipe.write; + let payload = payload.clone(); + move || write.write_all(&payload).unwrap() + }); + let bytes = read_fd_with_timeout(pipe.read, PIPE_READ_TIMEOUT).unwrap(); + writer.join().unwrap(); + assert_eq!(bytes, payload); + } + } } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index fc3697a8bb3256..5decbc34e6f8fc 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -78,10 +78,10 @@ use super::{ }; use crate::linux::{ - DOUBLE_CLICK_INTERVAL, LinuxClient, LinuxCommon, LinuxKeyboardLayout, SCROLL_LINES, - capslock_from_xkb, cursor_style_to_icon_names, get_xkb_compose_state, is_within_click_distance, - keystroke_from_xkb, keystroke_underlying_dead_key, modifiers_from_xkb, open_uri_internal, - read_fd, reveal_path_internal, + DOUBLE_CLICK_INTERVAL, LinuxClient, LinuxCommon, LinuxKeyboardLayout, PIPE_READ_TIMEOUT, + SCROLL_LINES, capslock_from_xkb, cursor_style_to_icon_names, get_xkb_compose_state, + is_within_click_distance, keystroke_from_xkb, keystroke_underlying_dead_key, + modifiers_from_xkb, open_uri_internal, read_fd_with_timeout, reveal_path_internal, wayland::{ clipboard::{Clipboard, DataOffer, FILE_LIST_MIME_TYPE, TEXT_MIME_TYPES}, cursor::Cursor, @@ -264,6 +264,7 @@ pub(crate) struct WaylandClientState { pending_activation: Option, event_loop: Option>, pub common: LinuxCommon, + ime_enabled: Option, } pub struct DragState { @@ -319,6 +320,7 @@ impl WaylandClientStatePtr { pub fn enable_ime(&self) { let client = self.get_client(); let mut state = client.borrow_mut(); + state.ime_enabled = Some(true); let Some(text_input) = state.text_input.take() else { return; }; @@ -344,6 +346,7 @@ impl WaylandClientStatePtr { pub fn disable_ime(&self) { let client = self.get_client(); let mut state = client.borrow_mut(); + state.ime_enabled = Some(false); state.composing = false; if let Some(text_input) = &state.text_input { text_input.disable(); @@ -351,6 +354,11 @@ impl WaylandClientStatePtr { } } + pub fn ime_enabled(&self) -> Option { + let client = self.get_client(); + client.borrow().ime_enabled + } + pub fn update_ime_position(&self, bounds: Bounds) { let client = self.get_client(); let state = client.borrow_mut(); @@ -723,6 +731,7 @@ impl WaylandClient { cursor, pending_activation: None, event_loop: Some(event_loop), + ime_enabled: None, })); WaylandSource::new(conn, event_queue) @@ -2277,7 +2286,7 @@ impl Dispatch for WaylandClientStatePtr { drop(pipe.write); let read_task = state.common.background_executor.spawn(async { - let buffer = unsafe { read_fd(fd)? }; + let buffer = read_fd_with_timeout(fd, PIPE_READ_TIMEOUT)?; let text = String::from_utf8(buffer)?; anyhow::Ok(text) }); diff --git a/crates/gpui_linux/src/linux/wayland/clipboard.rs b/crates/gpui_linux/src/linux/wayland/clipboard.rs index 49c58724dadc3b..dfedf53f6dfa7a 100644 --- a/crates/gpui_linux/src/linux/wayland/clipboard.rs +++ b/crates/gpui_linux/src/linux/wayland/clipboard.rs @@ -10,7 +10,10 @@ use strum::IntoEnumIterator; use wayland_client::{Connection, protocol::wl_data_offer::WlDataOffer}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1; -use crate::linux::{WaylandClientStatePtr, platform::read_fd}; +use crate::linux::{ + WaylandClientStatePtr, + platform::{PIPE_READ_TIMEOUT, read_fd_with_timeout}, +}; use gpui::{ClipboardEntry, ClipboardItem, Image, ImageFormat, hash}; /// Text mime types that we'll offer to other programs. @@ -86,7 +89,7 @@ impl DataOffer { connection.flush().unwrap(); - match unsafe { read_fd(fd) } { + match read_fd_with_timeout(fd, PIPE_READ_TIMEOUT) { Ok(bytes) => Some(bytes), Err(err) => { log::error!("error reading clipboard pipe: {err:?}"); diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index d8cf35686eb812..33b103e5fb246a 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -598,6 +598,30 @@ impl WaylandWindowStatePtr { force_render, ..Default::default() }); + self.update_ime_enabled(); + } + } + + fn update_ime_enabled(&self) { + let mut state = self.state.borrow_mut(); + if !state.active { + return; + } + let client = state.client.clone(); + let ime_enabled = state + .input_handler + .as_mut() + .map(|input_handler| input_handler.query_accepts_text_input()) + .unwrap_or(true); + drop(state); + if Some(ime_enabled) == client.ime_enabled() { + return; + } + + if ime_enabled { + client.enable_ime(); + } else { + client.disable_ime(); } } @@ -945,9 +969,7 @@ impl WaylandWindowStatePtr { let mut bounds: Option> = None; if let Some(mut input_handler) = state.input_handler.take() { drop(state); - if let Some(selection) = input_handler.marked_text_range() { - bounds = input_handler.bounds_for_range(selection.start..selection.start); - } + bounds = input_handler.ime_candidate_bounds(); self.state.borrow_mut().input_handler = Some(input_handler); } bounds diff --git a/crates/gpui_macos/src/metal_atlas.rs b/crates/gpui_macos/src/metal_atlas.rs index 5bedf9df8cd6f1..ce5664d778a82f 100644 --- a/crates/gpui_macos/src/metal_atlas.rs +++ b/crates/gpui_macos/src/metal_atlas.rs @@ -61,9 +61,10 @@ impl PlatformAtlas for MetalAtlas { fn remove(&self, key: &AtlasKey) { let mut lock = self.0.lock(); - let Some(id) = lock.tiles_by_key.remove(key).map(|v| v.texture_id) else { + let Some(tile) = lock.tiles_by_key.remove(key) else { return; }; + let id = tile.texture_id; let textures = match id.kind { AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, @@ -80,6 +81,7 @@ impl PlatformAtlas for MetalAtlas { }; if let Some(mut texture) = texture_slot.take() { + texture.allocator.deallocate(tile.tile_id.into()); texture.decrement_ref_count(); if texture.is_unreferenced() { textures.free_list.push(id.index as usize); @@ -338,6 +340,34 @@ mod tests { let _texture = atlas.metal_texture(tile_a2.texture_id); } + #[test] + fn test_remove_deallocates_tile_space_for_reuse() { + let Some(atlas) = create_atlas() else { + return; + }; + + let small = Size { + width: DevicePixels(64), + height: DevicePixels(64), + }; + let big = Size { + width: DevicePixels(700), + height: DevicePixels(700), + }; + + let keeper_key = make_image_key(1, 0); + let big_key_a = make_image_key(2, 0); + let big_key_b = make_image_key(3, 0); + + let keeper_tile = insert_tile(&atlas, &keeper_key, small); + let tile_a = insert_tile(&atlas, &big_key_a, big); + assert_eq!(keeper_tile.texture_id, tile_a.texture_id); + + atlas.remove(&big_key_a); + let tile_b = insert_tile(&atlas, &big_key_b, big); + assert_eq!(tile_b.texture_id, keeper_tile.texture_id); + } + #[test] fn test_remove_nonexistent_key_is_noop() { let Some(atlas) = create_atlas() else { diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 73b53ce6ea5d7e..5a72e23e140715 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -133,6 +133,10 @@ pub(crate) struct MetalRenderer { path_intermediate_texture: Option, path_intermediate_msaa_texture: Option, path_sample_count: u32, + /// Offscreen render target reused across `render_scene` calls when + /// rendering headlessly without reading pixels back. + #[cfg(any(test, feature = "test-support"))] + headless_render_target: Option, } #[repr(C)] @@ -347,6 +351,8 @@ impl MetalRenderer { path_intermediate_texture: None, path_intermediate_msaa_texture: None, path_sample_count: PATH_SAMPLE_COUNT, + #[cfg(any(test, feature = "test-support"))] + headless_render_target: None, } } @@ -729,6 +735,86 @@ impl MetalRenderer { } } + /// Renders a scene to a reused offscreen texture without reading pixels + /// back or blocking on GPU completion. + /// + /// This mirrors the CPU cost of presenting a frame to a window (scene + /// encoding, instance buffer writes, command submission) and is used by + /// headless benchmark rendering, where the produced pixels are never + /// inspected. + #[cfg(any(test, feature = "test-support"))] + pub fn render_scene(&mut self, scene: &Scene, size: Size) -> Result<()> { + if size.width.0 <= 0 || size.height.0 <= 0 { + anyhow::bail!("Invalid size for render_scene: {:?}", size); + } + + self.update_path_intermediate_textures(size); + + let needs_new_target = self.headless_render_target.as_ref().is_none_or(|texture| { + texture.width() != size.width.0 as u64 || texture.height() != size.height.0 as u64 + }); + if needs_new_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::Private); + self.headless_render_target = Some(self.device.new_texture(&texture_descriptor)); + } + let target_texture = self + .headless_render_target + .clone() + .expect("just ensured the render target exists"); + + 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); + + // Commit without waiting, mirroring presentation to a real + // window where the CPU doesn't block on the GPU. + command_buffer.commit(); + return Ok(()); + } + 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, @@ -1703,6 +1789,10 @@ impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer { self.renderer.render_scene_to_image(scene, size) } + fn render_scene(&mut self, scene: &Scene, size: Size) -> anyhow::Result<()> { + self.renderer.render_scene(scene, size) + } + fn sprite_atlas(&self) -> Arc { self.renderer.sprite_atlas().clone() } diff --git a/crates/gpui_macos/src/open_type.rs b/crates/gpui_macos/src/open_type.rs index 048ba13dd133d1..44891910380a2f 100644 --- a/crates/gpui_macos/src/open_type.rs +++ b/crates/gpui_macos/src/open_type.rs @@ -15,6 +15,10 @@ use core_foundation::{ }; use core_foundation_sys::locale::CFLocaleCopyPreferredLanguages; use core_graphics::{display::CFDictionary, geometry::CGAffineTransform}; +use core_text::font_descriptor::{ + TraitAccessors, kCTFontFamilyNameAttribute, kCTFontItalicTrait, kCTFontSlantTrait, + kCTFontTraitsAttribute, kCTFontWeightTrait, kCTFontWidthTrait, +}; use core_text::{ font::{CTFont, CTFontRef, cascade_list_for_languages}, font_descriptor::{ @@ -39,10 +43,7 @@ pub fn apply_features_and_fallbacks( && !fallbacks.fallback_list().is_empty() { keys.push(kCTFontCascadeListAttribute); - values.push(generate_fallback_array( - fallbacks, - font.native_font().as_concrete_TypeRef(), - )); + values.push(generate_fallback_array(fallbacks, font)); } let attrs = CFDictionaryCreate( kCFAllocatorDefault, @@ -98,16 +99,54 @@ fn generate_feature_array(features: &FontFeatures) -> CFMutableArrayRef { } } -fn generate_fallback_array(fallbacks: &FontFallbacks, font_ref: CTFontRef) -> CFMutableArrayRef { +fn generate_fallback_array(fallbacks: &FontFallbacks, font: &mut FontKitFont) -> CFMutableArrayRef { unsafe { + let symbolic_traits = font.native_font().symbolic_traits(); + let all_traits = font.native_font().all_traits(); + let fallback_array = CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks); for user_fallback in fallbacks.fallback_list() { let name = CFString::from(user_fallback.as_str()); - let fallback_desc = - CTFontDescriptorCreateWithNameAndSize(name.as_concrete_TypeRef(), 0.0); + + let traits_keys = [kCTFontWeightTrait, kCTFontSlantTrait]; + let weight_value = CFNumber::from(all_traits.normalized_weight()); + let slant_value = CFNumber::from(if (symbolic_traits & kCTFontItalicTrait) != 0 { + 1.0 + } else { + 0.0 + }); + let traits_values = [weight_value.as_CFTypeRef(), slant_value.as_CFTypeRef()]; + let traits = CFDictionaryCreate( + kCFAllocatorDefault, + &traits_keys as *const _ as _, + &traits_values as *const _ as _, + traits_keys.len() as isize, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks, + ); + drop(weight_value); + drop(slant_value); + + let attr_keys = [kCTFontFamilyNameAttribute, kCTFontTraitsAttribute]; + let attr_values = [name.as_CFTypeRef(), traits as _]; + let attrs = CFDictionaryCreate( + kCFAllocatorDefault, + &attr_keys as *const _ as _, + &attr_values as *const _ as _, + attr_keys.len() as isize, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks, + ); + CFRelease(traits as _); + + let fallback_desc = CTFontDescriptorCreateWithAttributes(attrs); + CFRelease(attrs as _); + CFArrayAppendValue(fallback_array, fallback_desc as _); CFRelease(fallback_desc as _); } + + let font_ref = font.native_font().as_concrete_TypeRef(); append_system_fallbacks(fallback_array, font_ref); fallback_array } @@ -125,12 +164,12 @@ fn append_system_fallbacks(fallback_array: CFMutableArrayRef, font_ref: CTFontRe let default_fallbacks: CFArray = CFArray::wrap_under_create_rule(default_fallbacks); - default_fallbacks + for desc in default_fallbacks .iter() .filter(|desc| desc.font_path().is_some()) - .map(|desc| { - CFArrayAppendValue(fallback_array, desc.as_concrete_TypeRef() as _); - }); + { + CFArrayAppendValue(fallback_array, desc.as_concrete_TypeRef() as _); + } } } diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 7ecfccfa1cc9cc..51adfe50b321b7 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -288,6 +288,15 @@ unsafe fn build_classes() { sel!(characterIndexForPoint:), character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> u64, ); + + // Undocumented SPI, also implemented by Chromium's content view. This + // lets full size content windows mark our Metal view as app owned title + // bar content, avoiding AppKit's title bar click-delay behavior. + decl.add_method( + sel!(_opaqueRectForWindowMoveWhenInTitlebar), + opaque_rect_for_window_move_when_in_titlebar + as extern "C" fn(&Object, Sel) -> NSRect, + ); decl.register() }; BLURRED_VIEW_CLASS = { @@ -321,17 +330,15 @@ pub(crate) fn convert_mouse_position(position: NSPoint, window_height: Pixels) - /// thread because it reads the active AppKit window and updates GPUI window state associated /// with Objective-C objects. pub(crate) unsafe fn set_active_window_cursor_style(style: CursorStyle) { - // SAFETY: The caller guarantees AppKit main-thread access. The class check ensures the + // SAFETY: The caller guarantees AppKit main-thread access. `is_gpui_window` ensures the // window has our WINDOW_STATE_IVAR before reading it. unsafe { let app = NSApplication::sharedApplication(nil); let key_window: id = msg_send![app, keyWindow]; let main_window: id = msg_send![app, mainWindow]; - let active_window = if !key_window.is_null() - && msg_send![key_window, isKindOfClass: WINDOW_CLASS] - { + let active_window = if !key_window.is_null() && is_gpui_window(key_window) { Some(key_window) - } else if !main_window.is_null() && msg_send![main_window, isKindOfClass: WINDOW_CLASS] { + } else if !main_window.is_null() && is_gpui_window(main_window) { Some(main_window) } else { None @@ -1828,6 +1835,14 @@ fn get_scale_factor(native_window: id) -> f32 { if factor == 0.0 { 2. } else { factor } } +/// Returns whether `window` is one of GPUI's managed windows. +unsafe fn is_gpui_window(window: id) -> bool { + unsafe { + msg_send![window, isKindOfClass: WINDOW_CLASS] + || msg_send![window, isKindOfClass: PANEL_CLASS] + } +} + unsafe fn get_window_state(object: &Object) -> Arc> { unsafe { let raw: *mut c_void = *object.get_ivar(WINDOW_STATE_IVAR); @@ -2747,6 +2762,25 @@ extern "C" fn accepts_first_mouse(this: &Object, _: Sel, _: id) -> BOOL { YES } +extern "C" fn opaque_rect_for_window_move_when_in_titlebar(this: &Object, _: Sel) -> NSRect { + unsafe { + let window: id = msg_send![this, window]; + if window == nil { + return NSRect::new(NSPoint::new(0., 0.), NSSize::new(0., 0.)); + } + + let style_mask: NSWindowStyleMask = msg_send![window, styleMask]; + if style_mask.contains(NSWindowStyleMask::NSFullSizeContentViewWindowMask) { + // Declare the entire view as opaque content for window move purposes + // when using a custom titlebar, so AppKit doesn't wait for double click + // disambiguation before delivering clicks to titlebar controls. + msg_send![this, bounds] + } else { + NSRect::new(NSPoint::new(0., 0.), NSSize::new(0., 0.)) + } + } +} + extern "C" fn character_index_for_point(this: &Object, _: Sel, position: NSPoint) -> u64 { let position = screen_point_to_gpui_point(this, position); with_input_handler(this, |input_handler| { diff --git a/crates/gpui_macros/src/bench.rs b/crates/gpui_macros/src/bench.rs index 7d7b2ad89399a2..d5fad465e4bd07 100644 --- a/crates/gpui_macros/src/bench.rs +++ b/crates/gpui_macros/src/bench.rs @@ -1,15 +1,35 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{ItemFn, spanned::Spanned}; +use syn::{ItemFn, parse::Parser, spanned::Spanned}; pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { + let mut fps: Option = None; if !args.is_empty() { - return error_to_stream(syn::Error::new( - proc_macro2::TokenStream::from(args).span(), - "#[gpui::bench] does not accept arguments yet", - )); + let parser = syn::meta::parser(|meta| { + if meta.path.is_ident("fps") { + let value: syn::LitInt = meta.value()?.parse()?; + let value = value.base10_parse::()?; + if value == 0 { + return Err(meta.error("#[gpui::bench] `fps` must be greater than zero")); + } + fps = Some(value); + Ok(()) + } else { + Err(meta.error("#[gpui::bench] only accepts `fps = N`")) + } + }); + if let Err(error) = parser.parse(args) { + return error_to_stream(error); + } } + // The frame budget math lives in `BenchReport` so `bench_context` is the + // single source of truth; `default()` supplies the default frame rate. + let report_expr = match fps { + Some(fps) => quote! { gpui::BenchReport::with_fps(#fps) }, + None => quote! { gpui::BenchReport::default() }, + }; + let mut inner_fn = match syn::parse::(function) { Ok(function) => function, Err(error) => return error_to_stream(error), @@ -30,11 +50,23 @@ pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { #inner_fn fn #outer_fn_name(criterion: &mut criterion::Criterion) { - criterion.bench_function(stringify!(#outer_fn_name), |bencher| { - let mut cx = gpui::BenchAppContext::new(Some(stringify!(#outer_fn_name))); - #inner_fn_name(bencher, &mut cx); - cx.teardown(); + let report = #report_expr; + criterion.bench_function(stringify!(#outer_fn_name), { + let report = report.clone(); + move |bencher| { + let mut cx = gpui::BenchAppContext::new_with_platform_and_report( + gpui::bench_platform(Some(Box::new(|| { + gpui_platform::current_headless_renderer() + }))), + Some(stringify!(#outer_fn_name)), + bencher, + report.clone(), + ); + #inner_fn_name(&mut cx); + cx.teardown(); + } }); + report.print(Some(stringify!(#outer_fn_name))); } }) diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index 1fe3fa97eaedba..41ad72c67ab34f 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -191,6 +191,10 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { } /// `#[gpui::bench]` annotates a Criterion benchmark that runs with GPUI support. +/// +/// The benchmark crate must add `criterion` and `gpui_platform` (with its +/// `test-support` feature) to its dev-dependencies and enable gpui's `bench` +/// feature, since the generated code references all three. #[proc_macro_attribute] pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { bench::bench(args, function) diff --git a/crates/gpui_macros/src/styles.rs b/crates/gpui_macros/src/styles.rs index 6a0095a6c798be..4a1bb5c98a3efb 100644 --- a/crates/gpui_macros/src/styles.rs +++ b/crates/gpui_macros/src/styles.rs @@ -402,56 +402,36 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_2xs(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; - self.style().box_shadow = Some(vec![BoxShadow { - color: hsla(0., 0., 0., 0.05), - offset: point(px(0.), px(1.)), - blur_radius: px(0.), - spread_radius: px(0.), - inset: false, - }]); + self.style().box_shadow = Some(vec![ + BoxShadow::new(px(0.), px(1.), hsla(0., 0., 0., 0.05)) + ]); self } /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_xs(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; - self.style().box_shadow = Some(vec![BoxShadow { - color: hsla(0., 0., 0., 0.05), - offset: point(px(0.), px(1.)), - blur_radius: px(2.), - spread_radius: px(0.), - inset: false, - }]); + self.style().box_shadow = Some(vec![ + BoxShadow::new(px(0.), px(1.), hsla(0., 0., 0., 0.05)).blur_radius(px(2.)) + ]); self } /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_sm(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; self.style().box_shadow = Some(vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(1.)), - blur_radius: px(3.), - spread_radius: px(0.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(1.)), - blur_radius: px(2.), - spread_radius: px(-1.), - inset: false, - } + BoxShadow::new(px(0.), px(1.), hsla(0., 0., 0., 0.1)).blur_radius(px(3.)), + BoxShadow::new(px(0.), px(1.), hsla(0., 0., 0., 0.1)).blur_radius(px(2.)).spread_radius(px(-1.)), ]); self } @@ -459,24 +439,12 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_md(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; self.style().box_shadow = Some(vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(4.)), - blur_radius: px(6.), - spread_radius: px(-1.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(2.)), - blur_radius: px(4.), - spread_radius: px(-2.), - inset: false, - } + BoxShadow::new(px(0.), px(4.), hsla(0., 0., 0., 0.1)).blur_radius(px(6.)).spread_radius(px(-1.)), + BoxShadow::new(px(0.), px(2.), hsla(0., 0., 0., 0.1)).blur_radius(px(4.)).spread_radius(px(-2.)), ]); self } @@ -484,24 +452,12 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_lg(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; self.style().box_shadow = Some(vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(10.)), - blur_radius: px(15.), - spread_radius: px(-3.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(4.)), - blur_radius: px(6.), - spread_radius: px(-4.), - inset: false, - } + BoxShadow::new(px(0.), px(10.), hsla(0., 0., 0., 0.1)).blur_radius(px(15.)).spread_radius(px(-3.)), + BoxShadow::new(px(0.), px(4.), hsla(0., 0., 0., 0.1)).blur_radius(px(6.)).spread_radius(px(-4.)), ]); self } @@ -509,24 +465,12 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_xl(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; self.style().box_shadow = Some(vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(20.)), - blur_radius: px(25.), - spread_radius: px(-5.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(8.)), - blur_radius: px(10.), - spread_radius: px(-6.), - inset: false, - } + BoxShadow::new(px(0.), px(20.), hsla(0., 0., 0., 0.1)).blur_radius(px(25.)).spread_radius(px(-5.)), + BoxShadow::new(px(0.), px(8.), hsla(0., 0., 0., 0.1)).blur_radius(px(10.)).spread_radius(px(-6.)), ]); self } @@ -534,16 +478,12 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_2xl(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; - self.style().box_shadow = Some(vec![BoxShadow { - color: hsla(0., 0., 0., 0.25), - offset: point(px(0.), px(25.)), - blur_radius: px(50.), - spread_radius: px(-12.), - inset: false, - }]); + self.style().box_shadow = Some(vec![ + BoxShadow::new(px(0.), px(25.), hsla(0., 0., 0., 0.25)).blur_radius(px(50.)).spread_radius(px(-12.)) + ]); self } }; diff --git a/crates/gpui_util/src/lib.rs b/crates/gpui_util/src/lib.rs index eac1e2559a2107..ee210cb74870ec 100644 --- a/crates/gpui_util/src/lib.rs +++ b/crates/gpui_util/src/lib.rs @@ -391,3 +391,58 @@ impl Drop for Deferred { pub fn defer(f: F) -> Deferred { Deferred(Some(f)) } + +#[derive(Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct TypeIdHashBuilder; + +impl std::hash::BuildHasher for TypeIdHashBuilder { + type Hasher = TypeIdHasher; + + fn build_hasher(&self) -> Self::Hasher { + TypeIdHasher::default() + } +} + +#[derive(Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct TypeIdHasher { + value: u64, +} + +impl std::hash::Hasher for TypeIdHasher { + #[inline] + fn write(&mut self, bytes: &[u8]) { + // TypeId should only hash its first 8 bytes + if let Some(bytes) = bytes.get(..8) { + bytes + .as_array() + .map(|&array| self.value = u64::from_ne_bytes(array)) + .unwrap_or_else(|| unreachable!("slice was sliced to 8 bytes")); + } else { + debug_panic!( + "expected a 64-bit value, did you use this hasher with something other than a TypeId?" + ); + } + } + + #[inline] + fn finish(&self) -> u64 { + self.value + } +} + +#[test] +fn type_id_hasher() { + use core::any::TypeId; + use core::hash::{Hash, Hasher}; + fn verify_hashing_with(type_id: TypeId) { + let mut hasher = TypeIdHasher::default(); + type_id.hash(&mut hasher); + assert_ne!(hasher.finish(), 0); + } + // Pick a variety of types, just to demonstrate it’s all sane. Normal, zero-sized, unsized, &c. + verify_hashing_with(TypeId::of::()); + verify_hashing_with(TypeId::of::<()>()); + verify_hashing_with(TypeId::of::()); + verify_hashing_with(TypeId::of::<&str>()); + verify_hashing_with(TypeId::of::>()); +} diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index 456ba59c0a529a..735a052a553eaf 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -106,7 +106,7 @@ impl PlatformTextSystem for CosmicTextSystem { .faces() .filter_map(|face| face.families.first().map(|family| family.0.clone())) .collect_vec(); - result.sort(); + result.sort_unstable(); result.dedup(); result } diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs index 94b4b561c00b3b..3cf71ac98cfc7c 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -130,15 +130,17 @@ impl PlatformAtlas for WgpuAtlas { fn remove(&self, key: &AtlasKey) { let mut lock = self.0.lock(); - let Some(id) = lock.tiles_by_key.remove(key).map(|tile| tile.texture_id) else { + let Some(tile) = lock.tiles_by_key.remove(key) else { return; }; + let id = tile.texture_id; let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { return; }; if let Some(mut texture) = texture_slot.take() { + texture.allocator.deallocate(tile.tile_id.into()); texture.decrement_ref_count(); if texture.is_unreferenced() { lock.pending_uploads @@ -461,6 +463,50 @@ mod tests { Ok(()) } + #[test] + fn remove_deallocates_tile_space_for_reuse() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + + let small = Size { + width: DevicePixels(64), + height: DevicePixels(64), + }; + let big = Size { + width: DevicePixels(700), + height: DevicePixels(700), + }; + + let make_key = |image_id: usize| { + AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }) + }; + let insert = |key: &AtlasKey, size: Size| { + let byte_count = (size.width.0 as usize) * (size.height.0 as usize) * 4; + atlas + .get_or_insert_with(key, &mut || { + Ok(Some((size, Cow::Owned(vec![0u8; byte_count])))) + }) + .expect("allocation should succeed") + .expect("callback returns Some") + }; + + let keeper_key = make_key(1); + let big_key_a = make_key(2); + let big_key_b = make_key(3); + + let keeper_tile = insert(&keeper_key, small); + let tile_a = insert(&big_key_a, big); + assert_eq!(keeper_tile.texture_id, tile_a.texture_id); + + atlas.remove(&big_key_a); + let tile_b = insert(&big_key_b, big); + assert_eq!(tile_b.texture_id, keeper_tile.texture_id); + Ok(()) + } + #[test] fn swizzle_upload_data_preserves_bgra_uploads() { let input = vec![0x10, 0x20, 0x30, 0x40]; diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs index a6642dc7dc6292..d5b1a67430951b 100644 --- a/crates/gpui_windows/src/directx_atlas.rs +++ b/crates/gpui_windows/src/directx_atlas.rs @@ -98,9 +98,10 @@ impl PlatformAtlas for DirectXAtlas { fn remove(&self, key: &AtlasKey) { let mut lock = self.0.lock(); - let Some(id) = lock.tiles_by_key.remove(key).map(|tile| tile.texture_id) else { + let Some(tile) = lock.tiles_by_key.remove(key) else { return; }; + let id = tile.texture_id; let textures = match id.kind { AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, @@ -113,6 +114,7 @@ impl PlatformAtlas for DirectXAtlas { }; if let Some(mut texture) = texture_slot.take() { + texture.allocator.deallocate(tile.tile_id.into()); texture.decrement_ref_count(); if texture.is_unreferenced() { textures.free_list.push(texture.id.index as usize); @@ -318,3 +320,83 @@ fn etagere_point_to_device(value: etagere::Point) -> Point { y: DevicePixels::from(value.y), } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ImageId, RenderImageParams}; + use std::borrow::Cow; + use windows::Win32::{ + Foundation::HMODULE, + Graphics::{ + Direct3D::D3D_DRIVER_TYPE_WARP, + Direct3D11::{D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice}, + }, + }; + + fn create_atlas() -> Option { + let mut device: Option = None; + let mut device_context: Option = None; + unsafe { + D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_WARP, + HMODULE::default(), + D3D11_CREATE_DEVICE_BGRA_SUPPORT, + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut device_context), + ) + } + .ok()?; + Some(DirectXAtlas::new(&device?, &device_context?)) + } + + fn make_image_key(image_id: usize) -> AtlasKey { + AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }) + } + + fn insert_tile(atlas: &DirectXAtlas, key: &AtlasKey, size: Size) -> AtlasTile { + atlas + .get_or_insert_with(key, &mut || { + let byte_count = (size.width.0 as usize) * (size.height.0 as usize) * 4; + Ok(Some((size, Cow::Owned(vec![0u8; byte_count])))) + }) + .expect("allocation should succeed") + .expect("callback returns Some") + } + + #[test] + fn test_remove_deallocates_tile_space_for_reuse() { + let Some(atlas) = create_atlas() else { + return; + }; + + let small = Size { + width: DevicePixels(64), + height: DevicePixels(64), + }; + let big = Size { + width: DevicePixels(700), + height: DevicePixels(700), + }; + + let keeper_key = make_image_key(1); + let big_key_a = make_image_key(2); + let big_key_b = make_image_key(3); + + let keeper_tile = insert_tile(&atlas, &keeper_key, small); + let tile_a = insert_tile(&atlas, &big_key_a, big); + assert_eq!(keeper_tile.texture_id, tile_a.texture_id); + + atlas.remove(&big_key_a); + + let tile_b = insert_tile(&atlas, &big_key_b, big); + assert_eq!(tile_b.texture_id, keeper_tile.texture_id); + } +} diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index b04b819a02b7d8..51205946ef00e5 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -938,7 +938,7 @@ impl WindowsWindowInner { unsafe { GetWindowRect(handle, &mut rect) }.log_err(); // right and bottom bounds of RECT are exclusive, thus `-1` let right = rect.right - rect.left - 1; - // the bounds include the padding frames, so accomodate for both of them + // the bounds include the padding frames, so accommodate for both of them if right - 2 * frame_x <= cursor_point.x { HTTOPRIGHT } else { diff --git a/crates/grammars/src/bash/config.toml b/crates/grammars/src/bash/config.toml index 9f749850272dbe..adc2063341af73 100644 --- a/crates/grammars/src/bash/config.toml +++ b/crates/grammars/src/bash/config.toml @@ -1,8 +1,8 @@ name = "Shell Script" code_fence_block_name = "bash" grammar = "bash" -path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD"] -modeline_aliases = ["sh", "shell", "shell-script", "zsh", "fish"] +path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "bats", "envrc", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD", "APKBUILD"] +modeline_aliases = ["sh", "shell", "shell-script", "zsh"] line_comments = ["# "] first_line_pattern = '^#!.*\b(?:ash|bash|bats|dash|sh|zsh)\b' autoclose_before = "}])" diff --git a/crates/grammars/src/c/indents.scm b/crates/grammars/src/c/indents.scm index 0b55631e5ca6cd..41f3c4fd3d4a20 100644 --- a/crates/grammars/src/c/indents.scm +++ b/crates/grammars/src/c/indents.scm @@ -9,6 +9,10 @@ (else_clause) ] @indent +(expression_statement + (_) @indent + ";" @end) + (_ "{" "}" @end) @indent diff --git a/crates/grammars/src/cpp/indents.scm b/crates/grammars/src/cpp/indents.scm index ebd5afb7c74d33..d8c71736e9dd54 100644 --- a/crates/grammars/src/cpp/indents.scm +++ b/crates/grammars/src/cpp/indents.scm @@ -9,6 +9,10 @@ (else_clause) ] @indent +(expression_statement + (_) @indent + ";" @end) + (_ "{" "}" @end) @indent diff --git a/crates/grammars/src/go/runnables.scm b/crates/grammars/src/go/runnables.scm index d00be6e1d0db4b..67a85e473b3962 100644 --- a/crates/grammars/src/go/runnables.scm +++ b/crates/grammars/src/go/runnables.scm @@ -85,24 +85,26 @@ (#eq? @_key_type "string")) ] body: (literal_value - [ + ([ (literal_element (literal_value - (keyed_element + ((keyed_element (literal_element (identifier) @_field_name) (literal_element [ (interpreted_string_literal) @run @_table_test_case_name (raw_string_literal) @run @_table_test_case_name - ])))) + ])) + ","?)+)) (keyed_element (literal_element [ (interpreted_string_literal) @run @_table_test_case_name (raw_string_literal) @run @_table_test_case_name ])) - ])))) + ] @run_item + ","?)+)))) (for_statement (range_clause left: (expression_list @@ -127,8 +129,7 @@ (selector_expression operand: (identifier) @_tc_var (#eq? @_tc_var @_loop_var_inner) - field: (field_identifier) @_field_check - (#eq? @_field_check @_field_name)) + field: (field_identifier) @_field_check) (identifier) @_arg_var (#eq? @_arg_var @_loop_var_outer) ] @@ -162,24 +163,26 @@ (#eq? @_key_type "string")) ] body: (literal_value - [ + ([ (literal_element (literal_value - (keyed_element + ((keyed_element (literal_element (identifier) @_field_name) (literal_element [ (interpreted_string_literal) @run @_table_test_case_name (raw_string_literal) @run @_table_test_case_name - ])))) + ])) + ","?)+)) (keyed_element (literal_element [ (interpreted_string_literal) @run @_table_test_case_name (raw_string_literal) @run @_table_test_case_name ])) - ]))) + ] @run_item + ","?)+))) body: (block (statement_list (expression_statement @@ -194,8 +197,7 @@ (selector_expression operand: (identifier) @_tc_var (#eq? @_tc_var @_loop_var_inner) - field: (field_identifier) @_field_check - (#eq? @_field_check @_field_name)) + field: (field_identifier) @_field_check) (identifier) @_arg_var (#eq? @_arg_var @_loop_var_outer) ] diff --git a/crates/grammars/src/markdown/config.toml b/crates/grammars/src/markdown/config.toml index 46c4147020905b..b366032579c6e1 100644 --- a/crates/grammars/src/markdown/config.toml +++ b/crates/grammars/src/markdown/config.toml @@ -14,6 +14,7 @@ brackets = [ { start = "'", end = "'", close = false, newline = false }, { start = "`", end = "`", close = false, newline = false }, { start = "*", end = "*", close = false, newline = false, surround = true }, + { start = "~", end = "~", close = false, newline = false, surround = true }, ] rewrap_prefixes = [ "[-*+]\\s+", diff --git a/crates/grammars/src/python/highlights.scm b/crates/grammars/src/python/highlights.scm index 620f243a47b2ec..00daf43fbc377f 100644 --- a/crates/grammars/src/python/highlights.scm +++ b/crates/grammars/src/python/highlights.scm @@ -354,3 +354,23 @@ "bool" "bytearray" "bytes" "complex" "dict" "float" "frozenset" "frozendict" "int" "list" "memoryview" "object" "range" "set" "slice" "str" "tuple") ] + +((identifier) @type.class.builtin + (#any-of? @type.class.builtin + ; Exceptions + "BaseException" "Exception" "ArithmeticError" "BufferError" "LookupError" "AssertionError" + "AttributeError" "EOFError" "FloatingPointError" "GeneratorExit" "ImportError" + "ModuleNotFoundError" "IndexError" "KeyError" "KeyboardInterrupt" "MemoryError" "NameError" + "NotImplementedError" "OSError" "OverflowError" "RecursionError" "ReferenceError" "RuntimeError" + "StopIteration" "StopAsyncIteration" "SyntaxError" "IndentationError" "TabError" "SystemError" + "SystemExit" "TypeError" "UnboundLocalError" "UnicodeError" "UnicodeEncodeError" + "UnicodeDecodeError" "UnicodeTranslateError" "ValueError" "ZeroDivisionError" "EnvironmentError" + "IOError" "WindowsError" "BlockingIOError" "ChildProcessError" "ConnectionError" + "BrokenPipeError" "ConnectionAbortedError" "ConnectionRefusedError" "ConnectionResetError" + "FileExistsError" "FileNotFoundError" "InterruptedError" "IsADirectoryError" + "NotADirectoryError" "PermissionError" "ProcessLookupError" "TimeoutError" "ExceptionGroup" + "BaseExceptionGroup" + ; Warnings + "Warning" "UserWarning" "DeprecationWarning" "PendingDeprecationWarning" "SyntaxWarning" + "RuntimeWarning" "FutureWarning" "ImportWarning" "UnicodeWarning" "EncodingWarning" + "BytesWarning" "ResourceWarning")) diff --git a/crates/http_client/src/github_download.rs b/crates/http_client/src/github_download.rs index 5d11f3e11b7ea9..2970d118ad3265 100644 --- a/crates/http_client/src/github_download.rs +++ b/crates/http_client/src/github_download.rs @@ -6,7 +6,7 @@ use std::{ use anyhow::{Context, Result}; use async_compression::futures::bufread::{BzDecoder, GzipDecoder}; -use futures::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, io::BufReader}; +use futures::{AsyncRead, AsyncSeek, AsyncSeekExt, AsyncWrite, AsyncWriteExt, io::BufReader}; use sha2::{Digest, Sha256}; use crate::{HttpClient, github::AssetKind}; @@ -68,6 +68,64 @@ pub async fn download_server_binary( Ok(()) } +pub async fn download_server_raw_binary( + http_client: &dyn HttpClient, + url: &str, + digest: Option<&str>, + destination_path: &Path, + binary_file_name: &str, +) -> Result<(), anyhow::Error> { + log::info!("downloading raw binary from {url}"); + let Some(destination_parent) = destination_path.parent() else { + anyhow::bail!("destination path has no parent: {destination_path:?}"); + }; + + let staging_path = staging_dir_path(destination_parent)?; + let result = async { + let mut response = http_client + .get(url, Default::default(), true) + .await + .with_context(|| format!("downloading release from {url}"))?; + + let binary_path = staging_path.join(binary_file_name); + let mut writer = HashingWriter { + writer: async_fs::File::create(&binary_path) + .await + .with_context(|| format!("creating a file {binary_path:?} for {url}"))?, + hasher: Sha256::new(), + }; + futures::io::copy(&mut BufReader::new(response.body_mut()), &mut writer) + .await + .with_context(|| format!("saving binary contents from {url}"))?; + let asset_sha_256 = writer + .finish() + .await + .with_context(|| format!("flushing binary contents for {url}"))?; + + if let Some(expected_sha_256) = digest { + anyhow::ensure!( + asset_sha_256 == expected_sha_256, + "{url} asset got SHA-256 mismatch. Expected: {expected_sha_256}, Got: {asset_sha_256}", + ); + } + + util::fs::make_file_executable(&binary_path) + .await + .with_context(|| format!("marking {binary_path:?} as executable"))?; + finalize_download(&staging_path, destination_path).await + } + .await; + + if let Err(err) = result { + if let Err(err) = async_fs::remove_dir_all(&staging_path).await { + log::warn!("failed to remove staging directory {staging_path:?}: {err:?}"); + } + return Err(err); + } + + Ok(()) +} + async fn extract_to_staging( body: impl AsyncRead + Unpin, digest: Option<&str>, @@ -117,15 +175,17 @@ async fn extract_to_staging( Ok(()) } +fn staging_dir_path(parent: &Path) -> Result { + let dir = tempfile::Builder::new() + .prefix(".tmp-github-download-") + .tempdir_in(parent) + .with_context(|| format!("creating staging directory in {parent:?}"))?; + Ok(dir.keep()) +} + fn staging_path(parent: &Path, asset_kind: AssetKind) -> Result { match asset_kind { - AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Zip => { - let dir = tempfile::Builder::new() - .prefix(".tmp-github-download-") - .tempdir_in(parent) - .with_context(|| format!("creating staging directory in {parent:?}"))?; - Ok(dir.keep()) - } + AssetKind::TarGz | AssetKind::TarBz2 | AssetKind::Zip => staging_dir_path(parent), AssetKind::Gz => { let path = tempfile::Builder::new() .prefix(".tmp-github-download-") @@ -261,6 +321,22 @@ struct HashingWriter { hasher: Sha256, } +impl HashingWriter { + /// Closes and drops the inner writer, returning the hex SHA-256 digest of + /// everything written. + /// + /// Taking `self` by value guarantees the writer is dropped before this + /// returns. For file writers this releases the OS handle, which Windows + /// requires before an ancestor directory can be renamed or deleted; note + /// that closing alone is not enough, as `async_fs::File` holds its handle + /// until dropped. + async fn finish(mut self) -> std::io::Result { + self.writer.close().await?; + drop(self.writer); + Ok(format!("{:x}", self.hasher.finalize())) + } +} + impl AsyncWrite for HashingWriter { fn poll_write( mut self: Pin<&mut Self>, @@ -290,3 +366,101 @@ impl AsyncWrite for HashingWriter { Pin::new(&mut self.writer).poll_close(cx) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AsyncBody, Response}; + use futures::future::BoxFuture; + use http::HeaderValue; + use url::Url; + + struct StaticResponseClient { + body: Vec, + } + + impl HttpClient for StaticResponseClient { + fn send( + &self, + _req: http::Request, + ) -> BoxFuture<'static, anyhow::Result>> { + let body = self.body.clone(); + Box::pin(async move { + Ok(Response::builder() + .status(200) + .body(AsyncBody::from(body)) + .unwrap()) + }) + } + + fn user_agent(&self) -> Option<&HeaderValue> { + None + } + + fn proxy(&self) -> Option<&Url> { + None + } + } + + #[test] + fn downloads_raw_binary_into_destination_dir() { + futures::executor::block_on(async { + let temp_dir = tempfile::tempdir().unwrap(); + let destination_path = temp_dir.path().join("v_1"); + let contents = b"#!/bin/sh\necho hello\n".to_vec(); + let expected_sha_256 = format!("{:x}", Sha256::digest(&contents)); + let client = StaticResponseClient { body: contents }; + + download_server_raw_binary( + &client, + "https://example.com/agent-binary", + Some(&expected_sha_256), + &destination_path, + "agent-binary", + ) + .await + .unwrap(); + + let binary_path = destination_path.join("agent-binary"); + assert_eq!( + std::fs::read(&binary_path).unwrap(), + b"#!/bin/sh\necho hello\n" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&binary_path) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o111, 0o111, "binary should be executable"); + } + }); + } + + #[test] + fn raw_binary_digest_mismatch_cleans_up_staging() { + futures::executor::block_on(async { + let temp_dir = tempfile::tempdir().unwrap(); + let destination_path = temp_dir.path().join("v_1"); + let client = StaticResponseClient { + body: b"some binary".to_vec(), + }; + + let error = download_server_raw_binary( + &client, + "https://example.com/agent-binary", + Some("0000000000000000000000000000000000000000000000000000000000000000"), + &destination_path, + "agent-binary", + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("SHA-256 mismatch")); + assert!(!destination_path.exists()); + let leftover_entries = std::fs::read_dir(temp_dir.path()).unwrap().count(); + assert_eq!(leftover_entries, 0, "staging directory should be removed"); + }); + } +} diff --git a/crates/icons/README.md b/crates/icons/README.md index e340a00277db55..e3b7c54c178b42 100644 --- a/crates/icons/README.md +++ b/crates/icons/README.md @@ -26,4 +26,4 @@ To introduce a new icon, add the `.svg` file to the `assets/icon` directory and - SVG files in the assets folder follow a snake_case name format. - Icons in the `icons.rs` file follow the PascalCase name format. -Make sure to tag a member of Zed's design team so we can review and adjust any newly introduced icon. +Make sure to tag a member of Zed's design team (@zed-industries/design) so we can review and adjust any newly introduced icon. diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 45812948cc9db7..34206b47671d4a 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -10,6 +10,7 @@ use strum::{EnumIter, EnumString, IntoStaticStr}; pub enum IconName { AcpRegistry, AiAnthropic, + AiAnthropicCompat, AiBedrock, AiClaude, AiDeepSeek, @@ -73,6 +74,7 @@ pub enum IconName { Code, Codeberg, Command, + Compact, Control, Copilot, CopilotDisabled, @@ -140,6 +142,8 @@ pub enum IconName { FolderOpen, FolderOpenAdd, FolderSearch, + FolderShare, + FolderShared, Font, FontSize, FontWeight, @@ -150,6 +154,7 @@ pub enum IconName { GenericMaximize, GenericMinimize, GenericRestore, + Gerrit, GitBranch, GitBranchPlus, GitCommit, @@ -231,6 +236,7 @@ pub enum IconName { SignalMedium, Slash, Sliders, + Sourcehut, Space, Sparkle, Split, @@ -249,6 +255,7 @@ pub enum IconName { TextUnwrap, ThinkingMode, ThinkingModeOff, + ThisWindow, Thread, ThreadFromSummary, ThreadsSidebarLeftClosed, diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index dc8d22b67270a5..d881bcc2561fa6 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -4,7 +4,7 @@ mod image_viewer_settings; use std::path::Path; use anyhow::Context as _; -use editor::{EditorSettings, items::entry_git_aware_label_color}; +use editor::{EditorSettings, RevealInFileManager, items::entry_git_aware_label_color}; use file_icons::FileIcons; use gpui::{ AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, @@ -171,6 +171,18 @@ impl ImageView { cx.notify(); } + fn reveal_in_file_manager( + &mut self, + _: &RevealInFileManager, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(path) = self.image_item.read(cx).abs_path(cx) { + self.project + .update(cx, |project, cx| project.reveal_path(&path, cx)); + } + } + fn set_zoom( &mut self, new_zoom: f32, @@ -678,6 +690,7 @@ impl Render for ImageView { .on_action(cx.listener(Self::reset_zoom)) .on_action(cx.listener(Self::fit_to_view)) .on_action(cx.listener(Self::zoom_to_actual_size)) + .on_action(cx.listener(Self::reveal_in_file_manager)) .size_full() .relative() .bg(cx.theme().colors().editor_background) diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs index 4c15196341e8c9..aa930418bf9c8a 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -667,6 +667,7 @@ impl CompletionProvider for RustStyleCompletionProvider { match_start: None, snippet_deduplication_key: None, icon_path: None, + icon_color: None, documentation: method.documentation.map(|documentation| { CompletionDocumentation::MultiLineMarkdown(documentation.into()) }), diff --git a/crates/install_cli/src/install_cli_binary.rs b/crates/install_cli/src/install_cli_binary.rs index 4c6d8cde40ca3b..ee8b8b2b669b05 100644 --- a/crates/install_cli/src/install_cli_binary.rs +++ b/crates/install_cli/src/install_cli_binary.rs @@ -70,7 +70,7 @@ pub fn install_cli_binary(window: &mut Window, cx: &mut Context) { PromptLevel::Warning, "CLI should already be installed", Some(LINUX_PROMPT_DETAIL), - &["Ok"], + &["OK"], ); cx.background_spawn(prompt).detach(); return Ok(()); diff --git a/crates/keymap_editor/src/action_completion_provider.rs b/crates/keymap_editor/src/action_completion_provider.rs index 34324f9a3374ea..849709b8ec68e9 100644 --- a/crates/keymap_editor/src/action_completion_provider.rs +++ b/crates/keymap_editor/src/action_completion_provider.rs @@ -102,6 +102,7 @@ impl CompletionProvider for ActionCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: None, + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 6677fe667dd3f6..0bb66ab988c565 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -3515,6 +3515,7 @@ impl CompletionProvider for KeyContextCompletionProvider { documentation: None, source: project::CompletionSource::Custom, icon_path: None, + icon_color: None, match_start: None, snippet_deduplication_key: None, insert_text_mode: None, diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index b4d24765de9c90..bde0659cf13d9d 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -36,7 +36,7 @@ encoding_rs.workspace = true fs.workspace = true futures.workspace = true futures-lite.workspace = true -fuzzy.workspace = true +fuzzy_nucleo.workspace = true globset.workspace = true gpui.workspace = true http_client.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index ae98ef1e28bd60..57612012c84c84 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -756,6 +756,14 @@ pub struct EditPreview { } impl EditPreview { + pub fn unchanged(snapshot: &BufferSnapshot) -> Self { + Self { + old_snapshot: snapshot.text.clone(), + applied_edits_snapshot: snapshot.text.clone(), + syntax_snapshot: snapshot.syntax.clone(), + } + } + pub fn as_unified_diff( &self, file: Option<&Arc>, @@ -2660,6 +2668,7 @@ impl Buffer { /// Applies the given edits to the buffer. Each edit is specified as a range of text to /// delete, and a string of text to insert at that location. Adjacent edits are coalesced. + /// Inserted text is normalized to LF line endings before being applied. /// /// If an [`AutoindentMode`] is provided, then the buffer will enqueue an auto-indent /// request for the edited ranges, which will be processed when the buffer finishes @@ -3321,6 +3330,76 @@ impl Buffer { pub fn set_group_interval(&mut self, group_interval: Duration) { self.text.set_group_interval(group_interval); } + + // TODO: see if ep can use this instead of Buffer::branch + pub fn snapshot_with_edits( + &mut self, + edits: I, + cx: &mut Context, + ) -> Task + where + I: IntoIterator, T)>, + S: ToOffset, + T: Into>, + { + let mut snapshot = self.snapshot(); + let text = snapshot.text.clone(); + let mut syntax = snapshot.syntax.clone(); + let language = self.language().cloned(); + let registry = self.language_registry(); + let new_text = self.text.snapshot_with_edits(edits); + cx.background_spawn(async move { + if let Some(language) = language.clone() { + syntax.reparse(&text, registry.clone(), language); + } + + syntax.interpolate(&new_text.snapshot); + + if let Some(language) = language { + syntax.reparse(&new_text.snapshot, registry, language); + } + + snapshot.text = new_text.snapshot.clone(); + snapshot.syntax = syntax; + + EditedBufferSnapshot { + text: new_text, + snapshot, + } + }) + } + + pub fn fast_forward(&mut self, edited: EditedBufferSnapshot, cx: &mut Context) { + let base_version = edited.text.base_version.clone(); + let did_edit = edited.text.did_edit; + self.text.fast_forward(edited.text); + if edited.snapshot.language == self.language { + self.reparse = None; + self.did_finish_parsing(edited.snapshot.syntax, None, cx); + if did_edit { + cx.emit(BufferEvent::Edited { + source: BufferEditSource::User, + }); + } + } else { + self.did_edit(&base_version, false, BufferEditSource::User, cx); + } + } +} + +pub struct EditedBufferSnapshot { + text: text::EditedBufferSnapshot, + snapshot: BufferSnapshot, +} + +impl EditedBufferSnapshot { + pub fn snapshot(&self) -> &BufferSnapshot { + &self.snapshot + } + + pub fn base_version(&self) -> &clock::Global { + &self.text.base_version + } } #[doc(hidden)] @@ -4591,7 +4670,7 @@ impl BufferSnapshot { depth: 0, // We'll calculate the depth later range: item_point_range, source_range_for_text: source_range_for_text.to_point(self), - text, + text: text.into(), highlight_ranges, name_ranges, body_range: open_point.zip(close_point).map(|(start, end)| start..end), @@ -4769,7 +4848,7 @@ impl BufferSnapshot { }) .filter(|(start, _, _)| chunk_range.contains(start)) .collect(); - unique_closes.sort(); + unique_closes.sort_unstable(); unique_closes.dedup(); // Build valid pairs by walking through closes in order diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index b46b3611a5d7de..86fdf519268ad9 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -850,23 +850,26 @@ async fn test_outline(cx: &mut gpui::TestAppContext) { ] ); - // Without space, we only match on names + // Single-atom queries (no whitespace): all matched chars must land in the leaf, + // so items whose ancestor path coincidentally contains the query chars don't + // show up unless the leaf itself matches. assert_eq!( search(&outline, "oon", cx).await, &[ - ("mod module", vec![]), // included as the parent of a match - ("enum LoginState", vec![]), // included as the parent of a match - ("LoggingOn", vec![1, 7, 8]), // matches - ("impl Drop for Person", vec![7, 18, 19]), // matches in two disjoint names + ("mod module", vec![]), // parent context for LoggingOn + ("enum LoginState", vec![]), // parent context for LoggingOn + ("LoggingOn", vec![1, 7, 8]), // all three chars in leaf + ("impl Eq for Person", vec![9, 16, 17]), // o-o-n in "for Person" + ("impl Drop for Person", vec![11, 18, 19]), // o-o-n in "for Person" ] ); + // Multi-atom queries: rows whose match lives entirely in an ancestor + // are kept as context (empty positions, score zeroed) so descendants + // of a matched container surface alongside it. assert_eq!( search(&outline, "dp p", cx).await, - &[ - ("impl Drop for Person", vec![5, 8, 9, 14]), - ("fn drop", vec![]), - ] + &[("impl Drop for Person", vec![5, 14]), ("fn drop", vec![]),] ); assert_eq!( search(&outline, "dpn", cx).await, @@ -875,8 +878,8 @@ async fn test_outline(cx: &mut gpui::TestAppContext) { assert_eq!( search(&outline, "impl ", cx).await, &[ - ("impl Eq for Person", vec![0, 1, 2, 3, 4]), - ("impl Drop for Person", vec![0, 1, 2, 3, 4]), + ("impl Eq for Person", vec![0, 1, 2, 3]), + ("impl Drop for Person", vec![0, 1, 2, 3]), ("fn drop", vec![]), ] ); @@ -891,12 +894,16 @@ async fn test_outline(cx: &mut gpui::TestAppContext) { query: &'a str, cx: &'a gpui::TestAppContext, ) -> Vec<(&'a str, Vec)> { - let matches = cx + let entries = cx .update(|cx| outline.search(query, cx.background_executor().clone())) .await; - matches + entries .into_iter() - .map(|mat| (outline.items[mat.candidate_id].text.as_str(), mat.positions)) + .map(|entry| { + let candidate_id = entry.candidate_id(); + let positions = entry.into_match().map(|m| m.positions).unwrap_or_default(); + (outline.items[candidate_id].text.as_str(), positions) + }) .collect::>() } } @@ -999,7 +1006,7 @@ fn test_outline_annotations(cx: &mut App) { .items .into_iter() .map(|item| ( - item.text, + item.text.to_string(), item.depth, item.annotation_range .map(|range| { buffer.read(cx).text_for_range(range).collect::() }) @@ -1099,7 +1106,7 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) { .into_iter() .map(|item| { ( - item.text, + item.text.to_string(), item.range.start.to_point(snapshot)..item.range.end.to_point(snapshot), ) }) diff --git a/crates/language/src/outline.rs b/crates/language/src/outline.rs index 875042bfc83ae4..5ff5c028ffa044 100644 --- a/crates/language/src/outline.rs +++ b/crates/language/src/outline.rs @@ -1,15 +1,18 @@ use crate::{BufferSnapshot, Point, ToPoint, ToTreeSitterPoint}; -use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::{BackgroundExecutor, HighlightStyle}; +use fuzzy_nucleo::{Case, LengthPenalty, StringMatch, StringMatchCandidate}; +use gpui::{BackgroundExecutor, HighlightStyle, SharedString}; use std::ops::Range; /// An outline of all the symbols contained in a buffer. #[derive(Debug)] pub struct Outline { pub items: Vec>, + /// Candidates contain the full path of each item, used for matching. candidates: Vec, - pub path_candidates: Vec, - path_candidate_prefixes: Vec, + /// leaf_offsets stores the byte offset within that full path where the + /// item's own text starts. Anything before this offset is ancestor + /// path text used purely as match context. + leaf_offsets: Vec, } #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] @@ -17,7 +20,7 @@ pub struct OutlineItem { pub depth: usize, pub range: Range, pub source_range_for_text: Range, - pub text: String, + pub text: SharedString, pub highlight_ranges: Vec<(Range, HighlightStyle)>, pub name_ranges: Vec>, pub body_range: Option>, @@ -25,7 +28,42 @@ pub struct OutlineItem { } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct SymbolPath(pub String); +pub struct SymbolPath(pub SharedString); + +/// Result of [`Outline::search`]. Real fuzzy matches are `Match`; `Ancestor` +/// rows are synthetic entries pointing at parent items, included so callers can +/// show the full path of each match (Even when the ancestor has been filtered +/// out due to not matching) but treat those synthetic ancestors differently +/// from an entry that actually matched (e.g. they are not eligible for +/// auto-selection). +#[derive(Clone, Debug)] +pub enum OutlineSearchEntry { + Match(StringMatch), + Ancestor { candidate_id: usize }, +} + +impl OutlineSearchEntry { + pub fn candidate_id(&self) -> usize { + match self { + Self::Match(m) => m.candidate_id, + Self::Ancestor { candidate_id } => *candidate_id, + } + } + + pub fn as_match(&self) -> Option<&StringMatch> { + match self { + Self::Match(m) => Some(m), + Self::Ancestor { .. } => None, + } + } + + pub fn into_match(self) -> Option { + match self { + Self::Match(m) => Some(m), + Self::Ancestor { .. } => None, + } + } +} impl OutlineItem { /// Converts to an equivalent outline item, but with parameterized over Points. @@ -66,7 +104,15 @@ impl OutlineItem { { break; } - cursor.goto_first_child_for_point(range.start.to_ts_point()); + // If we can't descend further, the current node is the most specific + // ancestor that contains `range.start`. Bail out rather than spinning + // forever re-checking the same node. + if cursor + .goto_first_child_for_point(range.start.to_ts_point()) + .is_none() + { + return None; + } } if !cursor.goto_last_child() { @@ -100,9 +146,8 @@ impl OutlineItem { impl Outline { pub fn new(items: Vec>) -> Self { - let mut candidates = Vec::new(); - let mut path_candidates = Vec::new(); - let mut path_candidate_prefixes = Vec::new(); + let mut candidates = Vec::with_capacity(items.len()); + let mut leaf_offsets = Vec::with_capacity(items.len()); let mut path_text = String::new(); let mut path_stack = Vec::new(); @@ -114,25 +159,16 @@ impl Outline { if !path_text.is_empty() { path_text.push(' '); } - path_candidate_prefixes.push(path_text.len()); + leaf_offsets.push(path_text.len()); path_text.push_str(&item.text); path_stack.push(path_text.len()); - - let candidate_text = item - .name_ranges - .iter() - .map(|range| &item.text[range.start..range.end]) - .collect::(); - - path_candidates.push(StringMatchCandidate::new(id, &path_text)); - candidates.push(StringMatchCandidate::new(id, &candidate_text)); + candidates.push(StringMatchCandidate::new(id, &path_text)); } Self { - candidates, - path_candidates, - path_candidate_prefixes, items, + candidates, + leaf_offsets, } } @@ -141,7 +177,7 @@ impl Outline { const SIMILARITY_THRESHOLD: f64 = 0.6; let (position, similarity) = self - .path_candidates + .candidates .iter() .enumerate() .map(|(index, candidate)| { @@ -151,7 +187,7 @@ impl Outline { .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())?; if similarity >= SIMILARITY_THRESHOLD { - self.path_candidates + self.candidates .get(position) .map(|candidate| SymbolPath(candidate.string.clone())) .zip(self.items.get(position)) @@ -160,95 +196,135 @@ impl Outline { } } - /// Find all outline symbols according to a longest subsequence match with the query, ordered descending by match score. - pub async fn search(&self, query: &str, executor: BackgroundExecutor) -> Vec { + /// Find all outline symbols that match with the nucleo fuzzy matcher, ordered by tree position. + /// Each real match is preceded by [`OutlineSearchEntry::Ancestor`] rows carrying parent + /// `candidate_id`s, so callers can render tree context above the match. + pub async fn search( + &self, + query: &str, + executor: BackgroundExecutor, + ) -> Vec { let query = query.trim_start(); - let is_path_query = query.contains(' '); - let smart_case = query.chars().any(|c| c.is_uppercase()); - let mut matches = fuzzy::match_strings( - if is_path_query { - &self.path_candidates - } else { - &self.candidates - }, + if query.is_empty() { + return Vec::new(); + } + let mut matches = fuzzy_nucleo::match_strings_async( + &self.candidates, query, - smart_case, - true, + Case::Smart, + LengthPenalty::On, 100, &Default::default(), - executor.clone(), + executor, ) .await; matches.sort_unstable_by_key(|m| m.candidate_id); - let mut tree_matches = Vec::new(); - - let mut prev_item_ix = 0; - for mut string_match in matches { - let outline_match = &self.items[string_match.candidate_id]; - string_match.string.clone_from(&outline_match.text); - - if is_path_query { - let prefix_len = self.path_candidate_prefixes[string_match.candidate_id]; - string_match - .positions - .retain(|position| *position >= prefix_len); - for position in &mut string_match.positions { - *position -= prefix_len; - } - } else { - let mut name_ranges = outline_match.name_ranges.iter(); - let Some(mut name_range) = name_ranges.next() else { - continue; - }; - let mut preceding_ranges_len = 0; - for position in &mut string_match.positions { - while *position >= preceding_ranges_len + name_range.len() { - preceding_ranges_len += name_range.len(); - name_range = name_ranges.next().unwrap(); - } - *position = name_range.start + (*position - preceding_ranges_len); - } + // Single-atom queries (no whitespace) require *all* matched chars to + // land in the leaf — typing "drop" should only surface leaves that + // actually contain "drop", not items whose ancestor path happens to. + // We can rely on that behavior because nucleo prefers matches at the + // end of the haystack, so the leafiest part of the candidate. + // + // Multi-atom queries (whitespace-separated) use the ancestor path + // for scoping. Rows whose entire match landed in an ancestor are + // kept as context, with empty positions and zero score, so + // descendants of a matched container surface alongside it. The + // picker's score-based auto-select skips them so they never steal + // focus from a row with real highlights. + let single_atom = !query.contains(char::is_whitespace); + matches.retain_mut(|string_match| { + let leaf_offset = self.leaf_offsets[string_match.candidate_id]; + let total = string_match.positions.len(); + string_match + .positions + .retain(|position| *position >= leaf_offset); + let kept = string_match.positions.len(); + if single_atom && kept != total { + return false; + } + if kept == 0 { + string_match.score = 0.0; + } + for position in &mut string_match.positions { + *position -= leaf_offset; } + string_match + .string + .clone_from(&self.items[string_match.candidate_id].text); + true + }); - let insertion_ix = tree_matches.len(); - let mut cur_depth = outline_match.depth; - for (ix, item) in self.items[prev_item_ix..string_match.candidate_id] - .iter() - .enumerate() - .rev() - { - if cur_depth == 0 { - break; - } + expand_tree(|i| self.items[i].depth, matches) + } +} - let candidate_index = ix + prev_item_ix; - if item.depth == cur_depth - 1 { - tree_matches.insert( - insertion_ix, - StringMatch { - candidate_id: candidate_index, - score: Default::default(), - positions: Default::default(), - string: Default::default(), - }, - ); - cur_depth -= 1; - } +/// Interleaves synthetic [`OutlineSearchEntry::Ancestor`] rows before each match so callers +/// can render the parent chain as tree context above the match. +/// +/// `matches` must be sorted ascending by `candidate_id` (which is what +/// [`Outline::search`] produces), this is so that we preserve the tree +/// structure of the outline. `depth_at` returns the tree depth for the item at +/// a given candidate index. Ancestors that already appear earlier in the output +/// either as their own match or as an ancestor of an earlier match, are not +/// duplicated. +fn expand_tree( + depth_at: impl Fn(usize) -> usize, + matches: Vec, +) -> Vec { + debug_assert!(matches.is_sorted_by_key(|m| m.candidate_id)); + let mut out = Vec::with_capacity(matches.len()); + let mut prev_item_ix = 0; + for string_match in matches { + let insertion_ix = out.len(); + let mut cur_depth = depth_at(string_match.candidate_id); + for ix in (prev_item_ix..string_match.candidate_id).rev() { + if cur_depth == 0 { + break; + } + if depth_at(ix) == cur_depth - 1 { + out.insert( + insertion_ix, + OutlineSearchEntry::Ancestor { candidate_id: ix }, + ); + cur_depth -= 1; } - - prev_item_ix = string_match.candidate_id + 1; - tree_matches.push(string_match); } - - tree_matches + prev_item_ix = string_match.candidate_id + 1; + out.push(OutlineSearchEntry::Match(string_match)); } + out } #[cfg(test)] mod tests { use super::*; - use gpui::TestAppContext; + use crate::{Buffer, rust_lang}; + use gpui::{AppContext as _, TestAppContext}; + + #[gpui::test] + fn test_body_range_hangs_when_outline_range_is_inside_leaf_node(cx: &mut TestAppContext) { + let text = "fn main() { let completion = 1; }"; + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(rust_lang(), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + let identifier_start = text.find("completion").unwrap() + 1; + let identifier_end = identifier_start + 1; + let range = + snapshot.offset_to_point(identifier_start)..snapshot.offset_to_point(identifier_end); + + let item = OutlineItem { + depth: 0, + range: range.clone(), + source_range_for_text: range, + text: "completion".into(), + highlight_ranges: Vec::new(), + name_ranges: Vec::new(), + body_range: None, + annotation_range: None, + }; + + assert_eq!(item.body_range(&snapshot), None); + } #[gpui::test] async fn test_entries_with_no_names(cx: &mut TestAppContext) { @@ -257,7 +333,7 @@ mod tests { depth: 0, range: Point::new(0, 0)..Point::new(5, 0), source_range_for_text: Point::new(0, 0)..Point::new(0, 9), - text: "class Foo".to_string(), + text: "class Foo".into(), highlight_ranges: vec![], name_ranges: vec![6..9], body_range: None, @@ -267,21 +343,27 @@ mod tests { depth: 0, range: Point::new(2, 0)..Point::new(2, 7), source_range_for_text: Point::new(0, 0)..Point::new(0, 7), - text: "private".to_string(), + text: "private".into(), highlight_ranges: vec![], name_ranges: vec![], body_range: None, annotation_range: None, }, ]); + assert!( + outline.search("", cx.executor()).await.is_empty(), + "empty queries return no matches; the picker handles 'show all' itself", + ); assert_eq!( outline - .search(" ", cx.executor()) + .search("foo", cx.executor()) .await .into_iter() - .map(|mat| mat.string) - .collect::>(), - vec!["class Foo".to_string()] + .filter_map(OutlineSearchEntry::into_match) + .map(|m| m.string) + .collect::>(), + vec![SharedString::from("class Foo")], + "'private' (empty name_ranges) is correctly excluded; only the matching 'class Foo' is returned", ); } @@ -292,7 +374,7 @@ mod tests { depth: 0, range: Point::new(0, 0)..Point::new(5, 0), source_range_for_text: Point::new(0, 0)..Point::new(0, 10), - text: "fn process".to_string(), + text: "fn process".into(), highlight_ranges: vec![], name_ranges: vec![3..10], body_range: None, @@ -302,7 +384,7 @@ mod tests { depth: 0, range: Point::new(7, 0)..Point::new(12, 0), source_range_for_text: Point::new(0, 0)..Point::new(0, 20), - text: "struct DataProcessor".to_string(), + text: "struct DataProcessor".into(), highlight_ranges: vec![], name_ranges: vec![7..20], body_range: None, diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index d0527d495d7024..7bb4f8100d3506 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -124,9 +124,11 @@ pub struct FakeLanguageModel { >, forbid_requests: AtomicBool, supports_thinking: AtomicBool, + supports_disabling_thinking: AtomicBool, supports_streaming_tools: AtomicBool, supports_images: AtomicBool, max_token_count: AtomicU64, + max_output_tokens: AtomicU64, } impl Default for FakeLanguageModel { @@ -139,9 +141,11 @@ impl Default for FakeLanguageModel { current_completion_txs: Mutex::new(Vec::new()), forbid_requests: AtomicBool::new(false), supports_thinking: AtomicBool::new(false), + supports_disabling_thinking: AtomicBool::new(true), supports_streaming_tools: AtomicBool::new(false), supports_images: AtomicBool::new(false), max_token_count: AtomicU64::new(1_000_000), + max_output_tokens: AtomicU64::new(0), } } } @@ -174,6 +178,10 @@ impl FakeLanguageModel { self.supports_thinking.store(supports, SeqCst); } + pub fn set_supports_disabling_thinking(&self, supports: bool) { + self.supports_disabling_thinking.store(supports, SeqCst); + } + pub fn set_supports_streaming_tools(&self, supports: bool) { self.supports_streaming_tools.store(supports, SeqCst); } @@ -186,6 +194,11 @@ impl FakeLanguageModel { self.max_token_count.store(count, SeqCst); } + pub fn set_max_output_tokens(&self, count: Option) { + self.max_output_tokens + .store(count.unwrap_or_default(), SeqCst); + } + pub fn pending_completions(&self) -> Vec { self.current_completion_txs .lock() @@ -299,6 +312,10 @@ impl LanguageModel for FakeLanguageModel { self.supports_thinking.load(SeqCst) } + fn supports_disabling_thinking(&self) -> bool { + self.supports_disabling_thinking.load(SeqCst) + } + fn supports_streaming_tools(&self) -> bool { self.supports_streaming_tools.load(SeqCst) } @@ -311,6 +328,15 @@ impl LanguageModel for FakeLanguageModel { self.max_token_count.load(SeqCst) } + fn max_output_tokens(&self) -> Option { + let max_output_tokens = self.max_output_tokens.load(SeqCst); + if max_output_tokens == 0 { + None + } else { + Some(max_output_tokens) + } + } + fn stream_completion( &self, request: LanguageModelRequest, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 7dc237a65dd768..fc91c00bdeedfb 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -1,5 +1,4 @@ mod api_key; -mod model; mod registry; mod request; @@ -17,7 +16,6 @@ use parking_lot::Mutex; use std::sync::Arc; pub use crate::api_key::{ApiKey, ApiKeyState}; -pub use crate::model::*; pub use crate::registry::*; pub use crate::request::{LanguageModelImageExt, gpui_size_to_image_size, image_size_to_gpui}; pub use env_var::{EnvVar, env_var}; @@ -60,6 +58,18 @@ pub trait LanguageModel: Send + Sync { false } + /// Whether requests to this model require the user to consent to the + /// upstream provider retaining inference logs (i.e. the model cannot be + /// offered with Zero Data Retention). + fn requires_data_retention(&self) -> bool { + false + } + + /// When this model refuses a request, the model ID to fall back to (same provider). + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + None + } + fn telemetry_id(&self) -> String; fn api_key(&self, _cx: &App) -> Option { @@ -76,6 +86,13 @@ pub trait LanguageModel: Send + Sync { false } + /// Whether thinking can be turned off entirely for this model. Some + /// models (e.g. Claude Fable 5) always think and cannot honor an "off" + /// request. Only meaningful when `supports_thinking` returns `true`. + fn supports_disabling_thinking(&self) -> bool { + true + } + fn supports_fast_mode(&self) -> bool { false } diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs deleted file mode 100644 index 8cd71928b10fb1..00000000000000 --- a/crates/language_model/src/model/cloud_model.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::fmt; - -use thiserror::Error; - -#[derive(Error, Debug)] -pub struct PaymentRequiredError; - -impl fmt::Display for PaymentRequiredError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "Payment required to use this language model. Please upgrade your account." - ) - } -} diff --git a/crates/language_model/src/model/mod.rs b/crates/language_model/src/model/mod.rs deleted file mode 100644 index db4c55daa7db99..00000000000000 --- a/crates/language_model/src/model/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod cloud_model; - -pub use cloud_model::*; diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index c2b7011ab2bdf1..bc3d8b8e521e7c 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -88,6 +88,14 @@ impl LanguageModelCompletionEvent { pub enum LanguageModelCompletionError { #[error("prompt too large for context window")] PromptTooLarge { tokens: Option }, + /// The model requires the user to consent to the upstream provider + /// retaining inference logs (see `LanguageModel::requires_data_retention`) + /// and that consent has not been given. + #[error( + "{model_name} cannot be offered with Zero Data Retention. \ + Anthropic will retain inference logs." + )] + DataRetentionConsentRequired { model_name: String }, #[error("missing {provider} API key")] NoApiKey { provider: LanguageModelProviderName }, #[error("{provider}'s API rate limit exceeded")] @@ -166,6 +174,8 @@ pub enum LanguageModelCompletionError { }, #[error("stream from {provider} ended unexpectedly")] StreamEndedUnexpectedly { provider: LanguageModelProviderName }, + #[error("payment required to use this language model; please upgrade your account")] + PaymentRequired, #[error(transparent)] Other(#[from] anyhow::Error), } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 7e2d2618ea9695..b96a377f02c19b 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -71,6 +71,7 @@ x_ai = { workspace = true, features = ["schemars"] } [dev-dependencies] client = { workspace = true, features = ["test-support"] } clock = { workspace = true, features = ["test-support"] } +cloud_llm_client.workspace = true db = { workspace = true, features = ["test-support"] } feature_flags.workspace = true gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 97ac0b2c0abb63..717da683dc8582 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; -use collections::HashSet; +use collections::{HashMap, HashSet}; use credentials_provider::CredentialsProvider; use gpui::{App, Context, Entity}; use language_model::{ @@ -17,6 +17,7 @@ mod settings; pub use crate::extension::init_proxy as init_extension_proxy; use crate::provider::anthropic::AnthropicLanguageModelProvider; +use crate::provider::anthropic_compatible::AnthropicCompatibleLanguageModelProvider; use crate::provider::bedrock::BedrockLanguageModelProvider; use crate::provider::cloud::CloudLanguageModelProvider; use crate::provider::copilot_chat::CopilotChatLanguageModelProvider; @@ -102,19 +103,15 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { }); } - let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx) - .openai_compatible - .keys() - .cloned() - .collect::>(); + let mut compatible_providers = CompatibleProviders::from_settings(cx); registry.update(cx, |registry, cx| { - register_openai_compatible_providers( + register_compatible_providers( registry, - &HashSet::default(), - &openai_compatible_providers, - client.clone(), - credentials_provider.clone(), + &CompatibleProviders::default(), + &compatible_providers, + &client, + &credentials_provider, cx, ); }); @@ -124,23 +121,19 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { let Some(registry) = registry.upgrade() else { return; }; - let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx) - .openai_compatible - .keys() - .cloned() - .collect::>(); - if openai_compatible_providers_new != openai_compatible_providers { + let compatible_providers_new = CompatibleProviders::from_settings(cx); + if compatible_providers_new != compatible_providers { registry.update(cx, |registry, cx| { - register_openai_compatible_providers( + register_compatible_providers( registry, - &openai_compatible_providers, - &openai_compatible_providers_new, - client.clone(), - credentials_provider.clone(), + &compatible_providers, + &compatible_providers_new, + &client, + &credentials_provider, cx, ); }); - openai_compatible_providers = openai_compatible_providers_new; + compatible_providers = compatible_providers_new; } }) .detach(); @@ -190,31 +183,77 @@ pub fn update_environment_fallback_model(cx: &mut App) { }); } -fn register_openai_compatible_providers( +#[derive(Default, PartialEq, Eq)] +struct CompatibleProviders(HashMap, CompatibleProviderKind>); + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum CompatibleProviderKind { + OpenAi, + Anthropic, +} + +impl CompatibleProviders { + fn from_settings(cx: &App) -> Self { + let settings = AllLanguageModelSettings::get_global(cx); + let mut providers: HashMap, CompatibleProviderKind> = settings + .openai_compatible + .keys() + .map(|id| (id.clone(), CompatibleProviderKind::OpenAi)) + .collect(); + for id in settings.anthropic_compatible.keys() { + // The registry has a single provider ID namespace, so a name can + // only refer to one provider. OpenAI-compatible entries win + // collisions because they predate Anthropic-compatible ones, so + // existing configurations keep working. + if providers.contains_key(id) { + log::warn!( + "ignoring `anthropic_compatible` provider `{id}`: \ + an `openai_compatible` provider with the same name exists" + ); + } else { + providers.insert(id.clone(), CompatibleProviderKind::Anthropic); + } + } + Self(providers) + } +} + +fn register_compatible_providers( registry: &mut LanguageModelRegistry, - old: &HashSet>, - new: &HashSet>, - client: Arc, - credentials_provider: Arc, + old: &CompatibleProviders, + new: &CompatibleProviders, + client: &Arc, + credentials_provider: &Arc, cx: &mut Context, ) { - for provider_id in old { - if !new.contains(provider_id) { + for (provider_id, old_kind) in &old.0 { + if new.0.get(provider_id) != Some(old_kind) { registry.unregister_provider(LanguageModelProviderId::from(provider_id.clone()), cx); } } - for provider_id in new { - if !old.contains(provider_id) { - registry.register_provider( - Arc::new(OpenAiCompatibleLanguageModelProvider::new( - provider_id.clone(), - client.http_client(), - credentials_provider.clone(), + for (provider_id, kind) in &new.0 { + if old.0.get(provider_id) != Some(kind) { + match kind { + CompatibleProviderKind::OpenAi => registry.register_provider( + Arc::new(OpenAiCompatibleLanguageModelProvider::new( + provider_id.clone(), + client.http_client(), + credentials_provider.clone(), + cx, + )), cx, - )), - cx, - ); + ), + CompatibleProviderKind::Anthropic => registry.register_provider( + Arc::new(AnthropicCompatibleLanguageModelProvider::new( + provider_id.clone(), + client.http_client(), + credentials_provider.clone(), + cx, + )), + cx, + ), + } } } } @@ -340,3 +379,209 @@ fn register_language_model_providers( cx, ); } + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use clock::FakeSystemClock; + use feature_flags::FeatureFlagAppExt as _; + use gpui::{AppContext as _, AsyncApp, BorrowAppContext as _}; + use http_client::FakeHttpClient; + use language_model::IconOrSvg; + use release_channel::AppVersion; + use std::future::Future; + use std::pin::Pin; + use ui::IconName; + + struct FakeCredentialsProvider; + + impl CredentialsProvider for FakeCredentialsProvider { + fn read_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + Box::pin(async { Ok(None) }) + } + + fn write_credentials<'a>( + &'a self, + _url: &'a str, + _username: &'a str, + _password: &'a [u8], + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + + fn delete_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + Box::pin(async { Ok(()) }) + } + } + + fn init_test(cx: &mut App) -> (Arc, Arc) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + cx.set_global(db::AppDatabase::test_new()); + let app_version = AppVersion::global(cx); + release_channel::init_test(app_version, release_channel::ReleaseChannel::Dev, cx); + gpui_tokio::init(cx); + cx.update_flags(false, Vec::new()); + + let client = Client::new( + Arc::new(FakeSystemClock::new()), + FakeHttpClient::with_404_response(), + cx, + ); + (client, Arc::new(FakeCredentialsProvider)) + } + + fn update_compatible_provider_settings( + openai: &[&str], + anthropic: &[&str], + cx: &mut App, + ) -> CompatibleProviders { + fn section(ids: &[&str]) -> serde_json::Value { + ids.iter() + .map(|id| { + ( + id.to_string(), + serde_json::json!({ + "api_url": "https://example.com", + "available_models": [], + }), + ) + }) + .collect::>() + .into() + } + + let content = serde_json::json!({ + "language_models": { + "openai_compatible": section(openai), + "anthropic_compatible": section(anthropic), + } + }) + .to_string(); + cx.update_global::(|store, cx| { + store + .set_user_settings(&content, cx) + .expect("failed to parse test settings"); + }); + CompatibleProviders::from_settings(cx) + } + + fn provider_icons(registry: &LanguageModelRegistry, id: &str) -> Vec { + registry + .providers() + .into_iter() + .filter(|provider| provider.id().0.as_ref() == id) + .map(|provider| provider.icon()) + .collect() + } + + #[gpui::test] + fn test_compatible_provider_id_collision_resolves_when_one_entry_is_removed(cx: &mut App) { + let (client, credentials_provider) = init_test(cx); + let registry = cx.new(|_| LanguageModelRegistry::default()); + + // The same provider name is configured in both `openai_compatible` + // and `anthropic_compatible` settings sections; the OpenAI-compatible + // entry wins the collision. + let both = update_compatible_provider_settings(&["acme"], &["acme"], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &CompatibleProviders::default(), + &both, + &client, + &credentials_provider, + cx, + ); + }); + assert_eq!( + registry.read_with(cx, |registry, _| provider_icons(registry, "acme")), + vec![IconOrSvg::Icon(IconName::AiOpenAiCompat)], + "the OpenAI-compatible provider should win the name collision" + ); + + // The user removes the `anthropic_compatible` entry; the remaining + // `openai_compatible` entry must stay registered. + let openai_only = update_compatible_provider_settings(&["acme"], &[], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &both, + &openai_only, + &client, + &credentials_provider, + cx, + ); + }); + assert_eq!( + registry.read_with(cx, |registry, _| provider_icons(registry, "acme")), + vec![IconOrSvg::Icon(IconName::AiOpenAiCompat)], + "the provider registered for `acme` should be the OpenAI-compatible one" + ); + } + + #[gpui::test] + fn test_compatible_provider_changes_kind_and_unregisters(cx: &mut App) { + let (client, credentials_provider) = init_test(cx); + let registry = cx.new(|_| LanguageModelRegistry::default()); + + let both = update_compatible_provider_settings(&["acme"], &["acme"], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &CompatibleProviders::default(), + &both, + &client, + &credentials_provider, + cx, + ); + }); + + // Removing the `openai_compatible` entry hands the name over to the + // remaining `anthropic_compatible` entry. + let anthropic_only = update_compatible_provider_settings(&[], &["acme"], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &both, + &anthropic_only, + &client, + &credentials_provider, + cx, + ); + }); + assert_eq!( + registry.read_with(cx, |registry, _| provider_icons(registry, "acme")), + vec![IconOrSvg::Icon(IconName::AiAnthropicCompat)], + "after removing the openai_compatible entry, the anthropic_compatible provider should be registered" + ); + + // Removing the last entry unregisters the provider entirely. + let none = update_compatible_provider_settings(&[], &[], cx); + registry.update(cx, |registry, cx| { + register_compatible_providers( + registry, + &anthropic_only, + &none, + &client, + &credentials_provider, + cx, + ); + }); + assert_eq!( + registry.read_with(cx, |registry, _| provider_icons(registry, "acme")), + Vec::new(), + "removing all entries should unregister the provider" + ); + } +} diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index 51b323e6c7babf..3a1369a2d1c2e4 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -3,6 +3,8 @@ use http_client::CustomHeaders; use http_client::http::{HeaderName, HeaderValue}; pub mod anthropic; +pub mod anthropic_compatible; +pub mod api_compatible; pub mod bedrock; pub mod cloud; pub mod copilot_chat; diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index cb7f8b7aa114fb..47af84de128445 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -463,6 +463,14 @@ impl LanguageModel for AnthropicModel { self.model.supports_speed } + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + if self.model.id.starts_with(anthropic::FABLE_MODEL_ID_PREFIX) { + Some(anthropic::FABLE_FALLBACK_MODEL_ID) + } else { + None + } + } + fn supported_effort_levels(&self) -> Vec { self.model .supported_effort_levels @@ -531,7 +539,7 @@ impl LanguageModel for AnthropicModel { let request = self.stream_completion(request, cx); let future = self.request_limiter.stream(async move { let response = request.await?; - Ok(AnthropicEventMapper::new().map_stream(response)) + Ok(AnthropicEventMapper::new(PROVIDER_NAME).map_stream(response)) }); async move { Ok(future.await?.boxed()) }.boxed() } diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs new file mode 100644 index 00000000000000..afc29e2b4aad8d --- /dev/null +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -0,0 +1,353 @@ +use anthropic::completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic}; +use anthropic::{AnthropicError, AnthropicModelMode}; +use anyhow::Result; +use credentials_provider::CredentialsProvider; +use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; +use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; +use http_client::{CustomHeaders, HttpClient}; +use language_model::{ + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, RateLimiter, +}; +use settings::Settings; +use std::sync::Arc; +use ui::IconName; + +use crate::provider::api_compatible::{ + ApiCompatibleProviderConfigurationView, ApiCompatibleProviderSettings, + ApiCompatibleProviderState, +}; + +pub use settings::AnthropicCompatibleAvailableModel as AvailableModel; +pub use settings::AnthropicCompatibleModelCapabilities as ModelCapabilities; + +const API_KEY_PLACEHOLDER: &str = "sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; + +#[derive(Default, Clone, Debug, PartialEq)] +pub struct AnthropicCompatibleSettings { + pub api_url: String, + pub available_models: Vec, + pub custom_headers: CustomHeaders, +} + +pub struct AnthropicCompatibleLanguageModelProvider { + id: LanguageModelProviderId, + name: LanguageModelProviderName, + http_client: Arc, + state: Entity, +} + +impl ApiCompatibleProviderSettings for AnthropicCompatibleSettings { + fn api_url(&self) -> &str { + &self.api_url + } +} + +pub type State = ApiCompatibleProviderState; + +fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic::Model { + let mode = match available.mode.unwrap_or_default() { + settings::ModelMode::Default => AnthropicModelMode::Default, + settings::ModelMode::Thinking { budget_tokens } => { + AnthropicModelMode::Thinking { budget_tokens } + } + }; + let supports_thinking = matches!(mode, AnthropicModelMode::Thinking { .. }); + + anthropic::Model { + display_name: available + .display_name + .clone() + .unwrap_or_else(|| available.name.clone()), + id: available.name.clone(), + max_input_tokens: available.max_tokens, + max_output_tokens: available.max_output_tokens.unwrap_or(4_096), + default_temperature: available.default_temperature.unwrap_or(1.0), + mode, + supports_thinking, + supports_adaptive_thinking: false, + supports_images: available.capabilities.images, + supports_speed: false, + supported_effort_levels: Vec::new(), + tool_override: available.tool_override.clone(), + extra_beta_headers: available.extra_beta_headers.clone(), + } +} + +impl AnthropicCompatibleLanguageModelProvider { + pub fn new( + id: Arc, + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { + let state = State::new( + id.clone(), + credentials_provider, + |id, cx| { + crate::AllLanguageModelSettings::get_global(cx) + .anthropic_compatible + .get(id) + }, + cx, + ); + + Self { + id: id.clone().into(), + name: id.into(), + http_client, + state, + } + } + + fn create_language_model(&self, model: AvailableModel) -> Arc { + let capabilities = model.capabilities.clone(); + // Compatible providers may not support Anthropic's automatic prompt + // caching; only request explicit (legacy) cache breakpoints when the + // user has opted in via the `prompt_caching` capability. + let cache_mode = if capabilities.prompt_caching { + AnthropicPromptCacheMode::Legacy + } else { + AnthropicPromptCacheMode::Disabled + }; + let model = available_model_to_anthropic_model(&model); + + Arc::new(AnthropicCompatibleLanguageModel { + id: LanguageModelId::from(model.id.clone()), + provider_id: self.id.clone(), + provider_name: self.name.clone(), + model, + capabilities, + cache_mode, + state: self.state.clone(), + http_client: self.http_client.clone(), + request_limiter: RateLimiter::new(4), + }) + } +} + +impl LanguageModelProviderState for AnthropicCompatibleLanguageModelProvider { + type ObservableEntity = State; + + fn observable_entity(&self) -> Option> { + Some(self.state.clone()) + } +} + +impl LanguageModelProvider for AnthropicCompatibleLanguageModelProvider { + fn id(&self) -> LanguageModelProviderId { + self.id.clone() + } + + fn name(&self) -> LanguageModelProviderName { + self.name.clone() + } + + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiAnthropicCompat) + } + + fn default_model(&self, cx: &App) -> Option> { + self.state + .read(cx) + .settings + .available_models + .first() + .map(|model| self.create_language_model(model.clone())) + } + + fn default_fast_model(&self, _cx: &App) -> Option> { + None + } + + fn provided_models(&self, cx: &App) -> Vec> { + self.state + .read(cx) + .settings + .available_models + .iter() + .map(|model| self.create_language_model(model.clone())) + .collect() + } + + fn is_authenticated(&self, cx: &App) -> bool { + self.state.read(cx).is_authenticated() + } + + fn authenticate(&self, cx: &mut App) -> Task> { + self.state.update(cx, |state, cx| state.authenticate(cx)) + } + + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + window: &mut Window, + cx: &mut App, + ) -> AnyView { + cx.new(|cx| { + ApiCompatibleProviderConfigurationView::new( + self.state.clone(), + "Anthropic", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() + } + + fn reset_credentials(&self, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(None, cx)) + } +} + +pub struct AnthropicCompatibleLanguageModel { + id: LanguageModelId, + provider_id: LanguageModelProviderId, + provider_name: LanguageModelProviderName, + model: anthropic::Model, + capabilities: ModelCapabilities, + cache_mode: AnthropicPromptCacheMode, + state: Entity, + http_client: Arc, + request_limiter: RateLimiter, +} + +impl AnthropicCompatibleLanguageModel { + fn stream_completion( + &self, + request: anthropic::Request, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let http_client = self.http_client.clone(); + let provider_name = self.provider_name.clone(); + + let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, _cx| { + let api_url = state.settings.api_url.clone(); + ( + state.api_key_state.key(&api_url), + api_url, + state.settings.custom_headers.clone(), + ) + }); + + let beta_headers = self.model.beta_headers(); + + async move { + let Some(api_key) = api_key else { + return Err(LanguageModelCompletionError::NoApiKey { + provider: provider_name, + }); + }; + + let request = anthropic::stream_completion( + http_client.as_ref(), + &api_url, + &api_key, + request, + beta_headers, + &extra_headers, + ); + + request + .await + .map_err(|error| anthropic::completion_error_from_anthropic(error, provider_name)) + } + .boxed() + } +} + +impl LanguageModel for AnthropicCompatibleLanguageModel { + fn id(&self) -> LanguageModelId { + self.id.clone() + } + + fn name(&self) -> LanguageModelName { + LanguageModelName::from(self.model.display_name.clone()) + } + + fn provider_id(&self) -> LanguageModelProviderId { + self.provider_id.clone() + } + + fn provider_name(&self) -> LanguageModelProviderName { + self.provider_name.clone() + } + + fn supports_tools(&self) -> bool { + self.capabilities.tools + } + + fn supports_images(&self) -> bool { + self.capabilities.images + } + + fn supports_streaming_tools(&self) -> bool { + self.capabilities.tools + } + + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { + match choice { + LanguageModelToolChoice::Auto | LanguageModelToolChoice::Any => self.capabilities.tools, + LanguageModelToolChoice::None => true, + } + } + + fn supports_thinking(&self) -> bool { + self.model.supports_thinking + } + + fn telemetry_id(&self) -> String { + format!("anthropic/{}", self.model.id) + } + + fn max_token_count(&self) -> u64 { + self.model.max_input_tokens + } + + fn max_output_tokens(&self) -> Option { + Some(self.model.max_output_tokens) + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + BoxStream<'static, Result>, + LanguageModelCompletionError, + >, + > { + let has_tools = !request.tools.is_empty(); + let request_id = self.model.request_id(has_tools).to_string(); + let mut request = into_anthropic( + request, + request_id, + self.model.default_temperature, + self.model.max_output_tokens, + self.model.mode.clone(), + self.cache_mode, + ); + if !self.model.supports_speed { + request.speed = None; + } + let completion_request = self.stream_completion(request, cx); + let provider_name = self.provider_name.clone(); + let future = self.request_limiter.stream(async move { + let response = completion_request.await?; + Ok(AnthropicEventMapper::new(provider_name).map_stream(response)) + }); + async move { Ok(future.await?.boxed()) }.boxed() + } +} diff --git a/crates/language_models/src/provider/api_compatible.rs b/crates/language_models/src/provider/api_compatible.rs new file mode 100644 index 00000000000000..e186f0baf956d0 --- /dev/null +++ b/crates/language_models/src/provider/api_compatible.rs @@ -0,0 +1,262 @@ +use std::sync::Arc; + +use ::util::ResultExt; +use anyhow::Result; +use convert_case::{Case, Casing}; +use credentials_provider::CredentialsProvider; +use gpui::{App, AppContext as _, Context, Entity, SharedString, Task, TaskExt, Window}; +use language_model::{ApiKeyState, AuthenticateError, EnvVar}; +use settings::SettingsStore; +use ui::{ElevationIndex, Tooltip, prelude::*}; +use ui_input::InputField; + +pub trait ApiCompatibleProviderSettings: Clone + Default + PartialEq + 'static { + fn api_url(&self) -> &str; +} + +pub struct ApiCompatibleProviderState { + id: Arc, + pub api_key_state: ApiKeyState, + pub settings: S, + credentials_provider: Arc, +} + +impl ApiCompatibleProviderState { + pub fn new( + id: Arc, + credentials_provider: Arc, + resolve_settings: for<'a> fn(&'a str, &'a App) -> Option<&'a S>, + cx: &mut App, + ) -> Entity { + let api_key_env_var_name: SharedString = + format!("{}_API_KEY", id).to_case(Case::UpperSnake).into(); + cx.new(|cx| { + cx.observe_global::(move |this: &mut Self, cx| { + let Some(settings) = resolve_settings(&this.id, cx).cloned() else { + return; + }; + this.update_settings(settings, cx); + }) + .detach(); + + let settings = resolve_settings(&id, cx).cloned().unwrap_or_default(); + Self { + id, + api_key_state: ApiKeyState::new( + SharedString::new(settings.api_url()), + EnvVar::new(api_key_env_var_name), + ), + settings, + credentials_provider, + } + }) + } + + pub fn is_authenticated(&self) -> bool { + self.api_key_state.has_key() + } + + pub fn set_api_key( + &mut self, + api_key: Option, + cx: &mut Context, + ) -> Task> { + let api_url = SharedString::new(self.settings.api_url()); + self.api_key_state.store( + api_url, + api_key, + |this| &mut this.api_key_state, + self.credentials_provider.clone(), + cx, + ) + } + + pub fn authenticate(&mut self, cx: &mut Context) -> Task> { + let api_url = SharedString::new(self.settings.api_url()); + self.api_key_state.load_if_needed( + api_url, + |this| &mut this.api_key_state, + self.credentials_provider.clone(), + cx, + ) + } + + pub fn update_settings(&mut self, settings: S, cx: &mut Context) { + if self.settings != settings { + let api_url = SharedString::new(settings.api_url()); + self.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + self.credentials_provider.clone(), + cx, + ); + self.settings = settings; + cx.notify(); + } + } +} + +pub struct ApiCompatibleProviderConfigurationView { + api_key_editor: Entity, + state: Entity>, + provider_name: &'static str, + load_credentials_task: Option>, +} + +impl ApiCompatibleProviderConfigurationView { + pub fn new( + state: Entity>, + provider_name: &'static str, + placeholder_text: &'static str, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let api_key_editor = cx.new(|cx| InputField::new(window, cx, placeholder_text)); + + cx.observe(&state, |_, _, cx| { + cx.notify(); + }) + .detach(); + + let load_credentials_task = Some(cx.spawn_in(window, { + let state = state.clone(); + async move |this, cx| { + let task = state.update(cx, |state, cx| state.authenticate(cx)); + match task.await { + Ok(()) | Err(AuthenticateError::CredentialsNotFound) => {} + Err(error) => { + log::error!( + "Failed to load {provider_name}-compatible provider API credentials: {error}" + ); + } + } + this.update(cx, |this, cx| { + this.load_credentials_task = None; + cx.notify(); + }) + .log_err(); + } + })); + + Self { + api_key_editor, + state, + provider_name, + load_credentials_task, + } + } + + fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); + if api_key.is_empty() { + return; + } + + // url changes can cause the editor to be displayed again + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); + + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) + .await + }) + .detach_and_log_err(cx); + } + + fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { + self.api_key_editor + .update(cx, |input, cx| input.set_text("", window, cx)); + + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(None, cx)) + .await + }) + .detach_and_log_err(cx); + } + + fn should_render_editor(&self, cx: &Context) -> bool { + !self.state.read(cx).is_authenticated() + } +} + +impl Render for ApiCompatibleProviderConfigurationView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let state = self.state.read(cx); + let env_var_set = state.api_key_state.is_from_env_var(); + let env_var_name = state.api_key_state.env_var_name(); + let provider_name = self.provider_name; + + let api_key_section = if self.should_render_editor(cx) { + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(Label::new(format!( + "To use Zed's agent with an {provider_name}-compatible provider, you need to add an API key." + ))) + .child( + div() + .pt(DynamicSpacing::Base04.rems(cx)) + .child(self.api_key_editor.clone()), + ) + .child( + Label::new(format!( + "You can also set the {env_var_name} environment variable and restart Zed.", + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any() + } else { + h_flex() + .mt_1() + .p_1() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().background) + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child( + div().w_full().overflow_x_hidden().text_ellipsis().child(Label::new( + if env_var_set { + format!("API key set in {env_var_name} environment variable") + } else { + format!("API key configured for {}", state.settings.api_url()) + }, + )), + ), + ) + .child( + h_flex().flex_shrink_0().child( + Button::new("reset-api-key", "Reset API Key") + .label_size(LabelSize::Small) + .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.", + ))) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_api_key(window, cx) + })), + ), + ) + .into_any() + }; + + if self.load_credentials_task.is_some() { + div().child(Label::new("Loading credentials…")).into_any() + } else { + v_flex().size_full().child(api_key_section).into_any() + } + } +} diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 8f7cbe6864cb33..47fcdaf3c4c7c3 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,6 +1,8 @@ use ai_onboarding::YoungAccountBanner; -use anyhow::Result; -use client::{Client, RefreshLlmTokenListener, UserStore, global_llm_token, zed_urls}; +use anyhow::{Result, anyhow}; +use client::{ + Client, RefreshLlmTokenListener, TelemetrySettings, UserStore, global_llm_token, zed_urls, +}; use cloud_api_client::LlmApiToken; use cloud_api_types::OrganizationId; use cloud_api_types::Plan; @@ -52,6 +54,8 @@ impl CloudLlmTokenProvider for ClientTokenProvider { let client = self.client.clone(); let llm_api_token = self.llm_api_token.clone(); Box::pin(async move { + let organization_id = + organization_id.ok_or_else(|| anyhow!("No organization selected."))?; client .cached_llm_token(&llm_api_token, organization_id) .await @@ -65,11 +69,21 @@ impl CloudLlmTokenProvider for ClientTokenProvider { let client = self.client.clone(); let llm_api_token = self.llm_api_token.clone(); Box::pin(async move { + let organization_id = + organization_id.ok_or_else(|| anyhow!("No organization selected."))?; client .refresh_llm_token(&llm_api_token, organization_id) .await }) } + + fn has_data_retention_consent(&self, cx: &impl AppContext) -> bool { + cx.read_global(|settings_store: &SettingsStore, _| { + settings_store + .get::(None) + .anthropic_retention + }) + } } #[derive(Default, Clone, Debug, PartialEq)] @@ -173,7 +187,7 @@ impl State { } fn is_signed_out(&self, cx: &App) -> bool { - self.user_store.read(cx).current_user().is_none() + self.status.is_signed_out() || self.user_store.read(cx).current_user().is_none() } fn sign_in(&self, cx: &mut Context) -> Task> { @@ -229,6 +243,12 @@ impl CloudLanguageModelProvider { _ = this.update(cx, |this, cx| { if this.status != status { this.status = status; + if status.is_signed_out() { + this.provider.update(cx, |provider, cx| { + provider.clear_models(); + cx.notify(); + }); + } cx.notify(); } }); @@ -326,7 +346,7 @@ impl LanguageModelProvider for CloudLanguageModelProvider { | client::Status::Reauthenticated | client::Status::Connected { .. } ) { - return Err(AuthenticateError::Other(anyhow::anyhow!( + return Err(AuthenticateError::Other(anyhow!( "sign-in did not complete: {current_status:?}" ))); } @@ -706,6 +726,77 @@ mod tests { .expect_err("provider authentication should fail when sign-in fails"); assert!(error.to_string().contains("AuthenticationError")); } + + #[gpui::test] + async fn sign_out_hides_cached_cloud_models(cx: &mut TestAppContext) { + let (client, _user_store, provider) = cx.update(init_test); + let (authenticate_tx, authenticate_rx) = futures::channel::oneshot::channel(); + let (authenticated_user_tx, authenticated_user_rx) = futures::channel::oneshot::channel(); + override_authenticate(&client, authenticate_rx); + respond_to_authenticated_user_after(&client, authenticated_user_rx); + + let sign_in_task = sign_in_until_authenticating(client.clone(), cx).await; + authenticate_tx + .send(Ok(Credentials { + user_id: TEST_USER_ID, + access_token: "token".to_string(), + })) + .expect("authenticate receiver dropped"); + authenticated_user_tx + .send(()) + .expect("authenticated user receiver dropped"); + sign_in_task.await.expect("sign-in should complete"); + cx.executor().run_until_parked(); + + let model_id = cloud_llm_client::LanguageModelId(Arc::from("test-model")); + cx.update(|cx| { + let cloud_model_provider = provider.state.read(cx).provider.clone(); + cloud_model_provider.update(cx, |cloud_model_provider, cx| { + cloud_model_provider.update_models(cloud_llm_client::ListModelsResponse { + models: vec![cloud_llm_client::LanguageModel { + provider: cloud_llm_client::LanguageModelProvider::Anthropic, + id: model_id.clone(), + display_name: "Test Model".to_string(), + is_latest: true, + max_token_count: 200_000, + max_token_count_in_max_mode: None, + max_output_tokens: 8_192, + supports_tools: true, + supports_images: false, + supports_thinking: false, + supports_disabling_thinking: false, + supports_fast_mode: false, + supported_effort_levels: Vec::new(), + supports_streaming_tools: false, + supports_parallel_tool_calls: false, + }], + default_model: Some(model_id.clone()), + default_fast_model: None, + recommended_models: vec![model_id], + }); + cx.notify(); + }); + }); + + assert!(cx.read(|cx| provider.is_authenticated(cx))); + assert_eq!(cx.read(|cx| provider.provided_models(cx).len()), 1); + assert!(cx.read(|cx| provider.default_model(cx).is_some())); + assert_eq!(cx.read(|cx| provider.recommended_models(cx).len()), 1); + + cx.update(|cx| { + cx.spawn({ + let client = client.clone(); + async move |cx| client.sign_out(cx).await + }) + }) + .await; + cx.executor().run_until_parked(); + + assert!(!cx.read(|cx| provider.is_authenticated(cx))); + assert!(cx.read(|cx| provider.provided_models(cx).is_empty())); + assert!(cx.read(|cx| provider.default_model(cx).is_none())); + assert!(cx.read(|cx| provider.recommended_models(cx).is_empty())); + } } impl Component for ZedAiConfiguration { diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index f42fad657c4052..43067965ca57c3 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -400,7 +400,7 @@ impl LanguageModel for CopilotChatLanguageModel { request_limiter .stream(async move { let events = stream.await?; - let mapper = AnthropicEventMapper::new(); + let mapper = AnthropicEventMapper::new(PROVIDER_NAME); Ok(mapper.map_stream(events).boxed()) }) .await diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 51f277be33f47e..c1f9f70a154dae 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -1,33 +1,35 @@ use anyhow::Result; -use convert_case::{Case, Casing}; use credentials_provider::CredentialsProvider; use futures::{FutureExt, StreamExt, future::BoxFuture}; -use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; +use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, }; -use menu; use open_ai::{ ResponseStreamEvent, responses::{Request as ResponseRequest, StreamEvent as ResponsesStreamEvent, stream_response}, stream_completion, }; -use settings::{Settings, SettingsStore}; +use settings::Settings; use std::sync::Arc; -use ui::{ElevationIndex, Tooltip, prelude::*}; -use ui_input::InputField; -use util::ResultExt; +use ui::IconName; +use crate::provider::api_compatible::{ + ApiCompatibleProviderConfigurationView, ApiCompatibleProviderSettings, + ApiCompatibleProviderState, +}; use crate::provider::open_ai::{ OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, }; pub use settings::OpenAiCompatibleAvailableModel as AvailableModel; pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; +const API_KEY_PLACEHOLDER: &str = "000000000000000000000000000000000000000000000000000"; + #[derive(Default, Clone, Debug, PartialEq)] pub struct OpenAiCompatibleSettings { pub api_url: String, @@ -35,6 +37,14 @@ pub struct OpenAiCompatibleSettings { pub custom_headers: CustomHeaders, } +impl ApiCompatibleProviderSettings for OpenAiCompatibleSettings { + fn api_url(&self) -> &str { + &self.api_url + } +} + +pub type State = ApiCompatibleProviderState; + pub struct OpenAiCompatibleLanguageModelProvider { id: LanguageModelProviderId, name: LanguageModelProviderName, @@ -42,42 +52,6 @@ pub struct OpenAiCompatibleLanguageModelProvider { state: Entity, } -pub struct State { - id: Arc, - api_key_state: ApiKeyState, - settings: OpenAiCompatibleSettings, - credentials_provider: Arc, -} - -impl State { - fn is_authenticated(&self) -> bool { - self.api_key_state.has_key() - } - - fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { - let credentials_provider = self.credentials_provider.clone(); - let api_url = SharedString::new(self.settings.api_url.as_str()); - self.api_key_state.store( - api_url, - api_key, - |this| &mut this.api_key_state, - credentials_provider, - cx, - ) - } - - fn authenticate(&mut self, cx: &mut Context) -> Task> { - let credentials_provider = self.credentials_provider.clone(); - let api_url = SharedString::new(self.settings.api_url.clone()); - self.api_key_state.load_if_needed( - api_url, - |this| &mut this.api_key_state, - credentials_provider, - cx, - ) - } -} - impl OpenAiCompatibleLanguageModelProvider { pub fn new( id: Arc, @@ -85,43 +59,16 @@ impl OpenAiCompatibleLanguageModelProvider { credentials_provider: Arc, cx: &mut App, ) -> Self { - fn resolve_settings<'a>(id: &'a str, cx: &'a App) -> Option<&'a OpenAiCompatibleSettings> { - crate::AllLanguageModelSettings::get_global(cx) - .openai_compatible - .get(id) - } - - let api_key_env_var_name = format!("{}_API_KEY", id).to_case(Case::UpperSnake).into(); - let state = cx.new(|cx| { - cx.observe_global::(|this: &mut State, cx| { - let Some(settings) = resolve_settings(&this.id, cx).cloned() else { - return; - }; - if &this.settings != &settings { - let credentials_provider = this.credentials_provider.clone(); - let api_url = SharedString::new(settings.api_url.as_str()); - this.api_key_state.handle_url_change( - api_url, - |this| &mut this.api_key_state, - credentials_provider, - cx, - ); - this.settings = settings; - cx.notify(); - } - }) - .detach(); - let settings = resolve_settings(&id, cx).cloned().unwrap_or_default(); - State { - id: id.clone(), - api_key_state: ApiKeyState::new( - SharedString::new(settings.api_url.as_str()), - EnvVar::new(api_key_env_var_name), - ), - settings, - credentials_provider, - } - }); + let state = State::new( + id.clone(), + credentials_provider, + |id, cx| { + crate::AllLanguageModelSettings::get_global(cx) + .openai_compatible + .get(id) + }, + cx, + ); Self { id: id.clone().into(), @@ -202,8 +149,16 @@ impl LanguageModelProvider for OpenAiCompatibleLanguageModelProvider { window: &mut Window, cx: &mut App, ) -> AnyView { - cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) - .into() + cx.new(|cx| { + ApiCompatibleProviderConfigurationView::new( + self.state.clone(), + "OpenAI", + API_KEY_PLACEHOLDER, + window, + cx, + ) + }) + .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { @@ -416,159 +371,3 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } } } - -struct ConfigurationView { - api_key_editor: Entity, - state: Entity, - load_credentials_task: Option>, -} - -impl ConfigurationView { - fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let api_key_editor = cx.new(|cx| { - InputField::new( - window, - cx, - "000000000000000000000000000000000000000000000000000", - ) - }); - - cx.observe(&state, |_, _, cx| { - cx.notify(); - }) - .detach(); - - let load_credentials_task = Some(cx.spawn_in(window, { - let state = state.clone(); - async move |this, cx| { - if let Some(task) = Some(state.update(cx, |state, cx| state.authenticate(cx))) { - // We don't log an error, because "not signed in" is also an error. - let _ = task.await; - } - this.update(cx, |this, cx| { - this.load_credentials_task = None; - cx.notify(); - }) - .log_err(); - } - })); - - Self { - api_key_editor, - state, - load_credentials_task, - } - } - - fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_editor.read(cx).text(cx).trim().to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.api_key_editor - .update(cx, |input, cx| input.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(Some(api_key), cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.api_key_editor - .update(cx, |input, cx| input.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_api_key(None, cx)) - .await - }) - .detach_and_log_err(cx); - } - - fn should_render_editor(&self, cx: &Context) -> bool { - !self.state.read(cx).is_authenticated() - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let state = self.state.read(cx); - let env_var_set = state.api_key_state.is_from_env_var(); - let env_var_name = state.api_key_state.env_var_name(); - - let api_key_section = if self.should_render_editor(cx) { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new("To use Zed's agent with an OpenAI-compatible provider, you need to add an API key.")) - .child( - div() - .pt(DynamicSpacing::Base04.rems(cx)) - .child(self.api_key_editor.clone()) - ) - .child( - Label::new( - format!("You can also set the {env_var_name} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ) - .into_any() - } else { - h_flex() - .mt_1() - .p_1() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child( - div() - .w_full() - .overflow_x_hidden() - .text_ellipsis() - .child(Label::new( - if env_var_set { - format!("API key set in {env_var_name} environment variable") - } else { - format!("API key configured for {}", &state.settings.api_url) - } - )) - ), - ) - .child( - h_flex() - .flex_shrink_0() - .child( - Button::new("reset-api-key", "Reset API Key") - .label_size(LabelSize::Small) - .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."))) - }) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), - ), - ) - .into_any() - }; - - if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials…")).into_any() - } else { - v_flex().size_full().child(api_key_section).into_any() - } - } -} diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index ef434eed859992..c414ccacee7581 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -59,13 +59,20 @@ impl State { fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { let credentials_provider = self.credentials_provider.clone(); let api_url = OpenRouterLanguageModelProvider::api_url(cx); - self.api_key_state.store( + let task = self.api_key_state.store( api_url, api_key, |this| &mut this.api_key_state, credentials_provider, cx, - ) + ); + + cx.spawn(async move |this, cx| { + let result = task.await?; + this.update(cx, |this, cx| this.restart_fetch_models_task(cx)) + .ok(); + Ok(result) + }) } fn authenticate(&mut self, cx: &mut Context) -> Task> { @@ -103,12 +110,7 @@ impl State { cx.spawn(async move |this, cx| { let models = list_models(http_client.as_ref(), &api_url, &api_key, &extra_headers) .await - .map_err(|e| { - LanguageModelCompletionError::Other(anyhow::anyhow!( - "OpenRouter error: {:?}", - e - )) - })?; + .map_err(LanguageModelCompletionError::from)?; this.update(cx, |this, cx| { this.available_models = models; @@ -125,7 +127,7 @@ impl State { let task = self.fetch_models(cx); self.fetch_models_task.replace(task); } else { - self.available_models = Vec::new(); + self.available_models.clear(); } } } diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 28501560c28b94..7ee2d980ed76c3 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -673,7 +673,7 @@ impl LanguageModel for OpenCodeLanguageModel { let stream = self.stream_anthropic(anthropic_request, http_client, extra_headers, cx); async move { - let mapper = AnthropicEventMapper::new(); + let mapper = AnthropicEventMapper::new(PROVIDER_NAME); Ok(mapper.map_stream(stream.await?).boxed()) } .boxed() diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index 9e3f6d708bc8e1..d2fc9f59a266f8 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -4,17 +4,18 @@ use collections::HashMap; use settings::RegisterSetting; use crate::provider::{ - anthropic, anthropic::AnthropicSettings, bedrock, bedrock::AmazonBedrockSettings, - cloud::ZedDotDevSettings, deepseek::DeepSeekSettings, google::GoogleSettings, - lmstudio::LmStudioSettings, mistral, mistral::MistralSettings, ollama::OllamaSettings, - open_ai::OpenAiSettings, open_ai_compatible::OpenAiCompatibleSettings, open_router, - open_router::OpenRouterSettings, opencode, opencode::OpenCodeSettings, resolve_custom_headers, - vercel_ai_gateway::VercelAiGatewaySettings, x_ai::XAiSettings, + anthropic, anthropic::AnthropicSettings, anthropic_compatible::AnthropicCompatibleSettings, + bedrock, bedrock::AmazonBedrockSettings, cloud::ZedDotDevSettings, deepseek::DeepSeekSettings, + google::GoogleSettings, lmstudio::LmStudioSettings, mistral, mistral::MistralSettings, + ollama::OllamaSettings, open_ai::OpenAiSettings, open_ai_compatible::OpenAiCompatibleSettings, + open_router, open_router::OpenRouterSettings, opencode, opencode::OpenCodeSettings, + resolve_custom_headers, vercel_ai_gateway::VercelAiGatewaySettings, x_ai::XAiSettings, }; #[derive(Debug, RegisterSetting)] pub struct AllLanguageModelSettings { pub anthropic: AnthropicSettings, + pub anthropic_compatible: HashMap, AnthropicCompatibleSettings>, pub bedrock: AmazonBedrockSettings, pub deepseek: DeepSeekSettings, pub google: GoogleSettings, @@ -47,6 +48,7 @@ impl settings::Settings for AllLanguageModelSettings { fn from_settings(content: &settings::SettingsContent) -> Self { let language_models = content.language_models.clone().unwrap(); let anthropic = language_models.anthropic.unwrap(); + let anthropic_compatible = language_models.anthropic_compatible.unwrap(); let bedrock = language_models.bedrock.unwrap(); let deepseek = language_models.deepseek.unwrap(); let google = language_models.google.unwrap(); @@ -70,6 +72,24 @@ impl settings::Settings for AllLanguageModelSettings { anthropic::RESERVED_HEADER_NAMES, ), }, + anthropic_compatible: anthropic_compatible + .into_iter() + .map(|(key, value)| { + let provider_label = format!("Anthropic Compatible ({key})"); + ( + key, + AnthropicCompatibleSettings { + api_url: value.api_url, + available_models: value.available_models, + custom_headers: custom_headers_from( + &provider_label, + value.custom_headers, + anthropic::RESERVED_HEADER_NAMES, + ), + }, + ) + }) + .collect(), bedrock: AmazonBedrockSettings { available_models: bedrock.available_models.unwrap_or_default(), custom_headers: custom_headers_from( diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 57ad96280b51c3..c4ed55d8538896 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -1,5 +1,5 @@ use anthropic::AnthropicModelMode; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result}; use cloud_llm_client::{ CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, CLIENT_SUPPORTS_X_AI_HEADER_NAME, CompletionBody, CompletionEvent, CompletionRequestStatus, @@ -23,9 +23,8 @@ use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProviderId, LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolSchemaFormat, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, - PaymentRequiredError, RateLimiter, X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, ZED_CLOUD_PROVIDER_ID, - ZED_CLOUD_PROVIDER_NAME, + LanguageModelToolSchemaFormat, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, RateLimiter, + X_AI_PROVIDER_ID, X_AI_PROVIDER_NAME, ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, }; use schemars::JsonSchema; @@ -55,6 +54,11 @@ pub trait CloudLlmTokenProvider: Send + Sync { fn auth_context(&self, cx: &impl AppContext) -> Self::AuthContext; fn cached_token(&self, auth_context: Self::AuthContext) -> BoxFuture<'static, Result>; fn refresh_token(&self, auth_context: Self::AuthContext) -> BoxFuture<'static, Result>; + + /// Whether the user has consented to upstream providers retaining + /// inference logs for models that require it (see + /// [`LanguageModel::requires_data_retention`]). + fn has_data_retention_consent(&self, cx: &impl AppContext) -> bool; } /// Sends an authenticated request to the Zed LLM service, retrying once with @@ -118,9 +122,16 @@ impl CloudLanguageModel { auth_context: TP::AuthContext, app_version: Option, body: CompletionBody, - ) -> Result { - let url = http_client.build_zed_llm_url("/completions", &[])?; - let body = serde_json::to_string(&body)?; + ) -> Result { + let url = http_client + .build_zed_llm_url("/completions", &[]) + .map_err(LanguageModelCompletionError::Other)?; + let body = serde_json::to_string(&body).map_err(|error| { + LanguageModelCompletionError::SerializeRequest { + provider: PROVIDER_NAME, + error, + } + })?; let mut response = authenticated_llm_request(http_client, token_provider, auth_context, |token| { Ok(http_client::Request::builder() @@ -135,7 +146,11 @@ impl CloudLanguageModel { .header(CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, "true") .body(body.clone().into())?) }) - .await?; + .await + .map_err(|error| LanguageModelCompletionError::HttpSend { + provider: PROVIDER_NAME, + error, + })?; let status = response.status(); if status.is_success() { @@ -151,17 +166,25 @@ impl CloudLanguageModel { } if status == StatusCode::PAYMENT_REQUIRED { - return Err(anyhow!(PaymentRequiredError)); + return Err(LanguageModelCompletionError::PaymentRequired); } let mut body = String::new(); let headers = response.headers().clone(); - response.body_mut().read_to_string(&mut body).await?; - Err(anyhow!(ApiError { + response + .body_mut() + .read_to_string(&mut body) + .await + .map_err(|error| LanguageModelCompletionError::ApiReadResponseError { + provider: PROVIDER_NAME, + error, + })?; + Err(ApiError { status, body, - headers - })) + headers, + } + .into()) } } @@ -298,6 +321,27 @@ impl LanguageModel for CloudLanguageModel bool { + // Anthropic cannot offer Fable models with Zero Data Retention + self.id + .0 + .as_ref() + .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + } + + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + if self + .id + .0 + .as_ref() + .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + { + Some(anthropic::FABLE_FALLBACK_MODEL_ID) + } else { + None + } + } + fn supports_tools(&self) -> bool { self.model.supports_tools } @@ -310,6 +354,10 @@ impl LanguageModel for CloudLanguageModel bool { + self.model.supports_disabling_thinking + } + fn supports_fast_mode(&self) -> bool { self.model.supports_fast_mode } @@ -379,6 +427,14 @@ impl LanguageModel for CloudLanguageModel, > { + if self.requires_data_retention() && !self.token_provider.has_data_retention_consent(cx) { + let model_name = self.model.display_name.clone(); + return async move { + Err(LanguageModelCompletionError::DataRetentionConsentRequired { model_name }) + } + .boxed(); + } + let thread_id = request.thread_id.clone(); let prompt_id = request.prompt_id.clone(); let app_version = self.app_version.clone(); @@ -435,17 +491,17 @@ impl LanguageModel for CloudLanguageModel() { - Ok(api_err) => anyhow!(LanguageModelCompletionError::from(api_err)), - Err(err) => anyhow!(err), - })?; + .await?; - let mut mapper = AnthropicEventMapper::new(); + let mut mapper = AnthropicEventMapper::new(provider_name.clone()); Ok(map_cloud_completion_events( Box::pin(response_lines(response, includes_status_messages)), &provider_name, @@ -500,8 +556,12 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel CloudModelProvider { self.models = models; } + pub fn clear_models(&mut self) { + self.models.clear(); + self.default_model = None; + self.default_fast_model = None; + self.recommended_models.clear(); + } + pub fn create_model( &self, model: &Arc, @@ -731,7 +806,7 @@ impl CloudModelProvider { } pub fn map_cloud_completion_events( - stream: Pin>> + Send>>, + stream: Pin, ResponseStreamError>> + Send>>, provider: &LanguageModelProviderName, mut map_callback: F, ) -> BoxStream<'static, Result> @@ -763,7 +838,7 @@ where Poll::Ready(Some(event)) => { let items = match event { Err(error) => { - vec![Err(LanguageModelCompletionError::from(error))] + vec![Err(error.into_completion_error(provider.clone()))] } Ok(CompletionEvent::Status(CompletionRequestStatus::StreamEnded)) => { saw_stream_ended = true; @@ -811,10 +886,36 @@ pub fn provider_name( } } +/// A failure while reading the streamed completion response body. +/// +/// Kept as a typed error (rather than `anyhow::Error`) so the consumer can +/// attach the provider name and build a structured +/// [`LanguageModelCompletionError`] without a runtime downcast. +pub enum ResponseStreamError { + Read(std::io::Error), + Deserialize(serde_json::Error), +} + +impl ResponseStreamError { + fn into_completion_error( + self, + provider: LanguageModelProviderName, + ) -> LanguageModelCompletionError { + match self { + ResponseStreamError::Read(error) => { + LanguageModelCompletionError::ApiReadResponseError { provider, error } + } + ResponseStreamError::Deserialize(error) => { + LanguageModelCompletionError::DeserializeResponse { provider, error } + } + } + } +} + pub fn response_lines( response: Response, includes_status_messages: bool, -) -> impl Stream>> { +) -> impl Stream, ResponseStreamError>> { futures::stream::try_unfold( (String::new(), BufReader::new(response.into_body())), move |(mut line, mut body)| async move { @@ -822,15 +923,19 @@ pub fn response_lines( Ok(0) => Ok(None), Ok(_) => { let event = if includes_status_messages { - serde_json::from_str::>(&line)? + serde_json::from_str::>(&line) + .map_err(ResponseStreamError::Deserialize)? } else { - CompletionEvent::Event(serde_json::from_str::(&line)?) + CompletionEvent::Event( + serde_json::from_str::(&line) + .map_err(ResponseStreamError::Deserialize)?, + ) }; line.clear(); Ok(Some((event, (line, body)))) } - Err(e) => Err(e.into()), + Err(error) => Err(ResponseStreamError::Read(error)), } }, ) @@ -986,4 +1091,32 @@ mod tests { ), } } + + #[test] + fn test_response_stream_error_maps_to_structured_variant() { + // Read/deserialize failures mid-stream must keep their structured + // variant rather than collapsing into `Other` (the source of the + // generic "Request failed." message). + let read = ResponseStreamError::Read(std::io::Error::from(std::io::ErrorKind::BrokenPipe)) + .into_completion_error(PROVIDER_NAME); + assert!( + matches!( + read, + LanguageModelCompletionError::ApiReadResponseError { .. } + ), + "Expected ApiReadResponseError, got: {read:?}" + ); + + let deserialize = ResponseStreamError::Deserialize( + serde_json::from_str::("not json").unwrap_err(), + ) + .into_completion_error(PROVIDER_NAME); + assert!( + matches!( + deserialize, + LanguageModelCompletionError::DeserializeResponse { .. } + ), + "Expected DeserializeResponse, got: {deserialize:?}" + ); + } } diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index e7c6d5b2160415..22b2795145fd20 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -551,6 +551,7 @@ impl LanguageServerState { HashSet::from_iter([LanguageServerSelector::Name( server_name_for_restart.clone(), )]), + true, cx, ); }) diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index 9c751dd8eaf712..8ceb3127509d83 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -377,7 +377,7 @@ impl SyntaxTreeView { row.child(if node.is_named() { Label::new(node.kind()).color(Color::Default) } else { - Label::new(format!("\"{}\"", node.kind())).color(Color::Created) + Label::new(format_anonymous_node_kind(node.kind())).color(Color::Created) }) .child( div() @@ -719,6 +719,10 @@ fn format_node_range(node: Node) -> String { ) } +fn format_anonymous_node_kind(kind: &str) -> String { + format!("\"{}\"", kind.escape_debug()) +} + impl Render for SyntaxTreeToolbarItemView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() @@ -749,3 +753,16 @@ impl ToolbarItemView for SyntaxTreeToolbarItemView { ToolbarItemLocation::Hidden } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anonymous_node_kinds_escape_control_characters() { + assert_eq!(format_anonymous_node_kind("\n"), "\"\\n\""); + assert_eq!(format_anonymous_node_kind("\r\n"), "\"\\r\\n\""); + assert_eq!(format_anonymous_node_kind("\t"), "\"\\t\""); + assert_eq!(format_anonymous_node_kind(","), "\",\""); + } +} diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 75aaf8048ad004..18dea9d61b21ae 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -586,6 +586,53 @@ fn adjust_runs( pub(crate) struct GoContextProvider; +pub(crate) struct GoRunnableResolver; + +impl RunnableResolver for GoRunnableResolver { + fn resolve( + &self, + local_captures: &[RunnableMatchCapture], + shared_captures: &[RunnableMatchCapture], + buffer: &BufferSnapshot, + ) -> Option { + const FIELD_CHECK: &str = "_field_check"; + const FIELD_NAME: &str = "_field_name"; + const TABLE_TEST_CASE_NAME: &str = "_table_test_case_name"; + + // A row may declare several string fields (e.g. `{ name: "x", label: "y" }`), so + // the query emits one `@_field_name` + `@run` pair per field, in source order. + // + // When the loop body calls `t.Run(tc., ...)`, `@_field_check` names that + // field; pick the pair whose `@_field_name` matches it. Without a `@_field_check` + // (e.g. map-keyed tables) the first pair wins. + let reference_text = shared_captures + .iter() + .find(|capture| capture.name() == Some(FIELD_CHECK)) + .map(|capture| buffer.text_for_range(capture.range()).collect::()); + let pair_index = match &reference_text { + Some(reference) => local_captures + .iter() + .filter(|capture| capture.name() == Some(FIELD_NAME)) + .position(|capture| buffer.text_for_range(capture.range()).equals_str(reference))?, + None => 0, + }; + + // `@run` and `@_table_test_case_name` tag the same string literal, so the chosen + // run's text is the case name. + let run_capture = local_captures + .iter() + .filter(|capture| capture.is_run()) + .nth(pair_index)?; + Some(ResolvedRunnable { + run_range: run_capture.range(), + extra_captures: smallvec::smallvec![( + TABLE_TEST_CASE_NAME.to_string(), + buffer.text_for_range(run_capture.range()).collect(), + )], + }) + } +} + const GO_PACKAGE_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("GO_PACKAGE")); const GO_MODULE_ROOT_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("GO_MODULE_ROOT")); @@ -864,6 +911,10 @@ impl ContextProvider for GoContextProvider { }, ]))) } + + fn runnable_resolver(&self) -> Option> { + Some(Arc::new(GoRunnableResolver)) + } } fn extract_subtest_name(input: &str) -> Option { @@ -894,10 +945,19 @@ mod tests { use gpui::{AppContext, Hsla, TestAppContext}; use theme::SyntaxTheme; + fn go_language() -> Arc { + let language = language("go", tree_sitter_go::LANGUAGE.into()); + Arc::new( + Arc::try_unwrap(language) + .unwrap() + .with_context_provider(Some(Arc::new(GoContextProvider))), + ) + } + #[gpui::test] async fn test_go_label_for_completion() { let adapter = Arc::new(GoLspAdapter); - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let theme = SyntaxTheme::new_test([ ("type", Hsla::default()), @@ -985,7 +1045,7 @@ mod tests { #[gpui::test] fn test_go_test_main_ignored(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let example_test = r#" package main @@ -1019,7 +1079,7 @@ mod tests { #[gpui::test] fn test_testify_suite_detection(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let testify_suite = r#" package main @@ -1072,7 +1132,7 @@ mod tests { #[gpui::test] fn test_go_runnable_detection(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let interpreted_string_subtest = r#" package main @@ -1161,7 +1221,7 @@ mod tests { #[gpui::test] fn test_go_example_test_detection(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let example_test = r#" package main @@ -1198,7 +1258,7 @@ mod tests { #[gpui::test] fn test_go_table_test_slice_detection(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let table_test = r#" package main @@ -1272,27 +1332,54 @@ mod tests { ); let go_test_count = tag_strings.iter().filter(|&tag| tag == "go-test").count(); - // This is currently broken; see #39148 - // let go_table_test_count = tag_strings - // .iter() - // .filter(|&tag| tag == "go-table-test-case") - // .count(); + let go_table_test_count = tag_strings + .iter() + .filter(|&tag| tag == "go-table-test-case") + .count(); assert!( go_test_count == 1, "Should find exactly 1 go-test, found: {}", go_test_count ); - // assert!( - // go_table_test_count == 3, - // "Should find exactly 3 go-table-test-case, found: {}", - // go_table_test_count - // ); + assert!( + go_table_test_count == 3, + "Should find exactly 3 go-table-test-case, found: {}", + go_table_test_count + ); + + let Some(first_case_offset) = table_test.find("anotherStr: \"foo\"") else { + panic!("missing first table test case"); + }; + let first_case_offset = first_case_offset + "anotherStr".len(); + let first_case_runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot + .runnable_ranges(first_case_offset..first_case_offset) + .collect() + }); + let table_test_case_names: Vec<_> = first_case_runnables + .iter() + .filter(|runnable| { + runnable + .runnable + .tags + .iter() + .any(|tag| tag.0 == "go-table-test-case") + }) + .filter_map(|runnable| runnable.extra_captures.get("_table_test_case_name")) + .collect(); + + assert_eq!( + table_test_case_names, + vec!["\"test case 1\""], + "Should only return the table test case containing the requested range" + ); } #[gpui::test] fn test_go_table_test_slice_without_explicit_variable_detection(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let table_test = r#" package main @@ -1351,17 +1438,26 @@ mod tests { ); let go_test_count = tag_strings.iter().filter(|&tag| tag == "go-test").count(); + let go_table_test_count = tag_strings + .iter() + .filter(|&tag| tag == "go-table-test-case-without-explicit-variable") + .count(); assert!( go_test_count == 1, "Should find exactly 1 go-test, found: {}", go_test_count ); + assert!( + go_table_test_count == 3, + "Should find exactly 3 go-table-test-case-without-explicit-variable, found: {}", + go_table_test_count + ); } #[gpui::test] fn test_go_table_test_map_without_explicit_variable_detection(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let table_test = r#" package main @@ -1435,7 +1531,7 @@ mod tests { #[gpui::test] fn test_go_table_test_slice_ignored(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let table_test = r#" package main @@ -1485,7 +1581,7 @@ mod tests { #[gpui::test] fn test_go_table_test_map_detection(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let table_test = r#" package main @@ -1574,7 +1670,7 @@ mod tests { #[gpui::test] fn test_go_table_test_map_ignored(cx: &mut TestAppContext) { - let language = language("go", tree_sitter_go::LANGUAGE.into()); + let language = go_language(); let table_test = r#" package main @@ -1622,6 +1718,237 @@ mod tests { ); } + #[gpui::test] + fn test_go_table_test_stress(cx: &mut TestAppContext) { + let language = go_language(); + + let mut entries = String::new(); + for i in 0..100 { + entries.push_str(&format!( + " {{ name: \"case {}\", value: {} }},\n", + i, i + )); + } + let table_test = format!( + r#" + package main + + import "testing" + + func TestStress(t *testing.T) {{ + testCases := []struct{{ + name string + value int + }}{{ +{entries} }} + + for _, tc := range testCases {{ + t.Run(tc.name, func(t *testing.T) {{ + _ = tc.value + }}) + }} + }} + "#, + entries = entries + ); + + let buffer = cx.new(|cx| { + crate::Buffer::local(table_test.clone(), cx).with_language(language.clone(), cx) + }); + cx.executor().run_until_parked(); + + let runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(0..table_test.len()).collect() + }); + + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + + let go_table_test_count = tag_strings + .iter() + .filter(|&tag| tag == "go-table-test-case") + .count(); + + assert_eq!( + go_table_test_count, 100, + "Should emit one go-table-test-case per row (got {}); tree-sitter match_limit overflow has regressed", + go_table_test_count + ); + } + + #[gpui::test] + fn test_go_table_test_mismatched_field(cx: &mut TestAppContext) { + let language = go_language(); + + let table_test = r#" + package main + + import "testing" + + func TestMismatchedField(t *testing.T) { + testCases := []struct{ + name string + }{ + { name: "test case 1" }, + { name: "test case 2" }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + // test code here + }) + } + } + "#; + + let buffer = + cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx)); + cx.executor().run_until_parked(); + + let runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(0..table_test.len()).collect() + }); + + let tag_strings: Vec = runnables + .iter() + .flat_map(|r| &r.runnable.tags) + .map(|tag| tag.0.to_string()) + .collect(); + + let go_table_test_count = tag_strings + .iter() + .filter(|&tag| tag == "go-table-test-case") + .count(); + + assert_eq!( + go_table_test_count, 0, + "Should not emit table-test runnables when t.Run uses a missing row field" + ); + } + + #[gpui::test] + fn test_go_table_test_slice_picks_correct_field_when_not_first(cx: &mut TestAppContext) { + // The subtest-name field `name` is declared AFTER `anotherStr`, but `t.Run(tc.name, ...)` + // still selects on `name`. The resolver must match `@_field_check` text to the right + // `@_field_name` regardless of source order; "first string field wins" would be a bug. + let language = go_language(); + + let table_test = r#" + package main + + import "testing" + + func TestExample(t *testing.T) { + testCases := []struct{ + anotherStr string + name string + }{ + { + anotherStr: "alpha", + name: "case alpha", + }, + { + anotherStr: "beta", + name: "case beta", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _ = tc.anotherStr + }) + } + } + "#; + + let buffer = + cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx)); + cx.executor().run_until_parked(); + + let case_offset = table_test + .find("anotherStr: \"alpha\"") + .expect("source should contain the first case body"); + let first_case_runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(case_offset..case_offset).collect() + }); + + let case_names: Vec<_> = first_case_runnables + .iter() + .filter(|runnable| { + runnable + .runnable + .tags + .iter() + .any(|tag| tag.0 == "go-table-test-case") + }) + .filter_map(|runnable| runnable.extra_captures.get("_table_test_case_name")) + .collect(); + + assert_eq!( + case_names, + vec!["\"case alpha\""], + "Resolver should pick the field matching `tc.name`, not the first string field" + ); + } + + #[gpui::test] + fn test_go_table_test_map_extras_include_case_name(cx: &mut TestAppContext) { + let language = go_language(); + + let table_test = r#" + package main + + import "testing" + + func TestExample(t *testing.T) { + testCases := map[string]struct { + fail bool + }{ + "test failure": {fail: true}, + "test success": {fail: false}, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + _ = tc.fail + }) + } + } + "#; + + let buffer = + cx.new(|cx| crate::Buffer::local(table_test, cx).with_language(language.clone(), cx)); + cx.executor().run_until_parked(); + + let all_runnables: Vec<_> = buffer.update(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + snapshot.runnable_ranges(0..table_test.len()).collect() + }); + let all_case_names: Vec<_> = all_runnables + .iter() + .filter(|runnable| { + runnable + .runnable + .tags + .iter() + .any(|tag| tag.0 == "go-table-test-case") + }) + .filter_map(|runnable| runnable.extra_captures.get("_table_test_case_name")) + .cloned() + .collect(); + assert_eq!( + all_case_names, + vec!["\"test failure\"", "\"test success\""], + "Map-based table tests should surface each row's key as `_table_test_case_name`" + ); + } + #[test] fn test_extract_subtest_name() { // Interpreted string literal diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index 8a72b5df40e8d3..bed43d22d6c0a9 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -22,9 +22,6 @@ use livekit::webrtc::{ }; use log::info; use parking_lot::Mutex; -use rodio::Source; -use rodio::conversions::SampleTypeConverter; -use rodio::source::{AutomaticGainControlSettings, LimitSettings}; use serde::{Deserialize, Serialize}; use settings::Settings; use std::cell::RefCell; @@ -337,14 +334,6 @@ impl AudioStack { let ten_ms_buffer_size = (config.channels() as u32 * config.sample_rate() / 100) as usize; let mut buf: Vec = Vec::with_capacity(ten_ms_buffer_size); - let mut rodio_effects = RodioEffectsAdaptor::new(buf.len()) - .automatic_gain_control(AutomaticGainControlSettings { - target_level: 0.50, - attack_time: Duration::from_secs(1), - release_time: Duration::from_secs(0), - absolute_max_gain: 5.0, - }) - .limit(LimitSettings::live_performance()); let stream = device .build_input_stream_raw( @@ -377,20 +366,6 @@ impl AudioStack { ) .to_owned(); - if audio::LIVE_SETTINGS - .auto_microphone_volume - .load(Ordering::Relaxed) - { - rodio_effects - .inner_mut() - .inner_mut() - .fill_buffer_with(&sampled); - sampled.clear(); - sampled.extend(SampleTypeConverter::<_, i16>::new( - rodio_effects.by_ref(), - )); - } - apm.lock() .process_stream( &mut sampled, @@ -434,69 +409,6 @@ impl AudioStack { } } -/// This allows using of Rodio's effects library within our home brewn audio -/// pipeline. The alternative would be inlining Rodio's effects which is -/// problematic from a legal stance. We would then have to make clear that code -/// is not owned by zed-industries while the code would be surrounded by -/// zed-industries owned code. -/// -/// This adaptor does incur a slight performance penalty (copying into a -/// pre-allocated vec and back) however the impact will be immeasurably low. -/// -/// There is no latency impact. -pub struct RodioEffectsAdaptor { - input: Vec, - pos: usize, -} - -impl RodioEffectsAdaptor { - // This implementation incorrect terminology confusing everyone. A normal - // audio frame consists of all samples for one moment in time (one for mono, - // two for stereo). Here a frame of audio refers to a 10ms buffer of samples. - fn new(samples_per_frame: usize) -> Self { - Self { - input: Vec::with_capacity(samples_per_frame), - pos: 0, - } - } - - fn fill_buffer_with(&mut self, integer_samples: &[i16]) { - self.input.clear(); - self.input.extend(SampleTypeConverter::<_, f32>::new( - integer_samples.iter().copied(), - )); - self.pos = 0; - } -} - -impl Iterator for RodioEffectsAdaptor { - type Item = rodio::Sample; - - fn next(&mut self) -> Option { - let sample = self.input.get(self.pos)?; - self.pos += 1; - Some(*sample) - } -} - -impl rodio::Source for RodioEffectsAdaptor { - fn current_span_len(&self) -> Option { - None - } - - fn channels(&self) -> rodio::ChannelCount { - rodio::nz!(2) - } - - fn sample_rate(&self) -> rodio::SampleRate { - rodio::nz!(48000) - } - - fn total_duration(&self) -> Option { - None - } -} - #[derive(Serialize, Deserialize, Debug)] pub struct Speaker { pub name: String, diff --git a/crates/lsp/src/input_handler.rs b/crates/lsp/src/input_handler.rs index 61cad0c15e605b..679ae5c1f6cdeb 100644 --- a/crates/lsp/src/input_handler.rs +++ b/crates/lsp/src/input_handler.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; use collections::HashMap; use futures::{ - AsyncBufReadExt, AsyncRead, AsyncReadExt as _, - channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded}, + AsyncBufReadExt, AsyncRead, AsyncReadExt as _, SinkExt as _, + channel::mpsc::{Receiver, Sender, channel}, }; use gpui::{BackgroundExecutor, Task}; use log::warn; @@ -18,10 +18,18 @@ use crate::{ }; const HEADER_DELIMITER: &[u8; 4] = b"\r\n\r\n"; + +/// Bounds the number of incoming LSP messages buffered between the background +/// reader and the foreground dispatcher. When the queue is full, the reader +/// stops reading the server's stdout, letting the OS pipe apply backpressure +/// to the server instead of buffering messages in memory without limit while +/// the foreground thread is unresponsive. +pub(crate) const INCOMING_MESSAGE_QUEUE_CAPACITY: usize = 128; + /// Handler for stdout of language server. pub struct LspStdoutHandler { pub(super) loop_handle: Task>, - pub(super) incoming_messages: UnboundedReceiver, + pub(super) incoming_messages: Receiver, } async fn read_headers(reader: &mut BufReader, buffer: &mut Vec) -> Result<()> @@ -51,7 +59,7 @@ impl LspStdoutHandler { where Input: AsyncRead + Unpin + Send + 'static, { - let (tx, notifications_channel) = unbounded(); + let (tx, notifications_channel) = channel(INCOMING_MESSAGE_QUEUE_CAPACITY); let loop_handle = cx.spawn(Self::handler(stdout, tx, response_handlers, io_handlers)); Self { loop_handle, @@ -61,7 +69,7 @@ impl LspStdoutHandler { async fn handler( stdout: Input, - notifications_sender: UnboundedSender, + mut notifications_sender: Sender, response_handlers: Arc>>>, io_handlers: Arc>>, ) -> anyhow::Result<()> @@ -98,7 +106,7 @@ impl LspStdoutHandler { } if let Ok(msg) = serde_json::from_slice::(&buffer) { - notifications_sender.unbounded_send(msg)?; + notifications_sender.send(msg).await?; } else if let Ok(AnyResponse { id, error, result, .. }) = serde_json::from_slice(&buffer) @@ -131,6 +139,53 @@ impl LspStdoutHandler { #[cfg(test)] mod tests { use super::*; + use futures::{AsyncWriteExt as _, StreamExt as _}; + use gpui::TestAppContext; + + #[gpui::test] + async fn test_backpressure_when_messages_are_not_consumed(cx: &mut TestAppContext) { + let total_messages = INCOMING_MESSAGE_QUEUE_CAPACITY * 4; + let (mut writer, reader) = async_pipe::pipe(); + let mut handler = LspStdoutHandler::new( + reader, + Arc::new(Mutex::new(Some(HashMap::default()))), + Arc::new(Mutex::new(HashMap::default())), + cx.background_executor.clone(), + ); + + cx.background_executor + .spawn(async move { + let payload = r#"{"jsonrpc":"2.0","method":"test/notification","params":{}}"#; + let message = format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload); + for _ in 0..total_messages { + writer.write_all(message.as_bytes()).await.unwrap(); + } + }) + .detach(); + + cx.run_until_parked(); + let mut received = 0; + while handler.incoming_messages.try_recv().is_ok() { + received += 1; + } + assert!( + received < total_messages, + "the reader buffered all {total_messages} messages while the consumer was wedged" + ); + assert!( + received <= INCOMING_MESSAGE_QUEUE_CAPACITY + 2, + "expected at most {} buffered messages, got {received}", + INCOMING_MESSAGE_QUEUE_CAPACITY + 2 + ); + + while received < total_messages { + assert!( + handler.incoming_messages.next().await.is_some(), + "the message stream ended after {received} of {total_messages} messages" + ); + received += 1; + } + } #[gpui::test] async fn test_read_headers() { diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index c9ccfa4a3c2e7a..6a7e15d0011e1a 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -198,7 +198,7 @@ impl PartialEq for LanguageServerName { pub enum Subscription { Notification { method: &'static str, - notification_handlers: Option>>>, + notification_handlers: Option>>>, }, Io { id: i32, @@ -1217,7 +1217,7 @@ impl LanguageServer { ); Subscription::Notification { method, - notification_handlers: Some(self.notification_handlers.clone()), + notification_handlers: Some(Arc::downgrade(&self.notification_handlers)), } } @@ -1296,7 +1296,7 @@ impl LanguageServer { ); Subscription::Notification { method, - notification_handlers: Some(self.notification_handlers.clone()), + notification_handlers: Some(Arc::downgrade(&self.notification_handlers)), } } @@ -1803,7 +1803,7 @@ impl Drop for Subscription { method, notification_handlers, } => { - if let Some(handlers) = notification_handlers { + if let Some(handlers) = notification_handlers.as_ref().and_then(|h| h.upgrade()) { handlers.lock().remove(method); } } @@ -2174,6 +2174,75 @@ mod tests { fake.receive_notification::().await; } + #[gpui::test] + async fn test_subscription_leaks_handlers_after_server_drop(cx: &mut TestAppContext) { + cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + let (server, mut fake) = FakeLanguageServer::new( + LanguageServerId(0), + LanguageServerBinary { + path: "path/to/language-server".into(), + arguments: vec![], + env: None, + }, + "the-lsp".to_string(), + Default::default(), + &mut cx.to_async(), + ); + + let detached_payload = Arc::new(()); + let detached_payload_handle = Arc::downgrade(&detached_payload); + server + .on_notification::(move |_, _| { + let _payload = &detached_payload; + }) + .detach(); + + let retained_payload = Arc::new(()); + let retained_payload_handle = Arc::downgrade(&retained_payload); + let subscription = + server.on_notification::(move |_, _| { + let _payload = &retained_payload; + }); + + let server = cx + .update(|cx| { + let params = server.default_initialize_params(false, false, cx); + let configuration = DidChangeConfigurationParams { + settings: Default::default(), + }; + server.initialize( + params, + configuration.into(), + DEFAULT_LSP_REQUEST_TIMEOUT, + cx, + ) + }) + .await + .unwrap(); + + drop(server); + cx.run_until_parked(); + fake.receive_notification::().await; + drop(fake); + cx.run_until_parked(); + + assert!( + detached_payload_handle.upgrade().is_none(), + "detached handler was kept alive after the server was dropped, \ + because an unrelated retained subscription pins the whole handler map" + ); + assert!( + retained_payload_handle.upgrade().is_none(), + "handler with a retained subscription was kept alive after the server was dropped" + ); + + drop(subscription); + assert!(detached_payload_handle.upgrade().is_none()); + assert!(retained_payload_handle.upgrade().is_none()); + } + #[gpui::test] fn test_deserialize_string_digit_id() { let json = r#"{"jsonrpc":"2.0","id":"2","method":"workspace/configuration","params":{"items":[{"scopeUri":"file:///Users/mph/Devel/personal/hello-scala/","section":"metals"}]}}"#; diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index e92b29e8af234c..99b3fd20118eb2 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -108,6 +108,7 @@ pub struct MarkdownStyle { pub selection_background_color: Hsla, pub heading: StyleRefinement, pub heading_level_styles: Option, + pub heading_border_color: Option, pub height_is_multiple_of_line_height: bool, pub prevent_mouse_interaction: bool, pub table_columns_min_size: bool, @@ -131,6 +132,7 @@ impl Default for MarkdownStyle { selection_background_color: Default::default(), heading: Default::default(), heading_level_styles: None, + heading_border_color: None, height_is_multiple_of_line_height: false, prevent_mouse_interaction: false, table_columns_min_size: false, @@ -188,8 +190,6 @@ impl MarkdownStyle { theme_settings.buffer_font.family.clone() }; - let text_color = colors.text; - let mut text_style = window.text_style(); let line_height = buffer_font_size * 1.75; @@ -199,11 +199,11 @@ impl MarkdownStyle { font_features: Some(theme_settings.ui_font.features.clone()), font_size: Some(ui_font_size.into()), line_height: Some(line_height.into()), - color: Some(text_color), + color: Some(colors.text), ..Default::default() }); - MarkdownStyle { + let style = MarkdownStyle { base_text_style: text_style.clone(), syntax: syntax.clone(), selection_background_color: colors.element_selection_background, @@ -300,9 +300,56 @@ impl MarkdownStyle { }, ), ..Default::default() + }; + + if is_preview { + style.with_preview_overrides(ui_font_size, colors) + } else { + style } } + fn with_preview_overrides(mut self, ui_font_size: Pixels, colors: &theme::ThemeColors) -> Self { + let body_font_size = ui_font_size * 0.92; + self.base_text_style.font_size = body_font_size.into(); + self.container_style.text.font_size = Some(body_font_size.into()); + + self.base_text_style.color = colors.text_muted.blend(colors.text.opacity(0.25)); + self.inline_code.color = Some(colors.text); + self.heading.text.color = Some(colors.text); + + self.heading_level_styles = Some(HeadingLevelStyles { + h1: Some(TextStyleRefinement { + font_size: Some(rems(1.45).into()), + ..Default::default() + }), + h2: Some(TextStyleRefinement { + font_size: Some(rems(1.3).into()), + ..Default::default() + }), + h3: Some(TextStyleRefinement { + font_size: Some(rems(1.1).into()), + ..Default::default() + }), + h4: Some(TextStyleRefinement { + font_size: Some(rems(1.01).into()), + ..Default::default() + }), + h5: Some(TextStyleRefinement { + font_size: Some(rems(0.95).into()), + ..Default::default() + }), + h6: Some(TextStyleRefinement { + font_size: Some(rems(0.85).into()), + ..Default::default() + }), + }); + + self.heading_border_color = Some(colors.border_variant); + + self + } + pub fn with_buffer_font(mut self, cx: &App) -> Self { let theme_settings = ThemeSettings::get_global(cx); self.base_text_style.font_family = theme_settings.buffer_font.family.clone(); @@ -564,11 +611,13 @@ impl Markdown { } } - fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle { - self.code_block_scroll_handles - .entry(id) - .or_insert_with(ScrollHandle::new) - .clone() + fn code_block_scroll_handle(&mut self, id: usize) -> Option { + (!self.is_code_block_wrapped(id)).then(|| { + self.code_block_scroll_handles + .entry(id) + .or_insert_with(ScrollHandle::new) + .clone() + }) } fn retain_code_block_scroll_handles(&mut self, ids: &HashSet) { @@ -1334,7 +1383,12 @@ impl MarkdownElement { ) { let align = text_align_override.unwrap_or(self.style.base_text_style.text_align); let mut heading = div().mt_4().mb_2(); - heading = apply_heading_style(heading, level, self.style.heading_level_styles.as_ref()); + heading = apply_heading_style( + heading, + level, + self.style.heading_level_styles.as_ref(), + self.style.heading_border_color, + ); heading = match align { TextAlign::Center => heading.text_center(), @@ -2087,13 +2141,15 @@ impl Element for MarkdownElement { let is_indented = matches!(kind, CodeBlockKind::Indented); let scroll_handle = if self.style.code_block_overflow_x_scroll { - code_block_ids.insert(range.start); - Some(self.markdown.update(cx, |markdown, _| { + self.markdown.update(cx, |markdown, _| { markdown.code_block_scroll_handle(range.start) - })) + }) } else { None }; + if scroll_handle.is_some() { + code_block_ids.insert(range.start); + } match (&self.code_block_renderer, is_indented) { (CodeBlockRenderer::Default { .. }, _) | (_, true) => { @@ -2133,18 +2189,11 @@ impl Element for MarkdownElement { parent_container.style().refine(&self.style.code_block); builder.push_div(parent_container, range, markdown_end); - let is_wrapped = - self.markdown.read(cx).is_code_block_wrapped(range.start); - let code_block = div() .id(("code-block", range.start)) .rounded_lg() .map(|mut code_block| { - if is_wrapped { - code_block.w_full() - } else if let Some(scroll_handle) = - scroll_handle.as_ref() - { + if let Some(scroll_handle) = scroll_handle.as_ref() { code_block.style().restrict_scroll_to_axis = Some(true); code_block @@ -2221,6 +2270,7 @@ impl Element for MarkdownElement { }), MarkdownTag::Strong => builder.push_text_style(TextStyleRefinement { font_weight: Some(FontWeight::BOLD), + color: Some(cx.theme().colors().text), ..Default::default() }), MarkdownTag::Strikethrough => { @@ -2424,7 +2474,7 @@ impl Element for MarkdownElement { == WrapButtonVisibility::AlwaysVisible; let use_hover = any_hover && !any_always; - let mut button_row = h_flex() + let button_row = h_flex() .gap_0p5() .absolute() .bg(cx.theme().colors().editor_background) @@ -2434,26 +2484,33 @@ impl Element for MarkdownElement { this.top_1().right_1().visible_on_hover("code_block") }, |this| this.top_1p5().right_1p5(), + ) + .when( + wrap_button_visibility != WrapButtonVisibility::Hidden, + |this| { + let is_wrapped = self + .markdown + .read(cx) + .is_code_block_wrapped(range.start); + + this.child(render_wrap_code_block_button( + range.start, + is_wrapped, + self.markdown.clone(), + )) + }, + ) + .when( + copy_button_visibility != CopyButtonVisibility::Hidden, + |this| { + this.child(render_copy_code_block_button( + range.end, + code, + self.markdown.clone(), + )) + }, ); - if wrap_button_visibility != WrapButtonVisibility::Hidden { - let is_wrapped = - self.markdown.read(cx).is_code_block_wrapped(range.start); - button_row = button_row.child(render_wrap_code_block_button( - range.start, - is_wrapped, - self.markdown.clone(), - )); - } - - if copy_button_visibility != CopyButtonVisibility::Hidden { - button_row = button_row.child(render_copy_code_block_button( - range.end, - code, - self.markdown.clone(), - )); - } - el.child(button_row) }); } @@ -2695,6 +2752,7 @@ fn apply_heading_style( mut heading: Div, level: pulldown_cmark::HeadingLevel, custom_styles: Option<&HeadingLevelStyles>, + border_color: Option, ) -> Div { heading = match level { pulldown_cmark::HeadingLevel::H1 => heading.text_3xl(), @@ -2705,6 +2763,17 @@ fn apply_heading_style( pulldown_cmark::HeadingLevel::H6 => heading.text_sm(), }; + if let Some(border_color) = border_color + && matches!( + level, + pulldown_cmark::HeadingLevel::H1 + | pulldown_cmark::HeadingLevel::H2 + | pulldown_cmark::HeadingLevel::H3 + ) + { + heading = heading.pb_1().border_b_1().border_color(border_color); + } + if let Some(styles) = custom_styles { let style_opt = match level { pulldown_cmark::HeadingLevel::H1 => &styles.h1, @@ -3802,6 +3871,22 @@ mod tests { render_markdown_with_language_registry(markdown, None, cx) } + #[gpui::test] + fn test_wrapped_code_block_has_no_scroll_handle(cx: &mut TestAppContext) { + let markdown = + cx.new(|cx| Markdown::new("```rust\nlet value = 1;\n```".into(), None, None, cx)); + + markdown.update(cx, |markdown, _| { + assert!(markdown.code_block_scroll_handle(0).is_some()); + + markdown.toggle_code_block_wrap(0); + assert!(markdown.code_block_scroll_handle(0).is_none()); + + markdown.toggle_code_block_wrap(0); + assert!(markdown.code_block_scroll_handle(0).is_some()); + }); + } + #[gpui::test] fn test_frontmatter_renders_without_delimiters(cx: &mut TestAppContext) { let rendered = render_markdown_with_options( diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 4acceb2577bbe0..6ecb3f07ff31e2 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -1,15 +1,14 @@ use collections::HashMap; use gpui::{ - Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, ImageSource, - RenderImage, StyledText, Task, img, pulsating_between, + Animation, AnimationExt, AnyElement, ClipboardItem, Context, Entity, ImageSource, RenderImage, + StyledText, Task, img, pulsating_between, }; use std::collections::BTreeMap; use std::ops::Range; use std::path::Path; use std::sync::{Arc, OnceLock}; use std::time::Duration; -use ui::CopyButton; -use ui::prelude::*; +use ui::{CopyButton, TintColor, prelude::*}; use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag}; use settings::Settings as _; @@ -340,9 +339,7 @@ pub(crate) fn render_mermaid_diagram( img(ImageSource::Render(render_image.clone())) .max_w_full() .with_fallback(|| { - div() - .child(Label::new("Failed to load mermaid diagram")) - .into_any_element() + Label::new("Failed to Load Mermaid Diagram").into_any_element() }), ) .into_any_element() @@ -452,58 +449,41 @@ fn render_mermaid_tab_header( h_flex() .gap_0p5() - .p_0p5() - .mb_1() - .child(render_mermaid_tab_button( - "Preview", - source_offset, - !showing_code, - move |_event, _window, cx| { + .mb_2p5() + .child( + Button::new( + ElementId::named_usize("mermaid-tab-preview", source_offset), + "Preview", + ) + .label_size(LabelSize::Small) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(!showing_code) + .on_click(move |_event, _window, cx| { preview_markdown.update(cx, |md, cx| { if md.is_mermaid_showing_code(source_offset) { md.toggle_mermaid_tab(source_offset); cx.notify(); } }); - }, - )) - .child(render_mermaid_tab_button( - "Code", - source_offset, - showing_code, - move |_event, _window, cx| { + }), + ) + .child( + Button::new( + ElementId::named_usize("mermaid-tab-code", source_offset), + "Code", + ) + .label_size(LabelSize::Small) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(showing_code) + .on_click(move |_event, _window, cx| { code_markdown.update(cx, |md, cx| { if !md.is_mermaid_showing_code(source_offset) { md.toggle_mermaid_tab(source_offset); cx.notify(); } }); - }, - )) -} - -fn render_mermaid_tab_button( - label: &'static str, - source_offset: usize, - is_selected: bool, - on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, -) -> impl IntoElement { - div() - .id(ElementId::named_usize( - SharedString::from(format!("mermaid-tab-{label}")), - source_offset, - )) - .cursor_pointer() - .px_1p5() - .py_0p5() - .rounded_md() - .text_size(rems(0.75)) - .when(is_selected, |this| this.bg(gpui::hsla(0., 0., 0.5, 0.15))) - .when(!is_selected, |this| { - this.hover(|this| this.bg(gpui::hsla(0., 0., 0.5, 0.08))) - }) - .child(label) - .on_click(on_click) + }), + ) } fn render_mermaid_copy_button( @@ -513,33 +493,30 @@ fn render_mermaid_copy_button( ) -> impl IntoElement { let id = ElementId::named_usize("copy-mermaid-code", source_offset); - h_flex() - .w_4() - .absolute() - .top_0() - .right_0() - .justify_end() - .visible_on_hover("code_block") - .child(CopyButton::new(id.clone(), code.clone()).custom_on_click({ - move |_window, cx| { - let id = id.clone(); - markdown.update(cx, |this, cx| { - this.copied_code_blocks.insert(id.clone()); - cx.write_to_clipboard(ClipboardItem::new_string(code.clone())); - cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - cx.update(|cx| { - this.update(cx, |this, cx| { - this.copied_code_blocks.remove(&id); - cx.notify(); + div().absolute().top_1().right_1().justify_end().child( + CopyButton::new(id.clone(), code.clone()) + .visible_on_hover("code_block") + .custom_on_click({ + move |_window, cx| { + let id = id.clone(); + markdown.update(cx, |this, cx| { + this.copied_code_blocks.insert(id.clone()); + cx.write_to_clipboard(ClipboardItem::new_string(code.clone())); + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + cx.update(|cx| { + this.update(cx, |this, cx| { + this.copied_code_blocks.remove(&id); + cx.notify(); + }) }) + .ok(); }) - .ok(); - }) - .detach(); - }); - } - })) + .detach(); + }); + } + }), + ) } fn render_mermaid_code_view(contents: &SharedString) -> AnyElement { diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 2db1e9b0a245bb..ec33f3d867ba43 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,4 +1,5 @@ use std::any::TypeId; +use std::borrow::Cow; use std::cmp::min; use std::ops::Range; use std::path::{Path, PathBuf}; @@ -25,12 +26,11 @@ use theme::{SystemAppearance, Theme, ThemeRegistry}; use theme_settings::ThemeSettings; use ui::{ContextMenu, WithScrollbar, prelude::*, right_click_menu}; use util::markdown::split_local_url_fragment; -use util::normalize_path; use workspace::item::{Item, ItemBufferKind, ItemHandle, SaveOptions}; use workspace::searchable::{ Direction, SearchEvent, SearchOptions, SearchToken, SearchableItem, SearchableItemHandle, }; -use workspace::{OpenOptions, OpenVisible, Pane, Workspace}; +use workspace::{Pane, Workspace}; use crate::{ OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem, @@ -785,26 +785,16 @@ fn open_preview_url( ) { let (path_text, _) = split_preview_url(url.as_ref()); - if let Some(path) = resolve_preview_path(path_text, base_directory.as_deref()) - && let Some(workspace) = workspace.upgrade() - { - let _ = workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - normalize_path(path.as_path()), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - .detach(); + // URL-decode the path for proper handling of encoded characters + let decoded_path = urlencoding::decode(path_text).unwrap_or_else(|_| Cow::Borrowed(path_text)); + + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.open_url_or_file(&decoded_path, base_directory.as_deref(), window, cx); }); - return; + } else { + cx.open_url(url.as_ref()); } - - cx.open_url(url.as_ref()); } fn split_preview_url(url: &str) -> (&str, Option<&str>) { @@ -814,30 +804,6 @@ fn split_preview_url(url: &str) -> (&str, Option<&str>) { } } -fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option { - if url.starts_with("http://") || url.starts_with("https://") { - return None; - } - - let (path_text, _) = split_preview_url(url); - let decoded_url = urlencoding::decode(path_text) - .map(|decoded| decoded.into_owned()) - .unwrap_or_else(|_| path_text.to_string()); - let candidate = PathBuf::from(&decoded_url); - - if candidate.is_absolute() && candidate.exists() { - return Some(candidate); - } - - let base_directory = base_directory?; - let resolved = base_directory.join(decoded_url); - if resolved.exists() { - Some(resolved) - } else { - None - } -} - fn resolve_preview_image( dest_url: &str, base_directory: Option<&Path>, @@ -1212,53 +1178,7 @@ mod tests { use util::test::TempTree; use workspace::{AppState, MultiWorkspace, SaveIntent, Workspace, open_paths}; - use super::{MarkdownPreviewView, resolve_preview_path}; - - #[test] - fn resolves_relative_preview_path_and_missing_cases() { - let tree = markdown_fixture_tree(json!({ - "notes.md": "# Notes" - })); - let base_directory = markdown_fixture_directory(&tree); - let file = base_directory.join("notes.md"); - - assert_eq!( - resolve_preview_path("notes.md", Some(base_directory.as_path())), - Some(file) - ); - assert_eq!( - resolve_preview_path("nonexistent.md", Some(base_directory.as_path())), - None - ); - assert_eq!(resolve_preview_path("notes.md", None), None); - } - - #[test] - fn resolves_urlencoded_preview_path_and_ignores_fragment_component() { - let tree = markdown_fixture_tree(json!({ - "release notes.md": "# Release Notes", - "notes.md": "# Notes" - })); - let base_directory = markdown_fixture_directory(&tree); - - assert_eq!( - resolve_preview_path( - "release%20notes.md#overview", - Some(base_directory.as_path()) - ), - Some(base_directory.join("release notes.md")) - ); - assert_eq!( - resolve_preview_path("notes.md#L10", Some(base_directory.as_path())), - Some(base_directory.join("notes.md")) - ); - } - - #[test] - fn does_not_treat_web_links_as_preview_files() { - assert_eq!(resolve_preview_path("https://zed.dev", None), None); - assert_eq!(resolve_preview_path("http://example.com", None), None); - } + use super::MarkdownPreviewView; #[test] fn resolves_workspace_absolute_preview_image_path_and_rejects_missing() { @@ -1463,12 +1383,6 @@ mod tests { }) } - fn markdown_fixture_tree(docs_tree: serde_json::Value) -> TempTree { - TempTree::new(json!({ - "docs": docs_tree - })) - } - fn markdown_fixture_directory(tree: &TempTree) -> PathBuf { tree.path().join("docs") } diff --git a/crates/mermaid_render/Cargo.toml b/crates/mermaid_render/Cargo.toml index 6706e028d1d15c..bf826ab6334e5e 100644 --- a/crates/mermaid_render/Cargo.toml +++ b/crates/mermaid_render/Cargo.toml @@ -18,7 +18,7 @@ test-support = [] [dependencies] anyhow.workspace = true gpui.workspace = true -merman = { git = "https://github.com/zed-industries/merman", rev = "1c765dcca2ef5092fcde7bebe8374819563623ef", features = ["render"] } +merman = { git = "https://github.com/zed-industries/merman", tag = "v0.6.2-with-patches", features = ["render"] } quick-xml.workspace = true serde_json.workspace = true diff --git a/crates/mermaid_render/src/mermaid_render.rs b/crates/mermaid_render/src/mermaid_render.rs index 1e17d8d780b91f..fdd3e80faaafd5 100644 --- a/crates/mermaid_render/src/mermaid_render.rs +++ b/crates/mermaid_render/src/mermaid_render.rs @@ -16,20 +16,21 @@ //! //! This module uses the [`merman`] crate for rendering, rather than //! `mermaid-rs`, which was used in the previous implementation of mermaid -//! rendering in Zed. Merman provides significantly more accurate rendering, and -//! seems to be somewhat faster, but by default has poor CSS, making diagrams -//! look weird without significant cleanup. This is made worse by the fact that -//! `usvg`/`resvg` doesn't support some features that [`merman`] relies on. +//! rendering in Zed. //! -//! As such, this crate is quite large. But the code is very self-contained, and -//! has few dependencies. In fact, the [`gpui`] dependency is only needed for -//! the [`Hsla`] and [`Rgba`] color types. +//! Historically, this crate also carried generic `usvg`/`resvg` cleanup for SVG +//! constructs that merman's parity output could emit, such as HTML labels in +//! `` and CSS/attribute forms that rasterizers do not handle. +//! Since merman 0.6, that generic cleanup is exposed as merman's raster-safe SVG +//! pipeline. Zed opts into that pipeline during rendering, then keeps +//! editor-specific theme and accent color rules in this crate. The [`gpui`] +//! dependency is only needed for the [`Hsla`] and [`Rgba`] color types. //! //! The [`render_to_svg`] function operates in two stages: -//! - [`render`] the mermaid text to SVG using [`merman`]. -//! - [`postprocess`] the SVG to clean incorrect output and add styling. +//! - [`render`] the mermaid text to raster-safe SVG using [`merman`]. +//! - [`postprocess`] the SVG to add Zed theme and accent styling. //! -//! The postprocessing is also split up into stages. We parse the generated SVG +//! Zed's postprocessing is split up into stages. We parse the generated SVG //! using [`quick_xml`], which produces an iterator of //! [`Event<'_>`](quick_xml::events::Event)s. This iterator is then repeatedly //! transformed, and finally collected back into an SVG string. @@ -179,3 +180,24 @@ pub fn render_to_svg(source: &str, theme: &MermaidTheme) -> Result { let svg = postprocess::postprocess(&svg, theme)?; Ok(svg) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A flowchart with mutually nested subgraphs (`A` contains `B` and `B` + /// contains `A`) is an invalid containment cycle. Rendering it must return + /// gracefully rather than overflowing the stack and aborting the process. + #[test] + fn cyclic_subgraphs_do_not_crash() { + let source = "flowchart TD\n subgraph A\n B\n end\n subgraph B\n A\n end"; + let result = render_to_svg(source, &MermaidTheme::default()); + if let Err(err) = result { + let message = format!("{err:#}"); + assert!( + message.contains("cycle"), + "expected a cycle-related error, got: {message}" + ); + } + } +} diff --git a/crates/mermaid_render/src/postprocess.rs b/crates/mermaid_render/src/postprocess.rs index af1e61f3367c34..1f3b818c47f6fb 100644 --- a/crates/mermaid_render/src/postprocess.rs +++ b/crates/mermaid_render/src/postprocess.rs @@ -1,4 +1,4 @@ -//! Post-processing of [`merman`]-produced SVGs for rasterization with `usvg`/`resvg`. +//! Zed-specific post-processing of [`merman`]-produced SVGs. //! //! Each submodule is a specific pass that tweaks the SVG event iterator in a particular way. //! @@ -13,11 +13,8 @@ mod accent_colors; mod element_fixup; -mod fallback_fixup; -mod foreignobject_wrap; mod inject_css; mod strip_foreignobject; -mod strip_invalid_css; pub(crate) mod util; use anyhow::{Context as _, Result}; @@ -27,27 +24,21 @@ use quick_xml::events::Event; use crate::MermaidTheme; pub(super) fn postprocess(svg: &str, theme: &MermaidTheme) -> Result { - // Pass 1: foreignObject preparation (\n fix + word wrapping) - let svg = foreignobject_wrap::process(svg)?; + // merman 0.6 already applies the generic resvg-safe cleanup before this point. + // The remaining passes are Zed-specific theme and accent adjustments. + let svg_id = extract_svg_id(svg); - // Add fallbacks alongside elements - let svg = merman::render::foreign_object_label_fallback_svg_text(&svg); - - // Extract SVG id for CSS scoping (quick scan of the first element) - let svg_id = extract_svg_id(&svg); - - // Pass 2: themed post-processing pipeline. - // Each adapter takes an iterator of events and returns an iterator of events. - // Events borrow from the `svg` string — no .into_owned() per event. - let mut reader = Reader::from_str(&svg); + let mut reader = Reader::from_str(svg); reader.config_mut().check_end_names = false; let events = ReaderIter::new(reader); - let events = strip_foreignobject::process(events); - let events = fallback_fixup::process(events, theme); + // merman's resvg-safe pipeline already removes foreignObject elements and + // replaces their labels with native fallback groups. This pass keeps + // those fallback labels, but drops any that merely duplicate a native + // (e.g. user journey renders some labels both ways). + let events = strip_foreignobject::process(events, svg); let events = element_fixup::process(events, theme); let events = accent_colors::process(events, theme); - let events = strip_invalid_css::process(events); let events = inject_css::process(events, theme, &svg_id); let mut writer = quick_xml::Writer::new(Vec::with_capacity(svg.len())); @@ -111,26 +102,3 @@ impl<'a> Iterator for ReaderIter<'a> { } } } - -#[cfg(test)] -mod tests { - use super::*; - - fn default_theme() -> MermaidTheme { - MermaidTheme::default() - } - - #[test] - fn strip_css_handles_style_element_with_attributes() { - let svg = r#""#; - let result = postprocess(svg, &default_theme()).unwrap(); - assert!( - !result.contains("@keyframes"), - "Unsupported @keyframes should be stripped from